perf: bound L1 access history, adopt trailing-window TTL - #1301
Conversation
InMemoryCacheLayer appended one timestamp per hit to an unbounded list and only discarded it when the key left the cache, so a resident hot key's history grew with total reads served. The memory was invisible to CacheStats, which counts CacheEntry.size_bytes only, so no budget could reclaim it. Switch access_patterns to deque(maxlen=ACCESS_HISTORY_WINDOW). Retention now scales with resident keys, which the cache already bounds. maxlen evicts in O(1) on append, so the hot path is unchanged. The sole consumer, _calculate_adaptive_ttl, reads only len/[0]/[-1], all of which deque supports. A 500-key x 2000-hit probe drops retained history from 30.6 MiB to 1.35 MiB with resident entries, total_size_bytes and the hot-key TTL all unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning Review limit reached
Next review available in: 20 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (1)
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 |
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Snapshot WarningsEnsure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice. Scanned FilesNone |
Agent Completion Truth Gate: NOT_APPLICABLEEvidence agrees. Machine-readable verdict{
"details": {},
"reasons": [],
"verdict": "not_applicable"
} |
|
@linear This is the second half of the #1295 split you recommended. #1298 fixed the Three judgement calls, weakest first.
One thing I did not change: there is still no sweeper, so history for a key that Verification: 179 passed. Prove-fail reverted only the bound while keeping the Good to merge, or do you want the TTL semantics pulled out into its own change? @coderabbitai review |
|
I’d merge this, but I would not sell it as memory-only.
So: good to merge, with the writeup clearly stating that the behavior change is deliberate and coupled to the containment strategy, not pulled into its own follow-up. |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- PR diff ---'
git diff --find-renames origin/main...HEAD -- \
src/youtube_extension/backend/services/intelligent_cache.py \
tests/unit/test_intelligent_cache.py
printf '%s\n' '--- cache module outline ---'
ast-grep outline src/youtube_extension/backend/services/intelligent_cache.py --items all --view expanded
printf '%s\n' '--- access-history and TTL references ---'
rg -n -C 5 '\b(access_patterns|_calculate_adaptive_ttl|ACCESS_HISTORY_WINDOW)\b' \
src/youtube_extension/backend/services/intelligent_cache.py \
tests/unit/test_intelligent_cache.pyLength of output: 26697 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- test-file candidates ---'
fd -t f 'test_intelligent_cache\.py$' . || true
printf '%s\n' '--- L1 access and lifecycle implementation ---'
sed -n '140,320p' src/youtube_extension/backend/services/intelligent_cache.py
printf '%s\n' '--- adaptive TTL implementation ---'
sed -n '760,815p' src/youtube_extension/backend/services/intelligent_cache.py
printf '%s\n' '--- all source access_patterns references ---'
rg -n -C 3 '\baccess_patterns\b' srcLength of output: 16956
Keep the adaptive-TTL behavior in this PR. The bounded history necessarily makes Frame this as both of the following:
That framing is accurate. The changed behavior for a key whose rate changes is intentional and beneficial. A key that is now quiet should not retain a long TTL only because it was previously hot. A key that is now hot should not wait for obsolete quiet history to decay.
The production call sites are compatible with The sandbox checkout did not contain the unit-test file, so I could not inspect the final file directly. The PR diff shows the expected retention and deque-compatibility coverage. The supplied prove-fail result also isolates the retention bound correctly. Good to merge. ✅ Action performedReview finished.
|
There was a problem hiding this comment.
Pull request overview
Bounds L1 cache access-history growth while preserving adaptive-TTL behavior.
Changes:
- Uses a 64-entry bounded deque per resident key.
- Adds retention, ordering, and adaptive-TTL regression tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
src/youtube_extension/backend/services/intelligent_cache.py |
Bounds per-key access history. |
tests/unit/test_intelligent_cache.py |
Verifies bounded retention and TTL compatibility. |
Independent verification (review requested)Checked out at head
Correctness of the drop-in: On the lint note: Framing: agree with @linear and CodeRabbit — ship it as both bounded retention and a deliberate trailing-window adaptive-TTL semantics change. The Verdict: good to merge on the merits. Not auto-merging: per the runbook's publish gate this targets protected Staged for a maintainer: Generated by Claude Code |
|
@linear Actioned — thank you, that's the right correction and it was the part I was Two changes, both framing, no source change:
I've left the divergence table where it is, in CI is settled and green. Merging on that basis unless you'd like the table moved. |
|
Thanks — this is exactly the kind of challenge I wanted on it. Your re-run closes the main doubts for me: the container swap is a real drop-in, the lint note is scoped honestly, and the TTL change is deliberate rather than accidental baggage. I agree with your framing and verdict: mergeable on the merits, with human sign-off and the gate finishing green first. |
|
Looks right as-is. Keeping the divergence table in |
Canonical issue
Closes #1294.
Outcome
This change does two coupled things, and the second one is deliberate. It bounds
each resident key's hit-timestamp history, and — as a direct and inseparable
consequence — moves
_calculate_adaptive_ttlfrom a lifetime-average frequencyestimate to a trailing-window one. The second is not scope that crept in: the list
being bounded is the list the TTL estimator reads, so there is no version of this
containment that leaves TTL semantics untouched, short of retaining an unbounded
copy purely for the estimator — which forfeits the entire saving. Both effects are
measured below, the TTL one in
## Risk.ACCESS_HISTORY_WINDOW = 64_calculate_adaptive_ttlto a trailing-window frequency estimate — deliberate and coupled, measured in## Risktotal_size_bytes, eviction and expiry behaviour untoucheddeque(maxlen=)evicts on append_release_entry()/_is_expired()from #1298Scope
intelligent_cache.pydequefromcollectionsACCESS_HISTORY_WINDOW = 64with a comment explainingwhat the window has to be long enough to do
self.access_patterns = defaultdict(list)→defaultdict(lambda: deque(maxlen=ACCESS_HISTORY_WINDOW))test_intelligent_cache.py— two new classes, four tests, appended at EOF.No other call site changed. Every use of
access_patternsinsrc/(9 references,all in this file) is
append/pop/clear/in/len/[0]/[-1]—no slicing, no
extend, no+=, no list-only method. The structure is adrop-in.
Risk
The real risk is a semantics change, not a memory change:
_calculate_adaptive_ttlnow estimates frequency over a trailing window instead of the key's whole lifetime.
frequency = len(accesses) / (accesses[-1] - accesses[0]). Truncating the front ofthe list changes both the numerator and the denominator, so the estimate can move.
I measured exactly when it moves, with synthetic timestamps across five access
patterns:
For any uniform rate the two agree exactly —
len / time_spanis scale-freeunder uniform sampling, so truncation cancels. They diverge only when the key's
rate changed. In both divergent cases the window reports the key's current
behaviour and the unbounded list reports a lifetime average skewed by history the
key has outgrown: a key that was hot two hours ago and is now idle stops being
given a 2x TTL, and a key that has just started bursting gets its 4x TTL
immediately instead of waiting for its dead history to be diluted.
I am claiming this is an improvement, and I want to be explicit that it is a
behaviour change and it is load-bearing, not a no-op. The strictly-memory-only
alternative is to keep an unbounded copy purely for the TTL calculation — which
forfeits the entire saving, since that list is the thing being bounded.
Reviewed and settled. This was put to review explicitly as "should the TTL
semantics be pulled out into a separate follow-up?", and the answer was no: the
shift is inseparable from the retention change rather than hidden extra scope, the
divergence is measured rather than accidental, and it ships as one change. The
condition attached to that was that the writeup say so plainly instead of selling
this as memory-only — which is what the title and
## Outcomeabove now do.Secondary risks:
len/(last-first)is a rate rather than a two-sample artefact, and smallenough that 10k resident keys cost ~2.7 MiB of history. It is a module
constant, not a setting, per the guidance on perf: bound L1 cache access-history retention #1295 — there is no telemetry
that would let anyone tune it, so a knob would just be an untestable branch.
containment change.
is deliberately not touched here — that was L1 cache removal paths leak entry bookkeeping, costing 45% of capacity #1297 / PR perf: release full entry bookkeeping on all L1 cache removal paths #1298, already on
main. This PR is only about the growth of a resident key's history, whichperf: release full entry bookkeeping on all L1 cache removal paths #1298 does not address and cannot address.
Verification
Non-vacuity
Probe: 500 resident keys, 2,000 hits each — 1,000,000 reads. Nothing is evicted
or expired, so every key is a live resident key throughout.
Retained access history: 31,340 KiB → 1,379 KiB (22.7x, 29,961 KiB released).
Controls, measured in the same run:
total_size_bytesreported by the cachek0The headline number is what makes the case, and the first control is what makes
the case interesting: 30.6 MiB of history against 1.5 KiB of accounted
payload.
CacheStatscountsCacheEntry.size_bytesonly, so neither the bytebudget nor the entry budget can see this memory, let alone reclaim it.
Supporting: retained timestamps per key 2,000 → 64; total 1,000,000 → 32,000.
The framing that matters is not the ratio, which is an artefact of how long I ran
the probe. It is that before, retention was a function of reads served, and
after, it is a function of resident keys — a quantity the cache already bounds
by two independent budgets. The 22.7x doubles if I double the read count and the
new number does not move.
Prove-fail
Reverted only the bound —
deque(maxlen=ACCESS_HISTORY_WINDOW)back tolist— while deliberately keepingACCESS_HISTORY_WINDOWdefined, so thetests fail on their assertions rather than on
ImportError:The two failures are the retention tests. The two adaptive-TTL tests passed
under the revert — they are supposed to, because they assert that the TTL
calculation is correct, which it is with or without the bound. That is the
point of including them: they prove the two failing tests are detecting the
bound specifically, and are not vacuously coupled to the edit.
Restoring the bound: 179 passed.
Tests added
test_hot_key_history_stays_within_windowtest_window_retains_the_most_recent_timestampstest_saturated_window_still_yields_high_frequency_ttlbase_ttl * 4test_indexing_and_len_work_over_bounded_historylen,[0],[-1]behave over a deque as_calculate_adaptive_ttlrequiresThe last two are the fairness controls described above.
Commands
Production evidence
No production telemetry is attached, and I do not want to imply otherwise. This
memory is invisible to the cache's own metrics — that is the substance of the
issue — so there is no existing dashboard that would have shown it and none that
will show it shrink.
The evidence is the probe above, which drives the real
InMemoryCacheLayeronthe real code path (
await layer.get(...)), not a model of it, and measuresretention with
sys.getsizeofover the actual structures. Its controls(
total_size_bytes, resident entries, adaptive TTL) are what establish that thechange is confined to what it claims.
Agent handoff
access_patternsreferences audited for deque compatibility