Coverage for src/lilbee/runtime/lock.py: 100%
77 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-14 11:46 +0000
1"""Cross-process locks: LanceDB write locking and the server singleton.
3Write locking combines an in-process mutex with a cross-process file lock
4(filelock) so separate processes also coordinate writes. Read consistency is
5handled by LanceDB's built-in MVCC via ``read_consistency_interval`` in
6``lilbee.data.store``. The server lock makes ``lilbee serve`` a singleton per
7data dir.
8"""
10import json
11import logging
12import threading
13import time
14from collections.abc import Generator
15from contextlib import contextmanager
16from dataclasses import dataclass
17from pathlib import Path
19from filelock import FileLock
20from filelock import Timeout as FileLockTimeout
22from lilbee.core.config import cfg
24log = logging.getLogger(__name__)
26# Default timeout (seconds) for acquiring the write lock
27LOCK_TIMEOUT = 30.0
28# Grace (seconds) for a dying predecessor to release the server lock during a
29# restart handoff before a new `lilbee serve` gives up. This budgets the PROMPT
30# path only: a predecessor whose llama-swaps honor SIGTERM releases well inside
31# it. It deliberately does NOT cover SIGKILL escalation -- the teardown that
32# actually holds the lock (_release_engines -> stop_engine -> _stop_stale_swap in
33# lilbee.providers.fleet.swap_manager) can spend _ORPHAN_STOP_TIMEOUT_S plus the
34# kill and reap waits per group, i.e. tens of seconds across four groups on a
35# wedged machine. Sizing this to that worst case would make every ordinary
36# restart wait on a pathological one; instead the successor exits with
37# LOCK_REFUSAL_EXIT_CODE and the operator retries.
38SERVER_LOCK_TIMEOUT = 15.0
39_SERVER_LOCK_NAME = "server.lock"
40_SCOPE_LOCK_NAME = "server.scope.lock"
41_SCOPE_OWNER_NAME = "server.scope.owner.json"
42# Minimum blocking wait granted to the in-process mutex even when the file lock
43# consumed the whole budget, so a deadline-edge acquire still gets a real attempt.
44_MUTEX_MIN_WAIT = 0.1
47class LockTimeoutError(TimeoutError):
48 """Raised when a lock cannot be acquired within the timeout."""
51# In-process write mutex: serializes writers within the same process
52_write_mutex = threading.Lock()
55def _lock_path(lancedb_dir: Path | None) -> Path:
56 return (lancedb_dir if lancedb_dir is not None else cfg.lancedb_dir) / ".lock"
59def server_lock_path(data_dir: Path) -> Path:
60 """Path of the one-server-per-data-dir lock file."""
61 return data_dir / _SERVER_LOCK_NAME
64def acquire_server_lock(data_dir: Path, timeout: float = SERVER_LOCK_TIMEOUT) -> FileLock | None:
65 """Hold the one-server-per-data-dir lock, or None when a live server owns it.
67 The lock is an OS file lock, so the kernel releases it the moment its holder
68 exits, however it died; a crashed or killed server leaves no stale state.
69 """
70 data_dir.mkdir(parents=True, exist_ok=True)
71 lock = FileLock(server_lock_path(data_dir))
72 try:
73 lock.acquire(timeout=timeout)
74 except FileLockTimeout:
75 return None
76 return lock
79@dataclass(frozen=True)
80class ScopeOwner:
81 """The data dir the server holding a scope lock is serving, for the refusal message."""
83 data_dir: str
86@dataclass(frozen=True)
87class ScopeHold:
88 """A held scope lock plus its owner sidecar; release removes both."""
90 lock: FileLock
91 owner_path: Path
93 def release(self) -> None:
94 """Remove the owner sidecar, then free the scope for the next server."""
95 self.owner_path.unlink(missing_ok=True)
96 self.lock.release()
99def acquire_scope_lock(
100 scope_dir: Path, data_dir: Path, timeout: float = SERVER_LOCK_TIMEOUT
101) -> ScopeHold | None:
102 """Hold the one-server-per-scope lock, or None when a live server owns the scope.
104 The scope is a directory shared by several would-be servers (the Obsidian
105 plugin's shared root). Like the data-dir lock, the OS releases it the moment
106 the holder exits. The owner sidecar records which data dir the holder is
107 serving so a refused starter can name it in its message.
108 """
109 scope_dir.mkdir(parents=True, exist_ok=True)
110 lock = FileLock(scope_dir / _SCOPE_LOCK_NAME)
111 try:
112 lock.acquire(timeout=timeout)
113 except FileLockTimeout:
114 return None
115 owner_path = scope_dir / _SCOPE_OWNER_NAME
116 owner_path.write_text(json.dumps({"data_dir": str(data_dir)}), encoding="utf-8")
117 return ScopeHold(lock, owner_path)
120def read_scope_owner(scope_dir: Path) -> ScopeOwner | None:
121 """The scope's recorded owner, or None when absent or unreadable."""
122 try:
123 payload = json.loads((scope_dir / _SCOPE_OWNER_NAME).read_text(encoding="utf-8"))
124 return ScopeOwner(data_dir=str(payload["data_dir"]))
125 except (OSError, ValueError, KeyError, TypeError):
126 return None
129@contextmanager
130def write_lock(
131 lancedb_dir: Path | None = None, timeout: float = LOCK_TIMEOUT
132) -> Generator[None, None, None]:
133 """Acquire the cross-process file lock then the in-process mutex.
135 The file lock lives next to the store's data, so cross-process writers
136 coordinate only when they lock the *same* directory: callers pass their
137 store's ``lancedb_dir`` (a per-instance ``Lilbee`` uses its own dir).
138 ``None`` falls back to the global ``cfg.lancedb_dir``.
140 The two stages share one budget: the time spent waiting on the file lock is
141 deducted before waiting on the mutex (plus a small ``_MUTEX_MIN_WAIT`` floor),
142 so a 30s request cannot stall for roughly twice that.
143 """
144 deadline = time.monotonic() + timeout
145 lock_path = _lock_path(lancedb_dir)
146 # The first write to a per-instance store can run before its data dir exists;
147 # the file lock cannot be created in a missing directory.
148 lock_path.parent.mkdir(parents=True, exist_ok=True)
149 flock = FileLock(lock_path)
150 try:
151 flock.acquire(timeout=timeout)
152 except FileLockTimeout:
153 raise LockTimeoutError("Timed out waiting for exclusive file lock") from None
154 try:
155 # Floor the mutex budget so a file lock that wins right at the deadline
156 # still gets a brief blocking attempt instead of a zero-timeout poll that
157 # spuriously fails when another thread holds the mutex for an instant.
158 remaining = max(_MUTEX_MIN_WAIT, deadline - time.monotonic())
159 acquired = _write_mutex.acquire(timeout=remaining)
160 if not acquired:
161 raise LockTimeoutError("Timed out waiting for write lock")
162 try:
163 yield
164 finally:
165 _write_mutex.release()
166 finally:
167 flock.release()