Coverage for /usr/local/lib/python3.10/site-packages/opal_common-0.0.0-py3.10.egg/opal_common/synchronization/hierarchical_lock.py: 0%
49 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 11:54 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 11:54 +0000
1import asyncio
2from contextlib import asynccontextmanager
3from typing import Set
5from loguru import logger
8class HierarchicalLock:
9 """A hierarchical lock for asyncio.
11 - If a path is locked, no ancestor or descendant path can be locked.
12 - Conversely, if a child path is locked, the parent path cannot be locked
13 until all child paths are released.
14 """
16 def __init__(self):
17 # locked_paths: set of currently locked string paths
18 self._locked_paths: Set[str] = set()
19 # Map of tasks to their acquired locks for re-entrant protection
20 self._task_locks: dict[asyncio.Task, Set[str]] = {}
21 # Internal lock for synchronizing access to locked_paths
22 self._lock = asyncio.Lock()
23 # Condition to wake up tasks when a path is released
24 self._cond = asyncio.Condition(self._lock)
26 @staticmethod
27 def _is_conflicting(p1: str, p2: str) -> bool:
28 """Check if two paths conflict with each other."""
29 return p1 == p2 or p1.startswith(p2) or p2.startswith(p1)
31 async def acquire(self, path: str):
32 """Acquire the lock for the given hierarchical path.
34 If an ancestor or descendant path is locked, this will wait
35 until it is released.
36 """
37 task = asyncio.current_task()
38 if task is None:
39 raise RuntimeError("acquire() must be called from within a task.")
41 async with self._lock:
42 # Prevent re-entrant locking by the same task
43 if path in self._task_locks.get(task, set()):
44 raise RuntimeError(f"Task {task} cannot re-acquire lock on '{path}'.")
46 # Wait until there is no conflict with existing locked paths
47 while any(self._is_conflicting(path, lp) for lp in self._locked_paths):
48 logger.debug(
49 f"Found conflicting path with {path!r}, waiting for release to check again..."
50 )
51 # Condition.wait() releases the lock and waits for notify_all()
52 await self._cond.wait()
54 # Acquire the path
55 self._locked_paths.add(path)
56 if task not in self._task_locks:
57 self._task_locks[task] = set()
58 self._task_locks[task].add(path)
59 logger.debug("Acquired lock for path: {}", path)
61 async def release(self, path: str):
62 """Release the lock for the given path and notify waiting tasks."""
63 task = asyncio.current_task()
64 if task is None:
65 raise RuntimeError("release() must be called from within a task.")
67 async with self._lock:
68 if path not in self._locked_paths:
69 raise RuntimeError(f"Cannot release path '{path}' that is not locked.")
71 if path not in self._task_locks.get(task, set()):
72 raise RuntimeError(
73 f"Task {task} cannot release lock on '{path}' it does not hold."
74 )
76 # Remove the path from locked paths and task locks
77 self._locked_paths.remove(path)
78 self._task_locks[task].remove(path)
79 if not self._task_locks[task]:
80 del self._task_locks[task]
82 # Notify all tasks that something was released
83 self._cond.notify_all()
84 logger.debug("Released lock for path: {}", path)
86 @asynccontextmanager
87 async def lock(self, path: str) -> "HierarchicalLock":
88 """Acquire the lock for the given path and return a context manager."""
89 await self.acquire(path)
90 try:
91 yield self
92 finally:
93 await self.release(path)