fix(caching): O(n^2) eviction, sub-second TTL crash, LFU frequency reset - #3
fix(caching): O(n^2) eviction, sub-second TTL crash, LFU frequency reset#3frankstupak wants to merge 1 commit into
Conversation
|
CI has now run on this branch for the first time — the earlier runs sat unapproved (fork PR, first-time-contributor policy) and expired, so nothing was ever reported here. Result: failure on Node 22, in this PR's own new test file. Node 18 and 20 were cancelled once 22 failed, so their verdict is unknown. The cause is the import form. export const redisMock: Constructor;
export { redisMock as default };So A default import is the spelling those types are written for, and it still resolves to the CJS import RedisMock from "ioredis-mock";That also lets the Worth knowing before you re-push: |
|
Status update. Two things had been masking the real verdicts:
This one is now the only thing standing between the branch and a merge — the failure is in your own change, detailed in the comment above. Once it is fixed, rebase or use "Update branch" so CI runs against current Also filed #15 for a flake you may hit on a re-run: |
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
64977e4 to
14a6f71
Compare
|
Rebased onto main, both checks green now. The failure was a type error in Ready for review whenever you get a chance. |
|
Sorry for the delay on this — you answered on Aug 30 and this sat. Confirmed green on my end: On the ioredis-mock workaround though — the root cause isn't what the comment says, and you don't need the cast. I checked the published typings. The version that actually resolves here is 8.2.6, not 8.2.7 — root ( And 8.2.7 isn't stale — it's the fix. The two shapes, straight from the packages: // @types/ioredis-mock@8.2.6 — the broken one
export const redisMock: Constructor;
export { redisMock as default };
// @types/ioredis-mock@8.2.7 and 8.2.8 — both of these
declare const redisMock: redisMock.Constructor;
export = redisMock;8.2.6 is exactly the shape I described on Aug 22: no That matters because const RedisMockCtor = RedisMock as unknown as new () => Redis;The real So I'd rather take the dependency bump than the cast. Either of these: // root package.json — pin the transitive types past the bad publish
"overrides": { "@types/ioredis-mock": "^8.2.7" }or just import RedisMock = require("ioredis-mock");
const raw = new RedisMock(); // typed as ioredis.Redis, no castand the Not asking you to redo the substance — the eviction/TTL/Lua work is untouched by any of this and that's the part I care about. It's ~15 lines at the top of one test file. One other thing worth knowing: this PR supersedes #16. Your Swap the cast for the version bump and I'll merge this. |
SkinnnyJay
left a comment
There was a problem hiding this comment.
Read the whole diff properly this time rather than just the CI failure. I have to walk back "swap the cast for the version bump and I'll merge this" — there's a blocking regression in the LFU rewrite.
Everything else in here is good work and I want to say that first: the memory-accounting leaks on overwrite and lazy expiry, the shared removeEntry/onRemove path, unref() on the cleanup timer, FIFO dropping the side array for Map insertion order, TTL evicting the first Map key instead of two O(n) scans — all correct, all well-motivated, and the comment headers explaining why each one changed are genuinely useful. The PTTL work is the right fix for #15 too.
The blocker: LFUMemoryCache deadlocks the event loop on concurrent set()
LFUMemoryCache.set() does its bucket bookkeeping after an await:
await super.set(key, value, ttl, nowMs); // <-- inserts into this.cache, then yields
this.freqOf.set(key, 0); // <-- bucket state lands one microtask later
this.bucketAdd(0, key);
this.minFreq = 0;BaseMemoryCache.set is async, so that await yields to the microtask queue even though the body is entirely synchronous. Between the yield and the resume, this.cache contains a key that buckets and freqOf know nothing about.
Now a second set() interleaves, finds the cache at capacity, and calls evict():
protected evict(): void {
if (this.cache.size === 0) return;
while (!this.buckets.has(this.minFreq)) { // buckets is EMPTY -> never terminates
this.minFreq++;
}cache.size is 2 so the guard passes, but every bucket is still unwritten, so that loop increments minFreq forever. It's synchronous, so it doesn't hang one request — it pins a core and wedges the entire Node process. No timeout fires, no abort signal lands, a health check can't even be served.
Reproduction
Compiled cache-memory.ts off this branch (14a6f71) and ran it directly:
const c = new LFUMemoryCache(2);
await Promise.all(['a','b','c','d'].map(k => c.set(k, k)));sequential : OK, size = 2
concurrent : dispatching 4 overlapping set() calls...
--- exit code: 124 --- (killed by 10s timeout)
Same script against current main: concurrent : OK (no hang). So this is a regression this PR introduces, not something it inherits.
I checked the other three strategies on this branch — LRUMemoryCache, TTLMemoryCache and FIFOMemoryCache all pass the same test. It's isolated to LFU.
Why the old code couldn't do this
The caller is a while, and it's pre-existing — this PR doesn't touch it:
while (this.cache.size >= this.maxSize && this.cache.size > 0) {
this.evict();
}That loop's contract is "evict() shrinks this.cache." The old O(n) implementation scanned this.cache itself, so whenever size > 0 it always found a victim and always deleted one — the contract held by construction. The new O(1) version reads from buckets/freqOf instead, and every disagreement between those and this.cache becomes either an unbounded spin (the minFreq loop) or a return that shrinks nothing (victim === undefined, and the else branch that cleans bookkeeping but leaves the cache entry in place) — which the while immediately retries. The two defensive branches you added are written as "return safely," but the caller reads not-shrinking as "try again."
That's the real trade here: the O(n) scan was hang-proof because it read the source of truth. Moving to side structures is the right call for the complexity fix, but it makes those structures load-bearing for termination.
Suggested fix
The await gap is the actual defect, and you've already built the hook that solves it. You added onRemove(key) to BaseMemoryCache for exactly this kind of side-structure sync — add the symmetric onInsert(key) and call it synchronously inside BaseMemoryCache.set() right where the entry goes into the Map, then drop the post-await block from LFUMemoryCache.set(). Bucket state then lands in the same synchronous turn as the cache entry and can never be observed out of step.
Two belt-and-braces changes I'd want regardless, since evict()'s contract is now "must shrink or the caller spins":
- Bound the
minFreqscan and fall back to the source of truth — if no bucket is found, evictthis.cache.keys().next().valuedirectly, the way LRU and TTL already do. That guarantees forward progress no matter what the side structures say. - Make the
victim === undefinedand stale-bookkeeping branches remove a real cache entry too, rather than returning withthis.cacheunchanged.
Worth a test for it as well — cache-uplift.test.ts is thorough on sequential behaviour, but every LFU case awaits one set() at a time, which is why this got through. A Promise.all case over a cache at capacity would have caught it.
Still outstanding from my last comment
The @types/ioredis-mock point stands: the lockfile resolves 8.2.6, not 8.2.7, and 8.2.7/8.2.8 both restored export = redisMock — so bumping past 8.2.6 removes the need for as unknown as new () => Redis entirely.
Fix the LFU hang and the types pin and I'll merge. The rest of this I'm happy with, and the O(1) bucket scheme is the right design — it just needs to survive its own async boundary.
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 🤖