You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(short-term-memory): protect shared summarization, clear state on reset, close reliably - #1720
Fixes three ShortTermMemory lifecycle bugs, one of which affects deletion.
Description
1. A cancelled query killed summarization for every waiter.wait_until_done() awaited the shared worker directly, so one cancelled query cancelled the worker, and its batch was lost. The worker is now shielded.
2. Clearing kept the summary of the cleared content, in memory and in the persisted copy: after delete_session_episodes() it still reached QueryResponse as episode_summary, and a new instance for the session restored it. clear_memory() now cancels in-flight summarization (its result would summarize the cleared content), drops the queued episodes and the summary, and overwrites the persisted copy. If a query is already waiting on that summarization, the query holds the lock, so the clear waits for it and then discards the result. It finishes even if the call is cancelled, holding the write lock until the cancelled worker has stopped, so no query or new batch meets the stopping worker. If overwriting the persisted copy fails, the error propagates after memory has been cleared. close() drops the in-memory summary too, but keeps the persisted copy for the next instance. In the server, session deletion closes the instance right after the clear and deletes the persisted row along with the session, so there this matters when a deletion fails before that row is deleted, which on main left the summary in the database.
3. A cancelled close() left the instance open and uncleared.close() now marks the instance closed before awaiting anything and runs its cleanup in a shielded task, so a cancelled close still completes, and a repeated close waits for the same cleanup. Calls made before close() still complete first, and get_summary() now waits for summarization and reads the summary under one hold of the lock, so no writer, the cleanup included, can run between the two.
Two guards pass on main and must keep passing: test_close_persists_in_flight_summarization (see below) and test_calls_queued_before_close_still_run.
Changes to existing tests: #1517's regression test now uses a shared gate_summarization() helper with bounded waits, and its deadlock timeout goes from 1 s to 30 s so a slow runner isn't mistaken for the deadlock. A wrong comment in the message-length test is corrected.
Test Results:
Server suite: 2004 passed, 3 skipped. The short-term memory test file: 30 passed. ruff is clean, and ty reports no new diagnostics.
Checklist
I have signed the commit(s) within this pull request
My code follows the style guidelines of this project (See STYLE_GUIDE.md)
I have performed a self-review of my own code
I have commented my code
My changes generate no new warnings
I have added unit tests that prove my fix is effective or that my feature works
New and existing unit tests pass locally with my changes
I have checked my code and corrected any misspellings
Maintainer Checklist
Confirmed all checks passed
Contributor has signed the commit(s)
Reviewed the code
Run, Tested, and Verified the change(s) work as expected
Screenshots/Gifs
N/A
Further comments
Why close() waits for summarization instead of cancelling it. With the default instance_cache_size of 0, the instance is closed at the end of nearly every request, including the request whose add_episodes() started summarization, so a close() that cancelled summarization would cancel it in nearly every request that starts one; test_close_persists_in_flight_summarization pins this. The trade-off: cancelling a close() no longer interrupts a hung model call, since its cleanup keeps waiting, as an uncancelled close always did.
Known gap.delete_episode() removes an episode but not its contribution to a summary it was already folded into; undoing that needs re-summarization, which is out of scope.
…sted summary
Review on #1720 found gaps in the close() and reset fixes from its
first commit.
close() set _closed in a finally inside the write lock. A close
cancelled while waiting for the lock never reached it, and a close
cancelled while waiting for summarization set it without clearing
anything, after which every later close() returned early from
_do_reset(), so the cleanup could never finish.
close() now sets _closed before any await, and runs its cleanup in a
task it shields: the cleanup runs to completion even if close() is
cancelled, and a repeated close() awaits the same task. The cleanup
still waits for in-flight summarization rather than cancelling it, so
the result is persisted for the next instance. That is deliberate:
with instance_cache_size at 0 the instance cache closes an instance as
soon as its last reference is released, which is at the end of nearly
every request, so a close that cancelled summarization would cancel it
in nearly every request that starts one.
clear_memory() dropped only the in-memory summary. The copy saved
through save_short_term_memory() survived, so a new instance for the
session restored a summary of what had been cleared, including after
delete_session_episodes(). clear_memory() now cancels in-flight
summarization, whose result would summarize the cleared content (no
query can be awaiting the worker while the write lock is held), and
overwrites the persisted copy with an empty one. close() leaves the
persisted copy alone: releasing a cached instance must keep it for the
next one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
Review on #1720 found a window in clear_memory(). It cancels the
summarization worker and waits for it to stop, but if clear_memory()
was itself cancelled during that wait, it released the write lock while
the worker was still running.
An add_episodes() that evicted in that window queued its batch behind
the stopping worker: summarize() starts no worker while one is running,
and the cancelled worker exits without draining the queue. The batch
then waited for a later eviction to start a worker, and was discarded
if close() ran first. With instance_cache_size at 0, close() runs at
the end of nearly every request. A query in that window failed with
CancelledError although nothing had cancelled it, because the shield in
wait_until_done() passes the worker's cancellation on to its waiters.
The cancelled clear also left behind the episodes and the summary it
was meant to clear. The cancellation came with the previous commit;
before it, clear_memory() waited for summarization instead.
clear_memory() now runs in a task it shields, as close() does, so it
holds the write lock until the worker has stopped, and finishes
clearing even when the call is cancelled.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
Review on #1720 found that a clear_memory() already waiting for the
lock did nothing if close() was called meanwhile: close() sets _closed
before it takes the lock, and clear_memory() checked _closed only once
it had the lock, so the queued clear returned normally without
clearing anything, the persisted summary included. add_episodes(),
get_summary() and get_short_term_memory_context() made the same check
and failed with ShortTermMemoryClosedError in that position, where on
main they ran before the close. Both came with an earlier commit on
this branch, the one that made close() set _closed first.
Each of these methods now checks _closed when it is called, before it
takes the lock, so a call made before close() queues ahead of the
cleanup and completes first. A call made after close() is still
rejected, and clear_memory() still does nothing then.
test_close_cancelled_while_waiting_for_the_lock_still_closes now waits
for the cleanup before checking that it cleared: the add it tries is
rejected at once, before the cleanup has had a turn, where it used to
wait for the lock behind it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
Review on #1720 found that _clear_memory() overwrote the persisted
summary before clearing the episodes, so a failing
save_short_term_memory() raised with the summary already cleared but
the episodes still readable. The episodes are now cleared first, and
the error still propagates, so the caller knows the stored copy may
remain.
Review also found that clear_memory() cannot cancel summarization that
a query is already waiting on: the query holds the lock, so the clear
waits for that summary and then discards it. On main, clear_memory()
always waited. This behavior is unchanged; the docstring now says so,
and a new test pins that the summary does not survive the clear.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
The new close() docstring says the instance rejects all new work, but delete_episode() can still acquire the write lock after close and get_summary_length() can still read. That makes the lifecycle contract misleading to callers. Limit the claim to additions and summary/context reads, which check _closed before taking the lock.
Review on #1720 pointed out that close()'s docstring said the instance
rejects new work as soon as close() is called, while delete_episode()
and get_summary_length() still run after it. The docstring now names
what happens: add_episodes(), get_summary() and
get_short_term_memory_context() raise ShortTermMemoryClosedError, and
clear_memory() does nothing. No behavior changes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
Three defects in the ShortTermMemory lifecycle (#1719):
- A cancelled query cancelled summarization for every waiter:
wait_until_done() awaited the shared worker directly, so one
cancelled query cancelled the worker and lost its batch. The worker
is now shielded.
- Clearing kept the summary of the cleared content, in memory and in
the persisted copy, so after delete_session_episodes() it still
reached QueryResponse and a new instance for the session restored
it. clear_memory() now cancels in-flight summarization, drops the
queued episodes and the summary, and overwrites the persisted copy.
If a query is already waiting on that summarization, the query holds
the lock, so the clear waits for it and then discards the result.
The clear runs in a shielded task and holds the write lock until the
cancelled worker has stopped, so a cancelled clear still finishes
and no query or new batch meets the stopping worker. The episodes are
cleared before the persisted copy is overwritten, so a failed save
still leaves memory cleared, and the error still propagates. close()
drops the in-memory summary too, but keeps the persisted copy for
the next instance.
- A cancelled close() left the instance open and uncleared. close()
now marks the instance closed before awaiting anything and runs its
cleanup in a shielded task, so a cancelled close still completes and
a repeated close waits for the same cleanup. add_episodes(),
clear_memory() and both query methods check for a close before
taking the lock, so calls made before close() still complete, and
get_summary() waits for summarization and reads the summary under
one hold of the lock.
close() keeps waiting for in-flight summarization rather than
cancelling it: with instance_cache_size at 0, an instance is closed at
the end of nearly every request, including the one whose
add_episodes() started summarization, so a close() that cancelled it
would cancel summarization in nearly every request that starts one.
The #1517 regression test moves onto a shared gate_summarization()
helper with bounded waits and a 30 s deadlock timeout, and a wrong
comment in the message-length test is corrected.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose of the change
Fixes three
ShortTermMemorylifecycle bugs, one of which affects deletion.Description
1. A cancelled query killed summarization for every waiter.
wait_until_done()awaited the shared worker directly, so one cancelled query cancelled the worker, and its batch was lost. The worker is now shielded.2. Clearing kept the summary of the cleared content, in memory and in the persisted copy: after
delete_session_episodes()it still reachedQueryResponseasepisode_summary, and a new instance for the session restored it.clear_memory()now cancels in-flight summarization (its result would summarize the cleared content), drops the queued episodes and the summary, and overwrites the persisted copy. If a query is already waiting on that summarization, the query holds the lock, so the clear waits for it and then discards the result. It finishes even if the call is cancelled, holding the write lock until the cancelled worker has stopped, so no query or new batch meets the stopping worker. If overwriting the persisted copy fails, the error propagates after memory has been cleared.close()drops the in-memory summary too, but keeps the persisted copy for the next instance. In the server, session deletion closes the instance right after the clear and deletes the persisted row along with the session, so there this matters when a deletion fails before that row is deleted, which onmainleft the summary in the database.3. A cancelled
close()left the instance open and uncleared.close()now marks the instance closed before awaiting anything and runs its cleanup in a shielded task, so a cancelled close still completes, and a repeated close waits for the same cleanup. Calls made beforeclose()still complete first, andget_summary()now waits for summarization and reads the summary under one hold of the lock, so no writer, the cleanup included, can run between the two.Fixes/Closes
Fixes #1719
Type of change
How Has This Been Tested?
Each bug test fails without its fix:
test_cancelled_query_does_not_kill_shared_summarizationtest_clear_memory_discards_the_summary,test_clear_memory_clears_the_persisted_summary,test_clear_memory_cancels_in_flight_summarization,test_clear_memory_failed_save_still_clears_memory,test_clear_behind_a_waiting_query_discards_the_new_summary,test_cancelled_clear_does_not_strand_the_next_batch,test_cancelled_clear_does_not_cancel_the_next_query,test_clear_cancelled_during_cleanup_still_finishes_it,test_clear_queued_before_close_still_runstest_close_cancelled_while_waiting_for_the_lock_still_closes,test_close_cancelled_during_cleanup_still_finishes_itTwo guards pass on
mainand must keep passing:test_close_persists_in_flight_summarization(see below) andtest_calls_queued_before_close_still_run.Changes to existing tests: #1517's regression test now uses a shared
gate_summarization()helper with bounded waits, and its deadlock timeout goes from 1 s to 30 s so a slow runner isn't mistaken for the deadlock. A wrong comment in the message-length test is corrected.Test Results:
Server suite:
2004 passed, 3 skipped. The short-term memory test file:30 passed.ruffis clean, andtyreports no new diagnostics.Checklist
Maintainer Checklist
Screenshots/Gifs
N/A
Further comments
Why
close()waits for summarization instead of cancelling it. With the defaultinstance_cache_sizeof 0, the instance is closed at the end of nearly every request, including the request whoseadd_episodes()started summarization, so aclose()that cancelled summarization would cancel it in nearly every request that starts one;test_close_persists_in_flight_summarizationpins this. The trade-off: cancelling aclose()no longer interrupts a hung model call, since its cleanup keeps waiting, as an uncancelled close always did.Known gap.
delete_episode()removes an episode but not its contribution to a summary it was already folded into; undoing that needs re-summarization, which is out of scope.