Skip to content

fix(caching): O(n^2) eviction, sub-second TTL crash, LFU frequency reset - #3

Open
frankstupak wants to merge 1 commit into
SkinnnyJay:mainfrom
frankstupak:lumen-uplift/caching
Open

fix(caching): O(n^2) eviction, sub-second TTL crash, LFU frequency reset#3
frankstupak wants to merge 1 commit into
SkinnnyJay:mainfrom
frankstupak:lumen-uplift/caching

Conversation

@frankstupak

@frankstupak frankstupak commented Jul 4, 2026

Copy link
Copy Markdown

Nine correctness bugs and two O(n^2) hot paths in the caching module — all fixed, all tested, measurements below.

What was broken

The performance suite is skipped. describe.skip on "📊 Performance and Edge Cases" with a comment blaming test hangs. The hang traces to a real defect: the TTL cache's setInterval is never unref()'d and clearAll() never destroys it. Fixed the timer, un-skipped the suite, and it passes with --detectOpenHandles on.

test:performance never ran anything. The jest testMatch is **/*.test.ts, so jest src/performance-tests.ts matches 0 tests and exits green. It has never executed. Also wouldn't have compiled standalone — beforeEach/afterEach aren't imported (same bug in cache.test.ts: jest used but not imported from @jest/globals).

Memory caches, per-operation costs at capacity:

path before after
LFU evict O(n) full scan O(1) frequency buckets
FIFO set (overwrite) O(n) indexOf+splice O(1) Map order
TTL evict O(n) sweep + O(n) oldest scan O(1)

Plus: memoryUsage leaks on every overwrite and every lazy-expired get, and LRU maintained a duplicate accessOrder Map for state the primary Map already had.

Redis Lua:

  • math.floor(ttl / 1000) → any TTL under 1s becomes SETEX key 0Redis runtime error. Every set with a sub-second TTL crashed.
  • LFU set resets the key's frequency to 1 → the hottest key becomes the next eviction victim every time it's written.
  • Order/frequency ZSETs desync from expired keys; phantom members caused premature eviction of live keys.
  • if not result[1] → a cached empty string is reported as a miss.
  • Bare-string storage → set(k, "123") reads back as number 123. Type corruption on round-trip.
  • Write-through get never touched backing storage on a miss — the entire point of the pattern, defeated.
  • clear() used blocking KEYS + one-by-one DEL. Now cursor-based SCAN.
  • Batch size was string-interpolated into the Lua source (new script compile per size). Now ARGV.
  • Unsupported strategy → silent miss. Now throws.

Manager: multi-level combined stats double-count — an L1-miss/L2-hit (a successful request) reported 50% hit rate; a full miss counted as 2 misses. And { ttl: undefined } clobbered defaultTtl via spread ordering.

Numbers

src/cache-bench.ts (committed, npm run bench), i7-5820K:

workload before after
LFU churn, 10k evicting sets @ cap 10k 1,643 ms 113 ms 14.5×
FIFO overwrite, 10k re-sets, full cache 505 ms 42 ms 11.9×
TTL at-capacity, 10k inserts 4,805 ms 65 ms 74×
LFU churn @ n=50k 36,550 ms 944 ms 38.7×
TTL at-capacity @ n=50k 82,073 ms 901 ms 91×

That's the O(n²) signature: the version gets quadratically worse as the cache grows. LRU (already O(1)) is unchanged within noise — and now carries one Map instead of two.

Tests

  • 43/43 in the suite, including the 6 that were skipped, no --forceExit crutch
  • 20 new regression tests in cache-uplift.test.ts — these run the actual Lua scripts under ioredis-mock, which was sitting in the dependencies unused while the tests pattern-matched script strings with a hand-rolled fake
  • test:performance: 13/13, now that it matches anything at all
  • Public API unchanged. tsc and eslint clean on the module.

LFU implementation follows the O(1) frequency-bucket scheme (Matani, Shah & Mitra, arXiv:2110.11602), tie-breaking least-recently-used to match the previous eviction order exactly.

— Lumen Industries 🤖

Memory caches:
- LFU: O(1) frequency-bucket eviction (was full O(n) scan per evict)
- FIFO: Map insertion order for eviction (was O(n) indexOf/splice side array)
- TTL: O(1) oldest-entry eviction; expired sweeps counted as cleanups
- LRU: reorder primary Map directly; duplicate accessOrder Map removed
- memoryUsage no longer leaks on overwrite or lazy expiry
- TTL cleanup interval unref()d and destroyed on clearAll (fixes the
  open-handle hang the perf suite was skipped to avoid)

Redis caches (Lua):
- ms-precision PX/PEXPIRE everywhere; sub-second TTLs no longer produce
  an invalid SETEX 0
- LFU set uses ZADD NX so overwrites preserve earned frequency
- gets lazily purge stale order/freq ZSET members; capacity enforcement
  purges phantoms before evicting live keys
- empty-string values report as hits; JSON round-trip preserves types
  (string '123' no longer comes back as number 123)
- TTL/write-behind gets are a single round trip (GET+PTTL in one eval)
- write-through gets read through to backing storage on cache miss
- clear() uses cursor-based SCAN instead of blocking KEYS
- write-behind drain uses RPOP count arg; batch size passed as ARGV
  (was string-interpolated into the script)
- unsupported strategies throw instead of silently reporting a miss

Manager:
- multi-level combined hitRate counts an L1-miss/L2-hit as one
  successful request (was reported as 50%)
- explicit { ttl: undefined } falls back to defaultTtl

Tests:
- cache.test.ts compiles standalone again (missing jest import) and the
  skipped 'Performance and Edge Cases' suite is re-enabled
- mock eval() dispatches on script markers instead of substring sniffing
- new cache-uplift.test.ts runs the real Lua scripts under ioredis-mock
  (already a dependency, previously unused)
- test:performance actually matches performance-tests.ts now (previously
  matched 0 tests); missing jest imports fixed
- src/cache-bench.ts added; LFU churn 38.7x and TTL at-capacity 91x
  faster at n=50k
@frankstupak frankstupak changed the title caching: fix O(n²) eviction, sub-second TTL crash, LFU frequency reset, 9 more correctness bugs (up to 91× faster) fix(caching): O(n^2) eviction, sub-second TTL crash, LFU frequency reset Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant