Skip to content

fix: build the handles for update() outside the cache lock, in chunks - #123

Merged
awolverp merged 7 commits into
awolverp:mainfrom
greenclaw:fix/update-collect
Oct 29, 2026
Merged

awolverp merged 7 commits into
awolverp:mainfrom
greenclaw:fix/update-collect

Conversation

@greenclaw

@greenclaw greenclaw commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

The update() part of #118 (see the comment there). Wrapped::extend held the cache lock while it iterated its argument and built the handles, so Python code ran under the lock: a.update(b) and b.update(a) on two threads deadlocked, and so did an iterable that reads the same cache, on a single thread:

a = cachebox.LRUCache(0, {i: i for i in range(10)})
a.update((k, a.get(k, 0) + 1) for k in range(20))  # never returns

extend no longer iterates under the lock unless the input is an exact dict, list, tuple, set or frozenset (iterating those runs no Python code, so they keep the old single pass). Anything else (a generator, another cache, a subclass) is iterated and its handles built (__hash__, getsizeof) without the lock, the way insert already does for one entry, in chunks of 1024; each chunk is inserted under one lock. The chunks keep memory bounded by the cache, not by the input: LRUCache(10).update() from a generator of 100k 1 KB values peaks at 1.1 MB. Entries read before an error in the iterable are still inserted, as before and as dict.update does. Two differences on the chunked path: a large update() is applied chunk by chunk, so another thread can see it half done; and when an entry is too large for the cache, the iterable is read to the end of its chunk (up to 1023 more items) before the OverflowError is raised.

Cost, LRUCache(0).update(...), release builds, min of 5 interleaved runs, ns per entry:

input main this PR
dict, 100 30.6 30.6
list of tuples, 100 23.8 23.3
another cache, 100 33.5 35.4
dict, 10 000 38.6 38.0
list of tuples, 10 000 26.0 26.3
another cache, 10 000 35.7 38.2

Tests

  • test_update_running_python_code (child process): the single-thread case above, then a.update(b) / b.update(a) on two threads.
  • test_update_from_tuple_set_and_list_subclass, test_update_keeps_entries_read_before_an_error, test_update_memory_follows_the_cache_not_the_input, test_update_ignores_a_wrong_len.

Fork CI with the first test commit alone on main (run): test_update_running_python_code hangs for all 7 classes on every job (3.10–3.15, 3.14t, 3.15t, pypy3.11, ubuntu, macos, windows). With the fix (run): all jobs green.

@awolverp

awolverp commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Great ❤️ As I mentioned in issue #118 (though I replied a bit late), for types like dict, set, and tuple where we're sure they don't have this problem, we can use the old approach. But for the other types, we can apply the same fix you did.
Can you also handle this? If not, I'll fix it myself.

@greenclaw

Copy link
Copy Markdown
Contributor Author

Done in 7fdbf4c and e08bbc3: an exact dict, list, tuple, set or frozenset goes through the old single pass under one lock, with no buffer; anything else (generators, other caches, subclasses, since a list subclass can override __iter__) uses the chunked path. On the fast path the keys' __hash__ and getsizeof still run under the lock, as before.

update() from a dict or a list is back to main's speed (within ±2%); from another cache it is about 6–7% slower.

@awolverp

awolverp commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Thanks a lot 🔥

I will review and merge this PR in about 2-3 hours

@awolverp
awolverp merged commit d071abe into awolverp:main Oct 29, 2026
12 checks passed
@greenclaw
greenclaw deleted the fix/update-collect branch October 29, 2026 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants