Summary
InMemoryCacheLayer has three code paths that remove an entry from the L1 cache. Only one of them releases everything the entry owns. The other two leave bookkeeping behind permanently, and one of those leaks cause a measurable, monotonic loss of usable cache capacity.
Current behaviour
src/youtube_extension/backend/services/intelligent_cache.py
| removal path |
drops entry |
total_entries -= 1 |
total_size_bytes -= … |
drops access_patterns[key] |
delete() |
yes |
yes |
yes |
yes |
_evict_if_needed() |
yes |
yes |
yes |
no |
get() lazy expiry |
yes |
no |
no |
no |
The lazy-expiry branch is the whole of the expiry handling:
# 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
There is no background sweeper. Searching the file for expires_at, expired, _sweep, cleanup_task and purge finds only the dataclass field, the set()-side construction, and this branch. It is the only place expiry is ever handled in the layer, so nothing reconciles the drift and it accumulates for the process lifetime.
Impact
total_size_bytes is not merely a reported statistic — _evict_if_needed budgets against it. Bytes that expiry never released are charged against max_size_bytes forever, so the layer evicts live entries to make room for entries that no longer exist.
Measured on a 100 KB layer with 200 entries inserted:
| scenario |
live entries retained |
| no prior expiry (control) |
110 |
| after 50 entries expired |
60 |
That is a 45% capacity loss, exactly 1:1 with the number of expired entries. A long-lived layer under TTL churn converges toward holding nothing while its own accounting reports it as full.
Two further consequences:
- Orphaned frequency history. 50 keys read 20 times each and then expired leave 50
access_patterns entries holding 1,000 timestamps, describing keys that are gone.
- Stale history inherited on reuse. 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 and grants it an inflated TTL.
Proposed fix
Introduce one private helper that releases an entry completely, and route all three removal paths through it so that omitting a step becomes structurally impossible rather than something each future path must remember.
Acceptance criteria
Out of scope
Bounding the length of access_patterns per key (the sliding-window change) is tracked separately in #1294. This issue is strictly the release-on-removal defect.
Summary
InMemoryCacheLayerhas three code paths that remove an entry from the L1 cache. Only one of them releases everything the entry owns. The other two leave bookkeeping behind permanently, and one of those leaks cause a measurable, monotonic loss of usable cache capacity.Current behaviour
src/youtube_extension/backend/services/intelligent_cache.pytotal_entries -= 1total_size_bytes -= …access_patterns[key]delete()_evict_if_needed()get()lazy expiryThe lazy-expiry branch is the whole of the expiry handling:
There is no background sweeper. Searching the file for
expires_at,expired,_sweep,cleanup_taskandpurgefinds only the dataclass field, theset()-side construction, and this branch. It is the only place expiry is ever handled in the layer, so nothing reconciles the drift and it accumulates for the process lifetime.Impact
total_size_bytesis not merely a reported statistic —_evict_if_neededbudgets against it. Bytes that expiry never released are charged againstmax_size_bytesforever, so the layer evicts live entries to make room for entries that no longer exist.Measured on a 100 KB layer with 200 entries inserted:
That is a 45% capacity loss, exactly 1:1 with the number of expired entries. A long-lived layer under TTL churn converges toward holding nothing while its own accounting reports it as full.
Two further consequences:
access_patternsentries holding 1,000 timestamps, describing keys that are gone.set()inherits its dead predecessor's timestamps, so_calculate_adaptive_ttltreats a brand-new entry as an established hot key and grants it an inflated TTL.Proposed fix
Introduce one private helper that releases an entry completely, and route all three removal paths through it so that omitting a step becomes structurally impossible rather than something each future path must remember.
Acceptance criteria
delete()behaviour is unchanged and still covered by its existing testset()key does not inherit its expired predecessor's historyOut of scope
Bounding the length of
access_patternsper key (the sliding-window change) is tracked separately in #1294. This issue is strictly the release-on-removal defect.