refactor(state): fold the speculative-open retry into acquire's loop; keep real inode-swap tests
- acquire(): the discard-and-recurse path becomes one more iteration of the existing wait loop; the two inline 'with lifecycle_lock: _teardown(db)' copies reuse _teardown_generation, and the type-narrowing asserts go away with the recursion. release() reads generation.path directly. - Restore the real os.replace inode swaps in test_state_db_file_identity.py and the registry tests: those files carry no windows_only marker so they never run on Windows, and the monkeypatched predicate stopped exercising the stat->identity->halt path anywhere. - Drop the auto-archive change and its 4 tests: on main the sweep gets a bare SessionDB and db.close() already releases a registry-shared handle, so the described NameError leak only existed on this branch's earlier head. trace_upload: acquire(None) already defaults. - Trim the barrier tests to the invariant pair (retired drain must not lift a pending current teardown; replacement not published before the last close settles) plus the raising-close settlement; comments say the WHY once.
This commit is contained in:
+42
-59
@@ -219,55 +219,47 @@ def acquire(db_path: Optional[Path] = None) -> "SessionDB":
|
||||
if opening is None:
|
||||
opening = _opening[path] = threading.Event()
|
||||
lifecycle_lock = _path_lifecycle_lock_locked(path)
|
||||
break
|
||||
wait_for = opening
|
||||
# Another caller is constructing this path; wait without holding the global
|
||||
# lock. A failed opener signals too, so a waiter can retry.
|
||||
wait_for.wait()
|
||||
else:
|
||||
wait_for = opening
|
||||
if wait_for is not None:
|
||||
# Another caller is constructing or closing this path; wait without holding the
|
||||
# global lock. A failed opener signals too, so a waiter can retry.
|
||||
wait_for.wait()
|
||||
continue
|
||||
|
||||
# Open OUTSIDE the registry lock; the per-path marker prevents redundant writers without
|
||||
# serialising other files, the lifecycle mutex keeps the open off a same-path close.
|
||||
try:
|
||||
with lifecycle_lock:
|
||||
db = _open_session_db(path)
|
||||
db._shared_registry_owned = True
|
||||
identity = _stat_db_file_identity(path)
|
||||
except BaseException:
|
||||
with _lock:
|
||||
_finish_opening(path, opening)
|
||||
raise
|
||||
|
||||
# Open OUTSIDE the lock; the per-path marker prevents redundant writers without
|
||||
# serialising other files.
|
||||
try:
|
||||
# Serialize connection construction with a final close/checkpoint for
|
||||
# this path, while keeping unrelated paths independent.
|
||||
with lifecycle_lock:
|
||||
db = _open_session_db(path)
|
||||
db._shared_registry_owned = True
|
||||
identity = _stat_db_file_identity(path)
|
||||
except BaseException:
|
||||
with _lock:
|
||||
teardown = _tearing_down.get(path)
|
||||
if teardown is None:
|
||||
existing = _generations.get(path)
|
||||
if existing is not None: # Defensive: installed by explicit registry manipulation mid-open.
|
||||
existing.refcount += 1
|
||||
winner = existing.db
|
||||
else:
|
||||
_generations[path] = _Generation(path, db, identity)
|
||||
winner = db
|
||||
_finish_opening(path, opening)
|
||||
raise
|
||||
|
||||
discard_barrier: Optional[_TeardownBarrier] = None
|
||||
with _lock:
|
||||
teardown = _tearing_down.get(path)
|
||||
if teardown is not None:
|
||||
# A shutdown or retired-generation final release began while this
|
||||
# opener was constructing the handle. Do not publish a new
|
||||
# generation into that teardown window; close this speculative
|
||||
# connection and retry after the barrier.
|
||||
discard_barrier = teardown
|
||||
winner = None
|
||||
else:
|
||||
existing = _generations.get(path)
|
||||
if existing is not None: # Defensive: installed by explicit registry manipulation mid-open.
|
||||
existing.refcount += 1
|
||||
winner = existing.db
|
||||
else:
|
||||
_generations[path] = _Generation(path, db, identity)
|
||||
winner = db
|
||||
_finish_opening(path, opening)
|
||||
if discard_barrier is not None:
|
||||
with lifecycle_lock:
|
||||
_teardown(db)
|
||||
discard_barrier.event.wait()
|
||||
return acquire(path)
|
||||
assert winner is not None
|
||||
if winner is not db:
|
||||
with lifecycle_lock:
|
||||
_teardown(db)
|
||||
return winner
|
||||
# A shutdown or retired-generation final release was admitted while this opener was
|
||||
# constructing: never publish into that window — discard the speculative handle and
|
||||
# go round again once the barrier lifts.
|
||||
_teardown_generation(path, db)
|
||||
teardown.event.wait()
|
||||
continue
|
||||
if winner is not db:
|
||||
_teardown_generation(path, db)
|
||||
return winner
|
||||
|
||||
|
||||
def release(db: "SessionDB") -> bool:
|
||||
@@ -278,7 +270,6 @@ def release(db: "SessionDB") -> bool:
|
||||
if db is None:
|
||||
return False
|
||||
key = id(db)
|
||||
teardown_path: Optional[Path] = None
|
||||
teardown_barrier: Optional[_TeardownBarrier] = None
|
||||
with _lock:
|
||||
generation = _retired.get(key)
|
||||
@@ -293,22 +284,17 @@ def release(db: "SessionDB") -> bool:
|
||||
generation.refcount -= 1
|
||||
needs_teardown = generation.refcount <= 0
|
||||
if needs_teardown:
|
||||
teardown_path = generation.path
|
||||
if generation.retired:
|
||||
_retired.pop(key, None)
|
||||
elif _generations.get(generation.path) is generation:
|
||||
_generations.pop(generation.path, None)
|
||||
# Remove the lendable entry and admit this close in the SAME lock
|
||||
# section, then keep the path blocked until checkpoint/close
|
||||
# completes. A retired generation's drain is admitted too: it
|
||||
# checkpoints and unlinks the same sidecars as the current one, so
|
||||
# a replacement writer must not open on top of it.
|
||||
# A retired generation's drain is admitted too: it checkpoints and unlinks the same
|
||||
# sidecars as the current one, so a replacement writer must not open on top of it.
|
||||
teardown_barrier = _admit_teardown_locked(generation.path)
|
||||
# Teardown OUTSIDE the lock: stopping the token writer, WAL checkpoint and read-pool
|
||||
# drain must not block acquisition for every other state.db.
|
||||
if needs_teardown:
|
||||
assert teardown_path is not None
|
||||
_teardown_generation(teardown_path, db, barrier=teardown_barrier)
|
||||
_teardown_generation(generation.path, db, barrier=teardown_barrier)
|
||||
return True
|
||||
|
||||
|
||||
@@ -340,11 +326,8 @@ def close_all() -> int:
|
||||
_teardown(generation.db)
|
||||
finally:
|
||||
_finish_teardown(path, teardown_barriers[path])
|
||||
# A concurrent final release may have removed its generation before this
|
||||
# sweep took the registry lock. It still owns the physical close; wait for
|
||||
# that barrier rather than returning while SQLite teardown is in flight.
|
||||
# A barrier is lifted only once EVERY teardown admitted for its path has
|
||||
# settled, so this cannot return over a close that is still running.
|
||||
# A final release that removed its generation before this sweep took _lock still owns
|
||||
# its physical close; wait for it rather than return over a running teardown.
|
||||
for barrier in active_teardowns:
|
||||
barrier.event.wait()
|
||||
return len(generations)
|
||||
|
||||
Reference in New Issue
Block a user