Repository navigation
fix(hindsight): fence prefetch publication to the owning session - #64745
yingliang-zhang wants to merge 1 commit into
Conversation
|
Thanks for the focused lifecycle work. The premise is confirmed on current main: The PR addresses that class by binding request identity and fencing publication ( The PR base Automated hermes-sweeper review. |
86e8ca5 to
8a7cbcb
Compare
9f728d2 to
2b85276
Compare
aef1bbc to
4abeb30
Compare
afd5827 to
0ea4817
Compare
|
Status update after the #102117 refactor: I audited this PR against the new decomposed plugins/memory/hindsight structure. Verdict: every core mechanism the PR adds is still absent on new main — the audit finds no absorbed part beyond the generic embedded retry-once helper. Missing on new main:
Plan: port the PR onto the new structure as one coordinated change together with #64499's lifecycle half — both PRs rebuild the same client-lifecycle machinery, and the audit flagged that porting them independently would duplicate it. |
…64745), stage 1 A background prefetch worker previously read mutable provider state (self._bank_id, self._budget) at RUN time and wrote a shared result slot; on_session_switch() only did a bounded join before clearing state. A mid-prefetch session switch could therefore publish the OLD session's query results into the NEW session's context (and attribute recall to the wrong bank). Stage 1 (request identity + fenced publication + switch hardening): - _PrefetchRequest: frozen snapshot of request-time identity (session/bank/budget/query + epoch, cache_key, inflight_key) taken under the admission lock; _PrefetchOperation tracks its worker. - queue_prefetch()/prefetch() admit through _admit_prefetch_locked: dedupe by cache_key, bounded worker pool (_MAX_PREFETCH_WORKERS=2) with one coalesced pending request, stale explicit-session rejection. - The worker recalls with the SNAPSHOT (request.bank_id/budget/query), never live state; _do_recall/_recall/_reflect thread bank_id/budget kwargs so the recall_sync path binds to the caller's session too. - Publication is fenced to the exact request (_request_is_current_locked: epoch + session + bank + latest-request identity); prefetch() consumes only the matching request's result (deadline-bounded condition wait). - on_session_switch() rotates session+prefetch identity atomically under the admission lock (epoch bump) instead of joining in-flight workers: late old-epoch completions are fenced out and never delay the switch. - shutdown() clears admission state under the lock and drains the worker set with a bounded budget; _get_client() creation is serialized. - _bind_legacy_prefetch_result_locked fences externally-seeded results (raw _prefetch_result writes) so legacy behavior stays consumable. Tests: TestPrefetchSessionIdentity (admission atomicity, no-cross-publish across a same-query switch, superseded-waiter release, wait-timeout keeps late result, failure retires inflight key, worker bounding/pending coalescing, stale-session admission rejected, shutdown recheck under the admission lock) + switch tests (no spurious flush, stale result cleared, in-flight prefetch does not delay the switch; prefetch with no cached result runs the current query).
…, stage 2 The prefetch worker schedules its async recall on the shared loop as a future whose done-callback fires off-thread; a client retired while that future is unresolved (timeout, reconnect, shutdown) used to be closed underneath it, and a closed client could be handed back out by a racing _get_client/_run_hindsight_operation. Stage 2 (client ownership around inflight prefetch work): - _execute_prefetch_attempt: reserve the client for the exact request, register the scheduled future on the request's _PrefetchOperation, and publish from the future's done-callback; a timeout retires the inflight key but keeps the operation until the future settles. - _retire_hindsight_client: claim-close once; while prefetch owners hold reservations the close is deferred (and later scheduled on its own thread) until the last future releases it. - Closed-client markers: attribute when possible, weakref registry with GC-safe eviction (id()-reuse safe), bounded strong-ref fallback for unweakrefable clients — a closed client can never be re-served. - _run_hindsight_operation / prefetch retry path publish replacement clients through _publish_replacement_client so concurrent recreation keeps exactly one current client. - _close_hindsight_client(client) replaces _close_client() (arg-taking, best-effort); shutdown retires the current client and schedules any deferred closes, so concurrent shutdowns claim the close exactly once. Tests: TestPrefetchClientLifecycle — timed-out future defers an owned close and later settles it; close between schedule and publication is owned (accepted/rejected scheduler arms); 64x reconnect loop is bounded, identity-safe and fully collectable; late-future ownership never consumes worker capacity; embedded prefetch reconnect retries once on a fresh client; _get_client serializes concurrent creation; retain-side reconnect defers a prefetch-owned close; concurrent shutdowns close the client exactly once.
0ea4817 to
92613bd
Compare
|
Rebuilt onto current Port notes (structure moved, mechanism unchanged — verified against the current seams):
Verification (local): full provider file 107 passed (22 session-identity + 9 lifecycle tests added; the two mutation-proof wire-call pins from review); |
|
Head |
…ration (NousResearch#64745) The background prefetch worker published its recall into the session slot unconditionally; a worker outliving on_session_switch's 3s join wrote the old session's memories into the new session's slot. queue_prefetch also spawned unbounded threads with the last finisher winning the slot. Workers now capture a slot generation at spawn, queue_prefetch bumps it and skips while a prior worker runs, on_session_switch/shutdown bump it to fence late publishers, and the publish + recall are gated on the current generation.
92613bd to
44e607b
Compare
|
Heads-up for reviewers: this PR is now a full rewrite — please ignore the old diff. The previous head ( Head is now
The leak this closes on Verification: The old head is preserved locally as |
|
Closing: the bundled Hindsight provider this PR patches has moved out of this repo. Thanks @yingliang-zhang for this contribution. In #119888 (merge Triage notes:
If you believe this was closed in error, comment and we will reopen. (Bulk-closed in the hindsight-move close pass.) |
Ported from NousResearch/hermes-agent#64745 (closed in the hindsight-move close pass). Tracked in vectorize-io#4662. The background prefetch worker published its recall into the session slot unconditionally; a worker outliving on_session_switch's 3s join wrote the old session's memories into the new session's slot. queue_prefetch also spawned unbounded threads with the last finisher winning the slot. Workers now capture a slot generation at spawn, queue_prefetch bumps it and skips while a prior worker runs, on_session_switch/shutdown bump it to fence late publishers, and the publish + recall are gated on the current generation.
Summary
Rewritten on top of current
mainafter a design review against the prefetch machinery that landed upstream while this PR aged.The leak that remains on main: the background prefetch worker publishes its recall result into the session slot unconditionally.
on_session_switchjoins the worker for 3.0s and then clears_prefetch_result, but the worker can legitimately spend up to 10s in_wait_for_retains_drainedplus up to 120s in the recall itself. When it outlives the join it writes the old session's memories into the new session's slot, and the new session's first turn injects another conversation's context.queue_prefetchalso spawned a fresh thread per turn with no liveness check, so a slow daemon plus rapid turns stacked N concurrent recalls against one embedded daemon and let the last finisher win the slot.Fix (minimal, on upstream's shape)
_prefetch_generation, bumped under the existing_prefetch_lockon every spawn, onon_session_switch, and onshutdown().queue_prefetchskips while a prior worker is alive — the same idiom_ensure_writeralready uses in this file (andhonchofor its prefetch).prefetch(),_join_prefetch,_wait_for_retains_drained, and the join-then-clear ordering on switch are untouched;test_in_flight_prefetch_thread_drained_on_switchstays green by construction.What the original branch carried, and why it's gone
The previous head (
92613bd5e1) carried a full_PrefetchRequestadmission redesign: request identity dataclasses, a condition-variable admission path, a 2-worker pool + pending slot, retry-once scheduling, and client reservation/deferred-close ownership. Reviewing it against current main:_run_hindsight_operation(reconnect + retry-once) and single-worker shape already cover;test_in_flight_prefetch_thread_drained_on_switch,test_prefetch_returns_empty_when_no_result) — a hard blocker;{session}bank template — strictly worse than main.Dropped in favour of the ~30-line fence above. Original head preserved locally as
archive/64745-originalfor reference.Verification (head
44e607be61)tests/plugins/memory/test_hindsight_provider.py: 92 passed, 1 skipped (87 upstream + 5 new)tests/plugins/memory/ tests/agent/test_memory_provider.py: 524 passed / 4 skipped / 2 failed — the 2test_mem0_v3.pybackend-routing failures are pre-existing environment failures that also fail on cleanmain(missingmem0import)test_stale_worker_cannot_publish_after_switch_join_timeout,test_superseded_worker_cannot_overwrite_newer_result,test_shutdown_fences_inflight_prefetch_publish); restoring it returns them to green.New regression tests
queue_prefetchperforms exactly one recall under a burst while a worker is busy