Skip to content

Commit b75bcbb

Browse files
authored
fix(l1): at-fork hook repairs every cache even when its drop warning raises (LAB-6370) (#392)
1 parent 4307cd5 commit b75bcbb

3 files changed

Lines changed: 114 additions & 10 deletions

File tree

‎docs/free-threading.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ the ticket, plus what the free-threaded CI lane surfaced and sites added since:
5656
| `backends/cachekitio/session.py` header cache | `threading.local` | Safe — per-thread state; its only cross-thread hazard was the session-identity publication above |
5757
| `decorators/stats_context.py` | `contextvars.ContextVar` | Safe by construction |
5858
| `decorators/wrapper.py` L1/SWR + L2/SWR single-flight (`_l1_swr_*`, `_l2_swr_*`) | `BoundedSemaphore` slots + in-flight `set` + PID-owner swap | Safe. The check-then-add on the in-flight set was already documented as benign (worst case one duplicate refresh, absorbed by last-write-wins / the backend lease); builtin `set`/`dict` single ops are atomic under free-threading's per-object locking. The fork-detection wholesale swap races are the same documented-benign shape |
59-
| `l1_cache.py` `L1CacheManager._take_over_if_forked` + `_reset_cache_locks_after_fork` (LAB-4772) | Owner-PID fast path; on a PID change one thread takes over under a lock fetched by `dict.setdefault(pid, Lock())`. An `os.register_at_fork` hook repairs cache locks orphaned at fork before the child's first `get()`; fork mechanics and the uWSGI limit are in the `_take_over_if_forked` docstring | Safe. `setdefault` is atomic under per-object locking, so every thread in a new child gets the same fresh lock, which no parent thread can have held at fork; the rest block on it and re-check. The fast path gates on `_owner_pid` alone, so a weak-memory reader may still see the inherited manager lock once: the same benign fork-swap race as the SWR row, since that lock is unheld unless a parent thread held it at fork. The hook runs while the child is single-threaded, so its non-blocking probe (`timeout=0`) is exact, and it starts no thread. Once it has run, the take-over skips its own timed cache-lock probe, which could otherwise clear a cache under a live child thread |
59+
| `l1_cache.py` `L1CacheManager._take_over_if_forked` + `_reset_cache_locks_after_fork` (LAB-4772) | Owner-PID fast path; on a PID change one thread takes over under a lock fetched by `dict.setdefault(pid, Lock())`. An `os.register_at_fork` hook repairs cache locks orphaned at fork before the child's first `get()`; fork mechanics and the uWSGI limit are in the `_take_over_if_forked` docstring | Safe. `setdefault` is atomic under per-object locking, so every thread in a new child gets the same fresh lock, which no parent thread can have held at fork; the rest block on it and re-check. The fast path gates on `_owner_pid` alone, so a weak-memory reader may still see the inherited manager lock once: the same benign fork-swap race as the SWR row, since that lock is unheld unless a parent thread held it at fork. The hook runs while the child is single-threaded, so its non-blocking probe (`timeout=0`) is exact, and it starts no thread. It repairs a cache before logging that cache's drop warning, and carries on past a log call that raises, so every cache is repaired. Once it has run, the take-over skips its own timed cache-lock probe, which could otherwise clear a cache under a live child thread |
6060
| `decorators/wrapper.py` `_cached_keys` | Builtin `set`, snapshot-copied before iteration in invalidation | Safe — single ops atomic; a copy racing an add can only miss a concurrently-written key, which invalidate-all semantics tolerate |
6161
| `object_cache.py` `ObjectCache` | `RLock` on every public method | Safe |
6262
| `reliability/metrics_collection.py` `get_async_metrics_collector` | Double-checked module-global singleton | Safe — single-assignment publication of a fully-constructed object; worst case a benign duplicate worker-restart check |

‎src/cachekit/l1_cache.py‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -260,16 +260,18 @@ def _reset_lock_after_fork(self, timeout: float = 1.0) -> None:
260260
if not lock._is_owned() and lock.acquire(timeout=timeout): # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
261261
lock.release()
262262
return
263-
logger.warning(
264-
"L1Cache %s: dropped %d entries (%d bytes) after fork; its lock was unavailable",
265-
self.namespace,
266-
len(self._cache),
267-
self._current_memory_bytes,
268-
)
263+
dropped, dropped_bytes = len(self._cache), self._current_memory_bytes
269264
# Clear before publishing the new lock: the orphaned one still shuts every other thread out.
270265
self._cache.clear()
271266
self._current_memory_bytes = 0
272267
self._lock = threading.RLock()
268+
# Log only once repaired: a raising logging Filter escapes Logger.handle.
269+
logger.warning(
270+
"L1Cache %s: dropped %d entries (%d bytes) after fork; its lock was unavailable",
271+
self.namespace,
272+
dropped,
273+
dropped_bytes,
274+
)
273275

274276
def _remove_entry(self, key: str) -> None:
275277
"""Remove entry from cache and update memory tracking.
@@ -451,6 +453,8 @@ def _take_over_if_forked(self) -> None:
451453
they put(), so _reset_cache_locks_after_fork repairs orphaned cache locks before that
452454
first get() on os.fork() servers; without at-fork hooks (uWSGI unless
453455
--py-call-osafterfork) a get() on an orphaned cache lock before the first put still hangs.
456+
The take-over resets cache locks only when that hook did not run in this PID
457+
(_locks_reset_pid): after it, a held cache lock belongs to a live child thread.
454458
"""
455459
pid = os.getpid()
456460
if self._owner_pid == pid:
@@ -594,10 +598,16 @@ def _reset_cache_locks_after_fork() -> None:
594598
Only the locks: starting the cleanup thread stays with _take_over_if_forked, outside the hook.
595599
"""
596600
pid = os.getpid()
601+
error: Optional[Exception] = None
597602
for manager in list(_managers):
598603
for cache in list(manager._caches.values()):
599-
cache._reset_lock_after_fork(timeout=0)
604+
try:
605+
cache._reset_lock_after_fork(timeout=0)
606+
except Exception as e: # its drop warning, logged after the repair: repair the rest too
607+
error = error or e
600608
manager._locks_reset_pid = pid
609+
if error is not None:
610+
raise error # only now; Python reports an at-fork hook's exception and carries on
601611

602612

603613
if hasattr(os, "register_at_fork"):

‎tests/unit/test_l1_memory_bounds.py‎

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ def hold() -> None:
359359

360360
holder = threading.Thread(target=hold)
361361
holder.start()
362-
held.wait(5)
362+
assert held.wait(5) # else the take-over below never meets a live holder and the test proves nothing
363363
other.put("k", b"v") # first put runs the take-over while a live child thread holds busy-ns
364364
release.set()
365365
holder.join(5)
@@ -379,6 +379,98 @@ def hold() -> None:
379379
finally:
380380
manager.stop_background_cleanup()
381381

382+
@pytest.mark.skipif(not hasattr(os, "fork"), reason="fork() not available on this platform")
383+
@pytest.mark.parametrize("two_managers", [False, True], ids=["one-manager", "two-managers"])
384+
def test_at_fork_hook_repairs_every_cache_when_its_warning_raises(self, two_managers):
385+
import multiprocessing
386+
import queue as queue_mod
387+
388+
from cachekit import l1_cache
389+
390+
first = L1CacheManager(default_max_memory_mb=10)
391+
second = L1CacheManager(default_max_memory_mb=10) if two_managers else first
392+
caches = [first.get_cache("raising-ns"), second.get_cache("later-ns")]
393+
394+
class RaiseOnDropWarning(logging.Filter):
395+
def filter(self, record: logging.LogRecord) -> bool:
396+
# Only this test's first cache: a drop warning from any other live manager passes.
397+
if record.args and record.args[0] == "raising-ns":
398+
raise RuntimeError("filter failed")
399+
return True
400+
401+
raising = RaiseOnDropWarning()
402+
l1_cache.logger.addFilter(raising)
403+
held, release = [threading.Event() for _ in caches], threading.Event()
404+
405+
def hold(cache: L1Cache, done: threading.Event) -> None:
406+
with cache._lock:
407+
done.set()
408+
release.wait()
409+
410+
holders = [threading.Thread(target=hold, args=pair, daemon=True) for pair in zip(caches, held, strict=True)]
411+
for holder in holders:
412+
holder.start()
413+
assert all(event.wait(5) for event in held)
414+
try:
415+
ctx = multiprocessing.get_context("fork")
416+
queue = ctx.Queue()
417+
418+
def child(q) -> None:
419+
# From the child's main thread: a lock the hook left orphaned hangs here for good.
420+
found = [cache.get("k")[0] for cache in caches]
421+
q.put({"found": found, "reset": [m._locks_reset_pid == os.getpid() for m in (first, second)]})
422+
423+
process = ctx.Process(target=child, args=(queue,))
424+
process.start()
425+
try:
426+
outcome = queue.get(timeout=20)
427+
except queue_mod.Empty:
428+
outcome = "child hung on a cache lock the at-fork hook left orphaned"
429+
finally:
430+
process.join(timeout=10)
431+
if process.is_alive(): # a hung child would otherwise block pytest's exit
432+
process.kill()
433+
434+
assert outcome == {"found": [False, False], "reset": [True, True]}
435+
assert process.exitcode == 0
436+
finally:
437+
l1_cache.logger.removeFilter(raising)
438+
release.set()
439+
for holder in holders:
440+
holder.join(5)
441+
442+
def test_at_fork_hook_reraises_only_after_repairing_every_cache(self, monkeypatch):
443+
import weakref
444+
445+
from cachekit import l1_cache
446+
447+
manager = L1CacheManager(default_max_memory_mb=10)
448+
monkeypatch.setattr(l1_cache, "_managers", weakref.WeakSet([manager])) # leave the global manager alone
449+
caches = [manager.get_cache("raising-ns"), manager.get_cache("later-ns")]
450+
orphaned = []
451+
for cache in caches:
452+
cache.put("pre-fork", b"v")
453+
cache._lock.acquire() # _is_owned(): the hook resets it, as for a hold orphaned at fork
454+
orphaned.append(cache._lock)
455+
456+
class RaiseOnDropWarning(logging.Filter):
457+
def filter(self, record: logging.LogRecord) -> bool:
458+
if record.args and record.args[0] == "raising-ns":
459+
raise RuntimeError("filter failed")
460+
return True
461+
462+
raising = RaiseOnDropWarning()
463+
l1_cache.logger.addFilter(raising)
464+
try:
465+
with pytest.raises(RuntimeError, match="filter failed"): # reported, not swallowed
466+
l1_cache._reset_cache_locks_after_fork()
467+
finally:
468+
l1_cache.logger.removeFilter(raising)
469+
470+
assert [cache._lock is lock for cache, lock in zip(caches, orphaned, strict=True)] == [False, False]
471+
assert [cache.get("pre-fork")[0] for cache in caches] == [False, False]
472+
assert manager._locks_reset_pid == os.getpid()
473+
382474
def test_cleanup_stopped_in_parent_stays_stopped(self):
383475
manager = L1CacheManager(default_max_memory_mb=10)
384476
cache = manager.get_cache("stopped-ns")
@@ -393,6 +485,7 @@ def test_orphaned_cache_lock_drop_is_exclusive_and_logged(self, caplog):
393485
manager = L1CacheManager(default_max_memory_mb=10)
394486
cache = manager.get_cache("orphan-ns")
395487
cache.put("pre-fork", b"v")
488+
held_bytes = cache._current_memory_bytes
396489
_as_if_forked(manager, parent_ran_cleanup=False)
397490
orphaned = cache._lock
398491
orphaned.acquire() # _is_owned(): how a child thread reusing the dead holder's ident sees the hold
@@ -407,7 +500,8 @@ def clear(self) -> None:
407500
cache.put("k", b"v")
408501

409502
assert not cache.get("pre-fork")[0] and cache.get("k")[0]
410-
assert any("dropped 1 entries" in r.message for r in caplog.records)
503+
# Logged after the clear, but with what the cache held before it.
504+
assert any(f"dropped 1 entries ({held_bytes} bytes)" in r.message for r in caplog.records)
411505

412506
def test_restart_failure_is_logged_once_and_put_still_stores(self, monkeypatch, caplog):
413507
manager = L1CacheManager(default_max_memory_mb=10)

0 commit comments

Comments
 (0)