fix(state): single-flight shared database opens

This commit is contained in:
fangliquanflq
2026-09-02 13:07:41 +08:00
committed by kshitij
parent 32fe129324
commit 61635e1b53
2 changed files with 191 additions and 28 deletions
+56 -26
View File
@@ -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