Collapse concurrent cache refreshes into a single fetch - #22
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Queued and will review — thanks for continuing to work through #6. Flagging the situation honestly rather than letting you rebase three times: you now have three open PRs (#19, #22, #23) that all rewrite the same cache path in Rebasing them one at a time means three rounds of the same conflict. Options, your call:
I have a slight preference for (2) given the overlap, but you are the one doing the work — pick whichever you prefer and I will review it that way. One more thing: |
28e0b19 to
a13ded4
Compare
|
This needs one more rebase, and there is a specific regression to watch for beyond the textual conflict. #19 merged, so def _fresh_cached() -> AlertsResponse | None:
if cached is not None and (time.monotonic() - cached[0]) < CACHE_TTL_SECONDS:where if cached is not None and (time.monotonic() - cached[0]) < _ttl_for(cached[1]):Rebased naively, The fix is small: Git did raise a conflict in Verification I will run after the rebase, so you can check it yourself first. Frozen clock, a source that fails once then recovers: Plus your own single-flight assertion: N concurrent callers on a cold cache produce exactly one upstream fetch. Both need to hold at once. #23 will need the same treatment afterwards, since it widens the cache tuple and |
collect() ran outside any lock, so the TTL check and the fetch were not atomic. Every caller arriving on a cold or expired cache saw a miss and started its own full fetch -- N concurrent requests meant N fetches at WMO and USGS, which is the exact burst the cache exists to prevent. An unfiltered SWIC fetch is ~774KB, so the cost is real. Add a second lock that serialises the fetch itself, with a re-check of the cache after acquiring it: threads that queued behind an in-flight refresh find the answer already there and return it instead of fetching again. The two locks stay separate on purpose. _cache_lock only ever guards the tuple and is never held across a fetch, and a cache hit returns without touching _refresh_lock at all -- otherwise one slow fetch would stall every reader, trading a thundering herd for a convoy. Refs jamiusaliu#6
a13ded4 to
ac8e9c9
Compare
|
Rebased onto current The One correction, in your favour. You said no test catches it. One does — It survives because it drives the TTL through a frozen clock and Verification. Your table is exactly what the three #19 tests assert, and they pass on the rebased branch alongside my single-flight assertion — the two hold at once rather than trading off:
Thanks for flagging it rather than letting me find it after the fact — and for the heads-up about |
|
Verified on a fresh clone rather than taking the report at face value, and you are right and I was wrong. Reproduced exactly as you described, by reverting the resolution on "No test catches this" was a claim I made without running it, which is exactly the thing I asked you to guard against. The test survives because it drives the TTL through a frozen clock and Stacking was the right call. Three failure modes, three PRs. One thing about your environment, because you should not be treating a red suite as normal. Fresh clone of The 8 MCP failures are what you get when those tests are named explicitly without the So On Rebase-merging this rather than squashing, so Merging now, #23 straight after. |
Addresses item 2 of #6 — the thundering herd. Independent of the item-4 change in #18 (merged) and the item-1 change in #19.
The problem
_collect_shared()released_cache_lockbefore callingcollect():The TTL check and the fetch are therefore not atomic. Every caller that arrives while the cache is cold or expired sees a miss and starts its own full fetch. N concurrent requests produce N fetches at WMO and USGS — precisely the burst the cache was added to prevent, and at ~774KB per unfiltered SWIC fetch it is not a cheap one.
As the issue says, this is not a correctness bug: they all compute the same answer and the last writer wins. It just undoes the rate-limiting.
The change
A second lock serialises the fetch, with a double-check after acquiring it:
The freshness check is extracted into
_fresh_cached()so both call sites use identical logic rather than a duplicated inequality.Why two locks and not one. Reusing
_cache_lockfor the whole operation would mean holding it across the fetch, so every reader — including the ones whose answer is already cached and fresh — would block behind a slow fetch. That trades a thundering herd for a convoy. As written,_cache_lockis never held across a fetch, and a cache hit never touches_refresh_lockat all.Tests
Two in
tests/test_api.py:test_concurrent_cold_requests_trigger_one_fetch— 8 threads released simultaneously through athreading.Barrieronto a cold cache, against an adapter that sleeps 0.2s so every thread is inside the window the old code left open. Asserts all 8 get200and the adapter was called exactly once.test_a_cache_hit_does_not_wait_on_an_in_flight_refresh— the control for the convoy risk above: a caller served from a warm cache must not be gated by the refresh lock.import timeis added at the top of the file; it was not previously imported there.Verifying the test catches the bug
Reverting
api.pytomainwhile keeping the new tests:With the fix applied,
tests/test_api.pyis 23 passed. So the test measures the actual behaviour change, not just the presence of a lock.Full suite
238 passed, 1 skipped, 7 deselected. The single failure —
test_gdacs.py::test_geometry_falls_back_to_bbox_polygon_when_no_point— is pre-existing onmainand unrelated.Item 3 of #6 (the ignored
adaptersargument) is untouched and still open; it interacts with this code, so it seemed better as its own change than folded in here.