Skip to content

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

Description

@groupthinking

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

  • lazy expiry releases the entry, both counters, and the access history
  • eviction releases the access history
  • delete() behaviour is unchanged and still covered by its existing test
  • capacity after expiry churn matches a control layer that saw no expiry
  • a re-set() key does not inherit its expired predecessor's history
  • each call site is proven independently, so the shared helper cannot mask a regression in one of them

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions