feat: add quick wins for caches#991
Conversation
Four internal-only wins in lib/CacheAndBufferLayer.ts: split clone() into cloneIn/cloneOut (structuredClone for the read path), maintain a dirty-key set so flush() does not scan the whole LRU, swap setInterval for an on-demand setTimeout, and add a lock-free fast path for get() cache hits. No API or default change; toJSON semantics preserved on the write path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TDD-structured plan for the four wins in the spec: cloneIn/cloneOut split, _dirtyKeys set, lazy setTimeout-based flush, and lock-free get() fast path. Five tasks (baseline + four wins + verification) broken into 2-5 minute steps with exact code, exact commands, and per-task commits.
Split clone() into cloneIn (write path, preserves toJSON semantics) and cloneOut (read path, delegates to V8-native structuredClone). The cached value is always JSON-safe by construction, so the read-path fast cloner does not need toJSON support.
flush() previously iterated the entire LRU (default capacity 10 000) to find entries with dirty != null. Maintain a Set<string> of currently dirty keys, populated in set() (synchronously, so callers observe the key in _dirtyKeys immediately) and in _setLocked (after the entry is in the buffer), drained in markDone with a reference-equality guard so entry replacement during an in-flight write is handled correctly.
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
Review Summary by QodoPerf: four internal wins in cache/buffer layer (clone split, dirty-key set, lazy flush, lock-free get)
WalkthroughsDescription• Split clone() into cloneIn/cloneOut using V8-native structuredClone on read path • Track dirty keys in Set to avoid scanning entire LRU during flush • Replace periodic setInterval with on-demand setTimeout for idle databases • Add lock-free fast path in get() for cache hits with no concurrent writer Diagramflowchart LR
A["Read Path<br/>get/getSub/findKeys"] -->|cloneOut| B["structuredClone<br/>V8-native"]
C["Write Path<br/>set/setSub"] -->|cloneIn| D["Recursive clone<br/>with toJSON"]
E["_setLocked"] -->|add key| F["_dirtyKeys Set"]
F -->|iterate only| G["flush loop<br/>not full LRU"]
H["_setLocked"] -->|call| I["_scheduleFlush"]
I -->|arm if dirty| J["setTimeout<br/>on-demand"]
K["get cache-hit<br/>no lock held"] -->|fast path| L["skip lock<br/>return cloneOut"]
M["get cache-miss<br/>or lock held"] -->|slow path| N["acquire lock<br/>_getLocked"]
File Changes1. lib/CacheAndBufferLayer.ts
|
Code Review by Qodo
1.
|
The constructor no longer arms a periodic timer. Instead, _setLocked calls _scheduleFlush() when it makes an entry dirty; the timer fires once, runs flush(), and clears itself. flush() re-arms the timer if new dirty entries remain (entry replacement during write). Idle databases consume zero timer ticks.
A cache-hit get() with no concurrent writer no longer constructs a SelfContainedPromise or mutates the per-key lock Map. The check (_locks.has(key)) and buffer read execute synchronously before the first await, so no writer can interleave; structuredClone produces a consistent snapshot. Adjusts test_metrics.spec.ts to reflect that cache-hit get() (but not getSub()) no longer increments lockAcquires or lockReleases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Side-effect of `pnpm run format` during Win 4 implementation. No logic changes — quote style, spacing, trailing-comma normalization, and the standard oxfmt rule set applied repo-wide. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
oxfmt broke the previously single-line `r.table(...).get(...).run(this.connection, ...)` across multiple lines. The leading `// @ts-ignore` only suppresses the line immediately below it, so the multi-line form left `.run(this.connection, ...)` unsuppressed and TS2769 fires because `connection` is typed `Connection | null`. Switch to a non-null assertion at the call site; semantically identical to the prior `@ts-ignore`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MongoDB (databases/mongodb_db.ts): - Remove the per-operation schedulePing() (clearInterval + setInterval on every get/set/findKeys/remove/doBulk). The mongodb driver already maintains its own server heartbeat; the redundant ping cost two timer mutations per DB operation. - Fix findKeys regex: previously stripped '*' instead of converting it to '.*' and had no '^...\$' anchor, producing unanchored substring matches that bypassed the _id index and could return false positives. Now escapes regex metacharacters and converts wildcards correctly. - Switch doBulk from initializeOrderedBulkOp() to initializeUnorderedBulkOp() — our bulk ops are keyed differently per op so ordering is unnecessary and unordered parallelizes server-side. PostgreSQL (databases/postgres_db.ts): - doBulk uses a single multi-row UPSERT (INSERT ... VALUES (\$1,\$2),(\$3,\$4),... ON CONFLICT DO UPDATE) on the native upsert path, collapsing N round-trips into one. The function-based fallback path is preserved for old PG versions. - Named prepared statements on the three hottest single-row queries (get, set, remove) so PG parses and plans them once per connection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cloneIn preserves functions inside objects (the recursive call hits
the `typeof !== 'object'` branch and returns them as-is), so the
cache can hold values that structuredClone refuses. The previous
cloneOut comment claimed this was impossible by construction; it
isn't. Wrap structuredClone in a try/catch and fall back to cloneIn
on the failure path. V8 try/catch is essentially free on the happy
path; only function-containing reads pay the slower walker.
Restores parity with the pre-refactor clone() semantics for the
`set({fn: ...}) -> get()` round-trip via the cache. Adds a
regression test in test/memory/test_tojson.spec.ts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mongo + postgres perf commit introduced three `any` casts that
qodo-style review flagged. Replace them with concrete types:
- mongodb_db.ts: type the findKeys selector as `Filter<UeberDoc>` where
`UeberDoc = { _id: string; value: string }` narrows mongo's default
ObjectId-typed `_id` to our string-keyed schema. The collection is
cast to `Collection<UeberDoc>` at the find() call so the rest of the
driver's typing is unchanged.
- postgres_db.ts: type doBulk's `tasks` array via an `AsyncTaskCb`
alias `(err?: Error | null) => void` matching async.parallel's
contract. Wrap each pg.query in a lambda that bridges pg's
`(err: Error, result)` signature to the async-shaped callback, so
the previous `tasks as any` cast goes away.
No behavior change; ts-check + lint stay clean; 79 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Split into three focused PRs per review feedback. Closing this in favor of:
|
Cache & Buffer Layer Performance Wins
Date: 2026-05-28
Scope:
lib/CacheAndBufferLayer.tsonlyType: Internal refactor — no public API change, no default change, no behavioral regression for documented features (including
toJSONsemantics).Motivation
lib/CacheAndBufferLayer.tssits on the hot path of every operation, regardless of which backend is in use. Profiling-by-inspection identified four cheap, well-isolated wins that compound:clone()JS function runs on everyget/set/setSub/getSub/findKeys. For the read direction the cache already holds a JSON-safe value, sostructuredClone(a V8 native, available since Node ≥17 — the package requires ≥24) is strictly faster than the recursive walker.flush()iterates the entire LRU buffer (default capacity 10 000) everywriteIntervalms (default 100 ms) just to find dirty entries. For idle or read-heavy workloads, ~99% of those iterations find nothing.setIntervalthat runs forever once init completes, even when the database has been idle for hours.get()acquires and releases a per-key lock on every call — even on a guaranteed cache hit with no concurrent writer.Each fix is internal and reviewable in isolation; together they cut sustained per-op CPU cost and eliminate idle-timer load. None of them changes settings, exported types, or method signatures.
Non-Goals
cache,writeInterval, orbulkLimit.Architecture
All work happens in
lib/CacheAndBufferLayer.ts. The four wins are listed below with their concrete implementation contract.Win 1 — Split
clone()intocloneIn/cloneOutProblem. The current
clone()(lines 608-634) is a recursive deep-copy that resolvestoJSONmethods en route. It runs on every read AND every write. For deeply nested values it is meaningfully slower than the V8-nativestructuredClone. However, a naive swap tostructuredClonewould (a) skiptoJSONresolution, breaking the documented behavior verified bytest/memory/test_tojson.spec.ts, and (b) throwDataCloneErrorwhen the user passes a value containing a function (including{toJSON: fn}).Fix. The write direction must keep
toJSON-aware cloning. The read direction can safely usestructuredClonebecause the cached value has already been throughcloneIn(orJSON.parse, which is also JSON-safe).Call-site mapping:
set(key, value)line 389cloneInsetSub(...)line 438cloneInget(key)line 275cloneOutgetSub(...)line 511cloneOutfindKeysline 338cloneOutfindKeysPagedline 371, 380cloneOut_writenon-JSON fallback line 557cloneOutThe
_writenon-JSON fallback usescloneOutbecause the entry value originated from acloneIncall and is therefore JSON-safe.Win 2 — Dirty-Key Set
Problem.
flush()(lines 522-528) iterates the entirebufferto find entries withdirty != null. With capacity 10 000 and one dirty entry, that's 10 000 Map iterations per scan.Fix. Maintain a
Set<string>of currently-dirty keys.Invariant:
key ∈ _dirtyKeys⇔this.buffer.get(key, false)?.dirty != null.Mutations:
In
_setLocked, immediately afterthis.buffer.set(key, entry)(line 420):this._dirtyKeys.add(key);In
markDone(key, entry, err)— signature gainskeyparameter — before callingentry.dirty!.done(err):Order matters: deleting from
_dirtyKeysbeforedone(err)prevents a subtle race where the resolved caller schedules a new write that re-adds the key, only for our latedeleteto wipe it out.LRU.get(k, isUse = true): already exists (line 113). We useisUse=falsefromflush()andmarkDoneso we read the entry without reordering the LRU.flush()new loop:The
writingInProgressskip preserves existing semantics: in-flight entries stay in the set but are not re-issued.Win 3 — Lazy Flush Scheduling
Problem.
setIntervalin the constructor (lines 217-220) fires everywriteIntervalms forever, regardless of whether anything is dirty.Fix. Replace the
setIntervalwith an on-demandsetTimeoutthat is set when the first dirty entry is added and cleared after flushing.Call sites:
_setLocked, right after_dirtyKeys.add(key):this._scheduleFlush();flush()'s outer wrapper, after the inner while-loop completes: ifthis._dirtyKeys.size > 0(some entries became dirty during_write), call_scheduleFlush().close(): replaceclearInterval(this.flushInterval)withif (this._flushTimer) clearTimeout(this._flushTimer);. TheflushIntervalfield is removed.Win 4 — Lock Fast-Path in
get()Problem.
get()(line 267) acquires_lock(key)and releases it on every call, even when the key is already in the buffer and no concurrent write is happening. The lock acquisition allocates aSelfContainedPromiseand performs twoMapmutations per call.Fix. Check synchronously whether a lock exists and the buffer is populated. If both checks pass, return the cloned value directly without locking.
Correctness argument.
All mutating operations (
set,setSub, and the internal write path during_setLocked's synchronous prelude) execute under the per-key lock. JS is single-threaded; the fast path runs fromthis._locks.has(key)throughcloneOut(entry.value)without yielding (noawaits). Therefore one of these holds:_locks.has(key)is false at the check → no writer is currently holding the lock; the fast path completes atomically andstructuredCloneproduces a consistent snapshot. Any writer that starts after our check will not interleave with thisgetcall._locks.has(key)is true at the check → fast path bails to slow path, which serializes via the lock exactly as today.A reader that arrives "between" a writer's lock-release and its
_writeenqueue still sees a consistent buffer entry:_setLocked's synchronous prelude (lines 410-420) updates the entry before awaitingentry.dirty, so the buffer is current whenever the lock is not held.The
setSubmutation walk (lines 449-476) happens entirely under the lock; it is not interleaved with the fast path.Data Flow Comparison
Cache-hit read, no contention
await _lock(key)(Map.set + Promise alloc)_locks.has(key)check_getLocked:buffer.get(key)(Map.delete+set)buffer.get(key)(Map.delete+set)entry.valueentry.value_unlock(key)(Map.delete + Promise.done)clone(v)— recursive walkercloneOut(v)—structuredCloneIdle DB, default settings
setIntervalticksWrite under buffering
set(k, v)_dirtyKeysonlyEdge Cases
Entry replacement during
writingInProgress. When_setLockedseesentry.writingInProgress, it allocates a fresh entry (line 414). The old entry'smarkDonewill then findbuffer.get(key, false) !== entryand leave the key in_dirtyKeys. The fresh entry's_dirtyKeys.add(key)is idempotent.markDoneorder. Always: (a) reference-compare, conditionally delete from_dirtyKeys, (b) callentry.dirty!.done(err), (c) null outentry.dirty. Reversing (a) and (b) would let a resolved caller re-enter and have its add wiped.flush()returning with remainingwritingInProgressentries. The new loop skips them. If a re-set during write left a new dirty entry, the outer while-loop inflush()picks it up on the next iteration. Ifflush()returns and_dirtyKeys.size > 0, the post-loop_scheduleFlush()re-arms the timer.setSuband the fast path.setSubholds the lock through its mutation walk;_locks.has(key)is true during that window; the fast path declines. AftersetSubreleases the lock the buffer is current and the fast path returns the post-mutation value (or a structurally cloned snapshot of it).writeInterval: 0._setLockedsynchronously invokes_write(line 427)._scheduleFlushearly-returns._dirtyKeysis still maintained because_setLockedadds andmarkDoneremoves —flush()still works for explicit calls.cache: 0. Buffer.set still happens in_setLocked. After write completes,buffer.evictOld()with capacity 0 removes everything;_dirtyKeysis empty by then (markDone cleared it). Consistent with today.Failed write.
markDoneis called witherr != null._dirtyKeys.delete(key)still runs (under the reference-equality guard). The caller'sawait prejects. The entry remains in the buffer withdirty = nullandvalueequal to the last-attempted write — same as today.structuredCloneon a value containing a function. Cannot happen incloneOutbecause the cache contains onlycloneIn-output orJSON.parse-output, both of which are function-free. A defensive comment in the code documents this invariant.Circular references. Today's recursive
clone()would stack-overflow on circular structures (set side).cloneInkeeps that behavior.cloneOut(viastructuredClone) tolerates them — strictly more permissive, no regression.Testing
Existing tests that must remain green
test/memory/test_tojson.spec.ts— verifiestoJSONinvocation; protected bycloneIn.test/mock/test_flush.spec.ts,test_metrics.spec.ts,test_lru.spec.ts,test_bulk.spec.ts,test_setSub.spec.ts,test_findKeys.spec.ts.*.spec.tsfiles (only the contract on the wrapped DB is exercised; no driver code changes).test/lib/test_lib.ts'sspeed is acceptableblock — should pass with margin.New tests (all under
test/mock/)test_dirty_set.spec.ts:await db.set('k', v)withwriteInterval > 0, the internal_dirtyKeys.sizeis 0 immediately afterawaitresolves (the await waits for the write to finish, so dirty is cleared). Afterdb.setbefore awaiting,_dirtyKeys.size === 1(use a paused mock driver)._writehangs; call set again on the same key; verify_dirtyKeys.size === 1(idempotent add); release the first write; verify the old entry'smarkDonedoes not clear the key (because buffer now holds the fresh entry); verifymetrics.writesObsoletedincrements._dirtyKeysstill cleared for that key; caller promise rejects.test_lazy_flush.spec.ts:writeInterval: 100, nodb.setcalls: wait 500 ms; verifymetrics.writesToDb === 0and (via a test-only getter orprocess._getActiveHandles().lengthsnapshot) no pending flush timer.writeInterval: 100, onedb.setto a paused mock: timer is armed; release; after 200 ms, write happened; timer is null again.db.close()clears the pending timer (no UnhandledHandle on exit).test_lock_fast_path.spec.ts:get: prime the cache withset+await, then readlockAcquiresmetric, then callget. Acquires count does not increment.set('k', 'v2')(does not await); issueget('k'); the get takes the slow path (because_locks.has('k')is true while set holds it); after releasing the mock, the get resolves with'v2'.Microbenchmark artifacts (non-CI)
pnpm test -- speedlocally onmemoryandsqlitebackends pre- and post-change. Capture the per-op ms table from the existingspeed is acceptableblock. Paste both into the PR body.test/mock/bench_clone.bench.tsusing vitest'sbench()to comparecloneInvscloneOutvs oldcloneon a synthetic Etherpad pad. Documentation only — no CI gate.Risks
clone()throws ("Unable to copy obj!") for non-Date, non-Array, non-plain-object types except via theinstanceof Objectclause — which catches almost everything.cloneInis the unchanged old function for write paths, so this is unaffected.markDonecould leak entries from_dirtyKeys(causing stale iteration) or wrongly clear them (causing missed flushes). The new tests intest_dirty_set.spec.tscover both error modes._scheduleFlush()call would leave dirty entries unwritten until the next explicitflush(). Covered bytest_lazy_flush.spec.ts.awaitbetween the lock check and the buffer read would break the guarantee. A code comment marks the synchronous region.Rollout
Single PR, all four wins together. The wins are mutually reinforcing (Win 2 enables Win 3 cheaply; Win 1 amortizes the cost of the buffer access in Win 4). Reverting any individual win is straightforward because the changes are localized to specific methods.