fix(caching): O(n^2) eviction, sub-second TTL crash, LFU frequency reset - #3
Open
frankstupak wants to merge 1 commit into
Open
fix(caching): O(n^2) eviction, sub-second TTL crash, LFU frequency reset#3frankstupak wants to merge 1 commit into
frankstupak wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.skipon "📊 Performance and Edge Cases" with a comment blaming test hangs. The hang traces to a real defect: the TTL cache'ssetIntervalis neverunref()'d andclearAll()never destroys it. Fixed the timer, un-skipped the suite, and it passes with--detectOpenHandleson.test:performancenever ran anything. The jesttestMatchis**/*.test.ts, sojest src/performance-tests.tsmatches 0 tests and exits green. It has never executed. Also wouldn't have compiled standalone —beforeEach/afterEacharen't imported (same bug in cache.test.ts:jestused but not imported from@jest/globals).Memory caches, per-operation costs at capacity:
indexOf+splicePlus:
memoryUsageleaks on every overwrite and every lazy-expired get, and LRU maintained a duplicateaccessOrderMap for state the primary Map already had.Redis Lua:
math.floor(ttl / 1000)→ any TTL under 1s becomesSETEX key 0→ Redis runtime error. Every set with a sub-second TTL crashed.if not result[1]→ a cached empty string is reported as a miss.set(k, "123")reads back asnumber 123. Type corruption on round-trip.clear()used blockingKEYS+ one-by-oneDEL. Now cursor-basedSCAN.ARGV.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 }clobbereddefaultTtlvia spread ordering.Numbers
src/cache-bench.ts(committed,npm run bench), i7-5820K: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
--forceExitcrutchcache-uplift.test.ts— these run the actual Lua scripts under ioredis-mock, which was sitting in thedependenciesunused while the tests pattern-matched script strings with a hand-rolled faketest:performance: 13/13, now that it matches anything at alltscand 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 🤖