Coverage for agentos/core/distributed_lock.py: 0%
195 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-05 22:09 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-05 22:09 +0800
1"""AgentOS Distributed Lock — production-grade distributed mutex.
3Backends:
4- InMemoryLock: single-process (test/local dev)
5- PostgresLock: advisory lock via pg_advisory_lock
6- RedisLock: Redlock-inspired with TTL + renew
8Design: ~340 lines, async-first, context-manager compatible.
9"""
11from __future__ import annotations
13import asyncio
14import logging
15import time
16import uuid
17from abc import ABC, abstractmethod
18from contextlib import asynccontextmanager
19from dataclasses import dataclass, field
20from enum import Enum
21from typing import Any, AsyncIterator, Optional
23logger = logging.getLogger(__name__)
26# ============================================================================
27# Data types
28# ============================================================================
30class LockBackend(str, Enum):
31 IN_MEMORY = "in_memory"
32 POSTGRES = "postgres"
33 REDIS = "redis"
36@dataclass
37class LockConfig:
38 """Configuration for distributed lock acquisition."""
40 ttl: float = 30.0 # Seconds until lock auto-expires
41 retry_interval: float = 0.1 # Polling interval when waiting
42 acquire_timeout: float = 10.0 # Max time to wait for lock
43 renew_interval: float = 0.0 # Auto-renew interval (0 = disabled)
46@dataclass
47class LockToken:
48 """Token representing a held lock — required to release."""
50 key: str
51 owner_id: str
52 acquired_at: float
53 ttl: float
54 _backend: Any = field(repr=False) # Backend reference for release
57class LockAcquireError(Exception):
58 """Failed to acquire lock within timeout."""
61class LockNotHeldError(Exception):
62 """Attempted to release a lock not held by this owner."""
65# ============================================================================
66# Abstract backend
67# ============================================================================
69class AbstractLockBackend(ABC):
70 """Interface all lock backends must implement."""
72 @abstractmethod
73 async def acquire(self, key: str, owner_id: str, ttl: float) -> bool:
74 """Try to acquire lock. Returns True on success."""
76 @abstractmethod
77 async def release(self, key: str, owner_id: str) -> bool:
78 """Release lock. Returns True if this owner held it."""
80 @abstractmethod
81 async def extend(self, key: str, owner_id: str, ttl: float) -> bool:
82 """Extend TTL. Returns True if this owner still holds it."""
84 @abstractmethod
85 async def is_held(self, key: str, owner_id: str) -> bool:
86 """Check if this owner holds the lock."""
89# ============================================================================
90# In-Memory backend
91# ============================================================================
93class InMemoryLockBackend(AbstractLockBackend):
94 """Single-process in-memory lock — for testing and single-worker scenarios."""
96 def __init__(self):
97 self._locks: dict[str, tuple[str, float]] = {} # key → (owner_id, expiry)
98 self._lock = asyncio.Lock()
100 async def acquire(self, key: str, owner_id: str, ttl: float) -> bool:
101 async with self._lock:
102 now = time.monotonic()
103 if key in self._locks:
104 owner, expiry = self._locks[key]
105 if expiry > now and owner != owner_id:
106 return False
107 self._locks[key] = (owner_id, now + ttl)
108 return True
110 async def release(self, key: str, owner_id: str) -> bool:
111 async with self._lock:
112 if key not in self._locks:
113 return False
114 owner, expiry = self._locks[key]
115 if owner != owner_id:
116 return False
117 del self._locks[key]
118 return True
120 async def extend(self, key: str, owner_id: str, ttl: float) -> bool:
121 async with self._lock:
122 if key not in self._locks:
123 return False
124 owner, expiry = self._locks[key]
125 if owner != owner_id:
126 return False
127 if expiry < time.monotonic():
128 del self._locks[key]
129 return False
130 self._locks[key] = (owner_id, time.monotonic() + ttl)
131 return True
133 async def is_held(self, key: str, owner_id: str) -> bool:
134 async with self._lock:
135 if key not in self._locks:
136 return False
137 owner, expiry = self._locks[key]
138 return owner == owner_id and expiry > time.monotonic()
141# ============================================================================
142# Postgres advisory lock backend
143# ============================================================================
145ADVISORY_LOCK_SQL = """
146SELECT pg_try_advisory_lock(%s) AS acquired;
147"""
149ADVISORY_UNLOCK_SQL = """
150SELECT pg_advisory_unlock(%s) AS released;
151"""
153# Hash key to an int64 for advisory lock
154def _key_to_int64(key: str) -> int:
155 import hashlib
156 return int(hashlib.sha256(key.encode()).hexdigest()[:16], 16) % (2**63 - 1)
159class PostgresLockBackend(AbstractLockBackend):
160 """PostgreSQL advisory-lock based backend.
162 Uses pg_try_advisory_lock for non-blocking acquire.
163 Client must provide a pool (asyncpg or similar) via _pool attribute.
164 """
166 def __init__(self, pool: Any = None):
167 self._pool = pool
168 self._acquired: set[tuple[str, str]] = set() # (key, owner_id) tracking
170 async def _get_conn(self):
171 if self._pool is None:
172 raise RuntimeError("PostgresLockBackend requires a pool")
173 return await self._pool.acquire()
175 async def acquire(self, key: str, owner_id: str, ttl: float) -> bool:
176 conn = await self._get_conn()
177 try:
178 lock_id = _key_to_int64(key)
179 result = await conn.fetchval(
180 "SELECT pg_try_advisory_lock($1) AS acquired;", lock_id
181 )
182 if result:
183 self._acquired.add((key, owner_id))
184 return True
185 return False
186 finally:
187 await self._pool.release(conn)
189 async def release(self, key: str, owner_id: str) -> bool:
190 if (key, owner_id) not in self._acquired:
191 return False
192 conn = await self._get_conn()
193 try:
194 lock_id = _key_to_int64(key)
195 result = await conn.fetchval(
196 "SELECT pg_advisory_unlock($1) AS released;", lock_id
197 )
198 if result:
199 self._acquired.discard((key, owner_id))
200 return bool(result)
201 finally:
202 await self._pool.release(conn)
204 async def extend(self, key: str, owner_id: str, ttl: float) -> bool:
205 # Advisory locks don't expire — always held until released
206 return (key, owner_id) in self._acquired
208 async def is_held(self, key: str, owner_id: str) -> bool:
209 return (key, owner_id) in self._acquired
212# ============================================================================
213# Redis lock backend (Redlock-inspired)
214# ============================================================================
216SET_IF_NOT_EXISTS = """
217local v = redis.call('GET', KEYS[1])
218if v == false then
219 redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
220 return 1
221end
222return 0
223"""
225RELEASE_SCRIPT = """
226if redis.call('GET', KEYS[1]) == ARGV[1] then
227 return redis.call('DEL', KEYS[1])
228end
229return 0
230"""
232EXTEND_SCRIPT = """
233if redis.call('GET', KEYS[1]) == ARGV[1] then
234 return redis.call('PEXPIRE', KEYS[1], ARGV[2])
235end
236return 0
237"""
240class RedisLockBackend(AbstractLockBackend):
241 """Redis-based distributed lock using Lua scripts for atomicity.
243 Uses SET NX PX for acquire, Lua-scripted DEL for safe release.
244 """
246 def __init__(self, redis_client: Any):
247 self._redis = redis_client
249 async def acquire(self, key: str, owner_id: str, ttl: float) -> bool:
250 result = await self._redis.set(key, owner_id, nx=True, px=int(ttl * 1000))
251 return bool(result)
253 async def release(self, key: str, owner_id: str) -> bool:
254 script = self._redis.register_script(RELEASE_SCRIPT) if hasattr(self._redis, 'register_script') else None
255 if script:
256 result = await script(keys=[key], args=[owner_id])
257 return int(result) == 1
258 # Fallback for sync redis clients
259 current = await self._redis.get(key)
260 if current and current.decode() if isinstance(current, bytes) else current == owner_id:
261 return bool(await self._redis.delete(key))
262 return False
264 async def extend(self, key: str, owner_id: str, ttl: float) -> bool:
265 current = await self._redis.get(key)
266 owner = current.decode() if isinstance(current, bytes) else current
267 if owner == owner_id:
268 return bool(await self._redis.pexpire(key, int(ttl * 1000)))
269 return False
271 async def is_held(self, key: str, owner_id: str) -> bool:
272 current = await self._redis.get(key)
273 owner = current.decode() if isinstance(current, bytes) else current
274 return owner == owner_id
277# ============================================================================
278# High-level Lock Manager
279# ============================================================================
281class DistributedLock:
282 """High-level distributed lock with auto-renew and context manager support.
284 Usage:
285 lock = DistributedLock(backend, LockConfig(ttl=30))
286 token = await lock.acquire("job:123")
287 try:
288 # critical section
289 finally:
290 await lock.release(token)
292 # Context manager
293 async with lock("job:123"):
294 # critical section
295 """
297 def __init__(self, backend: AbstractLockBackend, config: LockConfig = LockConfig()):
298 self._backend = backend
299 self._config = config
300 self._renew_tasks: dict[str, asyncio.Task] = {}
302 async def acquire(self, key: str) -> LockToken:
303 """Acquire lock, blocking up to acquire_timeout."""
304 owner_id = uuid.uuid4().hex
305 deadline = time.monotonic() + self._config.acquire_timeout
307 while True:
308 if await self._backend.acquire(key, owner_id, self._config.ttl):
309 token = LockToken(
310 key=key,
311 owner_id=owner_id,
312 acquired_at=time.monotonic(),
313 ttl=self._config.ttl,
314 _backend=self,
315 )
316 if self._config.renew_interval > 0:
317 self._start_renew(key, owner_id)
318 return token
320 if time.monotonic() >= deadline:
321 raise LockAcquireError(
322 f"Failed to acquire lock '{key}' within {self._config.acquire_timeout}s"
323 )
324 await asyncio.sleep(self._config.retry_interval)
326 async def release(self, token: LockToken) -> bool:
327 """Release a held lock."""
328 if token.key in self._renew_tasks:
329 self._renew_tasks.pop(token.key).cancel()
330 return await self._backend.release(token.key, token.owner_id)
332 async def extend(self, token: LockToken, ttl: Optional[float] = None) -> bool:
333 """Extend the TTL of a held lock."""
334 return await self._backend.extend(
335 token.key, token.owner_id, ttl or self._config.ttl
336 )
338 def _start_renew(self, key: str, owner_id: str):
339 """Start auto-renew background task."""
341 async def _renew():
342 while True:
343 await asyncio.sleep(self._config.renew_interval)
344 ok = await self._backend.extend(key, owner_id, self._config.ttl)
345 if not ok:
346 logger.warning("Lock renew failed for key=%s — lost ownership", key)
347 break
349 self._renew_tasks[key] = asyncio.ensure_future(_renew())
351 @asynccontextmanager
352 async def __call__(self, key: str) -> AsyncIterator[LockToken]:
353 token = await self.acquire(key)
354 try:
355 yield token
356 finally:
357 await self.release(token)
360# ============================================================================
361# Factory
362# ============================================================================
364def create_lock_backend(backend: LockBackend, **kwargs: Any) -> AbstractLockBackend:
365 """Factory for creating lock backends."""
366 if backend == LockBackend.IN_MEMORY:
367 return InMemoryLockBackend()
368 if backend == LockBackend.REDIS:
369 redis_client = kwargs.get("redis_client")
370 if redis_client is None:
371 raise ValueError("RedisLockBackend requires 'redis_client'")
372 return RedisLockBackend(redis_client)
373 if backend == LockBackend.POSTGRES:
374 pool = kwargs.get("pool")
375 return PostgresLockBackend(pool=pool)
376 raise ValueError(f"Unknown lock backend: {backend}")