Skip to content

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

Description

@groupthinking

Problem

InMemoryCacheLayer (L1) records one time.time() float per cache hit into
self.access_patterns[key], a defaultdict(list). Nothing ever trims that list.

self.access_patterns = defaultdict(list)   # unbounded
...
self.access_patterns[key].append(time.time())   # on every hit

The list is only discarded when the key itself leaves the cache. For a key that
stays resident — which is precisely what a hot key does — the history grows once
per read, forever. Its size is a function of total reads served, not of how
much data the cache holds.

This memory is invisible to the cache's own accounting. CacheStats.total_size_bytes
tracks CacheEntry.size_bytes only; the access history is not counted, so neither
the byte budget nor the entry-count budget can ever reclaim it.

Measurement

Probe: 500 resident keys, 2,000 hits each (1,000,000 reads total).

metric observed
retained timestamps 1,000,000
access-history memory 31,340 KiB (30.6 MiB)
total_size_bytes reported by the cache 1.5 KiB
resident entries 500

30.6 MiB of retained history against 1.5 KiB of accounted payload. The gap is
unbounded in the read count: doubling traffic doubles the history and leaves
every reported metric unchanged.

Consumer

The only reader is _calculate_adaptive_ttl, which uses just three things:

frequency = len(accesses) / (accesses[-1] - accesses[0])

len, first element, last element. It does not need the full history to compute
a rate — a trailing window is sufficient, and is arguably a better estimator
because it reflects the key's current behaviour rather than a lifetime average.

Proposal

Give the per-key history a fixed upper bound:

ACCESS_HISTORY_WINDOW = 64
self.access_patterns = defaultdict(lambda: deque(maxlen=ACCESS_HISTORY_WINDOW))

deque(maxlen=...) evicts from the left in O(1) on append, so the hot path cost
is unchanged. Retention becomes a function of resident keys, which the cache
already bounds, instead of reads served, which it does not.

Expected on the probe above: 30.6 MiB → 1.35 MiB (22.7x), with resident entry
count, total_size_bytes and the hot-key adaptive TTL all unchanged.

Scope

In scope: bound the retention; tests for the bound, for window ordering, and for
adaptive TTL continuing to work correctly over a saturated window.

Not in scope: the entry-lifecycle bookkeeping leaks in the same structure — those
were fixed separately in #1297 / PR #1298 and are already on main. This issue
is only about the growth of a resident key's history.

Acceptance

  • per-key retention never exceeds ACCESS_HISTORY_WINDOW
  • the retained samples are the most recent ones, in order
  • _calculate_adaptive_ttl returns the same TTL for a hot key before and after
  • resident entry count and total_size_bytes are unaffected

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