fix(state): single-flight shared database opens
This commit is contained in:
+56
-26
@@ -91,6 +91,11 @@ _lock = threading.Lock()
|
||||
_generations: Dict[Path, _Generation] = {}
|
||||
# Object-keyed retired generations still draining holders.
|
||||
_retired: Dict[int, _Generation] = {} # id(db) → generation
|
||||
# Paths whose next generation is currently being constructed. Construction
|
||||
# stays outside _lock because schema reconciliation can take seconds, but peers
|
||||
# for the SAME file must wait: otherwise every cold caller opens a writable
|
||||
# SQLite connection before the registry chooses one winner.
|
||||
_opening: Dict[Path, threading.Event] = {}
|
||||
|
||||
|
||||
def _open_session_db(path: Path) -> "SessionDB":
|
||||
@@ -132,42 +137,67 @@ def acquire(db_path: Optional[Path] = None) -> "SessionDB":
|
||||
"""
|
||||
from hermes_state import _default_db_path
|
||||
|
||||
path = Path(db_path) if db_path is not None else Path(_default_db_path())
|
||||
raw_path = Path(db_path) if db_path is not None else Path(_default_db_path())
|
||||
try:
|
||||
path = raw_path.resolve()
|
||||
except OSError:
|
||||
path = raw_path
|
||||
|
||||
with _lock:
|
||||
generation = _generations.get(path)
|
||||
if generation is not None:
|
||||
current = _stat_db_file_identity(path)
|
||||
if (
|
||||
current is not None
|
||||
and generation.identity is not None
|
||||
and current != generation.identity
|
||||
):
|
||||
# File replaced: retire the live generation (its
|
||||
# holders keep it until they release) and fall
|
||||
# through to opening a fresh one below.
|
||||
_retire_generation_locked(path, generation)
|
||||
else:
|
||||
generation.refcount += 1
|
||||
return generation.db
|
||||
while True:
|
||||
with _lock:
|
||||
generation = _generations.get(path)
|
||||
if generation is not None:
|
||||
current = _stat_db_file_identity(path)
|
||||
if (
|
||||
current is not None
|
||||
and generation.identity is not None
|
||||
and current != generation.identity
|
||||
):
|
||||
# File replaced: retire the live generation (its
|
||||
# holders keep it until they release) and elect one
|
||||
# caller to construct the replacement below.
|
||||
_retire_generation_locked(path, generation)
|
||||
else:
|
||||
generation.refcount += 1
|
||||
return generation.db
|
||||
|
||||
opening = _opening.get(path)
|
||||
if opening is None:
|
||||
opening = threading.Event()
|
||||
_opening[path] = opening
|
||||
break
|
||||
|
||||
# Another caller is constructing this path. Do not hold the global
|
||||
# registry lock while waiting: unrelated databases continue opening.
|
||||
# A failed opener signals too, so one waiter can retry as the successor.
|
||||
opening.wait()
|
||||
|
||||
# Open a fresh generation OUTSIDE the lock. The per-path opening marker
|
||||
# prevents redundant writer connections without serialising other files.
|
||||
try:
|
||||
db = _open_session_db(path)
|
||||
db._shared_registry_owned = True
|
||||
identity = _stat_db_file_identity(path)
|
||||
except BaseException:
|
||||
with _lock:
|
||||
if _opening.get(path) is opening:
|
||||
_opening.pop(path, None)
|
||||
opening.set()
|
||||
raise
|
||||
|
||||
# Open a fresh generation OUTSIDE the lock: construction can
|
||||
# take seconds (write-lock patience) and must not block every
|
||||
# other state.db acquisition in the process.
|
||||
db = _open_session_db(path)
|
||||
db._shared_registry_owned = True
|
||||
identity = _stat_db_file_identity(path)
|
||||
with _lock:
|
||||
existing = _generations.get(path)
|
||||
if existing is not None:
|
||||
# Someone else opened a generation while we were
|
||||
# constructing (or retired ours and installed a new one).
|
||||
# Ours loses — close it (outside the lock) and use theirs.
|
||||
# Defensive: a generation may have been installed by explicit
|
||||
# registry manipulation while this open was in flight.
|
||||
existing.refcount += 1
|
||||
winner = existing.db
|
||||
else:
|
||||
_generations[path] = _Generation(db, identity)
|
||||
winner = db
|
||||
if _opening.get(path) is opening:
|
||||
_opening.pop(path, None)
|
||||
opening.set()
|
||||
if winner is not db:
|
||||
_teardown(db)
|
||||
return winner
|
||||
|
||||
Reference in New Issue
Block a user