feat(redis): use native commands for cache writes - #131
Open
lan17 wants to merge 13 commits into
Open
Conversation
Move the payload out of the Lua VM on every write. Untracked writes become one native SET of a client-encoded frame. Tracked writes pipeline a zero-stamped placeholder SET with the payload-free WRITE_TRACKED_STAMP_SCRIPT, which fences against the watermark, patches the placeholder with server time behind a zeros-guard, and maintains watermark TTL. A zero-stamped placeholder is unreadable by construction, so a delayed or lost stamp degrades to a miss. BREAKING CHANGE: WRITE_CACHE_SCRIPT and WRITE_TRACKED_CACHE_SCRIPT are removed from dialcache/redis-protocol in favor of encodeRedisFrame and WRITE_TRACKED_STAMP_SCRIPT; dialcacheRedisScripts loses dialcacheWrite and dialcacheWriteTracked and gains dialcacheWriteTrackedStamp; node-redis structural clients must accept Buffer sendCommand arguments; the GLIDE runtime must expose Batch and ClusterBatch constructors and the adapter owns two Script handles; adapters validate cacheTtlMs (positive, at most 365 days, fractional values ceiled) before issuing commands; the stamp script additionally requires ACL permission for GETRANGE and SETRANGE.
…e nonce The stamp script previously promoted any all-zeros placeholder, so a leftover placeholder from an earlier failed write could be stamped readable by a later write whose SET failed, republishing a payload whose write had been reported as failed even across an invalidation. Tracked placeholders now carry version byte 0 — unreadable on both read paths — plus a random per-write nonce where a served frame keeps its timestamp, and the stamp promotes only the exact frame its paired SET wrote. A missing pair is reply 2, surfaced as a write failure so split pairs are observable instead of silent. Also from review: the node-redis factory validates script registration at construction and the placeholder SET stays observed if the stamp call throws synchronously; SET reply validation precedes stamp errors in both adapters; GLIDE batches EVALSHA under the Script handle's own hash and integration asserts the server caches that SHA; frame layout constants are shared with the stamp Lua instead of duplicated; the GLIDE untracked write and tracked cluster read use customCommand directly, deleting the single-command batch envelope; TTL validation rejects non-number input; and the write contract documents the stamp calling convention, the PX/ARGV TTL equality, and the reply domain.
lan17
force-pushed
the
claude/tracked-cache-write-optimization-72692d
branch
from
August 8, 2026 05:44
8ac00fa to
66e4164
Compare
…urface Reply 2 now throws the symbol-branded, root-exported DialCacheRedisPlaceholderLostError so benign same-key race losers are filterable from operational write failures across bundles. The reply helpers resolveTrackedRedisWriteReply and validateRedisSetReply are published from dialcache/redis-protocol so the must-fail-on-reply-2 rule ships as code, while the vestigial frame wire constants leave that entry in favor of the codec functions. Frame layout literals inside the codec now derive from one source, the GLIDE invoke wrapper is inlined into its last caller, and docs cover the persistent stamp-failure amplitude, the ACL preflight, hot-key cache_write alert sizing, shadow-fill blanking, untracked client-clock timestamps, the node-redis cold-cache gap, the wire-table version-byte domain, and the observed GLIDE 2.0.0 same-source Script release hazard. The cluster integration test writes tracked keys again so per-node stamp reload after SCRIPT FLUSH stays evidenced, and the node-redis factory guard is covered for partial registrations. BREAKING CHANGE: createNodeRedisDialCacheClient throws TypeError for clients constructed without scripts: dialcacheRedisScripts; dialcacheWriteTrackedStamp takes (valueKey, watermarkKey, cacheTtlMs, nonce); the tracked write reply domain is 0|1|2 and reply 2 must fail the write with DialCacheRedisPlaceholderLostError; ValkeyGlideScriptHandle requires getHash() and ValkeyGlideScriptingClient requires customCommand(); REDIS_FRAME_VERSION, REDIS_ENCODING_UTF8, and REDIS_ENCODING_BINARY are no longer exported from dialcache/redis-protocol in favor of encodeRedisFrame, encodeTrackedRedisPlaceholder, and the decoders.
… entry Add the identity and disjointness unit test for DialCacheRedisPlaceholderLostError matching its three siblings — a brand copy-paste slip between the four class bodies previously survived every suite — plus an ESM hand-branded packed assertion that stays discriminating under chunk splitting. dialcache/redis-protocol now re-exports DialCacheRedisPlaceholderLostError and DialCacheRedisProtocolError so the subpath names the outcomes its reply helpers throw without a root import. The frame header length is defined once (the stamp Lua interpolates it instead of re-deriving it), the orphaned redisPayloadEncoding export is internalized, and the README states precisely which surfaces distinguish lost placeholders (error class in logs and catch blocks; the cache_write counter stays single and bounded). The PR description now carries the machine-parseable BREAKING CHANGE paragraph, since squash merges adopt it as the commit body.
… re-exports The GLIDE stamp no longer holds a native Script handle: the batched EVALSHA uses the module-computed source SHA1 and the NOSCRIPT recovery resends the source via EVAL, which the server caches under that same digest, so getHash() leaves the public ValkeyGlideScriptHandle before it ever ships and the adapter owns only the invalidation handle. The round-3 error re-exports on dialcache/redis-protocol are reverted — the root entry stays the single home for the error catalog, matching the README — and the packed CommonJS suite gains the bundler-independent hand-branded assertion its ESM twin already had.
…er route The published factory JSDoc still described the removed invokeScript recovery; it now matches the EVAL-by-source mechanism, the dispose docs speak of one handle, and the README ACL guidance names EVAL as a required grant for cold-cache recovery on both adapters. New unit coverage pins the EVAL recovery's primarySlotKey route on cluster, the buffer-sizing guidance notes the fenced-write placeholder churn a long window now costs, alert guidance covers the per-occurrence warn stream, and the single-handle adapter drops its one-field scripts record while the integration harness drops its tautological dispatch-hash member in favor of the unit-level digest assertions.
The invalidation script now dispatches like the stamp — EVALSHA by its source SHA1 with EVAL-by-source recovery routed to the slot primary — so the adapter holds no native Script handles at all. That deletes dispose(), the in-flight guard, ValkeyGlideDialCacheClient, ValkeyGlideScriptHandle, the Script constructor and TScript parameter from ValkeyGlideRuntime, and invokeScript from the structural client; createValkeyGlideDialCacheClient returns a plain DialCacheRedisClient. The server's script cache was always the source of truth, so client-side handle bookkeeping bought nothing. Shutdown docs collapse to "close the client", the GLIDE 2.0.0 same-source release hazard note now only concerns applications holding their own handles, and invalidation gains EVALSHA-shape, EVAL-recovery, and cluster-route unit coverage. The PR description replaces its breaking-change footer with a plain heading so this releases as a minor while DialCache is pre-1.0.
… review Release mechanics: breaking commits now map to minor releases while DialCache is pre-1.0 (restore major at 1.0.0), so the restored BREAKING CHANGE footer in the PR body drives complete 0.18.0 release notes instead of a one-line entry, and the leftover footers on earlier branch commits can no longer trigger a surprise 1.0.0 under any merge configuration. GLIDE invalidation retries once with EVAL on any EVALSHA rejection — the script is idempotent (the watermark only advances and its TTL only widens), so the retry is safe after ambiguous failures and self-heals an EVALSHA-rejecting proxy without depending on error wording; the original rejection rides along as the retry failure's cause. The stamp's NOSCRIPT match is now case-insensitive, the three routed-option literals collapse into one keyedOptions helper, and validateRedisScriptInvalidationReply joins the redis-protocol reply kit. The 3-node cluster harness gains a real GlideClusterClient pass — tracked mutations, invalidation, CROSSSLOT, and SCRIPT FLUSH recovery on every master — gated on container-IP reachability, so it executes on Linux CI and skips under Docker Desktop. README: the ACL preflight now names EVALSHA alongside EVAL, and the error-class guidance states that DialCache's own request paths absorb lost-placeholder failures fail-open rather than rethrowing to callers.
…write-optimization-72692d # Conflicts: # AGENTS.md # README.md
…rotocol Compression envelopes (main, 0.18.0) and the nonce-placeholder tracked write protocol (this branch) were built in parallel, so no test covered their combination. The new integration case round-trips a zstd-compressed payload through a tracked write — placeholder, stamp promotion, watermark fence, invalidation, refill — and asserts the stored frame carries both the promoted version byte and the zstd envelope marker.
- node-redis invalidate retries any non-protocol rejection once with EVAL by source, chaining the original rejection as cause; reply-domain violations are deterministic and never retried - export ceilSupportedCacheTtlMs from dialcache/redis-protocol and point the custom-adapter docs at it instead of describing the domain in prose - document the registered scripts' raw replies, complete the ACL command lists for both issuers, and align README Releasing with the pre-1.0 breaking-to-minor rule that release.config.mjs implements - fail the GLIDE cluster integration gate closed under CI instead of silently skipping the only cluster coverage - pin nonce uniqueness, the packed invalidation and TTL helpers, retry call counts, and cause preservation; assert the seam test's refill is served from cache with its envelope intact
- node-redis invalidation no longer attaches a cause to its retry rejection: the library rejects every command flushed by one disconnect with a single shared error instance, so the adapter must not mutate an object other callers and the error listeners also hold; GLIDE keeps cause chaining because it constructs a fresh error per rejection, and both factory docs now state the per-adapter behavior with node-redis's missing per-command deadline spelled out - pin the retry seams the round-1 tests missed: the retry reply flowing through the shared validator on both adapters, non-Error rejections passing through undecorated, the flush-shared-instance case, and the retry dispatch's buffer reply options - assert packed Lua source identity across the node-redis, valkey-glide, and redis-protocol bundles in both module systems (CommonJS duplicates the sources per entry point) - treat explicitly falsy CI values as local for the GLIDE cluster gate and wrap its CI failure with the skip rationale and probe cause - align the fence-margin sleeps with why each exists and drop the vestigial one; document the write invariants in AGENTS.md and the cache_write floor in the metrics table
…ast pin gaps - README's commandstats guidance now names the one genuinely silent regime (invalidation-dispatch healing) with signals that actually fire: cmdstat_eval rising with invalidation volume while cmdstat_evalsha stays flat behind a proxy or accrues rejected_calls under an ACL denial; a sustained stamp fault is loud by contrast - pin the GLIDE bundle's invalidation source against the redis-protocol entry in both packed module systems by driving the invalidation retry, and carry swallowed pin diagnostics on the brand-check errors' cause - pin that GLIDE never retries a reply-domain violation; retitle the node-redis shared-instance test to what its fixture proves - drop a duplicated rationale clause from the GLIDE factory doc and correct AGENTS.md's error-class ownership lines
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.
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:
SETof a client-encoded frame carrying an informational client-clock timestampSETof a version-0 placeholder frame carrying a fresh random 8-byte nonce, then the payload-freeWRITE_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 beforeThe 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
SETviasendCommandSET+ registered stamp scriptSETrouted by value keycustomCommandSETBatch(false)SET+EVALSHAby the stamp SHA1,EVAL-by-source fallbackcustomCommandSETwithprimarySlotKeyrouteClusterBatch(false), same two commands, routedCorrectness invariants
SETwrote. A leftover placeholder from an earlier failed write can never be published by a later write's stamp, even across an intervening invalidation.SETrejected, overwritten by a concurrent writer, expired, or removed by a fenced write) fails the write with the new symbol-brandedDialCacheRedisPlaceholderLostError, so split pairs are observable instead of silent. Same-key herd-race losers produce a benign, self-healing floor of these on hot keys.SETfailure is the write's outcome even when the stamp settled.false, stillUNLINKthe value key, and never touch the watermark.MULTI/EXEC, which would consume caller-ownedWATCHstate.EVALwith its source and the same nonce, which the server caches under the same SHA1; onlyNOSCRIPT(proving non-execution) triggers that retry.cacheTtlMsclient-side (finite, positive, ≤365 days, fractional values ceiled) via the exportedceilSupportedCacheTtlMs; the stamp script re-checks the same domain server-side as defense in depth.Behavioral changes (documented in README)
GETRANGE/SETRANGE) now empties the cache tier within one TTL horizon instead of serving stale — verify ACL grants before upgrading.cache_writeerrors now include the benign lost-placeholder floor; the error class distinguishes it in logs and catch blocks.EVALSHAdispatch once withEVALby 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 itscause(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 fromNOSCRIPT, so anEVALSHA-rejecting proxy failed every invalidation permanently; a permanently healing invalidation dispatch is visible only in server-sideINFO commandstats.Validation
dialcache/redis-protocolhelper, 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 exporteddialcache/redis-protocolsource custom adapters use — while every behavioral test passes)Rollout note
During a rolling deploy, old-protocol writers racing new-protocol writers on the same tracked keys can produce transient
DialCacheRedisPlaceholderLostErrorwrite 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-protocolremovesWRITE_CACHE_SCRIPT,WRITE_TRACKED_CACHE_SCRIPT,REDIS_FRAME_VERSION,REDIS_ENCODING_UTF8, andREDIS_ENCODING_BINARY; it addsencodeRedisFrame,encodeTrackedRedisPlaceholder,TrackedRedisPlaceholder,WRITE_TRACKED_STAMP_SCRIPT,resolveTrackedRedisWriteReply,validateRedisSetReply,validateRedisScriptInvalidationReply, andceilSupportedCacheTtlMs.dialcacheRedisScriptsdropsdialcacheWrite/dialcacheWriteTrackedand gainsdialcacheWriteTrackedStamp(valueKey, watermarkKey, cacheTtlMs, nonce);createNodeRedisDialCacheClientthrowsTypeErrorwithout those registrations; the structuralsendCommandargument type widens tostring | Buffer.0 | 1 | 2; reply2must fail the write with the new root-exportedDialCacheRedisPlaceholderLostError.DialCacheRedisClient(nodispose()),ValkeyGlideDialCacheClientandValkeyGlideScriptHandleare removed,ValkeyGlideRuntime<TDecoder>loses itsScriptconstructor andTScriptparameter but requiresClusterBatch, andValkeyGlideScriptingClient<TDecoder>requirescustomCommandand dropsinvokeScript. Both mutation scripts dispatch asEVALSHAby source SHA1 withEVAL-by-source recovery.GET/MGET/SET(SETnewly carries every write) plusEVALSHAandEVAL, and the script-invokedTIME/GET/SET/PTTL(both scripts) plus the stamp'sPEXPIRE/UNLINK/GETRANGE/SETRANGE; adapters reject non-numeric or out-of-rangecacheTtlMswithRangeErrorbefore 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.