Skip to content

perf: release full entry bookkeeping on all L1 cache removal paths - #1298

Merged
groupthinking merged 2 commits into
mainfrom
perf/release-orphaned-access-history
Aug 3, 2026
Merged

perf: release full entry bookkeeping on all L1 cache removal paths#1298
groupthinking merged 2 commits into
mainfrom
perf/release-orphaned-access-history

Conversation

@groupthinking

@groupthinking groupthinking commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Canonical issue

Closes #1297.

Split out of #1295 on @linear's recommendation: that PR mixed a bug fix with a TTL-semantics change. This is the bug fix half. The sliding-window half follows separately under #1294.

Outcome

Does Does not
Releases the access history when an entry is evicted Change when entries are evicted or expired
Releases the access history, total_entries and total_size_bytes when an entry is lazily expired Change cache hit/miss semantics or returned values
Recovers 45% of usable capacity in a layer that has seen TTL churn Bound the length of a live key's access history (that is #1294)
Prevents a key written over an expired key from inheriting its dead predecessor's frequency history, and so from being handed a 4× TTL Add a background sweeper, a settings knob, or any new configuration
Preserves the access history when a live key is overwritten, which is the existing and intended behaviour Change how a live key's history is treated
Centralises release into one private helper, and the "is this entry dead" test into one private predicate, so a future path cannot silently omit a step or drift out of agreement Alter delete()'s observable behaviour

Scope

src/youtube_extension/backend/services/intelligent_cache.py (+48/−12)

  • get() lazy-expiry branch — del self.cache[key] replaced with self._release_entry(key, entry), plus a comment recording that this branch is the layer's only expiry handling and that no sweeper exists to reconcile drift. The inline expiry condition is replaced with the new predicate.
  • new _is_expired(entry) — private @staticmethod holding the single definition of "this entry is dead". Added because get() and set() previously each carried their own copy of the condition, which is the exact drift that produced the fourth defect below.
  • new _release_entry(key, entry) — private helper: drops the entry, decrements both counters, and pops the access history. Docstring enumerates the call sites and states that callers must already hold self._lock.
  • set() replacement branch — when the key being overwritten is already expired, its access history is dropped before the new value is stored. Guarded by _is_expired, so overwriting a live key still preserves history.
  • delete() — hand-rolled teardown replaced with a call to the helper. Behaviour-identical.
  • _evict_if_needed() — same substitution; stats.eviction_count += 1 is retained at the call site because it is eviction-specific.

tests/unit/test_intelligent_cache.py (+282)

  • _expire_now(layer, key) module-level helper — backdates expires_at so the next get() takes the expiry path deterministically, with no sleep and no added suite time.
  • TestEvictionReleasesAccessHistory (3 tests), TestExpiryReleasesEntryBookkeeping (4 tests), TestExpiredBytesDoNotConsumeCapacity (1 test), TestResetOfExpiredKeyStartsCleanHistory (4 tests).

Risk

The one behavioural change beyond releasing memory is that total_entries and total_size_bytes now decrease when a key is lazily expired, where previously they did not. Anything reading those counters will see smaller, and correct, numbers. Within this file the only consumer is _evict_if_needed, which is the point — it budgets against total_size_bytes, so the stale value was actively harmful. If an external dashboard has been calibrated against the inflated figures it will show a step change at deploy.

delete() is rewritten to call the helper. It was already correct, so this is refactor risk rather than behaviour risk; it stays covered by the pre-existing test_delete_cleans_access_patterns, and the prove-fail below exercises the other two call sites independently so the shared helper cannot mask a regression in either.

The helper deliberately does not acquire self._lock. All three callers already hold it, and it is a threading.RLock, so acquiring would also have been safe — this is documented in the docstring rather than left implicit.

Correction to this PR's own earlier claim. As originally opened, the Outcome row about re-set() keys was only true when a get() happened to land between expiry and the rewrite — that get() did the cleaning. @coderabbitai and @copilot both caught that the direct set()-over-expired path was still inheriting history. That is fixed in 481969fd2 and is now covered by a test that specifically does not call get() after expiry. The gap is called out here rather than quietly folded in, because it is the second time on this branch that enumerating removal paths missed a replacement path.

set() deliberately does not reuse _release_entry. The helper does del self.cache[key] and decrements total_entries, which is correct when a slot is freed and wrong when a slot is reused — set() immediately reinserts. A bare access_patterns.pop(key, None) is the right primitive there, and test_reset_after_expiry_keeps_entry_count_stable pins that down.

No public API, signature, return type or configuration changes.

Verification

Non-vacuity

A 100 KB L1 layer that has seen 50 entries expire permanently holds 60 live entries instead of 110 — a 45% capacity loss — because _evict_if_needed budgets against a total_size_bytes that lazy expiry never decremented.

Probe: insert 200 entries into a 100 KB layer, with and without prior expiry churn.

metric before after reading
live entries retained, after 50 expired 60 110 the defect: capacity lost 1:1 with expired entries
live entries retained, no prior expiry 110 110 control — unchanged, so the fix did not simply disable the size limit
access_patterns keys orphaned by expiry 50 0 supporting
orphaned timestamp samples 1000 0 supporting

The control row is the important one: capacity for a layer that never saw expiry is identical before and after, so the recovered capacity comes from releasing phantom bytes and not from relaxing enforcement.

Second, from @coderabbitai's finding: a value written over an expired key inherited its dead predecessor's entire access history, so a brand-new entry reported 20 accesses to _calculate_adaptive_ttl and was scored as a hot key — earning a 14400 s TTL instead of the 3600 s base.

Probe: set → 5 reads while live → expire → set again, with no intervening get().

case before after reading
history on a key rewritten after expiry 5 timestamps inherited 0 the defect
history on a key rewritten while live 5 kept 5 kept control — unchanged, so the fix is conditional and not a blanket wipe
accesses seen by _calculate_adaptive_ttl for a fresh entry 20 0 supporting
TTL granted to that fresh entry 14400 3600 the user-visible consequence

The second row is the control. If the guard had been unconditional it would have destroyed the frequency signal for genuinely hot keys, which is the signal _calculate_adaptive_ttl exists to read.

Prove-fail

Each defect was restored independently against the new tests. They are cleanly orthogonal — no test class can pass by accident from another's fix:

Restoring the eviction defect (_evict_if_needed back to its original body) — 2 failed, 169 passed:

FAILED TestEvictionReleasesAccessHistory::test_evicted_key_history_is_released
FAILED TestEvictionReleasesAccessHistory::test_eviction_churn_leaves_no_orphaned_histories
evicted key still holds 20 access timestamps after eviction

All expiry tests pass here, and test_resident_keys_keep_their_history also passes — it is the fairness control, asserting the fix does not discard history for keys that are still resident, which holds under both versions.

Restoring the expiry defect (get() back to bare del self.cache[key]) — 5 failed, 166 passed:

FAILED TestExpiryReleasesEntryBookkeeping::test_expired_key_history_is_released
FAILED TestExpiryReleasesEntryBookkeeping::test_expired_key_releases_size_accounting
FAILED TestExpiryReleasesEntryBookkeeping::test_expired_key_releases_entry_count
FAILED TestExpiryReleasesEntryBookkeeping::test_reused_key_does_not_inherit_expired_history
FAILED TestExpiredBytesDoNotConsumeCapacity::test_capacity_survives_expiry_churn
expired entry left 500 of 500 bytes in use; these phantom bytes are charged
against max_size_bytes forever

All eviction tests pass here.

Restoring the replacement defect (the _is_expired(old_entry) guard removed from set(), leaving get() and the helper intact) — 2 failed, 173 passed:

FAILED TestResetOfExpiredKeyStartsCleanHistory::test_reset_after_expiry_drops_dead_history
FAILED TestResetOfExpiredKeyStartsCleanHistory::test_adaptive_ttl_does_not_treat_reused_key_as_hot
a key with no accesses since it was rewritten was given a 14400s TTL instead of
the 3600s base; it inherited its expired predecessor's access timestamps and was
scored as a hot key

The other two tests in that class pass under this revert, by design:

  • test_reset_of_live_key_keeps_history is the control — replacing a live key must keep its history, and does, so the guard is genuinely conditional rather than a blanket wipe.
  • test_reset_after_expiry_keeps_entry_count_stable passes because the reverted code also left total_entries alone. It exists to pin down that the fix does not route through _release_entry, which would wrongly decrement a counter for a slot that is being reused rather than freed.

A subtlety worth recording: access_patterns only accrues on a hit, and the append happens after the expiry check. A key that expires before it is ever successfully read has no history to orphan, so any test for this must get() the key while it is still live. An earlier probe of mine missed the bug entirely for exactly this reason and produced a false negative. The mirror-image trap applies to the replacement path: the test must not get() the key after expiry, or the lazy-expiry fix pre-cleans the history and the test passes for the wrong reason.

Tests added

test asserts
test_evicted_key_history_is_released eviction drops the evicted key's history
test_eviction_churn_leaves_no_orphaned_histories access_patterns keys never exceed resident keys under churn
test_resident_keys_keep_their_history fairness control: still-resident keys keep their history
test_expired_key_history_is_released lazy expiry drops history, for a key read while live
test_expired_key_releases_size_accounting total_size_bytes returns to 0
test_expired_key_releases_entry_count total_entries returns to 0
test_reused_key_does_not_inherit_expired_history expire → get() → re-set() starts with clean history
test_capacity_survives_expiry_churn churned layer retains as many entries as a clean control layer
test_reset_after_expiry_drops_dead_history expire → re-set() with no intervening get() starts with clean history
test_reset_of_live_key_keeps_history control: replacing a live key preserves its history
test_reset_after_expiry_keeps_entry_count_stable replacement does not decrement total_entries
test_adaptive_ttl_does_not_treat_reused_key_as_hot the reused key is scored at the 3600 base TTL, not 3600 * 4

Commands

.venv/bin/python -m pytest tests/unit/test_intelligent_cache.py \
  --override-ini="addopts=" -p no:cacheprovider -p no:logging -q
# 175 passed in 0.22s   (163 pre-existing, 12 new, 0 regressions)

.venv/bin/ruff check src/youtube_extension/backend/services/intelligent_cache.py
# clean on base and on head

.venv/bin/black --check --diff <both files>
# no complaint touching new code; the file's pre-existing findings are unchanged

Ruff was compared by swapping origin/main content in at the real path, since pyproject.toml's per-file ignores are path-scoped and a /tmp copy would produce a false baseline.

Production evidence

No production telemetry is attached. This layer is in-process and the leaked state is not exported to any metrics sink — the total_size_bytes figure a dashboard would read is precisely the value this PR corrects, so pre-fix telemetry would have understated the problem by construction.

The evidence is therefore the reproductions above, run against the real InMemoryCacheLayer with no mocks or stubs on the code under test, using the layer's own public set/get API and its own accounting to expose the drift. Each measurement carries a control row so it is falsifiable.

Agent handoff

  • Blocking review finding from perf: bound L1 cache access-history retention #1295 confirmed and fixed
  • Additional defect found on the same branch (both stat decrements skipped) and fixed
  • Fourth path found by @coderabbitai (set() over an expired key) reproduced, fixed, and regression-tested
  • Verified no background sweeper exists that would have reconciled the drift
  • Prove-fail run separately per call site; all three results orthogonal
  • Ruff parity confirmed against origin/main at the real path
  • Black applied to new code only, matching the file's existing convention
  • Full file suite green, no regressions
  • Shared-helper design explicitly raised for challenge and endorsed by @linear
  • CI green

InMemoryCacheLayer had three paths that remove an entry, and only
delete() released everything the entry owned. Eviction left the key's
access history behind; lazy expiry in get() left the access history and
both size counters behind.

total_size_bytes is budgeted against by _evict_if_needed, so bytes that
expiry never released were charged against max_size_bytes forever. A
100 KB layer that has seen 50 entries expire retains 60 live entries
instead of 110 -- a 45% capacity loss that never recovers, because the
layer has no background sweeper and that branch is the only place
expiry is ever handled.

Route all three paths through a single private _release_entry() helper
so that omitting a step is structurally impossible rather than
something each future removal path has to remember. delete() is
behaviour-identical and stays covered by its existing test.

Closes #1297

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings August 3, 2026 22:15
@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 10:33pm

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved cache cleanup when entries expire, are deleted, or are evicted.
    • Ensured cache usage metrics and access history remain accurate after removal.

Walkthrough

The cache now centralizes entry removal. Lazy expiration, explicit deletion, and LRU eviction update counters and clear access history through _release_entry.

Changes

Cache cleanup

Layer / File(s) Summary
Unified entry release paths
src/youtube_extension/backend/services/intelligent_cache.py
_release_entry centralizes cache removal, counter updates, and access-history cleanup. Expiration, delete, and LRU eviction use the helper.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

  • #1294 — The changes centralize cleanup of access_patterns during cache entry removal, which overlaps with the issue objective described in the retrieved match.

Suggested reviewers: copilot

Poem

Entries depart on a single track,
Counters follow—no drift back.
Expired keys and evictions clear,
Old access trails disappear.
The cache keeps its books sincere.

🚥 Pre-merge checks | ✅ 4 | ❌ 3

❌ Failed checks (1 warning, 2 inconclusive)

Check name Status Explanation Resolution
Require Ai Unit Tests ⚠️ Warning The changed files include unit tests, but no repository evidence confirms the required copilot-rabbit label; label presence is not shown in the commit. Apply the copilot-rabbit label to the pull request and retain the added unit-test file in the committed diff.
Linked Issues check ❓ Inconclusive The source change matches issue #1297, but test-based acceptance criteria cannot be verified because tests/unit/test_intelligent_cache.py is excluded by !tests/**. Include the excluded test file in review or provide independently verifiable evidence for delete coverage, capacity recovery, key reuse, and separate call-site tests.
Enforce Copilot Verification ❓ Inconclusive Investigation is still in progress; no verdict has been recorded from the available pull-request evidence. Verify the pull request review records and confirm an explicit GitHub Copilot approval.
✅ Passed checks (4 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The described source and test changes are directly related to issue #1297 and exclude the separate sliding-window history work in #1294.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly identifies the main change: complete bookkeeping cleanup for all L1 cache entry removal paths.
Description check ✅ Passed The description covers the required sections with detailed scope, risks, verification, production evidence, and handoff status; CI remains unchecked.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/release-orphaned-access-history
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch perf/release-orphaned-access-history

Warning

Review ran into problems

🔥 Problems

These MCP integrations need to be re-authenticated in the Integrations settings: Sentry


Linked repositories: Public OSS repositories can only analyze public repositories installed in this organization. No linked repositories were analyzed; skipped groupthinking/uvai-skills.


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 481969f.
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
@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

@linear This is PR A of the split you recommended on #1295 — the pure bug fix, with the sliding window held back for #1294.

It contains the expiry-path hole you found, plus the two stat decrements that same branch was also skipping, and the _release_entry helper you signed off on. Both of your conditions are met: the helper is private and named around release semantics, and the prove-fail exercises each call site independently (2 failed when eviction is restored, 5 failed when expiry is restored, cleanly orthogonal) so the shared abstraction cannot hide a regression in one of them.

Three things I would like challenged, since they are judgement calls rather than mechanical transformations:

  1. I put the stat decrements in this PR rather than a third one. You split on bug-fix-versus-semantics, and I read total_entries and total_size_bytes as squarely bug-fix — they are the same defect on the same four lines, and _evict_if_needed budgets against total_size_bytes, so leaving them would ship a half-fixed file whose capacity number is still wrong. Is that the right side of your line, or would you rather see the counters separated from the history release?

  2. _evict_if_needed still increments eviction_count at the call site rather than inside the helper. My reasoning is that it is eviction-specific and does not belong to the release invariant, but it does mean the eviction path is "helper plus one extra line", which is exactly the shape that let the original omission happen. Would you rather it moved in behind a flag, or is call-site placement correct here?

  3. Magnitude — I have deliberately narrowed the claim. Per your note that the earlier framing was overstated, I dropped "footprint proportional to resident keys" entirely and lead instead with the measured capacity loss (110 → 60 live entries, 45%), with the unchanged control row printed directly beneath it so the number is falsifiable. The history and timestamp figures are demoted to labelled supporting evidence. If that still reads as overstated, say so.

One thing I did not change: there is still no background sweeper, so expiry is only ever handled when a key is next read. This PR makes that path correct but does not make it proactive — a key that is never read again still occupies its slot until eviction reaches it. That felt out of scope here; flag it if you disagree.

Verification: 171 passed, 0 regressions on the 163 pre-existing. Ruff parity confirmed against origin/main at the real path. test_resident_keys_keep_their_history passes under both reverts by design — it is the fairness control, not a gap in coverage.

@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

No blocking issues from me.

  • Keeping the counter decrements with this fix looks right. In src/youtube_extension/backend/services/intelligent_cache.py, total_entries and total_size_bytes are part of the same removal invariant as access_patterns, and the new tests prove the user-visible capacity regression rather than just the orphaned history.
  • Leaving eviction_count at the _evict_if_needed call site also looks right. _release_entry now means “remove bookkeeping common to every removal path,” while eviction remains a reason-specific side effect.
  • The narrowed claim reads calibrated to me. Leading with the measured capacity loss and keeping the history/timestamp numbers as supporting evidence fixes the earlier overreach.
  • The “no background sweeper” note feels correctly scoped as a non-change, not a hidden defect in this patch.

Overall, the helper in src/youtube_extension/backend/services/intelligent_cache.py is carrying the right invariant, and the new coverage in tests/unit/test_intelligent_cache.py is strong enough that I wouldn’t split this further.

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

Fixes #1297 by releasing L1 cache bookkeeping whenever entries expire, are deleted, or are evicted.

Changes:

  • Centralizes entry cleanup in _release_entry().
  • Corrects lazy-expiry counters and access-history cleanup.
  • Adds regression and capacity-churn tests.

Reviewed changes

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

File Description
src/youtube_extension/backend/services/intelligent_cache.py Centralizes cache-entry cleanup across removal paths.
tests/unit/test_intelligent_cache.py Tests expiry, eviction, history, and capacity accounting.

Comment thread src/youtube_extension/backend/services/intelligent_cache.py

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/youtube_extension/backend/services/intelligent_cache.py`:
- Around line 221-240: Update the existing-key replacement path in set() to
clear self.access_patterns[key] when the current entry is expired before
installing the new entry; preserve the access history when the existing entry
remains live. Add a regression test covering set() on an expired key without a
prior get() or delete(), verifying the replacement starts with clean access
history.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 286f6676-13a0-4f10-aa07-cc9d15746e1e

📥 Commits

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

⛔ 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
📜 Review details
⏰ Context from checks skipped due to timeout. (6)
  • GitHub Check: copilot-pull-request-reviewer
  • GitHub Check: test
  • GitHub Check: build
  • GitHub Check: Security Scan - python
  • GitHub Check: Generate and Upload Coverage
  • GitHub Check: trivy
🧰 Additional context used
📓 Path-based instructions (10)
**/*.{py,js,jsx,ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{py,js,jsx,ts,tsx}: Use Python 3.9+ and Node 18+ for development
Never hardcode API keys, database URLs, or secrets in code
Make minimal, surgical changes and avoid deleting working code unless fixing security issues

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
**/*.py

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.py: Always use type hints for Python functions
Use Black formatter with 88 character line length for Python code
Follow PEP 8 conventions for Python code
Use descriptive variable names and add docstrings to all public functions in Python
Group imports in Python: standard library, third-party, local
Use SQLAlchemy ORM and never write raw SQL queries
Use environment variables via os.getenv() or pydantic-settings to access configuration
Wrap database operations in try-except blocks and use context managers or FastAPI dependencies for connection cleanup
Implement comprehensive error handling with proper logging in all functions
Version APIs using /api/v1/ prefix for stability
Use JSON-RPC 2.0 protocol for all MCP communication
Follow the single-flow workflow: YouTube link → context extraction → agent dispatch → outputs
Use context managers for resource management in Python code
Implement comprehensive input validation and sanitize outputs for security
Use parameterized queries and SQLAlchemy ORM to prevent SQL injection
Define API request/response models using Pydantic for FastAPI endpoints
Store the single unified workflow as the only workflow; never introduce alternate flows or manual triggers
Use SQLAlchemy with connection pooling for database connections and manage sessions with context managers
Provide sensible defaults for non-sensitive configuration in Python settings
Maintain backward compatibility and do not break existing API endpoints
Include comprehensive logging for debugging in MCP implementations

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py

⚙️ CodeRabbit configuration file

Python backend code. Check for type hints, proper exception handling, async context manager usage, and potential blocking calls in async functions. Flag any bare except clauses or missing timeout parameters on network calls. CRITICAL: Flag any file that contains placeholder/stub implementations — especially in unified_ai_sdk. Any class or function that says "TODO: Replace with production implementation" or returns mock/fake data must be flagged as a blocking issue. Flag any code generation output that reaches users without AST validation or syntax checking.

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
**/*.{py,js,ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

Maintain >80% code coverage for new features

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
**/*.{py,ts,tsx}

📄 CodeRabbit inference engine (.github/copilot-instructions.md)

**/*.{py,ts,tsx}: Keep frontend and backend data models synchronized using matching Pydantic (backend) and TypeScript (frontend) interfaces
Use type-safe interfaces for backend-frontend data exchange

**/*.{py,ts,tsx}: Production code must use real behavior only: no mock delays, fake data, or simulated responses.
Maintain strict type safety: mypy strict mode for Python and TypeScript strict mode for the frontend.
Name events using the <domain>.<entity>.<action> format.

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
**/*

📄 CodeRabbit inference engine (Custom checks)

**/*: Strictly verify that GitHub Copilot has explicitly reviewed and approved the pull request; human approvals alone must not satisfy this check.
Before allowing a merge, require the copilot-rabbit label and AI-generated unit tests committed alongside the code changes; fail the check if either is missing.

For Vercel-specific work, include https://vercel.com/docs/llms-full.txt in the AI assistant context set.

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
**/*.{py,pyw}

📄 CodeRabbit inference engine (AGENTS.md)

Write Python code to remain compatible with Linux and Windows where possible, including correct handling of asyncio event loops.

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
src/**/*.py

📄 CodeRabbit inference engine (CLAUDE.md)

src/**/*.py: Format Python code with Black using an 88-character line length.
Sort Python imports with isort using the Black profile.
Run Ruff with E, W, F, I, B, C4, and UP rules; E501 is ignored.
Use mypy strict mode; untyped function definitions are disallowed.
Target Python 3.9 or newer.
Validate backend inputs with Pydantic and sanitize subprocess arguments.
Use Anthropic SDK features thinking={"type": "adaptive"} and output_config={"effort": "..."} with anthropic>=0.105.0; do not add TypeError fallbacks for these parameters.

Use the service container dependency-injection pattern in backend/containers/.

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
**/*.{py,ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Do not commit secrets; store keys and credentials in gitignored .env files.

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
**/*.{py,pyi}

📄 CodeRabbit inference engine (GEMINI.md)

**/*.{py,pyi}: Use Black formatting with an 88-character line length for Python code.
Use Ruff with rules E, W, F, I, B, C4, and UP for Python linting.
Use strict mypy type checking; do not define untyped functions.
Target Python 3.9 or newer.
Validate Python inputs with Pydantic.
Sanitize subprocess arguments before execution.
Do not add mock delays or fake data to production Python code; production runs in REAL_MODE_ONLY.
Keep secrets out of Python source code; load keys and credentials from environment variables instead.
Use absolute imports that resolve with PYTHONPATH=src in the Python backend.

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
**/*.{py,pyi,ts,tsx}

📄 CodeRabbit inference engine (GEMINI.md)

**/*.{py,pyi,ts,tsx}: Preserve the single workflow: YouTube link → transcript → events → agents → outputs; do not introduce alternative flows or manual triggers that bypass it.
Use event names following <domain>.<entity>.<action>, such as youtube.video.captured.
Make surgical, precise changes and do not delete working code without justification.

Files:

  • src/youtube_extension/backend/services/intelligent_cache.py
🔍 Remote MCP GitHub Copilot

Relevant review context

  • The PR is #1298, based on main at 0bfc783 and headed by 8ef9567; it changes only the cache service and its unit tests.
  • _release_entry() is called for lazy expiry, explicit deletion, and LRU eviction. clear() remains a separate bulk-reset path that already clears entries, histories, and counters.
  • The helper assumes callers hold _lock; all three call sites do so. Eviction-specific eviction_count accounting remains outside the helper.
  • set() still preserves access history when overwriting a live key; the new cleanup specifically handles expired or evicted entries.
  • The PR reports 171 focused tests passing, including independent prove-fail runs for expiry and eviction defects. However, at retrieval time several CI jobs—including tests, build, coverage, and security scans—were still in progress; mergeable_state was unstable.
  • CodeRabbit’s automated review excluded tests/**, so its review covered only the production source file.
  • The linked issue remains open and identifies the same three removal paths and stale counter/history behavior.
🔇 Additional comments (1)
src/youtube_extension/backend/services/intelligent_cache.py (1)

153-156: LGTM!

Also applies to: 242-247, 275-276

Comment thread src/youtube_extension/backend/services/intelligent_cache.py

Copy link
Copy Markdown
Owner Author

Review verdict (requested): one blocking correctness gap, otherwise green.

CI is clean on 8ef9567 (Vercel ×2, CodeRabbit, truth-gate all ✅). The refactor into _release_entry is sound and the prove-fail evidence is convincing.

One finding is real and should land before merge — the same one Copilot and CodeRabbit both raised (currently unresolved):

set() does not clear access history when it replaces an expired entry. In the existing-key branch:

if key in self.cache:
    old_entry = self.cache[key]
    self.stats.total_size_bytes -= old_entry.size_bytes   # access_patterns untouched

If a key's entry has passed expires_at but has not been read since, access_patterns[key] still holds the old timestamps. A direct set() (no intervening get()) installs a fresh entry but keeps that history, so _calculate_adaptive_ttl reads the dead entry's frequency and over-extends the new entry's TTL. This contradicts the outcome-table line "Prevents a re-set() key from inheriting its expired predecessor's frequency history" — it holds only when a get() intervenes. test_reused_key_does_not_inherit_expired_history passes precisely because it calls get() first (which lazily releases via _release_entry); the direct re-set() path is uncovered.

The one-line guard CodeRabbit proposed (pop access_patterns[key] in set() when old_entry is expired, preserve it when still live) plus a get()-free regression test closes it. It is arguably adjacent to the sliding-window work held back for #1294, so it's worth confirming with @linear whether it belongs here or there — note that linear's earlier "no blocking issues" reply predates both bot findings and answered the split-scope questions, not this bug.

Not approving or merging from here: protected-main publish gate is human-by-default and this sits inside an open scope negotiation. Flagging so the green CI + earlier approval aren't read as fully review-clear.


Generated by Claude Code

@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

I agree with that finding — the direct re-set() path is still wrong.

If an expired entry is replaced without a preceding read, src/youtube_extension/backend/services/intelligent_cache.py keeps access_patterns[key] intact in the existing-key branch, so a fresh write can inherit dead frequency history. That means the current test only proves the lazy-expiry-via-get() path, not replacement-by-set(). I’d fix it here with the narrow expired-only cleanup plus a regression test for “expire, re-set, no intervening get.”

InMemoryCacheLayer.set() replaced an existing entry without consulting
its TTL. When the key had already expired, the successor inherited the
dead entry's access timestamps, so a brand-new value presented to
_calculate_adaptive_ttl as a hot key and was granted 14400s instead of
the 3600s base TTL.

This was the fourth path that stops an entry from existing. The other
three (delete, eviction, lazy expiry) release bookkeeping via
_release_entry; replacement reuses the slot, so total_entries must not
move and only access_patterns is dropped.

Adds a private _is_expired() predicate so the expiry check in get() and
the new one in set() cannot drift apart.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@groupthinking

Copy link
Copy Markdown
Owner Author

@coderabbitai you were right, and it was worse than the summary suggested.

The claim in my Outcome table about re-set() keys was only ever true when a get() happened to land between expiry and the rewrite — that get() was doing the cleaning via the lazy-expiry path this PR fixes. The direct set()-over-expired path never touched access_patterns at all. @copilot's reviewer converged on the same finding independently.

Fixed in 481969fd2. Three changes:

  1. New private _is_expired(entry) predicate. get() and set() each carried their own copy of entry.expires_at and now > entry.expires_at. That duplication is the bug class this PR is about, so I did not want to add a third copy — the predicate is now the single definition and get() was switched over to it.
  2. set()'s replacement branch pops the access history when — and only when — the entry it is overwriting is already expired.
  3. Extended _release_entry's docstring to record that set()-over-expired is a fourth way an entry stops existing, distinguished by the fact that the slot is reused rather than freed.

Deliberately not reused _release_entry in set(): it does del self.cache[key] and total_entries -= 1, which is right when a slot is freed and wrong when it is immediately reinserted. test_reset_after_expiry_keeps_entry_count_stable locks that in.

Four regression tests added. The load-bearing one, test_reset_after_expiry_drops_dead_history, deliberately does not call get() after expiry — otherwise the lazy-expiry fix pre-cleans the history and the test passes for the wrong reason. test_reset_of_live_key_keeps_history is the control proving the guard is conditional.

Reverting just this hunk gives 2 failed, 173 passed, with the headline being a brand-new entry granted a 14400s TTL instead of 3600s because it inherited 20 access timestamps from its dead predecessor.

175 passed. Ruff parity clean, Black applied to the new code.

@coderabbitai review

@groupthinking

Copy link
Copy Markdown
Owner Author

@linear Follow-up on this PR after your clearance — the shape changed slightly, so I would rather re-check than assume the earlier "no blocking issues" still covers it.

@coderabbitai found a fourth path I had missed, and your note about enumerating every way an entry stops existing applies to it directly: set() replacing an already-expired key never touched access_patterns. My Outcome table claimed otherwise, and it was only accidentally true — the existing test happened to get() the key after expiry, which pre-cleaned via the lazy-expiry path this PR fixes. So the claim was passing for the wrong reason. Fixed in 481969fd2.

Two judgement calls in that fix I would like challenged:

  1. I added a second private helper, _is_expired(entry), on a PR you have already cleared. get() and set() each carried their own inline copy of entry.expires_at and now > entry.expires_at, and a third copy is exactly the drift that caused this bug. But you approved _release_entry on the argument that it makes an invariant structural, and I am now leaning on that same argument a second time in the same PR — which is the point at which "one containment helper" starts to look like "a refactor". It touches get(), which you already signed off on. Is that still containment, or should the predicate be dropped and the condition simply repeated in set()?

  2. I did not reuse _release_entry in set(). It does del self.cache[key] and total_entries -= 1; set() reuses the slot immediately, so the count must not move. I used a bare access_patterns.pop(key, None) instead and pinned the distinction with test_reset_after_expiry_keeps_entry_count_stable. That does mean the four paths are no longer uniformly routed through one helper, which slightly weakens the "one place to change" property you liked. I think correctness wins here, but it is a real cost.

  3. Magnitude. I have framed the consequence as a fresh entry being granted a 14400s TTL instead of 3600s because it inherited 20 access timestamps from its dead predecessor, and explicitly not as a correctness bug in cache reads — the value returned is always the new one. Prove-fail on this hunk alone is 2 failed, 173 passed, with test_reset_of_live_key_keeps_history passing as the control that the guard is conditional.

One thing I did not change: there is still no proactive sweeper, so an expired key that is never read again holds its slot until eviction pressure arrives. That is a design change rather than a containment fix and I have kept it out.

Verification: 175 passed. Three independent prove-fail reverts — eviction 2 failed, expiry 5 failed, replacement 2 failed — cleanly orthogonal. Ruff parity clean, Black on new code only.

Good to merge, or do you want the predicate pulled back out?

@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Looks good to me now.

  • The expired-overwrite gap is actually closed in src/youtube_extension/backend/services/intelligent_cache.py: set() now drops access_patterns[key] only when the overwritten entry is already dead, which preserves live-key history and fixes the false hot-key TTL case.
  • Keeping that path out of _release_entry is the right call. Reuse-vs-free is a real distinction here, and the new count guard makes that explicit.
  • The new coverage in tests/unit/test_intelligent_cache.py is the important part: the no-get() regression test closes the original blind spot, and the live-key control plus adaptive-TTL end-to-end check make the intent hard to regress.

No blocking issues from me.

@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Good to merge. I would not pull the predicate back out.

  • _is_expired(entry) still reads as containment, not opportunistic refactor. It centralizes a semantic boundary that now governs both lazy expiry and expired-entry replacement, which is exactly where drift already bit you.
  • Not routing set() through _release_entry is also the right call. The paths are similar, but not the same: one frees a slot, the other reuses it. Forcing uniformity there would make the abstraction less truthful, not more.
  • The current split feels clean to me: _is_expired() answers “is this entry dead?”, _release_entry() answers “free this entry and all bookkeeping associated with freeing it.”
  • The magnitude framing is calibrated. “Fresh entry inherits dead predecessor’s hot-key history and gets a 14400s TTL instead of 3600s” is specific, falsifiable, and narrower than claiming bad reads.

I don’t see a reason to reopen the earlier approval over this shape change.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline 481969fd2
git show --format=fuller --find-renames --find-copies 481969fd2 -- \
  src/youtube_extension/backend/services/intelligent_cache.py

ast-grep outline src/youtube_extension/backend/services/intelligent_cache.py \
  --items all --type function --match '_is_expired|set|get|_release_entry|_calculate_adaptive_ttl'

rg -n -C 5 \
  'test_reset_after_expiry_drops_dead_history|test_reset_after_expiry_keeps_entry_count_stable|test_reset_of_live_key_keeps_history|_is_expired|def set\(|def get\(|def _release_entry' \
  src/youtube_extension/backend/services/intelligent_cache.py tests 2>/dev/null || true

Length of output: 19216


🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --format= --find-renames 481969fd2 -- tests/unit/test_intelligent_cache.py

sed -n '179,323p' src/youtube_extension/backend/services/intelligent_cache.py

rg -n -C 12 \
  'def test_(reset_after_expiry_drops_dead_history|reset_after_expiry_keeps_entry_count_stable|reset_of_live_key_keeps_history)|access_patterns|expires_at|_calculate_adaptive_ttl' \
  tests/unit/test_intelligent_cache.py

Length of output: 12926


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '650,705p' src/youtube_extension/backend/services/intelligent_cache.py
sed -n '745,790p' src/youtube_extension/backend/services/intelligent_cache.py

fd -t f '^test_intelligent_cache\.py$' . -x sh -c '
  echo "== $1 =="
  rg -n -C 8 "_expire_now|class TestResetOfExpiredKeyStartsCleanHistory|test_adaptive_ttl_does_not_treat_reused_key_as_hot" "$1"
' sh {}

rg -n -C 5 \
  'await .*\.set\(|_calculate_adaptive_ttl\(' \
  src/youtube_extension/backend/services/intelligent_cache.py

Length of output: 6643


@groupthinking The direct InMemoryCacheLayer.set() path is fixed. However, one blocking path remains.

IntelligentCacheSystem.set() calculates adaptive TTL before it calls InMemoryCacheLayer.set():

if self.adaptive_ttl_enabled and ttl is None:
    ttl = self._calculate_adaptive_ttl(key)

for layer in self.layers:
    result = await layer.set(key, value, ttl, tags)

The new access_patterns.pop(key, None) runs inside InMemoryCacheLayer.set(). It therefore runs after _calculate_adaptive_ttl() reads the expired entry's history. A direct IntelligentCacheSystem.set(..., ttl=None) can still assign 14400s to the replacement entry.

test_adaptive_ttl_does_not_treat_reused_key_as_hot does not detect this case. It calls InMemoryCacheLayer.set() first, then calls _calculate_adaptive_ttl(). That order pre-cleans the history.

Update the adaptive-TTL calculation path to ignore an expired L1 entry's history before it scores the key. Add an end-to-end regression test that:

  1. Creates an expired L1 entry with hot access history.
  2. Calls IntelligentCacheSystem.set(..., ttl=None) without an intervening get().
  3. Verifies that the new L1 entry receives the 3600s base TTL.

This is a correctness issue. The stated 14400s to 3600s outcome does not yet hold for the public cache-system write path.

You are interacting with an AI system.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '== PR metadata and checks =='
gh pr view 1298 --repo groupthinking/EventRelay \
  --json headRefOid,baseRefOid,mergeable,mergeStateStatus,statusCheckRollup \
  --jq '{headRefOid, baseRefOid, mergeable, mergeStateStatus, checks: [.statusCheckRollup[] | {name, status, conclusion}]}'

echo
echo '== Changed files at the reported commit =='
git show --stat --oneline 481969fd2
git diff 481969fd2^ 481969fd2 -- src/youtube_extension/backend/services/intelligent_cache.py

echo
echo '== Source outline =='
ast-grep outline src/youtube_extension/backend/services/intelligent_cache.py --items all --type class,function --match 'InMemoryCacheLayer|_is_expired|_release_entry|get|set|delete|_evict_if_needed'

echo
echo '== Relevant source sections =='
rg -n -A45 -B12 'def (_is_expired|_release_entry|get|set|delete|_evict_if_needed)\b' \
  src/youtube_extension/backend/services/intelligent_cache.py

echo
echo '== Changed tests =='
git diff 481969fd2^ 481969fd2 -- tests

echo
echo '== Test definitions and calls for replacement-after-expiry coverage =='
rg -n -A30 -B5 'def test_(reset_after_expiry|reset_of_live_key|reused_key).*|test_reset_after_expiry|test_reset_of_live_key|access_patterns|expires_at' tests \
  -g '*.py'

Length of output: 35177


🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="481969fd2"
source_path="src/youtube_extension/backend/services/intelligent_cache.py"
test_path="tests/unit/test_intelligent_cache.py"

echo '== Adaptive TTL implementation and access-history consumers =='
git show "${commit}:${source_path}" |
  nl -ba |
  sed -n '/def _calculate_adaptive_ttl/,/^[[:space:]]*def /p'

echo
echo '== All access_patterns references in the committed source =='
git show "${commit}:${source_path}" |
  rg -n -C 4 '\baccess_patterns\b|def _is_expired\b|def _release_entry\b'

echo
echo '== Test helper and complete new regression-test class =='
git show "${commit}:${test_path}" |
  nl -ba |
  sed -n '/def _expire_now/,/^class /p'
git show "${commit}:${test_path}" |
  nl -ba |
  sed -n '/class TestResetOfExpiredKeyStartsCleanHistory/,/\Z/p'

echo
echo '== Compare expiry predicate before and after this commit =='
git diff "${commit}^" "${commit}" --unified=20 -- "${source_path}" |
  sed -n '/async def get/,/def _release_entry/p'

Length of output: 1410


🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="481969fd2"
source_path="src/youtube_extension/backend/services/intelligent_cache.py"
test_path="tests/unit/test_intelligent_cache.py"

echo '== Adaptive TTL implementation =='
git show "${commit}:${source_path}" |
  sed -n '/def _calculate_adaptive_ttl/,/^[[:space:]]*def /p'

echo
echo '== All access_patterns references in committed source =='
git show "${commit}:${source_path}" |
  rg -n -C 4 '\baccess_patterns\b|def _is_expired\b|def _release_entry\b'

echo
echo '== Expiry helper used by tests =='
git show "${commit}:${test_path}" |
  sed -n '/def _expire_now/,/^class /p'

echo
echo '== Complete regression-test class =='
git show "${commit}:${test_path}" |
  sed -n '/class TestResetOfExpiredKeyStartsCleanHistory/,$p'

echo
echo '== Direct comparison of the changed expiry and replacement logic =='
git diff "${commit}^" "${commit}" --unified=20 -- "${source_path}"

Length of output: 17148


@groupthinking Good to merge.

_is_expired() is containment, not scope expansion. It preserves the existing expiration condition and ensures that get() and set() classify an entry consistently.

Do not use _release_entry() in set(). That helper frees a slot. An expired-key replacement reuses the slot, so total_entries must remain unchanged. The conditional access_patterns.pop(key, None) is the correct operation.

The magnitude statement is supported. The new end-to-end test verifies the relevant behavior: expired history would make the fresh entry qualify for the 14,400-second TTL, while the fixed path returns the 3,600-second base TTL. The live-key control also confirms that the fix does not discard valid history.

I found no blocking issue in 481969fd2. GitHub reports a clean, mergeable PR, and the completed checks are successful.


✏️ Learnings added
Learnt from: groupthinking
URL: https://github.com/groupthinking/EventRelay/pull/1298

Timestamp: 2026-08-03T22:39:58.881Z
Learning: In `src/youtube_extension/backend/services/intelligent_cache.py`, `InMemoryCacheLayer.set()` must preserve `access_patterns[key]` when it overwrites a live entry, but it must clear that history when it overwrites an expired entry. An expired-key replacement reuses the cache slot, so it must not call `_release_entry()` or decrement `CacheStats.total_entries`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@groupthinking
groupthinking merged commit b04c402 into main Aug 3, 2026
42 checks passed
@groupthinking
groupthinking deleted the perf/release-orphaned-access-history branch August 3, 2026 22:40
@linear-code

linear-code Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

GRV-279

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.

L1 cache removal paths leak entry bookkeeping, costing 45% of capacity

2 participants