mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-01 21:28:10 +00:00
* feat(lock_store): make locking backend overridable Allow the centralised lock factory to use a pluggable backend instead of the hardcoded Redis/file selection. Backends are resolved with precedence override > CREWAI_LOCK_FACTORY env > built-in default: - set_lock_backend()/reset_lock_backend() and a scoped lock_backend() context manager for programmatic overrides - CREWAI_LOCK_FACTORY="module:callable" env import-path, resolved lazily and cached, with clear errors on malformed or non-callable specs - LockBackend Protocol documenting the contract (raw name in, context manager out; backend owns its namespacing) Default Redis/file behavior is unchanged when nothing is overridden. * refactor(lock_store): use explicit body for LockBackend protocol method Replace the no-op `...` body with `raise NotImplementedError` to satisfy the CodeQL ineffectual-statement check while keeping the Protocol structural-typing only. * refactor(lock_store): drop scoped lock_backend context manager Keep the backend overridable via set_lock_backend/reset_lock_backend and the CREWAI_LOCK_FACTORY env path, but remove the scoped lock_backend() context manager. It was speculative surface and the only thread-unsafe piece (racy save/restore of the module global); nothing depends on it. * refactor(lock_store): drop reset_lock_backend alias reset_lock_backend() was just set_lock_backend(None); callers use that directly. Clearing the override is documented on set_lock_backend. * style(lock_store): apply ruff format * refactor(lock_store): simplify overridable backend to a single setter Reduce the override surface to just set_lock_backend(): lock() uses the custom backend when one is set, otherwise the unchanged Redis/file default. Drop the CREWAI_LOCK_FACTORY env import-path, the runtime_checkable Protocol, the precedence resolver, and the getter — a custom backend is now any callable(name, *, timeout) -> context manager, registered in process. * fix(lock_store): snapshot backend to avoid check-then-call race Read the module-global backend once into a local before the None check and the call, so a concurrent set_lock_backend(None) cannot make lock() invoke None. * docs(lock_store): clarify name handling for custom backends The default namespaces the lock name; custom backends receive it verbatim. Correct the lock() docstring which implied namespacing always happens. * docs(lock_store): note set_lock_backend is for one-time startup setup
114 lines
3.0 KiB
Python
114 lines
3.0 KiB
Python
"""Tests for lock_store.
|
|
|
|
We verify our own logic: the _redis_available guard, which portalocker
|
|
backend is selected, and that a custom backend can be plugged in. We trust
|
|
portalocker to handle actual locking mechanics.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
import sys
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
|
|
import crewai_core.lock_store as lock_store
|
|
from crewai_core.lock_store import lock
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def no_redis_url(monkeypatch):
|
|
monkeypatch.setattr(lock_store, "_REDIS_URL", None)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_backend():
|
|
"""Ensure a custom backend never leaks across tests."""
|
|
lock_store.set_lock_backend(None)
|
|
yield
|
|
lock_store.set_lock_backend(None)
|
|
|
|
|
|
# _redis_available
|
|
|
|
|
|
def test_redis_not_available_without_url():
|
|
assert lock_store._redis_available() is False
|
|
|
|
|
|
def test_redis_not_available_when_package_missing(monkeypatch):
|
|
monkeypatch.setattr(lock_store, "_REDIS_URL", "redis://localhost:6379")
|
|
monkeypatch.setitem(sys.modules, "redis", None) # None → ImportError on import
|
|
assert lock_store._redis_available() is False
|
|
|
|
|
|
def test_redis_available_with_url_and_package(monkeypatch):
|
|
monkeypatch.setattr(lock_store, "_REDIS_URL", "redis://localhost:6379")
|
|
monkeypatch.setitem(sys.modules, "redis", mock.MagicMock())
|
|
assert lock_store._redis_available() is True
|
|
|
|
|
|
# lock strategy selection
|
|
|
|
|
|
def test_uses_file_lock_when_redis_unavailable():
|
|
with mock.patch("portalocker.Lock") as mock_lock:
|
|
with lock("file_test"):
|
|
pass
|
|
|
|
mock_lock.assert_called_once()
|
|
assert "crewai:" in mock_lock.call_args.args[0]
|
|
|
|
|
|
def test_uses_redis_lock_when_redis_available(monkeypatch):
|
|
fake_conn = mock.MagicMock()
|
|
monkeypatch.setattr(lock_store, "_redis_available", mock.Mock(return_value=True))
|
|
monkeypatch.setattr(lock_store, "_redis_connection", mock.Mock(return_value=fake_conn))
|
|
|
|
with mock.patch("portalocker.RedisLock") as mock_redis_lock:
|
|
with lock("redis_test"):
|
|
pass
|
|
|
|
mock_redis_lock.assert_called_once()
|
|
kwargs = mock_redis_lock.call_args.kwargs
|
|
assert kwargs["channel"].startswith("crewai:")
|
|
assert kwargs["connection"] is fake_conn
|
|
|
|
|
|
# custom backend
|
|
|
|
|
|
def test_custom_backend_is_used():
|
|
calls = []
|
|
|
|
@contextmanager
|
|
def fake_backend(name, *, timeout):
|
|
calls.append((name, timeout))
|
|
yield
|
|
|
|
lock_store.set_lock_backend(fake_backend)
|
|
|
|
# The default file/redis path must not be touched when overridden.
|
|
with mock.patch("portalocker.Lock") as mock_lock:
|
|
with lock("custom_test", timeout=5):
|
|
pass
|
|
|
|
mock_lock.assert_not_called()
|
|
assert calls == [("custom_test", 5)]
|
|
|
|
|
|
def test_clearing_backend_restores_default():
|
|
@contextmanager
|
|
def fake_backend(name, *, timeout):
|
|
yield
|
|
|
|
lock_store.set_lock_backend(fake_backend)
|
|
lock_store.set_lock_backend(None)
|
|
|
|
with mock.patch("portalocker.Lock") as mock_lock:
|
|
with lock("after_clear"):
|
|
pass
|
|
|
|
mock_lock.assert_called_once()
|