Skip to content

fix(model cache): release shared weights when a cache goes away - #9403

Merged
lstein merged 5 commits into
invoke-ai:mainfrom
lstein:lstein/fix/multigpu-shared-weights-collect
Aug 13, 2026
Merged

fix(model cache): release shared weights when a cache goes away#9403
lstein merged 5 commits into
invoke-ai:mainfrom
lstein:lstein/fix/multigpu-shared-weights-collect

Conversation

@lstein

@lstein lstein commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Follow-on to #9263, addressing the first of the non-blocking issues deferred from review there: a ModelCache dropped without shutdown()/clear() never routes its resident records through _delete_cache_entry() — the only caller of release_shared_weights() — so the process-global SharedCpuWeightsStore kept the entry's refcount and canonical tensors forever, and every surviving peer cache saw phantom RAM in the shared budget (evicting or refusing capacity for bytes no live cache held).

Design

Each cached-model wrapper now registers a weakref.finalize fallback when it acquires shared weights. Two constraints shape the implementation:

  • The finalizer runs in GC context, where taking the store's non-reentrant lock could self-deadlock: a collection can fire inside acquire()'s critical section on the same thread — the same rule ModelCache.release_first_use_grace documents. So the finalizer only enqueues the release into a SimpleQueue (lock-free, reentrant-safe); every public store method drains the queue under the lock, so the bytes disappear from the accounting no later than the next store operation — in particular the next budget query.
  • The eviction path stays exactly-once: release_shared_weights() detaches the finalizer before releasing synchronously, so a wrapper that was evicted and later collected cannot decrement a peer's reference. The finalizer's args carry the key and the canonical dict — not the wrapper (finalize holds args strongly; referencing self would make the wrapper immortal) — and the state-dict identity check keeps the release correct across invalidate()'s retired entries.

Tests

Five regression tests, each verified to fail before the fix:

  • dropping a cache returns the store's refcount, bytes, and RamBudget.total_in_use() to zero (the test JPPhoto specified);
  • the collection-time release is enqueue-only — the refcount is untouched until the next store operation drains it (this is the GC-deadlock-safety property, asserted deterministically);
  • eviction + later collection release exactly once across two caches sharing a key;
  • a retired (invalidate()d while referenced) entry is freed by a collected holder via state-dict identity;
  • CachedModelWithPartialLoad behaves identically to CachedModelOnlyFullLoad.

One existing test constructed a wrapper without binding it and relied on the abandoned wrapper leaking its reference; it now binds the wrapper.

Status

Stacked on #9263 (lstein/feat/multi-gpu); the diff shows that branch's commits until it merges. Marked draft until then — rebase onto main and un-draft after #9263 lands.

🤖 Generated with Claude Code

@github-actions github-actions Bot added api python PRs that change python files invocations PRs that change invocations backend PRs that change backend files services PRs that change app services frontend PRs that change frontend files python-tests PRs that change python tests docs PRs that change docs labels Jul 29, 2026
Nothing released a cache's SharedCpuWeightsStore references except
_delete_cache_entry(): shutdown() left every resident record's refcount
held, and a cache dropped without shutdown() (test teardown; any future
wiring that rebuilds caches at runtime) stranded the canonical tensors
and their accounting forever. Today's production wiring tears the store
down together with its caches, so the live exposure is cross-test
pollution of the process-global store and RAM pinned past
ModelManagerService.stop() — but the refcount invariant ('every acquire
is paired with exactly one release') was simply not upheld, and this
makes it self-healing before any wiring change turns it into a real
peer-accounting bug.

Two mechanisms, for the two ways a cache goes away:

- shutdown() now releases its resident records' shared references
  synchronously — it runs in a normal thread context, so the direct
  (locking) release is safe there, and teardown does not depend on a
  later store operation happening.

- Each wrapper registers a weakref.finalize fallback for the
  dropped-without-shutdown case. The finalizer runs in GC context,
  where taking the store's non-reentrant lock could self-deadlock (a
  collection can fire inside acquire()'s critical section on the same
  thread — the rule ModelCache.release_first_use_grace documents), so
  it only ENQUEUES into a SimpleQueue; every public store method drains
  the queue under the lock. The finalizer is registered inside the
  acquire's try (a registration failure must release too), its args
  carry the key and canonical dict rather than the wrapper (finalize
  holds args strongly — referencing self would make the wrapper
  immortal), and release_shared_weights() detaches it before releasing
  synchronously so eviction-then-collection releases exactly once. The
  state-dict identity keeps releases correct across invalidate()'s
  retired entries.

RamBudget.total_in_use() now documents why its store read must stay
outside the budget lock: the drain allocates under the store lock, so
GC can run _on_cache_collected (store→budget) there, and a
budget→store order anywhere would complete the deadlock cycle.

Six regression tests, verified to fail before the fix, covering:
shutdown releases synchronously with an empty queue; collection returns
refcount/bytes/budget to zero; the collection-time release is
enqueue-only (never applied inline by GC); eviction + collection
release exactly once across two caches; a retired (invalidated) entry
is freed by a collected holder; and the partial-load wrapper behaves
like the full-load one. One existing test relied on an abandoned
wrapper leaking its reference and now binds it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lstein
lstein force-pushed the lstein/fix/multigpu-shared-weights-collect branch from b295ec7 to f959cef Compare July 30, 2026 01:10
@lstein
lstein marked this pull request as ready for review July 30, 2026 01:14
@lstein lstein added the 6.14.1 label Jul 31, 2026
@lstein lstein moved this to 6.14.1: Bug fixes to 6.14.0 in Invoke - Community Roadmap Jul 31, 2026

@JPPhoto JPPhoto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Just one thing to address in a different PR:

  • invokeai/backend/model_manager/load/model_cache/model_cache.py:550: shutdown() releases shared-store ownership but retains cache records/models. RAM accounting becomes zero while tensors remain live; later same-key acquire creates duplicate canonical weights. Test: put shared model, call shutdown(), verify record/tensors remain while store reports zero, then reacquire same key and compare state-dict identity. Consider clearing records during shutdown, or retain ownership until record eviction; preserve accounting truth.

    Why does this matter?

    • shutdown() is not a hard barrier:
      • It does not prevent later put(), get(), or lock() calls.
      • ModelManagerService.stop() shuts caches down before stopping the session processor.
      • Session workers are cancelled but not joined; the code explicitly says put() after shutdown can occur.

    Evidence: model_cache.py:633, model_manager_default.py:64, session_processor_default.py:532.

    The issue is real, though likely non-blocking unless shutdown races active work.

@lstein

lstein commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Confirmed and addressed in #9494 (draft until this PR merges). It routes shutdown()'s idle records through _delete_cache_entry() and marks in-use records stale for unlock() to evict, per your "retain ownership until record eviction" suggestion — plus an identity guard in the stale-eviction path that an adversarial review of the fix surfaced. Details in the PR body.

@lstein
lstein merged commit 9127f0e into invoke-ai:main Aug 13, 2026
17 checks passed
@lstein
lstein deleted the lstein/fix/multigpu-shared-weights-collect branch August 13, 2026 02:02
lstein added a commit that referenced this pull request Sep 6, 2026
…ng shared weights (#9494)

* fix(model cache): release shared weights when a cache goes away

Nothing released a cache's SharedCpuWeightsStore references except
_delete_cache_entry(): shutdown() left every resident record's refcount
held, and a cache dropped without shutdown() (test teardown; any future
wiring that rebuilds caches at runtime) stranded the canonical tensors
and their accounting forever. Today's production wiring tears the store
down together with its caches, so the live exposure is cross-test
pollution of the process-global store and RAM pinned past
ModelManagerService.stop() — but the refcount invariant ('every acquire
is paired with exactly one release') was simply not upheld, and this
makes it self-healing before any wiring change turns it into a real
peer-accounting bug.

Two mechanisms, for the two ways a cache goes away:

- shutdown() now releases its resident records' shared references
  synchronously — it runs in a normal thread context, so the direct
  (locking) release is safe there, and teardown does not depend on a
  later store operation happening.

- Each wrapper registers a weakref.finalize fallback for the
  dropped-without-shutdown case. The finalizer runs in GC context,
  where taking the store's non-reentrant lock could self-deadlock (a
  collection can fire inside acquire()'s critical section on the same
  thread — the rule ModelCache.release_first_use_grace documents), so
  it only ENQUEUES into a SimpleQueue; every public store method drains
  the queue under the lock. The finalizer is registered inside the
  acquire's try (a registration failure must release too), its args
  carry the key and canonical dict rather than the wrapper (finalize
  holds args strongly — referencing self would make the wrapper
  immortal), and release_shared_weights() detaches it before releasing
  synchronously so eviction-then-collection releases exactly once. The
  state-dict identity keeps releases correct across invalidate()'s
  retired entries.

RamBudget.total_in_use() now documents why its store read must stay
outside the budget lock: the drain allocates under the store lock, so
GC can run _on_cache_collected (store→budget) there, and a
budget→store order anywhere would complete the deadlock cycle.

Six regression tests, verified to fail before the fix, covering:
shutdown releases synchronously with an empty queue; collection returns
refcount/bytes/budget to zero; the collection-time release is
enqueue-only (never applied inline by GC); eviction + collection
release exactly once across two caches; a retired (invalidated) entry
is freed by a collected holder; and the partial-load wrapper behaves
like the full-load one. One existing test relied on an abandoned
wrapper leaking its reference and now binds it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(model cache): evict records at shutdown() instead of only releasing shared weights

shutdown() released the resident records' shared-store references while
retaining the records themselves, so the accounting stopped describing
reality:

- The store (and RamBudget) reported zero for bytes whose tensors the
  retained wrappers still held.
- A post-shutdown load of the same key on a peer cache registered a
  duplicate canonical alongside the still-resident released copy.
- A post-shutdown eviction of a released record (put() after shutdown()
  is reachable: Invoker.stop() stops the model manager before the
  session processor) read uses_shared_weights as already-False and
  debited the non-shared budget for bytes that were admitted as shared.

shutdown() now routes idle records through _delete_cache_entry(), which
releases shared ownership and budget accounting together, exactly once.
Records still in use — locked by an in-flight generation or inside the
put()->lock() admission window — keep their references and are marked
stale; unlock() evicts them through the existing stale path when the
generation lets go, so the accounting stays truthful at every point.

All five regression tests verified to fail against the previous
shutdown() behavior.

Follow-on to #9403, addressing JPPhoto's review comment there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(model cache): match records by identity in stale eviction and _delete_cache_entry

Surfaced by adversarial review of the shutdown() change: a stale-marked
record can be detached while still locked (the VRAM-move error paths
call _delete_cache_entry on a locked record) and its key re-admitted
before the record's last unlock(). The stale-eviction path matched by
key only, so it popped the NEW record — detaching it from the cache and
all accounting — and, the old record's shared release having already
happened, read uses_shared_weights as False and debited the non-shared
budget for bytes that were admitted as shared.

The hazard predates the shutdown() change (drop_model() sets the same
flag), but shutdown() now arms stale marks at every server stop that
overlaps in-flight work, so close it here: _delete_cache_entry() and
unlock()'s stale eviction act only when the record passed in IS the
record currently held under its key; a delete of a detached record is a
full no-op.

Regression test verified to fail against the key-only matching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(model cache): track get()->lock() holders through shutdown and abandonment

Two defects found in review of the shutdown eviction change (JPPhoto, 2026-08-13):

1. shutdown() racing the gap between get() and the LoadedModel's first lock
   evicted the warm record out from under its holder: the holder locked a
   detached record whose shared-store ownership had just been released, so a
   peer's reload of the same key minted a duplicate canonical copy while the
   budget counted one.

2. A record retained by the shutdown sweep for a never-locked holder could
   never be evicted if that holder was simply dropped: the abandonment
   finalizer's deferred work was discarded post-shutdown (and the worker was
   stopped), pinning the record, its shared-store refcount and its budget
   bytes for the life of the process.

The fix tracks every wrapper's get()->lock() window with a per-record hold
count (CacheRecord.first_use_holds), armed in LoadedModelWithoutConfig's
constructor and released exactly once per wrapper — on its first lock, or by
its weakref finalizer if it is dropped un-entered. Held records are treated
like locked ones by every eviction path (shutdown, budget reconcile,
peer-requested eviction, make_room, drop_model, unlock's stale eviction);
stale-marked records whose last holder is abandoned are evicted by the
deferred worker, which now outlives shutdown() for exactly that purpose (it
already exits via the cache-collection finalizer). Holds are only granted
while a worker is alive to carry the finalizer's release, and a worker death
zeroes surviving holds at the next start so no record can stay shielded with
nothing left to unshield it. Admissions landing after shutdown() are marked
stale at birth so their final release evicts them too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(model cache): epoch-guard hold releases and recover stranded holds at shutdown

Hardening from adversarial review of the first-use-hold mechanism:

- Hold releases (the wrapper's first-lock release and the abandonment
  finalizer's deferred release) now quote the epoch the hold was armed under,
  and dead-worker recovery bumps the record's epoch when it zeroes stranded
  holds. Without this, a surviving wrapper's late release — or a release
  enqueued before the worker died and drained after the restart — would
  decrement a fresh hold armed by a different wrapper under the healthy
  replacement worker, silently unshielding that wrapper's window.

- shutdown() now runs the dead-worker hold recovery itself (and clears the
  put()-grace flags in the same situation): a hold whose abandonment release
  was dropped by the dead-thread dispatch check has no other releaser, and
  after shutdown no put() is guaranteed to run the usual next-start recovery
  — the sweep would stale-retain the record, its shared-store refcount and
  its budget bytes for the life of the process.

- register_first_use_hold() declines to arm on a record that is no longer the
  occupant under its key: an eviction already won the race against the
  wrapper's construction and a hold on a detached record shields nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(model cache): withhold the post-shutdown grace and recover from the worker's own death

Two follow-ups from review.

put() after shutdown() no longer arms the post-admission grace. That flag's
backstop releaser is the sweep at the top of the next put(), and after shutdown
no further put() is guaranteed: a load cancelled between put() and the
LoadedModel's construction leaves no wrapper (hence no finalizer either), so an
armed flag would stand for the life of the process, hiding the record from every
asynchronous eviction path while its bytes stayed charged to the shared budget.
Withholding it costs only the shield -- the record stays stale at birth, so its
eventual release still evicts it, and a loader that does come back gets the
ordinary first_use_holds shield.

The deferred worker now runs stranded-shield recovery from inside its own dying
frame. Previously recovery depended on something else happening first -- the
next admission, or shutdown() -- and neither is guaranteed when the worker dies
*after* shutdown()'s liveness check: the records the shutdown sweep retained for
a live holder were left shielded by holds nothing could release. The recovery is
scoped by thread identity (a replacement worker's shields are its own) and
retires the worker slot before sweeping, so a concurrent admission cannot arm a
shield the recovery is about to zero. It also drains the queue the dead worker
left behind, whose _AbandonedHolderRelease items pin their models' CPU weights.
_ensure_deferred_worker and shutdown() now share the same recovery, which also
lifts orphaned admission graces and evicts whatever that leaves unshielded.

Three tests, each reverted-and-confirmed-failing against the code it guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjAw9hpJe8d1GyvpdKJFGw

* fix(model cache): narrow the dead-worker recovery and stop it pinning records

Adversarial review of the previous commit found three problems with the shared
recovery it introduced.

The recovery cleared the put()-set admission grace unconditionally. On a live
cache that is a new failure mode, not a fix: the dying worker is is_alive() for
as long as it unwinds, so a cold load landing in that window starts no
replacement worker and is admitted with the ordinary grace, which the recovery
then zeroed while the loader was still between put() and get() -- a reconcile
could evict the record and the loader's get() would raise IndexError. The grace
only actually loses a releaser once the cache is shut down (its backstop is the
next put()'s sweep, not the worker), so it is now lifted only then.

The recovery also evicted stale-unshielded records from _ensure_deferred_worker,
which register_first_use_hold calls before arming -- so a second wrapper's
construction could detach the very record it was about to shield, releasing
shared-store ownership while live wrappers still held the tensors. That is the
accounting lie shutdown() itself refuses to make. The eviction moved to
_evict_stale_unshielded_entries, called only from the dying worker and only on a
shut-down cache, where nothing else can ever run it; it now also collects and
empties the device cache the way the other abandonment path does. Keeping
shutdown()'s call to pure field assignments restores its old property that the
branch cannot raise before the resident-record sweep.

The queue drain the previous commit added did not close the pin it targeted:
_dispatch_deferred's liveness gate is unsynchronized, so a finalizer that read
the worker slot just before it was retired still enqueues after the drain. The
drain is gone; _AbandonedHolderRelease now holds its record weakly instead, so a
stranded item pins nothing, and the worker clears the strong reference it
resolves before parking on the next get().

Also moves _reconcile_budget_if_pending's lock acquisition adjacent to its try:
a BaseException between the two leaked the cache RLock to an unwinding thread,
blocking every other thread for the life of the process.

Five tests, each reverted-and-confirmed-failing against the code it guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjAw9hpJe8d1GyvpdKJFGw

* fix(model cache): key the dead-worker backstops on worker liveness, not the slot

A second adversarial pass found that retiring the worker slot from inside the
dying worker silently disabled both remaining recovery sites, which gated on "a
dead thread still occupying the slot". The dying recovery deliberately leaves a
live cache's admission grace standing -- the next put()'s sweep is still its
backstop -- and hands the lift to shutdown(); with the slot already empty,
shutdown() skipped it and stale-retained the record, its shared-store reference
and its budget bytes for the life of the process. Both gates now key on "no live
worker": shutdown() lifts when the slot is empty or dead, and the worker start
recovers unconditionally (it has already returned if a worker is alive).

That also makes a failed recovery retryable, which matters because the recovery
was not exception-safe and had already retired the slot by the time it could
raise. _clear_stranded_first_use_holds now unshields every record before
reporting any of them -- a logging handler that raises is one of the ways the
worker dies in the first place, and logging inline let that same handler abort
the sweep partway -- and the post-eviction gc/empty_cache housekeeping, which
the codebase already documents can raise from a sick CUDA context, no longer
takes the eviction down with it.

Also corrects two overstated claims in the weakref rationale: a stranded queue
item can be drained later by a replacement worker (the queue is per-cache, not
per-worker), and the hold decrement in _release_abandoned_holder runs before the
identity check -- it is inert on a detached record for a different reason, which
the docstring now gives. Moving _reconcile_budget_if_pending's acquire adjacent
to its try narrows the RLock-leak window rather than closing it; said so.

Three tests, each reverted-and-confirmed-failing against the code it guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GjAw9hpJe8d1GyvpdKJFGw

* fix(model cache): claim the first-use window at the lookup, and let a cancelled admission release itself

Two findings from review.

The first-use shield was armed by LoadedModel's constructor, leaving the whole
stretch between the cache lookup and that constructor unshielded -- and that
stretch is not a couple of instructions: the configured loader retrieves its
record inside _load_and_cache and then does the shared-store shell registration
and two returns before load_model wraps it. A shutdown sweep or a peer's
reconcile landing there detached the record its holder was about to lock,
releasing shared-store ownership while the tensors lived on, so a peer's reload
minted a duplicate canonical the budget counted once.
ModelCache.get_with_first_use_claim() now arms the hold in the same lock
acquisition as the lookup and hands back a FirstUseClaim that owns it: the
wrapper adopts the claim and releases it at its first lock, and a claim dropped
without ever being adopted -- the load raised before a wrapper existed --
releases the hold by dying.

shutdown() stale-retained a record carrying only the put()-set admission grace.
That grace's three releasers are the loader's own get()->lock(), the abandonment
finalizer of a wrapper built from the record, and the sweep at the top of the
next put(); a load cancelled between its put() and its retrieval has neither of
the first two, and after shutdown no further put() is guaranteed to run the
third, so the record, its shared-store reference and its budget charge stood
until the cache object was collected. put(claim_admission=True) now hands the
loader a claim over that window too, so such a load releases its admission by
dying and the shutdown sweep finds an ordinary idle record.

Retiring the grace at shutdown instead -- the obvious shortcut, and what the
first two drafts of this commit did -- is not safe. The flag is unowned, so a
standing grace does not mean nobody is working on the record: it is equally the
state of a load still between its put() and its retrieval, and of a live
un-entered wrapper whose hold a worker death zeroed. Both were evicted out from
under their holder, with the duplicate-canonical accounting lie and an
IndexError from a retrieval that no longer found its own model.

For the same reason the admission window is not shielded by either flag but by
a weak reference to the claim (CacheRecord.admission_claim_ref): nothing has to
release it, so neither a worker death (which zeroes holds) nor another holder's
abandonment (which clears the grace) can make a running load look finished.
_recover_stranded_shields retires it once the cache is shut down, where the
eviction its expiry should trigger would otherwise travel through a dead worker
-- the same trade that method already makes for holds. The claim is armed only
after put() has committed its accounting, so a failure to allocate it cannot
leave a resident, store-owning record the budget never counted, and both
hand-back guards release the hold when the object that was to carry its release
cannot be built.

Nine tests, each reverted-and-confirmed-failing against the code it guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FCitU6EFcp76AaauNfaWTz

* fix(model cache): refuse post-shutdown prefetch admissions, and keep abandonment releases

Two records could survive a shut-down cache with nothing left to retire them.

A prefetch=True admission after shutdown() was marked stale and inserted
anyway. prefetch is the promise that no loader will come back for the
record, so there is no get() -> lock() -> unlock() to run the stale
eviction, no wrapper whose finalizer could carry an abandonment release,
and no claim whose expiry could stand in for either. What is left are the
paths that may or may not run -- another admission's make_room, a budget
reconcile, a peer's eviction request -- and after shutdown none of them is
guaranteed to come. put() now refuses such an admission, the same standard
the post-admission grace is already withheld under. It costs only a reload:
the sole caller takes the submodel it asked for from the pipeline object,
not from the cache.

The refusal sits after _ensure_deferred_worker() (a post-shutdown prefetch
must still revive the worker that carries the retained records' abandonment
releases), after the stale-grace sweep (on a shut-down cache with a LIVE
worker that sweep is the only backstop a stale grace has left, since
shutdown() runs the recovery only when no worker is alive), and before
_make_room_internal (nothing resident should be evicted to house a model
this call is about to refuse).

_dispatch_deferred dropped every item while no worker was running, which
included _AbandonedHolderRelease. Its holder is already gone -- finalizers
fire once -- so no lock, no unlock and no second finalizer is coming, and
that item is the only thing left that can retire the record. Dropping it
stranded a record the shutdown sweep had retained, resident and charged,
for the life of the process; no later sweep can repair that, because once
dead-worker recovery zeroes the hold, a record whose holder is gone is
indistinguishable from one a live wrapper is still holding, where retention
is required. Abandonment releases are now kept and drained by whichever
worker runs next; reconciles are still dropped, since cached_model_keys()
can request one on every call and the next cache operation's release hook
re-runs it anyway.

Evicting from _ensure_deferred_worker() is deliberately NOT the fix: it
runs _recover_stranded_shields() immediately before, so at that instant a
record a live wrapper still holds looks unshielded, and evicting it would
release shared-store ownership while the tensors live on.

Four tests, each checked for sensitivity by reverting the line it guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015n81CKSi7bUA217L2E9SwN

* fix(model cache): bound the abandonment queue, own worker-less admissions, keep live claims through recovery

JPPhoto's round-6 findings on the shutdown accounting, all three confirmed:

- A grace-only abandonment enqueued one kept item per dropped wrapper while
  no worker could be started, and nothing short of a lock() or another put()
  ever cleared the grace that kept them coming, so a warm get/drop loop grew
  the queue without bound. release_first_use_grace now clears the grace
  itself, lock-free (the flag is monotonic), queues an eviction only for a
  stale record and only once per record (CacheRecord.abandonment_release_pending,
  re-opened by the worker the moment it dequeues the item), and still wakes
  the worker for a pending budget reconcile the drained item used to run.

- A post-shutdown put(claim_admission=True) with no startable worker got no
  claim, so nothing owned the record once its loader died and no later
  admission swept it. _claim_first_use now mints a hold-less FirstUseClaim
  (hold_epoch=None) whenever the record is still the occupant, so the
  admission stays owned through CacheRecord.admission_claim_ref and its
  finalizer still queues the eviction a stale record owes; and put() runs
  _evict_stale_unshielded_entries() when the cache is shut down and no worker
  is alive after its revival attempt, the same terminal sweep the dying worker
  runs, placed after the grace sweep and before the prefetch refusal.

- _recover_stranded_shields retired a live admission claim on a shut-down
  cache, so a worker death followed by shutdown() evicted a record whose
  loader was between put() and get(), and that loader's retrieval raised
  IndexError. A live claim now survives every recovery; the eviction owed once
  it dies travels through the kept queue item or the next admission's sweep.

Two further defects surfaced by the adversarial passes over the fix: shutdown()
now marks a record stale BEFORE consulting its shield, so a hold-less
abandonment racing the sweep either sees the mark and queues the eviction or
has already cleared its shield when the sweep looks; and the coalescing gate
is opened at the dequeue site rather than in the handler, so a raise in the
handler, a worker death inside it, or a fork cannot leave it closed with
nothing queued behind it.

Ten new tests, two rewritten; each production change reverted individually
and confirmed to fail only the tests that guard it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JbML8Y8mHdXH72bcUry9o

* fix(model cache): refuse worker-less post-shutdown admissions

JPPhoto's round-7 blocker, the residual I disclosed rather than closed last
round: a claimed admission into a shut-down cache with no startable worker has
no releaser (no worker to drain the eviction its dropped claim's finalizer
queues) and no guaranteed future cache operation to stand in for one, so a
load that dies before retrieving it pins the record, its shared-store
reference and its budget bytes for the life of the cache object.

put() now refuses every admission -- claimed, plain, and prefetch -- once the
cache is shut down and no worker is alive after its revival attempt, the same
standard the post-shutdown prefetch was already refused under, generalized.
Nothing that could be stranded is admitted. The refusal is narrowly scoped:
a live cache still admits worker-less (a future op cleans up), and a normal
shutdown keeps its worker alive so the graceful retain-and-reclaim path is
unchanged -- only thread exhaustion reaches the refusal, where a load racing
shutdown is failing regardless (its retrieval raises IndexError, as a refused
prefetch's does; both loaders already handle a None put()).

The terminal sweep (_evict_stale_unshielded_entries) still runs before the
refusal returns, so a record already orphaned by a worker death is reclaimed
even though this admission -- possibly the last cache operation -- is refused.

Three tests whose orphan came from the now-refused admission are removed; their
mechanism coverage survives elsewhere. Two added: the refusal with its clean
loader-style IndexError, and the terminal-sweep-still-runs case. Both new
production lines mutated and confirmed to fail only their guarding tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011JbML8Y8mHdXH72bcUry9o

* fix(model cache): clean shutdown-retained records

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jonathan <34005131+JPPhoto@users.noreply.github.com>
Co-authored-by: JPPhoto <jpollack@jpollackphoto.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.1 api backend PRs that change backend files docs PRs that change docs frontend PRs that change frontend files invocations PRs that change invocations python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

Status: 6.14.1: Bug fixes to 6.14.0

Development

Successfully merging this pull request may close these issues.

2 participants