feat(redis): use native commands for cache reads - #123
Conversation
| /** @deprecated Bundled adapters use native GET. Retained for custom-adapter compatibility. */ | ||
| export const READ_CACHE_SCRIPT = [READ_FRAME_LUA, RETURN_PAYLOAD_LUA].join("\n\n"); | ||
|
|
||
| /** @deprecated Bundled adapters use native MGET. Retained for custom-adapter compatibility. */ |
There was a problem hiding this comment.
should we just remove altogether?
| .. ARGV[3] | ||
| redis.call("SET", KEYS[1], frame, "PX", cache_ttl_ms)`; | ||
|
|
||
| /** @deprecated Bundled adapters use native GET. Retained for custom-adapter compatibility. */ |
There was a problem hiding this comment.
same, probably best to just remove at this point.
| } | ||
| const createdAtMs = Number(raw.readBigUInt64BE(1)); | ||
| return createdAtMs <= watermark | ||
| ? null |
There was a problem hiding this comment.
will return null count as a miss logically and in instrumentation?
lan17
left a comment
There was a problem hiding this comment.
Verification
tsc --noEmiton the branch: clean.- Full unit suite: 418/418 pass, coverage thresholds hold.
- Real node-redis
RedisClientType/RedisClusterTypestructurally satisfy the newNodeRedisStandaloneClient/NodeRedisClusterClientinterfaces (compiled a standalone assignability probe against theredis@1.6.1typings). - Signatures check out:
RedisCluster.sendCommand(firstKey, isReadonly, args, options)andRedisClient.sendCommand(args, options)match the declared shapes. invokeScriptWithRouteexists only onGlideClusterClient, andcustomCommandexists on both, so the cluster type guard's conjunction is sound.- The TS decoder is a faithful port of the Lua. I diffed
parseRedisWatermarkagainstPARSE_WATERMARK_LUAgrammar-for-grammar (^%d+$/^%d+%.%d+$,>= math.hugerejection,1\nrejection via Lua's strict$), and the frame checks againstREAD_FRAME_LUA(len < 10, version byte,sub(value,10)=subarray(9),struct.unpack(">I8", sub(value,2,9))=readBigUInt64BE(1),created_at <= watermark). Missing watermark is a miss in both. No semantic drift found. - The GLIDE standalone-batch primary claim is documented, not just empirical —
Batch.d.ts:3086:@remarks Standalone Batches are executed on the primary node.
The core change is well-executed. Issues below are ordered by what I'd want addressed.
1. Delete the deprecated read API instead of retaining it
DialCache is new; unused API should be deleted, not deprecated.
The PR keeps READ_CACHE_SCRIPT and READ_TRACKED_CACHE_SCRIPT (src/internal/redis-scripts.ts:57) plus dialcacheRead and dialcacheReadTracked (src/node-redis.ts:56) as inert compatibility entries. The cost shows up immediately in the tests: test/node-redis.test.ts:248 now exercises transformReply on dead code purely to hold coverage, and line 78 asserts Object.keys(dialcacheRedisScripts) in exact order — a brittle assertion whose only job is to pin the dead entries in place. The README grows a sentence explaining that two registrations exist but do nothing.
The deletion is mechanical and fully enumerable:
READ_CACHE_SCRIPT/READ_TRACKED_CACHE_SCRIPTfromredis-scripts.tsandredis-protocol.tsREAD_FRAME_LUAandRETURN_PAYLOAD_LUAbecome unreferenced (PARSE_WATERMARK_LUAstays — write-tracked and invalidate still use it)dialcacheRead/dialcacheReadTrackedfromdialcacheRedisScripts, plus the now-unusedreadReplyhelperscripts/test-package.mjs:52importsREAD_CACHE_SCRIPT→ switch it to a write script- The two test assertions above, and the README sentences
2. The invalidated-miss regression has a worse case than the benchmark shows
The PR body honestly reports 77-84% throughput loss on invalidated tracked misses at 1 MiB, framed as a transient cost until the fallback repopulates. But WRITE_TRACKED_CACHE_SCRIPT returns 0 without writing when watermark >= now_ms, and INVALIDATE_CACHE_SCRIPT never touches the value key. So when a caller passes a non-zero futureBufferMs to invalidateRemote — the documented safety window for source replication lag — no write can repair the entry for the whole window, and every tracked read in that window ships the full stale payload only to discard it. Under Lua those reads returned ~3 bytes. Default futureBufferMs = 0 keeps this off the common path, but the feature exists precisely to be used.
Second-order effect worth stating: readTimeoutMs defaults to 50 ms. A 1 MiB stale transfer that used to be a null reply can plausibly blow that deadline over a real network, so invalidations of large tracked entries can spike cache_read timeout metrics — a failure mode that didn't previously exist.
I'd extend the tradeoff section to cover the future-buffer window explicitly. If you want to actually mitigate it, the cheapest option is for the tracked read path to fire-and-forget an UNLINK on the value key when it decodes a stale frame; that trades one extra command on a cold path for bounding the amplification.
3. The cluster test lost its strongest assertion
test/redis-cluster.integration.test.ts:110 changed the recovery namespace to "cluster-cache-recovery", so calls goes 30 → 60 and expect(second).toEqual(first) weakened to second.map(({ id }) => id). I understand why: the second round now has to miss in order to exercise mutation-script reload after SCRIPT FLUSH, since reads no longer reload anything.
But the assertion that disappeared — "after SCRIPT FLUSH, reads still serve the previously cached values with no extra source calls" — is exactly the one that catches a broken native cluster read path. Add a third round on the original "cluster-cache" namespace asserting calls stays at 60 and the values deep-equal first. That restores the coverage without undoing the rewrite.
4. The GLIDE cluster-detection fallback is unsound
src/valkey-glide.ts:76 requires both customCommand and invokeScriptWithRoute; if either is missing, a client falls through to new glide.Batch(false) at line 163. For a real GlideClusterClient that is the wrong class — GlideClusterClient.exec takes ClusterBatch, not Batch. Today the guard cannot miss a genuine cluster client, but the failure mode if GLIDE ever renames a method is silent misrouting, and the new test locks in the fallback as intended behavior.
node-redis got the "positive marker" treatment ("masters" in client); do the same here. Detect cluster-ness on one stable marker and throw a clear error if the routing capability is absent, rather than degrading into a code path that cannot be correct for a cluster client.
Nits
- Error-message asymmetry. node-redis distinguishes
"…expected a bulk string or null"from"…expected two bulk strings or nulls"; GLIDE throws the same generic"Invalid DialCache Redis payload reply"from three distinct sites (src/valkey-glide.ts:152-176). The README sells these errors as a way to distinguish failure sites in logs — worth matching node-redis's specificity. - Unbounded watermark stringification.
parseRedisWatermarkdoesraw.toString("utf8")on the whole buffer before validating. A watermark key holding a large string allocates it fully on every tracked read. Araw.length > 32 → nullguard costs one line. - Empty-value behavior change. The GLIDE test dropped
read({ valueKey: "empty" })→DialCacheRedisPayloadError; a zero-lengthGETreply is now a clean miss. Sensible fail-open, but it is an intentional loosening that is not called out anywhere. - Cite the GLIDE doc. The comment at
src/valkey-glide.ts:96asserts the standalone-batch invariant as if it were an observation. It is in GLIDE's own docs — quoting that makes the invariant auditable instead of dependent on a probe someone ran once. - Optional.
BatchOptionssupportstimeout, so the standalone tracked path could forwardcontext.timeoutMsand get a real per-request budget where the other GLIDE paths cannot. Asymmetric, so only worth it if partial coverage beats none.
Bottom line
The read-path rewrite is correct and the Lua→TS port is faithful — I checked it closely and found no semantic drift. §1 is the one I would block on; §2 and §3 are documentation and test-coverage debt that are cheap to pay now.
BREAKING CHANGE: READ_CACHE_SCRIPT, READ_TRACKED_CACHE_SCRIPT, dialcacheRedisScripts.dialcacheRead, and dialcacheRedisScripts.dialcacheReadTracked are removed. Custom wrappers must expose native read commands.
lan17
left a comment
There was a problem hiding this comment.
Re-reviewed at e64577e (one new commit since the last pass: refactor(redis)!: remove legacy read Lua).
Re-verified on the new head
tsc --noEmit: clean.- Unit suite: 418/418 pass, coverage thresholds hold.
- Grepped every removed name across
src,test,scripts,README.md,AGENTS.md— the only remaining hits are the intentional negative assertions intest-package.mjs. Clean deletion. - Verified the
@ts-expect-error-over-a-multi-specifier-import pattern actually compiles as intended (standalone probe with--strict --noUnusedLocals; the directive absorbs bothTS2305s and is not flagged unused).
§1 — Delete the deprecated read API: done, and past what I asked for
Everything on the enumeration came out: READ_CACHE_SCRIPT, READ_TRACKED_CACHE_SCRIPT, READ_FRAME_LUA, RETURN_PAYLOAD_LUA, dialcacheRead, dialcacheReadTracked, the orphaned readReply, the redis-protocol.ts re-exports, both README sentences, and the two coverage-driven test assertions. Object.keys(dialcacheRedisScripts) is down to the three real scripts.
The addition I did not ask for and like: test-package.mjs now pins the removal at two levels — @ts-expect-error on the consumer side, plus runtime in checks against the packed ESM and CommonJS entries for all four names. That is the right way to make a deletion stay deleted.
PARSE_WATERMARK_LUA correctly survived (write-tracked and invalidate still use it), and REDIS_FRAME_VERSION is still live in both WRITE_FRAME_LUA and the TS decoder.
§2 — Invalidated-miss regression: partially addressed
The new records a stale tracked frame as a remote miss without a read error integration test is a good addition — it drives the real decoder end to end, asserts request / miss / observeGet / observeFallback fire with the right labels and error does not, and it sits exactly on the createdAt == watermark boundary. That closes the "does a stale frame look like a failure to the metrics layer" question.
It does not cover what I raised, though. The PR body's tradeoff paragraph is unchanged, so it still reads as if the invalidated-miss cost is one-shot until the fallback repopulates. It is not: WRITE_TRACKED_CACHE_SCRIPT returns 0 without writing while watermark >= now_ms, and INVALIDATE_CACHE_SCRIPT never touches the value key — so a non-zero futureBufferMs on invalidateRemote blocks the repairing write for the entire window, and every tracked read in it ships the full stale payload. Still worth a sentence, along with the readTimeoutMs (50 ms default) interaction: a 1 MiB stale transfer that used to be a null reply can now blow the read deadline over a real network.
§3 — Cluster test assertion: not addressed
test/redis-cluster.integration.test.ts is byte-identical to 7c76bac. The expect(second).toEqual(first) / calls === 30 coverage is still gone.
§4 — GLIDE cluster-detection fallback: not addressed
src/valkey-glide.ts is byte-identical to 7c76bac. A cluster client that fails the two-method guard still falls through to new glide.Batch(false), which GlideClusterClient.exec does not take.
Nits
The five from the previous review still stand (GLIDE error-message asymmetry, unbounded watermark toString, the undocumented empty-value loosening, citing GLIDE's own Batch.d.ts:3086 remark, optional BatchOptions.timeout).
One new, minor: the single @ts-expect-error at scripts/test-package.mjs:53 covers both specifiers on that line, so it only asserts at least one of the two is missing — if one export came back, the directive would still be satisfied by the other. The runtime in checks at lines 656-663 / 906-913 do pin them individually, so nothing is actually unguarded; splitting into two directives would just make the compile-time half as strong as it looks.
Net: the blocking item is resolved cleanly. What is left is §3 and §4 plus documentation on §2 — none of which touch the read path's correctness.
lan17
left a comment
There was a problem hiding this comment.
review-loop — report mode
Run: report mode, lanes brutal + reliability + performance. Snapshot target=fad2736 base=fad2736 head=e64577e.
Incomplete, deliberately posted early: all 7 leaf lanes finished and I adjudicated them, but the holistic stage-one pass was still running and stage two never ran. So this is an adjudicated leaf-lane result, not a completed review-loop run — no cross-lane audit challenged my dispositions below.
16 raw findings deduped to 8 accepted. correctness returned clean.
Verified, not just relayed
Three lanes independently re-derived Lua→TS decode parity and all three agree it holds exactly: REDIS_FRAME_MIN_BYTES = 10 ≡ string.len(value) < 10; raw[0] === REDIS_FRAME_VERSION ≡ string.byte(value, 1) ~= 1; raw.subarray(9) ≡ string.sub(value, 10); readBigUInt64BE(1) ≡ struct.unpack(">I8", string.sub(value, 2, 9)); /^[0-9]+(?:\.[0-9]+)?/ plus full-length match ≡ ^%d+$/^%d+%.%d+$ including trailing-newline and embedded-NUL rejection; Number.isFinite ≡ value >= math.huge for digit-only input. Frames written by the unchanged write Lua decode identically under old and new readers, so rolling deploy and rollback are safe.
Primary routing was verified in the compiled internals rather than assumed: node-redis sendCommand(firstKey, false, …) resolves through #execute → slots.getClient(firstKey, false) to slots[slot].master; returnBuffers applies to nested array members, so MGET really does yield [Buffer|null, Buffer|null]; GLIDE Decoder.Bytes applies to the whole response tree; ClusterResponse<T> returns bare T for single-node routes.
A latent pre-existing hazard is removed by this change: node-redis always emits plain EVALSHA (client/index.js:207) regardless of IS_READ_ONLY — that flag only picks the cluster node. So the old untracked read script could be routed to a replica under useReplicas: true and rejected. Native GET eliminates that.
Accepted findings
1. Stale tracked frames re-transfer on every read for the whole invalidation window
defect · medium · src/internal/redis-scripts.ts:68 (mitigation site)
Three code facts make the invalidated-miss cost sustained rather than one-shot:
INVALIDATE_CACHE_SCRIPTonly bumps the watermark and cannot delete the value key — one watermark covers everyargsvariant of every tracked use case sharingkeyType+id.WRITE_TRACKED_CACHE_SCRIPTreturns0without writing whilewatermark >= now_ms, so during a nonzerofutureBufferMswindow no fill ever replaces the stale frame.src/dialcache.ts:783setssuppressCacheWrite = wroteRemote === false, which then skipsputLocalFailOpenat line 789. There is no local backstop and no negative caching, so every request in the window reaches Redis.
I corrected the reporting lane's magnitude. It sized this at ~200 MB per invalidation from 1000 rps × 2 s × 100 KiB. That ignores DialCache's own process-scope coalescing: one Redis read per key is in flight per process, and each cycle also pays fallback latency. Real amplification is roughly processes × window / (read + fallback time) full-payload transfers — one to two orders of magnitude smaller. I reduced severity from high accordingly. The mechanism stands.
The sharpest consequence is the deadline interaction: readTimeoutMs defaults to 50 ms, which must now cover a full stale-payload transfer. Invalidating a large tracked value can convert cheap misses into cache_read_timeout errors on a real network, and GLIDE has no per-invocation cancellation, so the discarded payload keeps streaming after DialCache has already fallen back.
Recommendation: add redis.call("UNLINK", KEYS[1]) immediately before the return 0. I checked this is read-neutral: at that point watermark >= now_ms, so any frame at KEYS[1] was written with created_at <= now_ms <= watermark and the TS gate already rejects it; Lua atomicity closes the race; KEYS[1]/KEYS[2] share the {namespace:keyType:id} tag so it is cluster-safe. One caveat I'd want acknowledged: under a failover with clock skew a frame could carry created_at > now_ms and would then be deleted where the gate would have accepted it — a miss, not incorrectness.
Also worth a sentence in the README future-buffer sizing guidance, which currently says overestimating only "increases fallback load."
2. GLIDE topology is chosen by probing a method the adapter never calls
defect · medium · src/valkey-glide.ts:76-83
isValkeyGlideClusterClient requires both customCommand and invokeScriptWithRoute. The adapter never calls invokeScriptWithRoute — it is a nominal brand implemented as a capability probe, needed only because standalone GlideClient also has customCommand.
Both mis-detections fail silently, and the reachable one is not hypothetical:
- Cluster read as standalone — a caller wrapping a cluster client in exactly the documented
ValkeyGlideScriptingClientsurface (which the README invites) typechecks fine, fails the sniff, and takes thenew glide.Batch(false)path.BatchandClusterBatchboth extendBaseBatch, so it likely executes and lets GLIDE routeMGETunder the client'sreadFrom. - Standalone read as cluster —
GlideClient.customCommand(args, options?: DecoderOption)takes noRouteOption, so the excessrouteis silently ignored andMGETagain followsreadFrom.
Either path reads the invalidation watermark from a replica with no error, no metric, no log — defeating the invariant AGENTS.md calls critical, and turning an invalidated payload into a hit.
Nothing can catch it. There is no runtime GLIDE cluster test in the repository: GlideClusterClient appears exactly once, at scripts/test-package.mjs:531, as a never-executed declare const — and it is passed only to the factory, which checks the public parameter type, while ValkeyGlideClusterReadClient is unexported and reachable only through the predicate. The unit test "requires the complete cluster command capability" exercises a client with invokeScriptWithRoute but no customCommand, a shape no real client presents, so no test proves the guard needs both.
Contrast src/node-redis.ts:127-147, where the same distinction is a declared union with masters on the cluster arm: topology is carried by the type and a mis-sniff fails loudly on the wrong sendCommand overload.
Recommendation: discriminate on identity, not shape. The caller already must pass the same GLIDE namespace, so add GlideClusterClient to ValkeyGlideRuntime and use instanceof. Failing that, probe a capability the adapter actually consumes and throw on ambiguity rather than defaulting to the replica-eligible path.
(I raised a weaker version of this in an earlier review and then withdrew it as speculative. The withdrawal was wrong — I had not found the narrow-wrapper path or the second mis-detection direction.)
3. glide.Batch is resolved lazily, on one code path
defect · medium · src/valkey-glide.ts:109-111 vs :163
The factory eagerly constructs all three new glide.Script(...) handles at wiring time, but never touches glide.Batch. The first dereference is new glide.Batch(false).mget(...) inside read(), reached only on the tracked standalone path.
@valkey/valkey-glide is a devDependency only — it is absent from peerDependencies, and the README install line carries no version floor. Batch/ClusterBatch are the v2 names (the 2.4.2 typings still carry Transaction extends Batch marked deprecated, which is the v1→v2 rename), and the pre-change adapter needed only Script, Decoder, and invokeScript — all present in v1.
So on an older runtime, or a JS consumer, or a runtime/typings mismatch: construction succeeds, dispose() succeeds, untracked reads, writes, invalidation and every cluster read succeed — then tracked standalone reads throw an opaque TypeError, not a DialCache error type. Reads fail open, so the deployment silently degrades to 100% source load on tracked use cases with only a cache_read metric. Because the cluster branch never touches Batch, this is invisible in cluster-backed staging and appears first in standalone production.
This is precisely the mixed-install hazard the eager-Script design already guards against.
Recommendation: validate typeof glide.Batch === "function" in the factory alongside the eager Script construction, and state the minimum GLIDE version in the README and the breaking-change note — ideally as an optional peer dependency.
4. The cluster SCRIPT FLUSH test can no longer fail for its stated reason
defect · medium · test/redis-cluster.integration.test.ts:110-157
Re-namespacing the recovery round to cluster-cache-recovery makes its 30 keys disjoint from round one, so every read is a guaranteed miss and the surviving assertions — expect(calls).toBe(60) and an id-only comparison — are derived purely from the source function.
DialCache fails open on both sides: read errors are recorded and rethrown but absorbed upstream, and write errors are swallowed at src/dialcache.ts:784-785 (logger.warn, then suppressCacheWrite = key.trackForInvalidation). So if the write script failed to reload on every shard after SCRIPT FLUSH — or if Redis were entirely unreachable — calls would still be 60 and the ids would still match. sizesBeforeFlush is captured before the flush and cannot compensate.
The pre-change assertions (calls === 30, second deep-equals first) did prove post-flush recovery, because 30 remote hits were required. The standalone suite keeps the correct pattern at test/redis-real.integration.test.ts:1098-1126.
Recommendation: add a third round against cluster-cache-recovery asserting calls stays 60 and the values deep-equal second. That proves the post-flush writes landed on every shard and that native GET returns hits across all 30 slots.
(I flagged this in an earlier review, then downgraded it in a self-audit as "thin — the lost coverage is marginal." That downgrade was wrong. I was measuring lost hit-coverage; the real problem is that the test is now vacuous with respect to its own name.)
5. The read half of the Redis protocol lost its published source of truth
improvement · medium · src/redis-protocol.ts:1-9
dialcache/redis-protocol previously exported READ_CACHE_SCRIPT and READ_TRACKED_CACHE_SCRIPT. The rules that replaced them — header length, version equality, watermark grammar, and the createdAt <= watermark fence — now live only in src/internal/redis-payload.ts, which no entry point re-exports. DialCacheRedisClient.read documents neither the primary-routing requirement nor any miss rule nor the comparison direction, and the README still invites custom adapters in the same paragraph that now offers only write and invalidation sources.
The write side ships as an executable spec any adapter can EVAL and get right by construction. The read side ships as prose plus a private module. A third-party adapter that inverts <=, or treats a missing watermark as fresh, silently serves invalidated data — failing at exactly the guarantee the library exists to provide. Both bundled adapters are safe only because they share the internal module.
Recommendation: re-export the two frame decoders under public names from src/redis-protocol.ts, and move the read invariants into the DialCacheRedisClient.read doc comment. Both changes are additive.
6. Read-reply shape validation is written three times
improvement · medium · src/node-redis.ts:155-190, src/valkey-glide.ts:136-173, src/internal/redis-payload.ts:54-75
decodeRedisFrame and decodeTrackedRedisFrame accept only pre-narrowed Buffers, so each adapter re-derives "a DialCache read reply is a bulk string or null" independently: node-redis grows validateRedisBulkStringReply and validateRedisMGetReply plus a tuple threaded through readTracked; GLIDE grows asRedisFrame, an inline length check, and three copies of the same message string. Five throw sites, three distinct messages, two wire shapes.
The narrow parameter type is load-bearing in the wrong direction: decodeRedisFrame applied to a 10+ character string returns null rather than throwing, because raw.length >= 10 holds and raw[0] is a character. Any future adapter that forgets the pre-validator converts a wrong-typed reply into a silent cache miss. The safe path is opt-in rather than structural.
A shared home already exists as precedent: src/internal/redis-script-reply.ts owns the cross-adapter write/invalidate reply-domain checks.
Recommendation: widen the decoders to accept unknown and let them own the shape check and its single error message. Adapters collapse to return decodeRedisFrame(await client.get(options, valueKey)), deleting both node-redis validators, asRedisFrame, the tuple type, and two of three message strings. This also fixes the diagnostic asymmetry: GLIDE currently collapses three materially different wire failures — a non-Buffer MGET member, a malformed exec envelope, and a malformed MGET pair — into one message, which is the pair that most needs distinguishing in production, on the path that has no cancellation signal.
7. Wrong-type handling now diverges by tracked-ness
improvement · low · src/node-redis.ts:200-216
Pre-change both read paths ran GET inside Lua, so a wrong-type key raised WRONGTYPE uniformly. Now the untracked path still surfaces WRONGTYPE while the tracked path uses MGET, which reports a wrong-type member as nil and yields a clean miss.
The consequence is asymmetric and untested at one end. A wrong-type tracked value key self-heals — the integration test proves it. A wrong-type untracked key errors on every read forever and is never repaired, because a read failure never triggers a post-fallback write. And a wrong-type tracked watermark is the one case that never self-heals at all: WRITE_TRACKED_CACHE_SCRIPT does GET KEYS[2] on the hash, which aborts the script, so every request misses and every write errors. That case also lost half its observability in this PR — it previously recorded a cache_read error, and now only cache_write remains.
Recommendation: pick one semantic and state it in the read contract, and extend the wrong-type test to drive a tracked read twice against a hash-typed watermark, asserting the source is called both times and that cache_write records the error.
8. decodeRedisPayload's empty-payload guard is unreachable
improvement · low · src/internal/redis-payload.ts:38-41
Two lanes reached this independently and I confirmed it: decodeRedisPayload has exactly two callers, both in the same file, both gated by isSupportedRedisFrame requiring length >= 10. raw.subarray(9) therefore always has at least the encoding byte, so raw.length === 0 cannot hold. The function is not re-exported from any entry point, so no custom adapter can reach it either. Its only exercise is the direct call at test/redis-payload.test.ts:48, which keeps a dead branch green.
Moving the length invariant from Lua into TypeScript is what made this provable. Given the delete-don't-deprecate stance this PR otherwise applies cleanly, it's the same cleanup one level down.
Raised but not filed, with rationale
- Untracked GLIDE reads became replica-eligible. Three lanes raised this independently:
invokeScriptissuesEVALSHA, whichreadFromcannot route to a replica;client.getcan. Correct and undocumented, but you explicitly set it aside earlier, so I am surfacing it for you to re-decide rather than silently including or dropping it. release.config.mjsmapsbreaking: true → major, so the!marker cuts 0.15.0 → 1.0.0, not 0.16.0. Same treatment — you set the version question aside; a lane found it independently.- node-redis
legacyMode: trueis newly broken. Legacy mode rewritessendCommandand redefinesgetbut leavesdefineScriptregistrations alone, so the old script-only read path survived it and the native path returnsundefined. Not filed: legacy mode is a v3 compat shim DialCache never claims to support. A one-line README statement would close it. - Two nits from my earlier review, withdrawn. No lane raised the unbounded watermark
toStringor the shared@ts-expect-errordirective. Both defended the wrong boundary; dropping them.
Open, needs a live cluster
- Whether GLIDE's explicitly-routed
customCommandstill refreshes the slot map and retries onMOVEDduring resharding. If it does not, tracked cluster reads could surface rawMOVEDerrors where the pre-changeinvokeScriptpath did not. - GLIDE standalone batch primary-routing rests on GLIDE's own doc comment (
Batch.d.ts:3086, "Standalone Batches are executed on the primary node"), not on the Rust core. Load-bearing for finding 3's alternative: if standalonecustomCommand(["MGET", ...])already reaches the primary, the entireBatchconcept — the interface, the required runtime member, and the exec/unwrap branch — collapses into the same call the cluster path uses.
Bound repeated stale-frame transfers after a completed fallback, fail closed on ambiguous GLIDE topology, publish the shared decoders, and strengthen real-engine and package coverage. BREAKING CHANGE: Legacy read-Lua exports are removed; node-redis adapters require the promise-mode native-command surface; GLIDE adapters require a direct GLIDE 2.x client from the supplied runtime; and fenced tracked writes require Redis UNLINK support plus ACL permission.
## Summary Replace write-side Lua with native Redis commands, mirroring what #123 did for reads. The payload no longer crosses the Redis-to-Lua boundary on any path: - untracked writes are one native `SET` of a client-encoded frame carrying an informational client-clock timestamp - tracked writes pipeline two ordered commands in one round trip: a native `SET` of a version-0 placeholder frame carrying a fresh random 8-byte nonce, then the payload-free `WRITE_TRACKED_STAMP_SCRIPT` (`EVALSHA`), which fences against the watermark (reply 0), promotes exactly the placeholder carrying its nonce to a served frame with Redis server time (reply 1), or reports the placeholder gone (reply 2), and maintains watermark TTL exactly as before - invalidation remains unchanged Lua (though both adapters now retry its dispatch once on rejection — see behavioral change 6) The old write scripts made the payload cross the Lua VM ~3 times with two full-payload interning hashes plus Lua GC on the Redis main thread — on every cache miss, so low-hit-ratio caches paid it constantly. Based on #123's measurements of the same elimination on reads (719.8µs → 32.6µs server-side at 1 MiB), expect ~95% server-side command-time reduction at 1 MiB writes and flat small-payload p50 with large Redis-CPU savings. ## Write architecture | Adapter / mode | Untracked | Tracked | Ordering guarantee | | --- | --- | --- | --- | | node-redis standalone | native `SET` via `sendCommand` | same-tick `SET` + registered stamp script | synchronous FIFO enqueue, one corked flush | | node-redis Cluster | native `SET` routed by value key | same-tick pair routed by value key | per-node FIFO in steady state; resharding splits surface as failed writes | | GLIDE standalone | direct `customCommand` `SET` | `Batch(false)` `SET` + `EVALSHA` by the stamp SHA1, `EVAL`-by-source fallback | ordered within the batch | | GLIDE Cluster | `customCommand` `SET` with `primarySlotKey` route | `ClusterBatch(false)`, same two commands, routed | ordered within the routed batch | ## Correctness invariants - A placeholder is unreadable on both read paths (version byte 0), so an interleaved, delayed, or lost stamp degrades to a miss bounded by the value TTL — never partial or stale data. - The per-write nonce gives the stamp identity: it can only promote the exact frame its paired `SET` wrote. A leftover placeholder from an earlier failed write can never be published by a later write's stamp, even across an intervening invalidation. - Reply 2 (placeholder gone: `SET` rejected, overwritten by a concurrent writer, expired, or removed by a fenced write) fails the write with the new symbol-branded `DialCacheRedisPlaceholderLostError`, so split pairs are observable instead of silent. Same-key herd-race losers produce a benign, self-healing floor of these on hot keys. - SET-error precedence: a `SET` failure is the write's outcome even when the stamp settled. - Fenced writes still return `false`, still `UNLINK` the value key, and never touch the watermark. - The pair is deliberately not `MULTI`/`EXEC`, which would consume caller-owned `WATCH` state. - GLIDE recovers a flushed script cache by re-sending the stamp as `EVAL` with its source and the same nonce, which the server caches under the same SHA1; only `NOSCRIPT` (proving non-execution) triggers that retry. - Adapters validate `cacheTtlMs` client-side (finite, positive, ≤365 days, fractional values ceiled) via the exported `ceilSupportedCacheTtlMs`; the stamp script re-checks the same domain server-side as defense in depth. ## Behavioral changes (documented in README) 1. A failed or fenced tracked write leaves an unreadable placeholder (until TTL or the next successful write) instead of preserving prior bytes; a fenced write transiently stores the placeholder before unlinking it. 2. A previously readable key blanks briefly while its overwrite's pair is in flight; NOSCRIPT retries widen that gap by one round trip on cold script caches (both adapters). 3. A persistent stamp failure (e.g. an ACL missing `GETRANGE`/`SETRANGE`) now empties the cache tier within one TTL horizon instead of serving stale — verify ACL grants before upgrading. 4. Untracked frames carry an informational client-clock timestamp (untracked reads never consult it). 5. `cache_write` errors now include the benign lost-placeholder floor; the error class distinguishes it in logs and catch blocks. 6. Invalidation on both adapters now retries any rejected `EVALSHA` dispatch once with `EVAL` by source (the script is idempotent, so duplicate execution is harmless); reply-domain violations are never retried. When the retry also fails, the surfaced error is the retry's: on GLIDE the original rejection rides its `cause` (GLIDE mints a fresh error per rejection), while on node-redis the rejection surfaces unmodified and the original is discarded, because node-redis rejects every command flushed by one disconnect with a single shared error instance that the adapter must not mutate. Previously node-redis recovered only from `NOSCRIPT`, so an `EVALSHA`-rejecting proxy failed every invalidation permanently; a permanently healing invalidation dispatch is visible only in server-side `INFO commandstats`. ## Validation - typecheck; 512 unit tests with coverage thresholds; build - packed ESM/CJS artifact tests including the GLIDE 2.0.0-floor compile, removed-export absence checks, cross-bundle error-brand assertions, runtime pins on every `dialcache/redis-protocol` helper, and Lua-source identity for both scripts across the node-redis, valkey-glide, and redis-protocol bundles (CommonJS duplicates the sources per entry point; a silent fork would leave entry points running different Lua under different SHA1s in one fleet — including divergence from the exported `dialcache/redis-protocol` source custom adapters use — while every behavioral test passes) - 139 integration tests across Redis 6.2, Valkey 8, and Redis Cluster: foreign-nonce stamp refusal, paired-nonce promotion, stamp-after-lost-SET, per-node stamp reload after SCRIPT FLUSH with a server-side SHA assertion, CROSSSLOT on the tracked pair, watermark TTL trajectories, fence trajectories, and the compression seam (a zstd envelope survives placeholder promotion, invalidation, and refill) - multi-lane adversarial review across three campaigns (pre-merge until-clean, a safety re-run, post-merge until-clean); every correctness pass from round two onward returned clean - GLIDE cluster paths run against the real 3-node cluster harness; the reachability gate fails closed under CI, so those cases can only skip on local Docker Desktop ## Rollout note During a rolling deploy, old-protocol writers racing new-protocol writers on the same tracked keys can produce transient `DialCacheRedisPlaceholderLostError` write failures (fail-open). Both frame formats interoperate in both directions; no data migration or cache flush is required. Rollback is safe by the same argument: old readers treat version-0 placeholders as a miss and old writers overwrite atomically, so no flush is needed in either direction. ## Breaking changes Released as a **minor** (0.19.0 — main took 0.18.0 with zstd compression): release.config.mjs maps breaking commits to minor releases while DialCache is pre-1.0 MVP, so the machine-parseable footer below drives full release notes without forcing 1.0.0. - `dialcache/redis-protocol` removes `WRITE_CACHE_SCRIPT`, `WRITE_TRACKED_CACHE_SCRIPT`, `REDIS_FRAME_VERSION`, `REDIS_ENCODING_UTF8`, and `REDIS_ENCODING_BINARY`; it adds `encodeRedisFrame`, `encodeTrackedRedisPlaceholder`, `TrackedRedisPlaceholder`, `WRITE_TRACKED_STAMP_SCRIPT`, `resolveTrackedRedisWriteReply`, `validateRedisSetReply`, `validateRedisScriptInvalidationReply`, and `ceilSupportedCacheTtlMs`. - `dialcacheRedisScripts` drops `dialcacheWrite`/`dialcacheWriteTracked` and gains `dialcacheWriteTrackedStamp(valueKey, watermarkKey, cacheTtlMs, nonce)`; `createNodeRedisDialCacheClient` throws `TypeError` without those registrations; the structural `sendCommand` argument type widens to `string | Buffer`. - The tracked write reply domain is `0 | 1 | 2`; reply `2` must fail the write with the new root-exported `DialCacheRedisPlaceholderLostError`. - The GLIDE adapter is stateless: it returns a plain `DialCacheRedisClient` (no `dispose()`), `ValkeyGlideDialCacheClient` and `ValkeyGlideScriptHandle` are removed, `ValkeyGlideRuntime<TDecoder>` loses its `Script` constructor and `TScript` parameter but requires `ClusterBatch`, and `ValkeyGlideScriptingClient<TDecoder>` requires `customCommand` and drops `invokeScript`. Both mutation scripts dispatch as `EVALSHA` by source SHA1 with `EVAL`-by-source recovery. - ACLs must allow the client-issued `GET`/`MGET`/`SET` (`SET` newly carries every write) plus `EVALSHA` and `EVAL`, and the script-invoked `TIME`/`GET`/`SET`/`PTTL` (both scripts) plus the stamp's `PEXPIRE`/`UNLINK`/`GETRANGE`/`SETRANGE`; adapters reject non-numeric or out-of-range `cacheTtlMs` with `RangeError` before issuing commands. BREAKING CHANGE: the write protocol is rebuilt on native commands. dialcache/redis-protocol removes WRITE_CACHE_SCRIPT, WRITE_TRACKED_CACHE_SCRIPT, REDIS_FRAME_VERSION, REDIS_ENCODING_UTF8, and REDIS_ENCODING_BINARY, and adds encodeRedisFrame, encodeTrackedRedisPlaceholder, TrackedRedisPlaceholder, WRITE_TRACKED_STAMP_SCRIPT, resolveTrackedRedisWriteReply, validateRedisSetReply, validateRedisScriptInvalidationReply, and ceilSupportedCacheTtlMs. dialcacheRedisScripts drops dialcacheWrite and dialcacheWriteTracked and gains dialcacheWriteTrackedStamp(valueKey, watermarkKey, cacheTtlMs, nonce); createNodeRedisDialCacheClient throws TypeError for clients constructed without those registrations; the structural node-redis sendCommand argument type widens to string or Buffer. The tracked write reply domain is 0, 1, or 2, and reply 2 must fail the write with the new root-exported DialCacheRedisPlaceholderLostError. The GLIDE adapter is stateless: createValkeyGlideDialCacheClient returns a plain DialCacheRedisClient with no dispose method, ValkeyGlideDialCacheClient and ValkeyGlideScriptHandle are removed, ValkeyGlideRuntime takes a single TDecoder parameter and requires the ClusterBatch constructor instead of Script, and ValkeyGlideScriptingClient requires customCommand and drops invokeScript. ACLs must allow the client-issued GET, MGET, and SET (SET newly carries every write) plus EVALSHA and EVAL, and the script-invoked TIME, GET, SET, and PTTL for both mutation scripts plus the stamp script's PEXPIRE, UNLINK, GETRANGE, and SETRANGE; adapters reject non-numeric or out-of-range cacheTtlMs with RangeError before issuing commands.
…19 measurements (#135) ## Summary Adds `pnpm benchmark:redis-write`, a maintainer benchmark for the Redis write path, and records the measured server-side cost of the 0.18.0 → 0.19.0 write-protocol change ([#131](#131)). The 0.19.0 release notes carried an expectation extrapolated from #123's read measurements ("expect ~95% server-side command-time reduction at 1 MiB writes"); this PR replaces that expectation with numbers. ## Measured: 0.18.0 (Lua write scripts) vs 0.19.0 (native `SET` + payload-free stamp) Three full runs per version, sequential awaited writes via the node-redis adapter against a dedicated local Redis 6.2 container. Server-side cost is `INFO commandstats` µsec summed over the commands the client dispatches per logical write (0.18.0 tracked: `EVALSHA`; 0.19.0 tracked: `SET` + `EVALSHA`; the `EVALSHA` entry envelopes script-internal calls), divided by writes. Ranges are min–max across the three runs. | Path | Payload | 0.18.0 server µs/write | 0.19.0 server µs/write | Server-side reduction | | --- | --- | --- | --- | --- | | tracked | 100 B | 6.8–8.8 | 6.7–8.2 | parity (within noise) | | tracked | 10 KiB | 12.7–15.7 | 10.5–15.7 | parity (within noise) | | tracked | 100 KiB | 24.0–25.7 | 7.3–10.3 | **60–69%** | | tracked | 1 MiB | 218–241 | 11.4–18.5 | **92–95%** | | untracked | 100 B | 6.0–7.8 | 0.5–0.7 | **~90%** | | untracked | 10 KiB | 12.2–13.4 | 0.8–1.6 | **87–94%** | | untracked | 100 KiB | 20.5–22.6 | 0.8–1.5 | **93–96%** | | untracked | 1 MiB | 210–237 | 2.4–3.0 | **~99%** | Client-side latency (localhost, RTT-dominated): 1 MiB tracked p50 improved from 772–861 µs to 566–703 µs (~25%); small payloads are roughly flat with overlapping ranges. Readings: - **The release-notes expectation is confirmed where it was made:** ~95% server-side reduction at 1 MiB tracked writes (observed 92–95%). - **The new protocol's server cost is nearly payload-size-independent** (tracked ~7–18 µs at every size), which is the direct signature of the payload no longer crossing the Redis↔Lua boundary; the old protocol's cost grows roughly linearly with payload. - **Small tracked payloads (≤10 KiB) are parity within this machine's noise floor**: per-run deltas swung from −23% to +33% across runs, i.e. a few µs either way. The protocol's costs there are offset by its savings; neither wins measurably. - Untracked writes win at every size, ~90%+ — one native `SET` versus a payload-carrying script. ## Methodology caveats Single machine (Apple Silicon laptop, Docker Desktop VM) with unrelated containers running; sequential awaited writes (per-op cost, not throughput under pipelined concurrency); Redis 6.2 only; three runs. Treat the small-payload cells as "no measurable difference" rather than precise deltas — the µs-scale numbers there sit at the environment's noise floor. The 100 KiB and 1 MiB signals are stable across runs and large relative to noise. ## What ships in this PR - `scripts/benchmark-redis-write.mjs` + `pnpm benchmark:redis-write` — the same methodology, pointed at the local build, for future regression checks (maintainer tool, not part of the published package, no timing thresholds asserted). - A README section documenting how to run it.
Summary
Replace read-side Lua with native Redis commands and decode DialCache's frame in TypeScript:
GETMGETfor the value and watermarkdecodeRedisFrameanddecodeTrackedRedisFramehelpersThis removes the Redis-to-Lua payload materialization and
string.subcopy on every hit while preserving the semanticDialCacheRedisClient.read()boundary.Read architecture
GETMGETGETMGETsendCommand(..., false, ...)routes to the slot primaryGETBatch(false).mget(...)MGETitself is atomicGETMGETprimarySlotKeyrouteThe shared decoder:
DialCacheRedisPayloadEncodingErrorBuffer.subarray()viewTracked value and watermark reads retain one atomic snapshot, with both values returned by a single
MGET. Their existing shared Cluster hash tag remains required; mismatched tags still fail withCROSSSLOT.Breaking change
READ_CACHE_SCRIPTandREAD_TRACKED_CACHE_SCRIPTare removed fromdialcache/redis-protocol.dialcacheRedisScripts.dialcacheReadanddialcacheRedisScripts.dialcacheReadTrackedare removed fromdialcache/node-redis.get/sendCommand;legacyModeclients are unsupported because neither their callback surface nor.v4view exposes the complete native-command-plus-custom-script contract.GlideClientorGlideClusterClient, and the same module namespace that created it. Forwarding wrappers should implementDialCacheRedisClientdirectly because their topology cannot be inferred safely.false, but now also unlinks the stale value key. No data migration or cache flush is required.UNLINK(Redis 4.0+ or compatible Valkey) and permission for scripts to invoke it. With a command-restricted ACL that deniesUNLINK, the write fails open ascache_writeand leaves the stale value for a later cleanup or expiry.BREAKING CHANGE:the four deprecated read-Lua exports and registrations above are removed; node-redis adapters require the promise-mode native-command surface; the GLIDE helper requires a direct GLIDE 2.x client from the supplied runtime; and the fenced-write cleanup requires RedisUNLINKsupport plus ACL permission. Under the repository's release configuration, this change should release asv1.0.0.Adapter behavior changes
getandsendCommandmethods in addition to the three registered mutation methods.@valkey/valkey-glide ^2.0.0peer, validatesBatchsupport eagerly, and classifies standalone versus cluster behavior from the supplied runtime's client identities before allocating scripts. Its standalone non-atomic primary batch avoids consuming caller-ownedWATCHstate.MGETreturnsnullfor wrong-type members. A tracked wrong-type value is therefore a clean miss and may be repaired with a valid DialCache frame after fallback succeeds, while a wrong-type watermark prevents the tracked write from succeeding. An untrackedGETstill surfacesWRONGTYPE. Real-engine tests cover both repair and repeated fail-open behavior, including metrics.Benchmark
The benchmark harness and JSON results were intentionally kept outside the repository. Methodology:
INFO commandstatsexecution time, and network bytesAt 1 MiB, native fresh-hit throughput improved 15-45% across the two engines and adapters. Server-reported command execution time per logical read fell 95-98%. Small 100 B / 1 KiB end-to-end results were mostly flat/noisy while reported command time still fell about 80-90%; the notable small-case regression was Redis/node-redis's 100 B tracked hit at about -10% throughput. These loopback, one-in-flight results are directional rather than production-capacity measurements.
Representative Redis 6.2 + node-redis medians:
The invalidated-miss row is the main tradeoff: Lua returns only a null reply, while native
MGETtransfers the stale frame before TypeScript rejects it. At 1 MiB this changes roughly 3-5 response bytes into about 1.05 MB. Across both engines and adapters, invalidated-miss throughput fell 77-84% at 1 MiB (46-58% at 100 KiB), even though server-reported command time still fell 91-94%.The benchmark intentionally measured the read itself and therefore includes that full transfer. In the application path, the first completed fallback that reaches a still-fenced tracked write now atomically unlinks the stale value, bounding subsequent transfers for that entry. This is only a partial mitigation: a read failure or timeout never reaches the write-side cleanup, so the stale payload can continue to transfer or time out until another completed read cleans it up or its TTL expires.
Scope
This branch is updated onto the current
v0.15.0read contract, including the untracked-cache shadowing changes from #122. It deliberately does not include the server-time / maximum-age behavior proposed in #121. That work can be evaluated separately against this read path and its benchmark tradeoffs.Validation
corepack pnpm typecheckcorepack pnpm test- 424 tests, coverage thresholds passedcorepack pnpm buildcorepack pnpm test:package- including real node-redis and GLIDE standalone and Cluster consumer types, plus packed ESM/CommonJS absence checks for all four removed APIscorepack pnpm test:integration- 113 tests across Redis 6.2, Valkey 8, and Redis ClusterSCRIPT FLUSHrecovery proves mutation scripts repopulate every master and a subsequent identical read is a cache hitgit diff --check