fix(key-value store): Avoid "dictionary changed size" error in KVS autosave#2075
Merged
vdusek merged 2 commits intoJul 16, 2026
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a concurrency bug in KeyValueStore’s autosave cache by serializing cache iteration with cache mutation, preventing RuntimeError: dictionary changed size during iteration during persist_autosaved_values() and drop().
Changes:
- Guard autosave-cache iteration in
persist_autosaved_values()and_clear_cache()withself._autosave_lock. - Add regression tests that reproduce the concurrent insert-during-iteration scenario across storage backends.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
src/crawlee/storages/_key_value_store.py |
Wrap autosave cache iteration/cleanup with the existing autosave lock to prevent concurrent mutation during awaited I/O. |
tests/unit/storages/test_key_value_store.py |
Add regression tests that simulate concurrent cache insertion during persist/drop to prevent the iteration-size-change runtime error. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
vdusek
requested changes
Jul 15, 2026
persist_autosaved_values and _clear_cache iterated the shared per-store autosave cache and awaited I/O inside the loop without holding self._autosave_lock. get_auto_saved_value mutates that same dict under the lock, so a request adding a new autosaved key while a persist or drop was suspended mid-iteration grew the dict during iteration and raised RuntimeError: dictionary changed size during iteration. Both loops now run under the autosave lock, matching get_auto_saved_value. Add regression tests that park the iteration mid-loop and insert a new key concurrently for both persist_autosaved_values and drop. Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
anxkhn
force-pushed
the
fix/kvs-autosave-cache-iteration-lock
branch
from
July 16, 2026 05:46
4abe73f to
a83e0a8
Compare
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
KeyValueStore.get_auto_saved_valuemutates the shared per-store autosave cache(
self._autosaved_values[self.id]) while holdingself._autosave_lock. But thetwo consumers of that cache iterated it and awaited I/O inside the loop without
taking the lock:
persist_autosaved_valuesiteratedcache.values()andawaitedvalue.persist_state()per entry._clear_cache(called fromdrop()) iteratedcache.values()andawaitedvalue.teardown()per entry, then cleared the cache.Because both loops suspend at an
await, a request handler that callsget_auto_saved_valuewith a new key while a persist or drop is mid-iterationinserts into the same dict and grows it during iteration, raising
RuntimeError: dictionary changed size during iterationand aborting thepersist/drop.
This wraps both loops in
async with self._autosave_lock:, the same lockget_auto_saved_valuealready uses, so the iteration and any concurrent insertare serialized. No public API changes;
_clear_cachestill callscache.clear()(now inside the lock, which is strictly safer).The awaited bodies (
RecoverableState.persist_state()/.teardown()) bottomout in
set_valueand never re-enterget_auto_saved_value,persist_autosaved_values, or_clear_cache, so the non-reentrantasyncio.Lockis never re-acquired on the same instance (no deadlock).Issues
use_statecode path.Testing
Added two regression tests in
tests/unit/storages/test_key_value_store.py(both run across all four storage backends via the
storage_clientfixture):test_persist_autosaved_values_with_concurrent_new_keytest_drop_with_concurrent_new_autosaved_keyEach parks the persist/clear loop mid-iteration (by blocking the underlying
set_value, whichpersist_state/teardowncall) and then inserts a newautosaved key concurrently. Without the fix they fail with
RuntimeError: dictionary changed size during iteration; with the fix they pass.uv run pytest tests/unit/storages/test_key_value_store.py-> 296 passed, 2 skippeduv run pytest tests/unit/storages/-> 887 passed, 6 skippeduv run poe lint-> cleanuv run poe type-check-> no new diagnostics vsmasterChecklist