Coverage for src/lexigram/admin/core/distributed_lock.py: 0%
106 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Distributed lock support for lexigram-admin.
3Provides decorators and utilities for distributed locking
4to prevent concurrent operations on the same resources.
6FWK-29: @distributed_lock decorator for concurrent safety.
8All locking is backed by a ``LockStoreProtocol`` injected via the DI
9container (e.g. Redis-backed ``RedisLockStore`` or SQL advisory locks).
10There is intentionally no in-memory fallback — a missing store is a
11misconfiguration, not a condition to silently degrade from.
12"""
14from __future__ import annotations
16import asyncio
17from dataclasses import dataclass
18import functools
19import time
20from typing import TYPE_CHECKING, Any, ParamSpec, Self, TypeVar
22from lexigram.contracts.exceptions import LockError as CoreLockError
24if TYPE_CHECKING:
25 from collections.abc import Callable
27 from lexigram.contracts.core.stores import LockStoreProtocol
29# Type variables
30P = ParamSpec("P")
31R = TypeVar("R")
34# ============================================================================
35# Lock Errors
36# ============================================================================
39class LockError(CoreLockError):
40 """Base lock error."""
42 _code: str = "LEX_ERR_ADMIN_023"
44 def __init__(self, message: str = "Lock error", **kwargs: Any) -> None:
45 super().__init__(message, **kwargs)
48class LockAcquisitionError(LockError):
49 """Could not acquire lock — another process holds it."""
51 _code: str = "LEX_ERR_ADMIN_024"
53 def __init__(self, message: str = "Could not acquire lock", **kwargs: Any) -> None:
54 super().__init__(message, **kwargs)
57class LockTimeoutError(LockError):
58 """Lock acquisition timed out waiting for the lock to be released."""
60 _code: str = "LEX_ERR_ADMIN_025"
62 def __init__(
63 self, message: str = "Lock acquisition timed out", **kwargs: Any
64 ) -> None:
65 super().__init__(message, **kwargs)
68# ============================================================================
69# Lock Context Manager
70# ============================================================================
73class AdminLockContext:
74 """Async context manager that holds a single named distributed lock.
76 Acquired via :meth:`AdminLockManager.acquire`. Do not instantiate
77 directly — use the manager.
78 """
80 def __init__(
81 self,
82 lock_store: LockStoreProtocol,
83 key: str,
84 ttl: int,
85 timeout: float,
86 ) -> None:
87 self._lock_store = lock_store
88 self.key = key
89 self.ttl = ttl
90 self.timeout = timeout
91 self.owner = f"admin:{id(self)}:{time.time()}"
92 self.acquired = False
94 async def __aenter__(self) -> Self:
95 """Poll until the lock is acquired or *timeout* expires."""
96 start = time.monotonic()
97 while time.monotonic() - start < self.timeout:
98 if await self._lock_store.acquire(self.key, self.owner, self.ttl):
99 self.acquired = True
100 return self
101 await asyncio.sleep(0.1)
102 raise LockTimeoutError(
103 f"Timed out waiting for distributed lock: {self.key!r} "
104 f"(timeout={self.timeout}s)"
105 )
107 async def __aexit__(self, *args: object) -> None:
108 """Release the lock if this context holds it."""
109 if self.acquired:
110 await self._lock_store.release(self.key, self.owner)
113# ============================================================================
114# Admin Lock Manager
115# ============================================================================
118@dataclass
119class LockConfig:
120 """Configuration for distributed locks."""
122 default_ttl: int = 30
123 acquisition_timeout: float = 30.0
124 key_prefix: str = "admin:lock:"
127class AdminLockManager:
128 """Manager for admin distributed locks.
130 Requires a ``LockStoreProtocol`` injected via the DI container.
131 Implementations are provided by ``lexigram-cache`` (Redis) or
132 ``lexigram-sql`` (advisory locks / lock table).
134 Example::
136 class MyService:
137 def __init__(
138 self,
139 lock_manager: AdminLockManager,
140 ) -> None:
141 self._locks = lock_manager
143 async def safe_bulk_delete(self, ids: list[str]) -> None:
144 async with self._locks.acquire("users:bulk-delete"):
145 await self._repo.delete_many(ids)
146 """
148 def __init__(
149 self,
150 lock_store: LockStoreProtocol,
151 config: LockConfig | None = None,
152 ) -> None:
153 """Initialise with a persistent distributed lock store.
155 Args:
156 lock_store: A ``LockStoreProtocol`` implementation — must be
157 backed by a shared persistent store (Redis, SQL, etc.), not
158 in-memory. Register it via the DI container.
159 config: Optional lock configuration. Defaults to
160 :class:`LockConfig`.
161 """
162 self.config = config or LockConfig()
163 self._lock_store = lock_store
165 def _full_key(self, key: str) -> str:
166 """Prepend the configured key prefix."""
167 return f"{self.config.key_prefix}{key}"
169 def acquire(
170 self,
171 key: str,
172 ttl: int | None = None,
173 timeout: float | None = None,
174 ) -> AdminLockContext:
175 """Return an async context manager that acquires the named lock.
177 Args:
178 key: Lock identifier (the configured prefix is prepended).
179 ttl: Lock TTL in seconds. Defaults to
180 :attr:`LockConfig.default_ttl`.
181 timeout: Maximum seconds to wait for acquisition. Defaults to
182 :attr:`LockConfig.acquisition_timeout`.
184 Returns:
185 :class:`AdminLockContext` — use as ``async with manager.acquire(...)``.
187 Example::
189 async with manager.acquire("resource:123"):
190 await process_resource(123)
191 """
192 return AdminLockContext(
193 lock_store=self._lock_store,
194 key=self._full_key(key),
195 ttl=ttl if ttl is not None else self.config.default_ttl,
196 timeout=timeout if timeout is not None else self.config.acquisition_timeout,
197 )
200# ============================================================================
201# Distributed Lock Decorator
202# ============================================================================
205def distributed_lock(
206 key: str | Callable[..., str],
207 lock_manager: AdminLockManager,
208 ttl: int = 30,
209 timeout: float = 30.0,
210 on_locked: Callable[..., Any] | None = None,
211) -> Callable[[Callable[P, R]], Callable[P, R]]:
212 """Decorator that acquires a distributed lock before function execution.
214 The ``lock_manager`` **must** be injected — it is not created internally.
215 This guarantees that all decorated calls coordinate through the same
216 persistent backend (Redis, SQL, etc.) rather than silently falling back
217 to a process-local store.
219 Args:
220 key: Static lock key, or a callable that derives the key from the
221 decorated function's positional/keyword arguments.
222 lock_manager: :class:`AdminLockManager` instance wired via DI.
223 ttl: Lock TTL in seconds.
224 timeout: Maximum seconds to wait for acquisition.
225 on_locked: Optional callback invoked *instead of raising* when the
226 lock cannot be acquired within *timeout*. Receives the same
227 ``*args, **kwargs`` as the decorated function.
229 Returns:
230 A decorator that wraps the target coroutine function.
232 Example::
234 manager = container.resolve(AdminLockManager)
236 @distributed_lock("bulk-export", lock_manager=manager)
237 async def run_export() -> None:
238 ...
240 @distributed_lock(
241 lambda resource_id: f"resource:{resource_id}",
242 lock_manager=manager,
243 )
244 async def process_resource(resource_id: int) -> None:
245 ...
246 """
248 def decorator(func: Callable[P, R]) -> Callable[P, R]:
249 @functools.wraps(func)
250 async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
251 lock_key = key(*args, **kwargs) if callable(key) else key
253 try:
254 async with lock_manager.acquire(lock_key, ttl=ttl, timeout=timeout):
255 return await func(*args, **kwargs) # type: ignore[misc]
256 except LockTimeoutError:
257 if on_locked is not None:
258 result_on_locked = on_locked(*args, **kwargs)
259 if getattr(result_on_locked, "__await__", None):
260 return await result_on_locked
261 return result_on_locked
262 raise
264 return wrapper # type: ignore[return-value]
266 return decorator
269# ============================================================================
270# Resource Lock Context Manager
271# ============================================================================
274class ResourceLock:
275 """Convenience context manager for locking a single admin resource.
277 Example::
279 async with ResourceLock("users", user_id, lock_manager=manager):
280 await update_user(user_id, data)
281 """
283 def __init__(
284 self,
285 resource_type: str,
286 resource_id: Any,
287 lock_manager: AdminLockManager,
288 ttl: int = 30,
289 operation: str = "edit",
290 ) -> None:
291 self.resource_type = resource_type
292 self.resource_id = resource_id
293 self.ttl = ttl
294 self.operation = operation
295 self._manager = lock_manager
296 self._ctx: AdminLockContext | None = None
298 @property
299 def key(self) -> str:
300 """Stable lock key for this resource + operation pair."""
301 return f"{self.resource_type}:{self.resource_id}:{self.operation}"
303 async def __aenter__(self) -> Self:
304 self._ctx = self._manager.acquire(self.key, ttl=self.ttl)
305 await self._ctx.__aenter__()
306 return self
308 async def __aexit__(self, *args: object) -> None:
309 if self._ctx is not None:
310 await self._ctx.__aexit__(*args)
313# ============================================================================
314# Bulk Operation Lock
315# ============================================================================
318class BulkOperationLock:
319 """Lock for bulk operations to prevent concurrent modifications.
321 Uses a longer default TTL suitable for batch workloads.
323 Example::
325 async with BulkOperationLock("users", "delete", lock_manager=manager):
326 await bulk_delete_users(ids)
327 """
329 def __init__(
330 self,
331 resource_type: str,
332 operation: str,
333 lock_manager: AdminLockManager,
334 ttl: int = 300, # 5 minutes for bulk operations
335 ) -> None:
336 self.resource_type = resource_type
337 self.operation = operation
338 self.ttl = ttl
339 self._manager = lock_manager
340 self._ctx: AdminLockContext | None = None
342 @property
343 def key(self) -> str:
344 """Stable lock key for this bulk operation."""
345 return f"bulk:{self.resource_type}:{self.operation}"
347 async def __aenter__(self) -> Self:
348 self._ctx = self._manager.acquire(self.key, ttl=self.ttl)
349 await self._ctx.__aenter__()
350 return self
352 async def __aexit__(self, *args: object) -> None:
353 if self._ctx is not None:
354 await self._ctx.__aexit__(*args)
357__all__ = [
358 # Context
359 "AdminLockContext",
360 # Manager + config
361 "AdminLockManager",
362 # Convenience locks
363 "BulkOperationLock",
364 # Errors
365 "LockAcquisitionError",
366 "LockConfig",
367 "LockError",
368 "LockTimeoutError",
369 "ResourceLock",
370 # Decorator
371 "distributed_lock",
372]