Skip to content

feat(redis): use native commands for cache writes - #131

Open
lan17 wants to merge 13 commits into
mainfrom
claude/tracked-cache-write-optimization-72692d
Open

feat(redis): use native commands for cache writes#131
lan17 wants to merge 13 commits into
mainfrom
claude/tracked-cache-write-optimization-72692d

Conversation

@lan17

@lan17 lan17 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

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.

lan17 added 2 commits August 7, 2026 22:44
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
lan17 force-pushed the claude/tracked-cache-write-optimization-72692d branch from 8ac00fa to 66e4164 Compare August 8, 2026 05:44
lan17 added 11 commits August 7, 2026 23:00
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant