Skip to content

perf: bound L1 cache access-history retention - #1295

Closed
groupthinking wants to merge 1 commit into
mainfrom
perf/bound-l1-access-history
Closed

perf: bound L1 cache access-history retention#1295
groupthinking wants to merge 1 commit into
mainfrom
perf/bound-l1-access-history

Conversation

@groupthinking

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1294.

Outcome

Does Does not
Bounds each key's hit-timestamp history to ACCESS_HISTORY_WINDOW (64) using a deque(maxlen=…) Change _calculate_adaptive_ttl() — it reads only accesses[0], accesses[-1] and len(accesses), all O(1) on a deque
Releases an evicted key's history in _evict_if_needed(), matching what delete() already did Change get(), which stays serial by design (L1→L2 short-circuit)
Makes L1's history footprint proportional to resident keys instead of to total reads ever served Change the cache layer fan-out, the Redis layer, or any public signature
Keeps hit rate, stats.total_size_bytes, resident entry count and wall time identical Add a background sweeper, a timer, or any new task

Scope

src/youtube_extension/backend/services/intelligent_cache.py (+21/−2)

  • deque added to the existing collections import.
  • New module constant ACCESS_HISTORY_WINDOW = 64, placed beside the existing
    TAG_WRITE_CONCURRENCY / TAG_WRITE_POOL_RESERVE constants introduced by perf: invalidate Redis tags concurrently instead of serially #1262 and
    documented in the same style.
  • InMemoryCacheLayer.__init__defaultdict(list)defaultdict(lambda: deque(maxlen=ACCESS_HISTORY_WINDOW)).
  • InMemoryCacheLayer._evict_if_neededself.access_patterns.pop(oldest_key, None)
    immediately after del self.cache[oldest_key].

tests/unit/test_intelligent_cache.py (+164) — 4 new classes, 7 tests. No existing test modified.

Risk

The behaviour change worth arguing about is the frequency semantics.
_calculate_adaptive_ttl() computes frequency = len(accesses) / (accesses[-1] - accesses[0]).
Before this change that ratio was an all-time average over the key's entire life. Once the
window saturates it becomes a sliding-window rate over the most recent 64 hits. For a key
whose access rate is stable the two agree. For a key that was hot an hour ago and is cold now,
the new reading is lower — and for a key that is hot now but was cold, higher. I claim
recency-weighting is the more appropriate input to an adaptive TTL, but this is a genuine
semantic change and not a pure win, so it is called out here rather than buried.

Bounded risks:

  • Window size 64 is a judgement call. It is large enough that the ratio is a real frequency
    estimate rather than a two-sample artefact, and large enough to keep every existing test
    fixture intact (the largest seeds 20 samples). It is not derived from production telemetry.
  • Existing tests assign plain lists to access_patterns[k] directly (4 sites). Those keep
    working because only [0], [-1] and len() are ever read — confirmed by the full suite.
  • _calculate_adaptive_ttl guards with key in l1_layer.access_patterns before subscripting,
    so the new defaultdict factory is not accidentally triggered by a read path. Verified.

No public signature, serialization format, or persisted artefact changes.

Verification

Non-vacuity

Retained access-history dropped from 7,461,354 bytes to 587,664 — 12.7× less — while every
control metric held exactly constant.
The headline number is the leak itself, measured
directly via sys.getsizeof over the retained structures, not inferred from a proxy.

metric before (origin/main) after reading
retained history (bytes) 7,461,354 587,664 the fix — 12.7× less retained
cache hit rate (control) 1.0000 1.0000 unchanged — no correctness cost
stats.total_size_bytes (control) 2,200 2,200 unchanged
resident cache entries (control) 200 200 unchanged
workload wall time (control) 0.14 s 0.14 s unchanged — deque append is not slower
tracked history keys 5,000 200 (supporting) orphans gone; now equals resident entries
retained timestamp samples 205,000 12,800 (supporting) consistent with 200 × 64

The gap between rows 1 and 3 is the point: the layer's own accounting reports 2,200 bytes
while the process actually holds 7.1 MiB of timestamps.
Because access_patterns is never
added to stats.total_size_bytes, the max_size_bytes budget cannot see this memory and the
LRU can never reclaim it. Eviction — the mechanism that is supposed to bound the layer — was
the very thing creating the orphans.

Workload: 200-entry cache, 5,000 churn keys forcing continuous eviction, 200,000 reads against
a hot working set. Run 3× before and 3× after; results were byte-identical across all runs.
Harness is deterministic (no wall-clock thresholds in the headline row).

Prove-fail

Each defect was reverted independently, with the other fix left in place, so neither test
pair can be passing for the wrong reason.

A — revert only the deque factory (defaultdict(list); constant still defined, eviction pop
still present) → 2 failed, 168 passed:

FAILED test_hot_key_history_stays_within_window
  AssertionError: history for a single key grew to 1280 entries after 1280 hits;
  expected it to stay within ACCESS_HISTORY_WINDOW=64
  assert 1280 <= 64
FAILED test_window_retains_the_most_recent_timestamps
  AssertionError: history retained samples recorded before the most recent 64 hits;
  the window is dropping the wrong end

B — revert only the eviction pop (deque bound still in place) → 2 failed, 168 passed:

FAILED test_evicted_key_history_is_released
  AssertionError: history for an evicted key was retained; nothing will ever reclaim it
  because the cache entry is already gone
FAILED test_eviction_churn_leaves_no_orphaned_histories
  AssertionError: 195 evicted keys still have access history retained while only 5
  entries are resident; this memory is invisible to stats.total_size_bytes so the
  max_size_bytes budget can never reclaim it

The other 5 new tests pass in both reverted states, which is expected: they assert the
surviving behaviour (bounded-history arithmetic still feeding the correct adaptive-TTL tier,
resident keys keeping their history) rather than the defect under test.

Tests added

7 tests across 4 classes in tests/unit/test_intelligent_cache.py:

  • TestAccessHistoryRetentionIsBounded — the window holds, and holds the recent end
  • TestEvictionReleasesAccessHistory — evicted keys release; churn leaves zero orphans;
    resident keys are not collateral damage
  • TestAdaptiveTtlOverBoundedHistory — a saturated window still classifies a hot key into the
    3600 * 4 tier, and [0] / [-1] / len() behave identically over a deque

All import ACCESS_HISTORY_WINDOW from the module rather than hardcoding 64, so the tests track
the constant if it is ever retuned.

Commands

.venv/bin/python -m pytest tests/unit/test_intelligent_cache.py \
  --override-ini="addopts=" -p no:cacheprovider -p no:logging -q
170 passed in 0.22s          # 163 baseline + 7 new, zero regressions

.venv/bin/ruff check src/youtube_extension/backend/services/intelligent_cache.py
.venv/bin/ruff check tests/unit/test_intelligent_cache.py
# identical to origin/main at the same paths: src clean/clean, tests 1×I001 / 1×I001

Black was checked against the origin/main baseline for both files (27 and 21 pre-existing
hunks respectively, unchanged by this PR). No Black hunk covers any line this PR adds; the one
hunk mentioning access_patterns is a pre-existing single→double-quote nit at line 732 that is
already present on main and is deliberately left alone.

Production evidence

No production telemetry is attached, and I would rather say so than dress up the bench numbers
as observed traffic. The measurement above is a local harness, and the argument for shipping is
structural rather than statistical:

  • Growth is unbounded in the number of reads the process serves, so it has no steady state —
    a long-lived worker accumulates until restart regardless of traffic shape.
  • The retained memory is invisible to the layer's own byte accounting, so no existing
    dashboard, get_stats() caller, or max_size_bytes tuning can surface or contain it.
  • The orphan path is created by eviction, which means the leak scales with exactly the
    workload the cache is designed for: a working set larger than max_size.

The bench reproduces that shape at small scale; it does not claim a specific production figure.

Agent handoff

  • Canonical issue open and referenced exactly once
  • Change implemented using this repo's established idioms (module constant + comment style
    mirroring TAG_WRITE_CONCURRENCY from perf: invalidate Redis tags concurrently instead of serially #1262)
  • Tests added; full file suite green (170 passed)
  • Prove-fail performed independently per defect, both quoted verbatim above
  • Benchmark run 3× before and after; deterministic
  • Lint parity with origin/main verified at real paths for ruff and Black
  • Behaviour change (sliding-window frequency) disclosed in ## Risk rather than omitted

InMemoryCacheLayer.access_patterns appended one float per cache hit and
never trimmed, and _evict_if_needed() removed the cache entry without
releasing the matching history. delete() already released it, so eviction
was the sole path that orphaned a history with no resident key left to
ever trigger its cleanup.

The retained bytes are invisible to stats.total_size_bytes, so the
max_size_bytes LRU budget could neither see nor reclaim them. Under a
200-entry cache serving 200k reads across 5k churn keys the layer reports
2,200 bytes while actually holding 7.1 MiB of timestamps.

Bound each key's history to ACCESS_HISTORY_WINDOW=64 via a deque and pop
it on eviction. _calculate_adaptive_ttl() reads only accesses[0],
accesses[-1] and len(accesses), all O(1) on a deque, so it needs no
change. Retained history drops 7,461,354 -> 587,664 bytes with hit rate,
total_size_bytes, resident entry count and wall time all unchanged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 3, 2026 21:58
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
v0-uvai Ready Ready Preview, v0 Aug 3, 2026 9:58pm

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@groupthinking, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Repository YAML (base), Repository UI (inherited), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ca682c07-b726-45fe-ad50-2f639ba67f26

📥 Commits

Reviewing files that changed from the base of the PR and between 0bfc783 and 0f2612b.

⛔ Files ignored due to path filters (1)
  • tests/unit/test_intelligent_cache.py is excluded by !tests/**
📒 Files selected for processing (1)
  • src/youtube_extension/backend/services/intelligent_cache.py

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Snapshot Warnings

⚠️: No snapshots were found for the head SHA 0f2612b.
Ensure 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 Files

None

@github-actions github-actions Bot added the python label Aug 3, 2026
@groupthinking

Copy link
Copy Markdown
Owner Author

@linear Please review this one.

Context: this is the second in a series of performance fixes in this repo. The first
(#1288, merged as 0bfc783) offloaded a blocking cache-directory scan off the event loop.
This one is a different failure mode in the same subsystem family — a retention leak in the
L1 cache layer rather than a latency problem — so I would value a fresh look rather than
pattern-matching against the last one.

Three things I would specifically like challenged, because they are the parts where I made a
judgement call rather than a mechanical transformation:

  1. The frequency semantics change, which I think is the weakest point in this PR and want
    flagged if you disagree with how I have handled it.
    _calculate_adaptive_ttl() computes
    len(accesses) / (accesses[-1] - accesses[0]). Before this change that is an all-time
    average over the key's entire life; once the 64-sample window saturates it becomes a
    sliding-window rate over recent hits. My argument is that recency-weighting is more
    appropriate for an adaptive TTL — a key that was hot an hour ago and is cold now should not
    keep earning a 4-hour TTL on the strength of history it will never repeat. But this is a
    real behaviour change, not a pure win, so I put it first and in bold in ## Risk rather
    than burying it in a footnote. Is that the right call, or should this PR be split so the
    eviction-orphan fix (which is unambiguously a bug) lands separately from the windowing
    (which is a design decision)?

  2. ACCESS_HISTORY_WINDOW = 64 is not derived from telemetry. I picked it as the smallest
    value that is (a) comfortably above the largest existing test fixture (20 samples), so no
    existing test is silently truncated, and (b) large enough that the first/last ratio is a
    frequency estimate rather than a two-sample artefact. I deliberately did not make it
    configurable, because a knob nobody tunes is worse than a documented constant, and the
    repo's existing precedent (TAG_WRITE_CONCURRENCY from perf: invalidate Redis tags concurrently instead of serially #1262) is a plain module constant
    with an explanatory comment. Do you agree, or would you want this reading from settings?

  3. Magnitude. I have framed this as a memory retention win (7,461,354 → 587,664 bytes
    retained, 12.7×) and explicitly not as a latency win — wall time is reported as an
    unchanged control, because it is unchanged. The sharpest framing I can honestly make is
    that the layer's own accounting reports 2,200 bytes while the process holds 7.1 MiB, so
    max_size_bytes is structurally blind to it and the LRU can never reclaim it. I also
    declined to attach production telemetry and said so in ## Production evidence rather than
    dressing the bench up as observed traffic. If you think I have over- or under-stated the
    win, say so.

Verification summary: 170 passed (163 baseline + 7 new, zero regressions). Prove-fail was run
independently per defect so neither pair can pass for the wrong reason — reverting only the
deque bound fails exactly the two windowing tests (assert 1280 <= 64), and reverting only the
eviction pop fails exactly the two orphan tests (195 evicted keys still have access history retained while only 5 entries are resident). The other 5 new tests pass under both reverts,
which is expected: they assert surviving behaviour (adaptive-TTL tiering over a saturated
window, resident keys keeping their history) rather than the defect under test. Benchmark run
3× before and 3× after with byte-identical results.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Agent Completion Truth Gate: NOT_APPLICABLE

Evidence agrees.

Machine-readable verdict
{
  "details": {},
  "reasons": [],
  "verdict": "not_applicable"
}

Workflow evidence

@groupthinking

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

I found one blocking hole: in InMemoryCacheLayer.get() in src/youtube_extension/backend/services/intelligent_cache.py, the expired-entry path still does del self.cache[key] and returns without dropping self.access_patterns[key]. That means this still leaks history for keys that age out via lazy expiry, and it can also leave stale frequency history behind if the same key is later reused.

On your three judgment calls:

  • I’d split it. The cleanup is a straight bug fix; the 64-sample window is a real TTL-semantics change and is easier to reason about, validate, and roll back separately.
  • Keeping ACCESS_HISTORY_WINDOW as a constant seems right for now; I wouldn’t add a settings knob without telemetry or an operator use case.
  • The magnitude framing is mostly right, but until the expiry-path orphan is fixed I think “footprint proportional to resident keys” and the stronger retention claims are overstated.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Bounds L1 access-history memory while preserving adaptive TTL behavior.

Changes:

  • Uses a 64-entry deque per cache key.
  • Removes histories during LRU eviction.
  • Adds regression tests for retention and TTL behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
intelligent_cache.py Bounds and cleans access histories.
test_intelligent_cache.py Tests bounded retention, eviction, and TTL behavior.

Comment on lines +151 to +152
# Bounded per key: see ACCESS_HISTORY_WINDOW. Entries are released when
# the key leaves the cache, via delete() or _evict_if_needed().

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — this is a valid, merge-blocking gap, not a false positive.

get() releases nothing on the TTL-expiry branch:

if entry.expires_at and datetime.now(timezone.utc) > entry.expires_at:
    del self.cache[key]
    self.stats.miss_count += 1
    return None

delete() and the patched _evict_if_needed() both pop the history, but this path does not — so any key that expires by TTL orphans its access_patterns[key] with no cache entry left to ever trigger cleanup. That is precisely the leak this PR sets out to bound, still open for expiring entries, so the "history footprint proportional to resident keys" guarantee does not hold once TTLs are in play.

Minimal in-scope fix — mirror the eviction path:

if entry.expires_at and datetime.now(timezone.utc) > entry.expires_at:
    del self.cache[key]
    self.access_patterns.pop(key, None)   # release history on expiry, as delete()/_evict_if_needed() do
    self.stats.miss_count += 1
    return None

Suggested regression (parallels test_evicted_key_history_is_released): set() a key with a short ttl, force expires_at into the past, get() it (asserting the None/miss), then assert key not in cache.access_patterns.

Separate, pre-existing observation worth a follow-up rather than folding in here: this same expiry branch also never decrements stats.total_entries / stats.total_size_bytes, so an expired-then-get()'d entry leaves the byte/entry accounting overstated until the next set() of that key. That predates #1295 and is out of its stated scope — flagging it, not asking for it in this PR.

Once the pop and its regression land, this PR is clean; nothing else here is blocking.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Red-team pass — confirming the expiry-path orphan, plus an adjacent stats-drift bug at the same site

I independently verified linear-code's blocking finding against intelligent_cache.py@0f2612b and it holds. The lazy-expiry branch in InMemoryCacheLayer.get() is the gap:

# Check expiration
if entry.expires_at and datetime.now(timezone.utc) > entry.expires_at:
    del self.cache[key]
    self.stats.miss_count += 1
    return None

Compare delete(), which is the shape this PR is trying to make uniform across all removal paths:

del self.cache[key]
self.stats.total_entries -= 1
self.stats.total_size_bytes -= entry.size_bytes
if key in self.access_patterns:
    del self.access_patterns[key]

The expiry branch omits three of those four steps, not one:

  1. access_patterns is not released — the leak linear-code flagged. _evict_if_needed() and delete() now pop it, but a key that ages out via lazy TTL and is then read again hits this branch, leaves the cache, and orphans its (now deque-bounded, but still never-reclaimed) history. Since the entry is gone, no later delete()/evict will ever fire for it. So the PR's headline claim — "footprint proportional to resident keys" — is not yet true: it's proportional to resident keys + keys-that-expired-while-still-tracked.
  2. total_entries is not decremented.
  3. total_size_bytes is not decremented.

(2) and (3) are a pre-existing drift bug, not introduced here — but they're worth folding in because they undercut the same "the layer's own accounting" argument you asked to have challenged in point 3 of your @linear prompt. The accounting is doubly blind on the expiry path: it over-counts live bytes/entries and retains invisible history.

Suggested patch (mirrors delete())

if entry.expires_at and datetime.now(timezone.utc) > entry.expires_at:
    del self.cache[key]
    self.stats.total_entries -= 1
    self.stats.total_size_bytes -= entry.size_bytes
    self.access_patterns.pop(key, None)
    self.stats.miss_count += 1
    return None

A prove-fail test would seed a key with a short TTL, read it once, let it expire, get() it again, then assert key not in layer.access_patterns and layer.stats.total_entries == 0 — both fail on the current head.

On the split question

I agree with linear-code: split it. The removal-path cleanup (eviction pop and this expiry-path pop) is an unambiguous bug fix; the ACCESS_HISTORY_WINDOW = 64 sliding-window is a real adaptive-TTL semantics change. If you do split, note that this expiry-path fix belongs in the bug-fix half, alongside the eviction-orphan fix — not with the windowing — since it's the same "release history on every exit path" invariant.

What I did / did not do

I did not merge (open blocking finding; protected main requires your sign-off; no automerge label) and did not push to perf/bound-l1-access-history (leaving the split-vs-combine call to you). This comment is the deliverable — the one-line + stats fix is yours to apply in whichever branch the split lands in.


Generated by Claude Code

@groupthinking groupthinking left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review (requested) — fix is correct; one confirmed blocker, plus one adjacent bug the other reviewers didn't flag

The core change is sound and unusually well-evidenced. The deque bound and the _evict_if_needed pop are the right two edits, the prove-fail-per-defect is convincing, and the div-by-zero risk I'd have worried about (windowing shrinks accesses[-1] - accesses[0]) is already guarded by if time_span > 0, so a saturated tight-burst window degrades to "no adaptive bump," not a crash. Good.

Confirming the blocker (already raised by Copilot r3707973213 and linear-code)

The lazy-expiry path in InMemoryCacheLayer.get() deletes the entry without releasing its history:

if entry.expires_at and datetime.now(timezone.utc) > entry.expires_at:
    del self.cache[key]
    self.stats.miss_count += 1
    return None

I independently reach the same conclusion: this is the only remaining entry-removal path that doesn't pop access_patterns (the other three — delete(), _evict_if_needed(), clear() — all do), so the "footprint proportional to resident keys" guarantee genuinely doesn't hold for TTL'd keys. A self.access_patterns.pop(key, None) here closes it. Worth an expiry regression test.

New finding — same path also drifts stats, which undercuts this PR's own thesis

Neither of the other two reviewers mentioned this: that expiry branch also skips the byte/entry accounting that every other removal path performs:

del self.cache[key]
# missing:  self.stats.total_entries  -= 1
# missing:  self.stats.total_size_bytes -= entry.size_bytes

Compare delete() and _evict_if_needed(), which both decrement these. So lazily-expired entries leave total_size_bytes overstated until they're re-set(). This is pre-existing (not introduced here), but it's squarely on-thesis: the PR's headline argument is that access_patterns memory is invisible to total_size_bytes — and here's the mirror image, total_size_bytes overcounting memory that's already gone. If you're touching this exact branch to add the history pop, decrementing the two stat fields in the same three lines is the natural companion fix (and would need the same expiry regression to pin it). If you'd rather keep this PR tightly scoped, file it as a separate issue — but it shouldn't silently ride along unfixed next to the pop.

On your three questions

  1. Split vs. combined — agree with splitting. The eviction-orphan pop is an unambiguous bug; the 64-sample window is a real TTL-semantics change. But note the split doesn't change the above: the expiry-path pop must land regardless of which half it rides with, since the "proportional to resident keys" claim depends on it more than on the eviction pop.
  2. Constant vs. setting — constant is right. No telemetry and no operator use case justifies a knob; the TAG_WRITE_CONCURRENCY precedent is the correct idiom. Don't add settings plumbing on spec.
  3. Magnitude framing — the framing is honest and I wouldn't soften it, with one caveat the others also raised: until the expiry orphan is popped, "proportional to resident keys" is aspirational for any workload with TTLs, so scope that claim to eviction-driven churn (which is what the bench actually exercises) or land the expiry fix first.

Net: not approving yet — the expiry-path orphan is a real gap in the stated guarantee, and I'd fold the stats-decrement into the same fix. Everything else is merge-quality.


Generated by Claude Code

@groupthinking

Copy link
Copy Markdown
Owner Author

@linear You were right, and it is worse than you described. Thank you — I am closing this PR and splitting it as you suggested.

1. The blocking hole — confirmed, and it leaks more than history

You flagged that the expired-entry path drops the entry without releasing access_patterns. Correct. On investigation that same four-line branch also skips both stat decrements:

# Check expiration
if entry.expires_at and datetime.now(timezone.utc) > entry.expires_at:
    del self.cache[key]          # <- the ONLY thing released
    self.stats.miss_count += 1
    return None

So the three removal paths were inconsistent in two different ways:

path del cache[k] total_entries -= 1 total_size_bytes -= … access_patterns released
delete() yes yes yes yes
_evict_if_needed() yes yes yes no
get() lazy expiry yes no no no

delete() was the only path that fully released an entry. I had fixed eviction and missed expiry entirely — my benchmark never exercised TTL, so it could not have caught this.

And there is no compensating sweeper. Grepping expires_at|expired|_sweep|cleanup_task|purge across the file returns only the dataclass field, the set()-side construction, and this one branch. That if is the only place expiry is ever handled in the entire L1 layer, so the drift is permanent and monotonic — nothing ever reconciles it.

2. Why the stat decrements matter more than the history leak

total_size_bytes is not merely reported. _evict_if_needed budgets against it, so bytes never released by expiry permanently consume capacity. Measured on a 100 KB layer, inserting 200 entries:

scenario live entries retained reading
no prior expiry 110 control
after 50 entries expired 60 45% of capacity lost

Loss is exactly 1:1 with expired entries. A long-lived layer with TTL churn silently converges toward holding nothing while its own accounting reports itself full. I had not measured this and it is a materially bigger deal than the history retention I opened the PR about.

Also confirmed: a key that expires and is later re-set() inherits its dead predecessor's timestamps, so _calculate_adaptive_ttl treats a brand-new entry as an established hot key — the reuse hazard you predicted.

3. On your three verdicts

  • Split it — agreed, doing exactly that. PR A is the pure bug fix; PR B carries the 64-sample window on its own.
  • ACCESS_HISTORY_WINDOW stays a module constant — agreed, no settings knob. Unchanged in PR B.
  • Magnitude overstated — agreed, and I have dropped "footprint proportional to resident keys" entirely. PR A's headline is the capacity number above, which is measured rather than extrapolated, with the unchanged control row printed directly beneath it.

4. One judgement call I would like challenged

Your finding was literally "you missed a path", so rather than patch each site I introduced a shared private helper and routed all three through it:

def _release_entry(self, key: str, entry: CacheEntry) -> None:
    """Callers must already hold ``self._lock``."""
    del self.cache[key]
    self.stats.total_entries -= 1
    self.stats.total_size_bytes -= entry.size_bytes
    self.access_patterns.pop(key, None)

Rationale: it makes forgetting a step structural rather than something each future removal path has to remember. Cost is that it touches delete(), which was already correct — that rewrite is behaviour-identical and stays covered by the pre-existing test_delete_cleans_access_patterns.

If you would rather see three surgical edits with delete() untouched, say so and I will do that instead — I do not want to smuggle a refactor into a bug fix, especially right after you told me to split on exactly that axis. The helper deliberately does not acquire the lock (_lock is an RLock, so either would be safe; all three callers already hold it, and that is documented).

5. Verification on the split-out fix

171 passed. Prove-fail run separately per defect, and they are cleanly orthogonal:

  • reverting eviction to its original body → 2 failures, both in TestEvictionReleasesAccessHistory; all five expiry tests still pass
  • reverting expiry to bare del self.cache[key] → 5 failures (4 bookkeeping + the capacity test); all three eviction tests still pass

The capacity test asserts against a clean control layer in the same run, so it fails if the fix were to work by simply not enforcing the size limit.

PR A follows shortly on perf/release-orphaned-access-history; I will link it here.

@groupthinking

Copy link
Copy Markdown
Owner Author

Closing in favour of the split @linear recommended, and to free issue #1294 for the follow-up (the governance gate allows only one open PR per canonical issue).

  • PR A — perf/release-orphaned-access-history: the pure bug fix. Releases the full entry bookkeeping on all three removal paths, including the lazy-expiry path @linear found that this PR missed. New canonical issue; link to follow.
  • PR B — this branch, rebased: the ACCESS_HISTORY_WINDOW sliding window on its own, keeping perf: bound L1 access history, adopt trailing-window TTL #1294 (which I will re-scope to windowing only).

No work is lost — both halves are preserved and land separately.

@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

I’m good with the helper.

It keeps the fix scoped to one invariant — “releasing an entry means removing it from all four places” — and makes the original omission harder to reintroduce. Since all three callers already hold the lock and delete() already has coverage, this reads as a containment improvement, not a refactor.

Two things I’d keep:

  • leave the helper private and narrowly named around release/removal semantics
  • keep the tests proving both call sites independently, so the abstraction doesn’t hide regressions

With that, the split looks cleaner, not riskier.

@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Makes sense. Splitting the bookkeeping fix from the retention-window change is the cleanest path, and freeing #1294 for the follow-up avoids the governance collision cleanly.

The revised scoping looks right: one fix for entry-release correctness on all removal paths, one separate change for bounded history retention.

@groupthinking

Copy link
Copy Markdown
Owner Author

Split landed: #1298 carries the bug fix (including the lazy-expiry hole this PR missed). #1294 stays reserved for the sliding-window change, which follows once #1298 is in.

@groupthinking
groupthinking deleted the perf/bound-l1-access-history branch August 3, 2026 22:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: bound L1 access history, adopt trailing-window TTL

2 participants