From 42b336374fd054b29a8dfb45d4c98d9eb5677572 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 7 Aug 2026 22:44:46 -0700 Subject: [PATCH 01/12] feat(redis): use native commands for cache writes 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. --- AGENTS.md | 4 +- README.md | 18 +- scripts/test-package.mjs | 106 ++++++++- src/internal/duration.ts | 15 ++ src/internal/redis-payload.ts | 34 ++- src/internal/redis-script-reply.ts | 11 + src/internal/redis-scripts.ts | 39 +--- src/node-redis.ts | 109 +++++----- src/redis-client.ts | 21 +- src/redis-protocol.ts | 10 +- src/valkey-glide.ts | 116 ++++++++-- test/node-redis.test.ts | 225 ++++++++++++++++--- test/redis-cluster.integration.test.ts | 10 +- test/redis-payload.test.ts | 41 ++++ test/redis-real.integration.test.ts | 184 ++++++++++------ test/valkey-glide.test.ts | 288 +++++++++++++++++++++---- 16 files changed, 950 insertions(+), 281 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ff465fd..98fee0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,9 @@ src/ prometheus.ts # Optional Prometheus adapter redis-client.ts # Client-independent semantic Redis interface node-redis.ts # node-redis adapter and script registration - redis-protocol.ts # Public Lua protocol exports + redis-protocol.ts # Public frame codec and Lua protocol exports serializer.ts # Serializer contract and JSON implementation - internal/ # Cache layers, runtime config, and Lua scripts + internal/ # Cache layers, runtime config, and mutation Lua scripts test/ # Unit and Redis integration tests ``` diff --git a/README.md b/README.md index e34fa77..2f71484 100644 --- a/README.md +++ b/README.md @@ -398,17 +398,19 @@ The application owns the complete Redis lifecycle. It creates and connects the u Awaiting those public promises does not drain detached shadow work. Shadow scheduling and deadline timers are unreferenced and completion is not guaranteed during shutdown; Redis operations, source reads, serializers, and asynchronous telemetry already started by shadow work remain caller-owned and may still be active. Stop new work before closing their dependencies and accept that an in-flight shadow fill may have been dispatched even if its final outcome is lost during teardown. DialCache does not add a shutdown hook or keep the process alive to deliver best-effort outcomes. -The node-redis adapter owns no additional resources, so the application closes the underlying node-redis client after draining work. The GLIDE adapter owns three native `Script` handles for writes and invalidation, but not the wrapped connection. After outstanding operations finish, call its idempotent `dispose()` before closing GLIDE as shown above; disposal while an adapter operation is in flight throws rather than releasing a live script. +The node-redis adapter owns no additional resources, so the application closes the underlying node-redis client after draining work. The GLIDE adapter owns two native `Script` handles for the tracked write stamp and invalidation, but not the wrapped connection. After outstanding operations finish, call its idempotent `dispose()` before closing GLIDE as shown above; disposal while an adapter operation is in flight throws rather than releasing a live script. Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. -Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore a clean miss and may be replaced with a valid DialCache frame after the fallback succeeds, while a wrong-type watermark prevents the tracked write from succeeding. +Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` whose frame carries an all-zeros timestamp placeholder, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, patches the placeholder with Redis server time, and maintains the watermark TTL. A placeholder is never readable — tracked reads miss without a watermark and fence `createdAt <= watermark` otherwise — so a delayed or lost stamp degrades to a miss that expires with the value TTL rather than partial state. The stamp only patches an all-zeros timestamp, so it cannot revive an older fenced frame when its paired `SET` failed, and a `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. + +Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore a clean miss and may be replaced with a valid DialCache frame after the fallback succeeds, while a wrong-type watermark prevents the tracked write from succeeding: its paired `SET` still lands, leaving only an unreadable placeholder until expiry or a later successful write. Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. -For mutations, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`. Its cluster client routes scripts by their first key and performs that fallback on the selected shard. The GLIDE adapter uses GLIDE's native `Script` lifecycle and byte decoder; GLIDE routes mutation scripts from their declared keys. +For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-running the stamp through GLIDE's `Script`-based `invokeScript`, which reloads it; a late stamp is safe because the placeholder stays unreadable until it lands. Invalidation uses GLIDE's native `Script` lifecycle directly. -A tracked write rejected by an active future watermark uses `UNLINK` to remove the stale value without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow scripts to invoke `UNLINK`; otherwise that fenced write fails open as a `cache_write` error and the stale value remains until a later successful cleanup or expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. +A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`; otherwise the tracked write fails open as a `cache_write` error and leaves an unreadable placeholder or the prior stale value until a later successful cleanup or expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. #### Remote read deadlines and async liveness @@ -424,7 +426,7 @@ Writes, invalidations, async `cacheConfigProvider` calls, and custom serializer #### Serialization -The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. The shared `decodeRedisFrame` and `decodeTrackedRedisFrame` helpers, write and invalidation Lua sources, and wire constants are available from `dialcache/redis-protocol`, so custom adapters can reuse the bundled adapters' exact miss and watermark-fencing rules. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed replies, unsupported encodings, and mutation-script reply-domain violations in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. +The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. The shared `encodeRedisFrame`, `decodeRedisFrame`, and `decodeTrackedRedisFrame` helpers, the tracked stamp and invalidation Lua sources, and wire constants are available from `dialcache/redis-protocol`, so custom adapters can reuse the bundled adapters' exact framing, miss, and watermark-fencing rules. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed replies, unsupported encodings, and mutation-script reply-domain violations in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. Redis values use a compact binary frame: @@ -435,7 +437,7 @@ byte 10 payload encoding (0 = UTF-8, 1 = raw binary) bytes 11... serialized payload ``` -The Redis write scripts use Lua's `struct` library to pack the timestamp; adapters decode it with Node's buffer primitives. Redis TTL is authoritative, so expiry metadata is not duplicated in the frame. `payload` is produced by the operation's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; strings are stored as UTF-8 and Buffers are stored byte-for-byte without base64 expansion. Adapters restore the same representation before calling `serializer.load`. +Adapters build frames in the Node process with `encodeRedisFrame`. Untracked frames carry an informational client-clock timestamp that untracked reads never consult; tracked frames are written with an all-zeros placeholder that the stamp script patches to Redis server time using Lua's `struct` library, and adapters decode it with Node's buffer primitives. Redis TTL is authoritative, so expiry metadata is not duplicated in the frame. `payload` is produced by the operation's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; strings are stored as UTF-8 and Buffers are stored byte-for-byte without base64 expansion. Adapters restore the same representation before calling `serializer.load`. DialCache uses native `JSON.stringify` and `JSON.parse` by default. There is no runtime validation pass, so the default adds no traversal beyond JSON serialization itself. A top-level `undefined` result is supported with an internal sentinel. @@ -627,7 +629,7 @@ Invalidation writes a Redis watermark at `{encodedNamespace:encodedKeyType:encod The internal `:dialcache-frame-v1` suffix identifies values written with DialCache's binary protocol. Watermarks are stored as decimal timestamps. -A cached Redis value whose Redis-created timestamp is older than or equal to the watermark is treated as stale and refreshed through fallback. `invalidateRemote(keyType, id, futureBufferMs)` sets the watermark to the greater of its existing value and Redis's current time plus the buffer. While that future window is active, an invocation that reaches the tracked Redis read treats the covered value as a miss. Native `MGET` must transfer an existing stale frame before the Node decoder can reject it, so completed reads can repeatedly pay the full stale-payload transfer during a nonzero buffer window. If a successful fallback then reaches the tracked Redis write while the watermark still fences it, Redis rejects the write, atomically unlinks that logically stale value key, and DialCache suppresses the corresponding process-local population; later reads of that entry avoid retransferring its payload. The fallback value still returns to its caller. A read failure or timeout never reaches that write-side cleanup, so a large stale value can continue to consume network bandwidth and trigger `cache_read_timeout` until another completed read cleans it up or its TTL expires. Request-local memoization remains unconditional. A ramped-out invocation without shadow work does not consult the watermark; a selected shadow path for that tracked key does consult it for `C0`, `C1` when needed, and any clean-miss fill, although caller-path request-local/process-local publication remains independent. +A cached Redis value whose Redis-created timestamp is older than or equal to the watermark is treated as stale and refreshed through fallback. `invalidateRemote(keyType, id, futureBufferMs)` sets the watermark to the greater of its existing value and Redis's current time plus the buffer. While that future window is active, an invocation that reaches the tracked Redis read treats the covered value as a miss. Native `MGET` must transfer an existing stale frame before the Node decoder can reject it, so completed reads can repeatedly pay the full stale-payload transfer during a nonzero buffer window. If a successful fallback then reaches the tracked Redis write while the watermark still fences it, the stamp script reports the write as blocked, unlinks the value key — the placeholder that write just stored, along with the logically stale frame it replaced — and DialCache suppresses the corresponding process-local population; later reads of that entry avoid retransferring its payload. The fallback value still returns to its caller. A read failure or timeout never reaches that write-side cleanup, so a large stale value can continue to consume network bandwidth and trigger `cache_read_timeout` until another completed read cleans it up or its TTL expires. Request-local memoization remains unconditional. A ramped-out invocation without shadow work does not consult the watermark; a selected shadow path for that tracked key does consult it for `C0`, `C1` when needed, and any clean-miss fill, although caller-path request-local/process-local publication remains independent. The bundled timestamp protocol assumes that system clocks are synchronized across every Redis node eligible for primary promotion. Redis does not guarantee that `TIME` is monotonic across nodes, and DialCache does not detect or compensate for cross-node clock skew. If this deployment assumption is violated, failover can temporarily suppress tracked cache fills or allow a pre-invalidation value to remain readable until it expires or a later invalidation advances the watermark past its timestamp. @@ -637,7 +639,7 @@ Tracked writes create a baseline watermark and extend its TTL to at least the va `futureBufferMs` must be a nonnegative safe integer no greater than 31,536,000,000 (a fixed 365-day duration). The default is zero, but zero provides no stale-publication protection once Redis time advances. Every production invalidation should pass a named, application-owned nonzero value based on that application's measured or conservatively bounded timings; there is no universally safe library value. -Size the buffer to cover the maximum expected negative clock skew between promotion-eligible Redis nodes plus the complete interval in which stale data could still reach the Redis write: source visibility or replication lag, the full remaining tail of any fallback that may already have observed the pre-mutation value, `serializer.dump`, Redis client queue and network latency, Lua script execution, the write itself, and a safety margin. Invalidate only after the source mutation commits. Underestimating this interval can allow a delayed stale fallback to repopulate Redis after the watermark window ends. Overestimating it lengthens the tracked Redis miss/write-suppression window described above, increasing fallback load and, until write-side cleanup succeeds, stale-payload transfer and read-timeout risk without publishing stale values. A larger buffer does not delay or suppress returning fallback values to callers. +Size the buffer to cover the maximum expected negative clock skew between promotion-eligible Redis nodes plus the complete interval in which stale data could still reach the Redis write: source visibility or replication lag, the full remaining tail of any fallback that may already have observed the pre-mutation value, `serializer.dump`, Redis client queue and network latency, the placeholder write and the stamp script that assigns its server timestamp, and a safety margin. Invalidate only after the source mutation commits. Underestimating this interval can allow a delayed stale fallback to repopulate Redis after the watermark window ends. Overestimating it lengthens the tracked Redis miss/write-suppression window described above, increasing fallback load and, until write-side cleanup succeeds, stale-payload transfer and read-timeout risk without publishing stale values. A larger buffer does not delay or suppress returning fallback values to callers. This is a timing contract rather than a cancellation or acquisition fence: the buffer prevents stale fallback results from passing that tracked Redis write only while the configured window remains active, and it does not force a fallback to read from an authoritative source. diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 0256b34..ffd802c 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -49,11 +49,20 @@ const rootConsumer = `import { // @ts-expect-error The unused MissingKeyConfigError class was removed instead of deprecated. import { MissingKeyConfigError } from "dialcache"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "dialcache/node-redis"; -import { decodeRedisFrame, decodeTrackedRedisFrame } from "dialcache/redis-protocol"; +import { + decodeRedisFrame, + decodeTrackedRedisFrame, + encodeRedisFrame, + WRITE_TRACKED_STAMP_SCRIPT, +} from "dialcache/redis-protocol"; // @ts-expect-error Read Lua sources were removed from the mutation-only Redis protocol. import { READ_CACHE_SCRIPT } from "dialcache/redis-protocol"; // @ts-expect-error Tracked read Lua was removed from the mutation-only Redis protocol. import { READ_TRACKED_CACHE_SCRIPT } from "dialcache/redis-protocol"; +// @ts-expect-error The untracked write Lua was replaced by a native client-framed SET. +import { WRITE_CACHE_SCRIPT } from "dialcache/redis-protocol"; +// @ts-expect-error The tracked write Lua was replaced by a native SET plus the stamp script. +import { WRITE_TRACKED_CACHE_SCRIPT } from "dialcache/redis-protocol"; import { DatadogDialCacheMetrics, createDatadogDialCacheMetrics, @@ -149,6 +158,13 @@ const decodedStaleRedisPayload: string | Buffer | null = decodeTrackedRedisFrame emptyRedisFrame, Buffer.from("1"), ); +const placeholderRedisFrame: Buffer = encodeRedisFrame("pending", 0); +const stampScriptSource: string = WRITE_TRACKED_STAMP_SCRIPT; +const stampArguments: Array = dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformArguments( + "tracked:{id}:value", + "tracked:{id}:watermark", + 1_000, +); const fallbackTimeoutError = new FallbackTimeoutError("Load", 1_000); const redisReadTimeoutError = new RedisReadTimeoutError("Load", 100); const coalescingState: CoalescingState = cache.getCoalescingState(); @@ -456,8 +472,18 @@ void decodedStaleRedisPayload; void dialcacheRedisScripts.dialcacheRead; // @ts-expect-error Native tracked reads removed the legacy node-redis registration. void dialcacheRedisScripts.dialcacheReadTracked; +// @ts-expect-error Native SET writes removed the legacy node-redis registration. +void dialcacheRedisScripts.dialcacheWrite; +// @ts-expect-error The stamp protocol removed the legacy tracked-write registration. +void dialcacheRedisScripts.dialcacheWriteTracked; +void dialcacheRedisScripts.dialcacheWriteTrackedStamp; void READ_CACHE_SCRIPT; void READ_TRACKED_CACHE_SCRIPT; +void WRITE_CACHE_SCRIPT; +void WRITE_TRACKED_CACHE_SCRIPT; +void placeholderRedisFrame; +void stampScriptSource; +void stampArguments; void customRedisClient; const globalSerializer: Serializer = { dump: () => "global", @@ -659,7 +685,7 @@ try { console.log("${fallbackTimeoutMarker}"); } try { - nodeRedis.dialcacheRedisScripts.dialcacheWrite.transformReply(2); + nodeRedis.dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(2); throw new Error("Expected an invalid node-redis script reply to fail"); } catch (error) { if (!(error instanceof root.DialCacheRedisProtocolError)) { @@ -681,6 +707,27 @@ if ( ) { throw new Error("The removed read scripts must not be exported by the packed ESM Redis protocol entry"); } +if ( + "dialcacheWrite" in nodeRedis.dialcacheRedisScripts + || "dialcacheWriteTracked" in nodeRedis.dialcacheRedisScripts +) { + throw new Error("The removed write scripts must not be registered by the packed ESM node-redis entry"); +} +if ( + "WRITE_CACHE_SCRIPT" in redisProtocol + || "WRITE_TRACKED_CACHE_SCRIPT" in redisProtocol +) { + throw new Error("The removed write scripts must not be exported by the packed ESM Redis protocol entry"); +} +if (typeof redisProtocol.WRITE_TRACKED_STAMP_SCRIPT !== "string") { + throw new Error("The packed ESM Redis protocol entry must export the tracked stamp script source"); +} +if (redisProtocol.decodeRedisFrame(redisProtocol.encodeRedisFrame("value", 1)) !== "value") { + throw new Error("The packed ESM Redis protocol encoder did not round-trip through the decoder"); +} +if (redisProtocol.decodeTrackedRedisFrame(redisProtocol.encodeRedisFrame("pending", 0), Buffer.from("0")) !== null) { + throw new Error("The packed ESM Redis protocol encoder did not produce a fenced placeholder frame"); +} const esmEmptyFrame = Buffer.alloc(10); esmEmptyFrame[0] = 1; esmEmptyFrame.writeBigUInt64BE(1n, 1); @@ -937,7 +984,7 @@ void (async () => { } })(); try { - nodeRedis.dialcacheRedisScripts.dialcacheWrite.transformReply(2); + nodeRedis.dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(2); throw new Error("Expected an invalid node-redis script reply to fail"); } catch (error) { if (!(error instanceof root.DialCacheRedisProtocolError)) { @@ -959,6 +1006,27 @@ if ( ) { throw new Error("The removed read scripts must not be exported by the packed CommonJS Redis protocol entry"); } +if ( + "dialcacheWrite" in nodeRedis.dialcacheRedisScripts + || "dialcacheWriteTracked" in nodeRedis.dialcacheRedisScripts +) { + throw new Error("The removed write scripts must not be registered by the packed CommonJS node-redis entry"); +} +if ( + "WRITE_CACHE_SCRIPT" in redisProtocol + || "WRITE_TRACKED_CACHE_SCRIPT" in redisProtocol +) { + throw new Error("The removed write scripts must not be exported by the packed CommonJS Redis protocol entry"); +} +if (typeof redisProtocol.WRITE_TRACKED_STAMP_SCRIPT !== "string") { + throw new Error("The packed CommonJS Redis protocol entry must export the tracked stamp script source"); +} +if (redisProtocol.decodeRedisFrame(redisProtocol.encodeRedisFrame("value", 1)) !== "value") { + throw new Error("The packed CommonJS Redis protocol encoder did not round-trip through the decoder"); +} +if (redisProtocol.decodeTrackedRedisFrame(redisProtocol.encodeRedisFrame("pending", 0), Buffer.from("0")) !== null) { + throw new Error("The packed CommonJS Redis protocol encoder did not produce a fenced placeholder frame"); +} const cjsEmptyFrame = Buffer.alloc(10); cjsEmptyFrame[0] = 1; cjsEmptyFrame.writeBigUInt64BE(1n, 1); @@ -1090,6 +1158,15 @@ if (appGlide.Script === otherGlide.Script) { throw new Error("The package test requires two distinct GLIDE module instances"); } const esmFakeGlideClient = { + exec: async (batch, _raiseOnError, options) => { + if (!(batch instanceof appGlide.Batch) || batch instanceof otherGlide.Batch) { + throw new Error("The ESM adapter did not use the caller-supplied GLIDE Batch constructor"); + } + if (options.decoder !== appGlide.Decoder.Bytes) { + throw new Error("The ESM adapter did not use the caller-supplied GLIDE byte decoder"); + } + return ["OK", new Error("NOSCRIPT No matching script. Please use EVAL.")]; + }, invokeScript: async (script, options) => { if (!(script instanceof appGlide.Script) || script instanceof otherGlide.Script) { throw new Error("The ESM adapter did not use the caller-supplied GLIDE Script constructor"); @@ -1107,7 +1184,12 @@ const esmGlideRuntime = { }; const adapter = glide.createValkeyGlideDialCacheClient(esmFakeGlideClient, esmGlideRuntime); try { - await adapter.write({ valueKey: "value", cacheTtlMs: 1_000, value: "payload" }); + await adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "payload", + }); throw new Error("Expected an invalid GLIDE script reply to fail"); } catch (error) { if (!(error instanceof root.DialCacheRedisProtocolError)) { @@ -1136,6 +1218,15 @@ void (async () => { throw new Error("The package test requires two distinct GLIDE module instances"); } const cjsFakeGlideClient = { + exec: async (batch, _raiseOnError, options) => { + if (!(batch instanceof appGlide.Batch) || batch instanceof otherGlide.Batch) { + throw new Error("The CommonJS adapter did not use the caller-supplied GLIDE Batch constructor"); + } + if (options.decoder !== appGlide.Decoder.Bytes) { + throw new Error("The CommonJS adapter did not use the caller-supplied GLIDE byte decoder"); + } + return ["OK", new Error("NOSCRIPT No matching script. Please use EVAL.")]; + }, invokeScript: async (script, options) => { if (!(script instanceof appGlide.Script) || script instanceof otherGlide.Script) { throw new Error("The CommonJS adapter did not use the caller-supplied GLIDE Script constructor"); @@ -1153,7 +1244,12 @@ void (async () => { }; const adapter = glide.createValkeyGlideDialCacheClient(cjsFakeGlideClient, cjsGlideRuntime); try { - await adapter.write({ valueKey: "value", cacheTtlMs: 1_000, value: "payload" }); + await adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "payload", + }); throw new Error("Expected an invalid GLIDE script reply to fail"); } catch (error) { if (!(error instanceof root.DialCacheRedisProtocolError)) { diff --git a/src/internal/duration.ts b/src/internal/duration.ts index 06cfa8f..b80f76c 100644 --- a/src/internal/duration.ts +++ b/src/internal/duration.ts @@ -20,6 +20,21 @@ export function cacheTtlSecToMs(ttlSec: number): number { return ttlSec * 1_000; } +/** + * Validate and ceil an adapter-level write TTL. Native SET PX requires an + * integer, and the Lua write validation this replaces rounded fractional + * durations upward, so adapters preserve that exact acceptance domain. + */ +export function ceilSupportedCacheTtlMs(cacheTtlMs: number): number { + const ceiled = Math.ceil(cacheTtlMs); + if (!Number.isFinite(ceiled) || ceiled <= 0 || ceiled > MAX_SUPPORTED_DURATION_MS) { + throw new RangeError( + `DialCache Redis write cacheTtlMs must be a positive duration no greater than ${MAX_SUPPORTED_DURATION_MS} milliseconds`, + ); + } + return ceiled; +} + export function assertSupportedFutureBufferMs(futureBufferMs: unknown): asserts futureBufferMs is number { if ( typeof futureBufferMs !== "number" diff --git a/src/internal/redis-payload.ts b/src/internal/redis-payload.ts index 1210678..0e45d51 100644 --- a/src/internal/redis-payload.ts +++ b/src/internal/redis-payload.ts @@ -3,11 +3,10 @@ import { DialCacheRedisPayloadError, type RedisCachePayload, } from "../redis-client.js"; -import { - REDIS_ENCODING_BINARY, - REDIS_ENCODING_UTF8, - REDIS_FRAME_VERSION, -} from "./redis-scripts.js"; + +export const REDIS_FRAME_VERSION = 1; +export const REDIS_ENCODING_UTF8 = 0; +export const REDIS_ENCODING_BINARY = 1; const REDIS_FRAME_HEADER_BYTES = 9; const REDIS_FRAME_MIN_BYTES = REDIS_FRAME_HEADER_BYTES + 1; @@ -56,6 +55,31 @@ function decodeRedisPayload(raw: Buffer): RedisCachePayload { throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding"); } +/** + * Encode a serializer payload into a DialCache Redis frame. + * + * Untracked writes stamp an informational client-clock `createdAtMs`; + * untracked reads never consult it. Tracked writes must pass zero: an + * all-zeros timestamp is a placeholder that tracked reads can never serve, + * and `WRITE_TRACKED_STAMP_SCRIPT` patches it with server time. + */ +export function encodeRedisFrame(payload: RedisCachePayload, createdAtMs: number): Buffer { + if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) { + throw new RangeError("DialCache frame createdAtMs must be a nonnegative safe integer"); + } + const payloadBytes = Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(payload, "utf8"); + const frame = Buffer.allocUnsafe(REDIS_FRAME_MIN_BYTES + payloadBytes); + frame[0] = REDIS_FRAME_VERSION; + frame.writeBigUInt64BE(BigInt(createdAtMs), 1); + frame[REDIS_FRAME_HEADER_BYTES] = redisPayloadEncoding(payload); + if (Buffer.isBuffer(payload)) { + payload.copy(frame, REDIS_FRAME_MIN_BYTES); + } else { + frame.write(payload, REDIS_FRAME_MIN_BYTES, "utf8"); + } + return frame; +} + /** * Decode an untracked DialCache frame returned as a Redis bulk string. * Missing, short, and unsupported-version frames are cache misses. Invalid diff --git a/src/internal/redis-script-reply.ts b/src/internal/redis-script-reply.ts index d156bc7..311cbdd 100644 --- a/src/internal/redis-script-reply.ts +++ b/src/internal/redis-script-reply.ts @@ -1,5 +1,16 @@ import { DialCacheRedisProtocolError } from "../redis-client.js"; +export function validateRedisSetReply(reply: unknown): void { + const text = typeof reply === "string" + ? reply + : Buffer.isBuffer(reply) + ? reply.toString("utf8") + : null; + if (text !== "OK") { + throw new DialCacheRedisProtocolError("Invalid DialCache Redis SET reply; expected OK"); + } +} + export function validateRedisScriptWriteReply(reply: unknown): 0 | 1 { if (reply !== 0 && reply !== 1) { throw new DialCacheRedisProtocolError("Invalid DialCache Redis write reply; expected integer 0 or 1"); diff --git a/src/internal/redis-scripts.ts b/src/internal/redis-scripts.ts index 5d725cb..b88ebf1 100644 --- a/src/internal/redis-scripts.ts +++ b/src/internal/redis-scripts.ts @@ -1,9 +1,5 @@ import { MAX_SUPPORTED_DURATION_MS } from "./duration.js"; -export const REDIS_FRAME_VERSION = 1; -export const REDIS_ENCODING_UTF8 = 0; -export const REDIS_ENCODING_BINARY = 1; - const WATERMARK_TTL_MARGIN_MS = 60_000; const PARSE_WATERMARK_LUA = String.raw`local function parse_watermark(raw) @@ -25,36 +21,18 @@ const CEIL_FINITE_NUMBER_LUA = String.raw`local function ceil_finite_number(raw) return math.ceil(value) end`; -const VALIDATE_WRITE_ARGUMENTS_LUA = String.raw`local cache_ttl_ms = ceil_finite_number(ARGV[1]) -local encoding = tonumber(ARGV[2]) +const VALIDATE_STAMP_ARGUMENTS_LUA = String.raw`local cache_ttl_ms = ceil_finite_number(ARGV[1]) if not cache_ttl_ms or cache_ttl_ms <= 0 or cache_ttl_ms > ${MAX_SUPPORTED_DURATION_MS} then return redis.error_reply("ERR invalid DialCache TTL") -end -if not encoding or (encoding ~= ${REDIS_ENCODING_UTF8} and encoding ~= ${REDIS_ENCODING_BINARY}) then - return redis.error_reply("ERR invalid DialCache payload encoding") end`; const REDIS_TIME_LUA = String.raw`local redis_time = redis.call("TIME") local now_ms = tonumber(redis_time[1]) * 1000 + math.floor(tonumber(redis_time[2]) / 1000)`; -const WRITE_FRAME_LUA = String.raw`local frame = string.char(${REDIS_FRAME_VERSION}) - .. struct.pack(">I8", now_ms) - .. string.char(encoding) - .. ARGV[3] -redis.call("SET", KEYS[1], frame, "PX", cache_ttl_ms)`; - -export const WRITE_CACHE_SCRIPT = [ - CEIL_FINITE_NUMBER_LUA, - VALIDATE_WRITE_ARGUMENTS_LUA, - REDIS_TIME_LUA, - WRITE_FRAME_LUA, - "return 1", -].join("\n\n"); - -export const WRITE_TRACKED_CACHE_SCRIPT = [ +export const WRITE_TRACKED_STAMP_SCRIPT = [ PARSE_WATERMARK_LUA, CEIL_FINITE_NUMBER_LUA, - VALIDATE_WRITE_ARGUMENTS_LUA, + VALIDATE_STAMP_ARGUMENTS_LUA, REDIS_TIME_LUA, String.raw`local raw_watermark = redis.call("GET", KEYS[2]) local watermark = 0 @@ -66,12 +44,17 @@ if raw_watermark then end if watermark >= now_ms then - -- A fenced fallback write can remove the stale frame that led to it. Reads that - -- fail before reaching this script cannot benefit from this partial mitigation. + -- A fenced fallback write removes the placeholder it paired with, along with any + -- stale frame that led to it. Reads that fail before reaching this script cannot + -- benefit from this partial mitigation. redis.call("UNLINK", KEYS[1]) return 0 end`, - WRITE_FRAME_LUA, + String.raw`if redis.call("GETRANGE", KEYS[1], 1, 8) == string.rep("\0", 8) then + -- Only stamp an all-zeros placeholder: when the paired SET did not land, + -- restamping an existing frame could unfence a stale value. + redis.call("SETRANGE", KEYS[1], 1, struct.pack(">I8", now_ms)) +end`, String.raw`local desired_ttl_ms = cache_ttl_ms + ${WATERMARK_TTL_MARGIN_MS} if not raw_watermark then redis.call("SET", KEYS[2], "0", "PX", desired_ttl_ms) diff --git a/src/node-redis.ts b/src/node-redis.ts index 5fd2f5a..4931225 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -2,17 +2,18 @@ import { commandOptions, defineScript } from "redis"; import { INVALIDATE_CACHE_SCRIPT, - WRITE_CACHE_SCRIPT, - WRITE_TRACKED_CACHE_SCRIPT, + WRITE_TRACKED_STAMP_SCRIPT, } from "./internal/redis-scripts.js"; import { decodeRedisFrame, decodeTrackedRedisFrame, - redisPayloadEncoding, + encodeRedisFrame, } from "./internal/redis-payload.js"; +import { ceilSupportedCacheTtlMs } from "./internal/duration.js"; import { validateRedisScriptInvalidationReply, validateRedisScriptWriteReply, + validateRedisSetReply, } from "./internal/redis-script-reply.js"; import { DialCacheRedisPayloadError, @@ -50,18 +51,8 @@ function defineDialCacheScript, Reply>( } export type DialCacheNodeRedisScripts = { - readonly dialcacheWrite: NodeRedisScript< - [valueKey: string, cacheTtlMs: number, encoding: number, payload: string | Buffer], - number - >; - readonly dialcacheWriteTracked: NodeRedisScript< - [ - valueKey: string, - watermarkKey: string, - cacheTtlMs: number, - encoding: number, - payload: string | Buffer, - ], + readonly dialcacheWriteTrackedStamp: NodeRedisScript< + [valueKey: string, watermarkKey: string, cacheTtlMs: number], number >; readonly dialcacheInvalidate: NodeRedisScript< @@ -71,23 +62,8 @@ export type DialCacheNodeRedisScripts = { }; export const dialcacheRedisScripts: DialCacheNodeRedisScripts = { - dialcacheWrite: defineDialCacheScript({ - SCRIPT: WRITE_CACHE_SCRIPT, - NUMBER_OF_KEYS: 1, - FIRST_KEY_INDEX: 0, - IS_READ_ONLY: false, - transformArguments( - valueKey: string, - cacheTtlMs: number, - encoding: number, - payload: string | Buffer, - ): Array { - return [valueKey, String(cacheTtlMs), String(encoding), payload]; - }, - transformReply: writeReply, - }), - dialcacheWriteTracked: defineDialCacheScript({ - SCRIPT: WRITE_TRACKED_CACHE_SCRIPT, + dialcacheWriteTrackedStamp: defineDialCacheScript({ + SCRIPT: WRITE_TRACKED_STAMP_SCRIPT, NUMBER_OF_KEYS: 2, FIRST_KEY_INDEX: 0, IS_READ_ONLY: false, @@ -95,10 +71,8 @@ export const dialcacheRedisScripts: DialCacheNodeRedisScripts = { valueKey: string, watermarkKey: string, cacheTtlMs: number, - encoding: number, - payload: string | Buffer, - ): Array { - return [valueKey, watermarkKey, String(cacheTtlMs), String(encoding), payload]; + ): Array { + return [valueKey, watermarkKey, String(cacheTtlMs)]; }, transformReply: writeReply, }), @@ -115,13 +89,10 @@ export const dialcacheRedisScripts: DialCacheNodeRedisScripts = { }; interface NodeRedisWriteClient { - dialcacheWrite(valueKey: string, cacheTtlMs: number, encoding: number, payload: string | Buffer): Promise; - dialcacheWriteTracked( + dialcacheWriteTrackedStamp( valueKey: string, watermarkKey: string, cacheTtlMs: number, - encoding: number, - payload: string | Buffer, ): Promise; dialcacheInvalidate(watermarkKey: string, futureBufferMs: number): Promise; } @@ -129,7 +100,7 @@ interface NodeRedisWriteClient { interface NodeRedisStandaloneClient extends NodeRedisWriteClient { get(options: BufferReplyOptions, valueKey: string): Promise; sendCommand( - args: Array, + args: Array, options: BufferReplyOptions, ): Promise; } @@ -141,7 +112,7 @@ interface NodeRedisClusterClient extends NodeRedisWriteClient { sendCommand( firstKey: string, isReadonly: false, - args: Array, + args: Array, options: BufferReplyOptions, ): Promise; } @@ -179,12 +150,27 @@ async function readTracked( return validateRedisMGetReply(raw); } +function sendFrameSet( + client: NodeRedisClient, + valueKey: string, + frame: Buffer, + cacheTtlMs: number, +): Promise { + const args: Array = ["SET", valueKey, frame, "PX", String(cacheTtlMs)]; + return isNodeRedisClusterClient(client) + ? client.sendCommand(valueKey, false, args, bufferReplyOptions) + : client.sendCommand(args, bufferReplyOptions); +} + /** * Create a resource-free semantic view over a caller-owned node-redis client. * Read signals are passed to node-redis so queued commands can be removed when * supported. Aborting after dispatch does not unsend a command or prove the - * server stopped executing it. The caller remains responsible for finite - * native command budgets, draining work, and closing the client. + * server stopped executing it. Tracked writes enqueue their placeholder SET + * and stamp script in one synchronous tick, so node-redis pipelines them in + * order on one connection (per slot node in cluster mode). The caller remains + * responsible for finite native command budgets, draining work, and closing + * the client. */ export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCacheRedisClient { return { @@ -204,18 +190,29 @@ export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCac return decodeTrackedRedisFrame(rawValue, rawWatermark); }, async write(request) { - const { valueKey, watermarkKey, cacheTtlMs, value } = request; - const encodingByte = redisPayloadEncoding(value); - const result = watermarkKey === undefined - ? await client.dialcacheWrite(valueKey, cacheTtlMs, encodingByte, value) - : await client.dialcacheWriteTracked( - valueKey, - watermarkKey, - cacheTtlMs, - encodingByte, - value, - ); - return validateRedisScriptWriteReply(result) === 1; + const { valueKey, watermarkKey, value } = request; + const cacheTtlMs = ceilSupportedCacheTtlMs(request.cacheTtlMs); + if (watermarkKey === undefined) { + validateRedisSetReply( + await sendFrameSet(client, valueKey, encodeRedisFrame(value, Date.now()), cacheTtlMs), + ); + return true; + } + // Both commands must enqueue in this synchronous tick so they pipeline + // in order; an await between them would allow reordering around them. + const setPromise = sendFrameSet(client, valueKey, encodeRedisFrame(value, 0), cacheTtlMs); + const stampPromise = client.dialcacheWriteTrackedStamp(valueKey, watermarkKey, cacheTtlMs); + const [setResult, stampResult] = await Promise.allSettled([setPromise, stampPromise]); + // A failed SET is the write outcome even when the stamp settled: the + // stamp may have patched an unrelated frame's placeholder or no-opped. + if (setResult.status === "rejected") { + throw setResult.reason; + } + if (stampResult.status === "rejected") { + throw stampResult.reason; + } + validateRedisSetReply(setResult.value); + return validateRedisScriptWriteReply(stampResult.value) === 1; }, async invalidate({ watermarkKey, futureBufferMs }) { const result = await client.dialcacheInvalidate(watermarkKey, futureBufferMs); diff --git a/src/redis-client.ts b/src/redis-client.ts index aab67c8..b9ff78d 100644 --- a/src/redis-client.ts +++ b/src/redis-client.ts @@ -140,7 +140,26 @@ export interface DialCacheRedisClient { * dedicated Buffer. */ read(request: RedisReadRequest, context?: RedisReadContext): Awaitable; - /** Atomically write using server time. False means invalidation blocked the write. */ + /** + * Write a DialCache Redis frame produced by `encodeRedisFrame` from + * `dialcache/redis-protocol`, or preserve its exact behavior. + * + * Untracked writes are one native `SET valueKey frame PX cacheTtlMs` whose + * frame carries an informational client-clock `createdAtMs`; untracked + * reads never consult it. + * + * Tracked writes issue two commands ordered on one connection without a + * transaction: a native `SET` of a frame whose `createdAtMs` is zero, + * followed by `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the + * watermark, patches the placeholder timestamp with server time, and + * maintains the watermark's existence and TTL. An all-zeros placeholder is + * never readable — tracked reads miss on a missing watermark and fence + * `createdAt <= watermark` otherwise — so an interleaved, delayed, or lost + * stamp degrades to a miss that expires with the value TTL. Implementations + * must not reorder the pair and must surface a SET failure as the write + * error even when the stamp settled. False means invalidation blocked the + * write. + */ write(request: RedisWriteRequest): Awaitable; /** * Advance the watermark monotonically after the source mutation commits. diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index e726ae4..01e7eda 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -1,12 +1,12 @@ export { INVALIDATE_CACHE_SCRIPT, - REDIS_ENCODING_BINARY, - REDIS_ENCODING_UTF8, - REDIS_FRAME_VERSION, - WRITE_CACHE_SCRIPT, - WRITE_TRACKED_CACHE_SCRIPT, + WRITE_TRACKED_STAMP_SCRIPT, } from "./internal/redis-scripts.js"; export { decodeRedisFrame, decodeTrackedRedisFrame, + encodeRedisFrame, + REDIS_ENCODING_BINARY, + REDIS_ENCODING_UTF8, + REDIS_FRAME_VERSION, } from "./internal/redis-payload.js"; diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 0918faf..d3989ae 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -1,22 +1,30 @@ +import { createHash } from "node:crypto"; + +import { ceilSupportedCacheTtlMs } from "./internal/duration.js"; import { decodeRedisFrame, decodeTrackedRedisFrame, - redisPayloadEncoding, + encodeRedisFrame, } from "./internal/redis-payload.js"; import { INVALIDATE_CACHE_SCRIPT, - WRITE_CACHE_SCRIPT, - WRITE_TRACKED_CACHE_SCRIPT, + WRITE_TRACKED_STAMP_SCRIPT, } from "./internal/redis-scripts.js"; import { validateRedisScriptInvalidationReply, validateRedisScriptWriteReply, + validateRedisSetReply, } from "./internal/redis-script-reply.js"; import { DialCacheRedisPayloadError, type DialCacheRedisClient } from "./redis-client.js"; type ValkeyGlideString = string | Buffer; +// EVALSHA inside a GLIDE batch cannot use a native Script handle, so the +// stamp script's SHA1 is computed once from its exact source bytes. +const WRITE_TRACKED_STAMP_SHA1 = createHash("sha1").update(WRITE_TRACKED_STAMP_SCRIPT).digest("hex"); + interface ValkeyGlideBatch { + customCommand(args: ValkeyGlideString[]): ValkeyGlideBatch; mget(keys: ValkeyGlideString[]): ValkeyGlideBatch; } @@ -43,7 +51,10 @@ export interface ValkeyGlideScriptingClient { exec( batch: ValkeyGlideBatch, raiseOnError: boolean, - options: { decoder: TDecoder }, + options: { + decoder: TDecoder; + route?: { type: "primarySlotKey"; key: string }; + }, ): Promise; invokeScript( script: TScript, @@ -62,6 +73,8 @@ interface ValkeyGlideClientIdentity { export interface ValkeyGlideRuntime { /** The Batch constructor exported by the same GLIDE module instance as the client. */ readonly Batch: new (isAtomic: boolean) => ValkeyGlideBatch; + /** The ClusterBatch constructor exported by the same GLIDE module instance as the client. */ + readonly ClusterBatch: new (isAtomic: boolean) => ValkeyGlideBatch; /** The standalone client class exported by the same GLIDE module instance as the client. */ readonly GlideClient: ValkeyGlideClientIdentity; /** The cluster client class exported by the same GLIDE module instance as the client. */ @@ -75,8 +88,7 @@ export interface ValkeyGlideRuntime { - readonly write: TScript; - readonly writeTracked: TScript; + readonly writeTrackedStamp: TScript; readonly invalidate: TScript; } @@ -126,7 +138,7 @@ export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { /** * Wrap a caller-owned GLIDE connection. The returned adapter owns only its - * three mutation Script handles and preserves the connection's + * two mutation Script handles and preserves the connection's * `requestTimeout`. Pass the same GLIDE module namespace used to create the * client so native Batch and Script objects come from that client's runtime. * Only direct GlideClient and GlideClusterClient instances are accepted; @@ -137,16 +149,20 @@ export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { * read deadline may return before this adapter's invocation settles. Tracked * standalone reads use a one-command primary batch, while tracked cluster * reads route MGET explicitly to the slot primary, so replica lag cannot hide - * an invalidation watermark. The standalone batch is deliberately non-atomic: - * MGET itself is atomic, and MULTI/EXEC would consume caller-owned WATCH state. + * an invalidation watermark. Writes batch a native placeholder SET with an + * EVALSHA of the stamp script — cluster write batches route to the slot + * primary — and a flushed script cache falls back to invokeScript, which + * reloads and re-runs the stamp. Batches are deliberately non-atomic: MGET + * and SET are atomic themselves, an interleaved stamp is safe by design, and + * MULTI/EXEC would consume caller-owned WATCH state. */ export function createValkeyGlideDialCacheClient( client: ValkeyGlideScriptingClient, glide: ValkeyGlideRuntime, ): ValkeyGlideDialCacheClient { - if (typeof glide.Batch !== "function") { + if (typeof glide.Batch !== "function" || typeof glide.ClusterBatch !== "function") { throw new Error( - "Valkey GLIDE DialCache requires @valkey/valkey-glide >=2.0.0 with a Batch constructor", + "Valkey GLIDE DialCache requires @valkey/valkey-glide >=2.0.0 with Batch and ClusterBatch constructors", ); } const clientKind = classifyValkeyGlideClient(client, glide); @@ -155,8 +171,7 @@ export function createValkeyGlideDialCacheClient : undefined; const scripts: DialCacheGlideScripts = { - write: new glide.Script(WRITE_CACHE_SCRIPT), - writeTracked: new glide.Script(WRITE_TRACKED_CACHE_SCRIPT), + writeTrackedStamp: new glide.Script(WRITE_TRACKED_STAMP_SCRIPT), invalidate: new glide.Script(INVALIDATE_CACHE_SCRIPT), }; let disposed = false; @@ -215,16 +230,71 @@ export function createValkeyGlideDialCacheClient clusterClient !== undefined + ? new glide.ClusterBatch(false) + : new glide.Batch(false); + const execOptions: { + decoder: TDecoder; + route?: { type: "primarySlotKey"; key: string }; + } = clusterClient !== undefined + ? { decoder: glide.Decoder.Bytes, route: { type: "primarySlotKey", key: valueKey } } + : { decoder: glide.Decoder.Bytes }; + + if (watermarkKey === undefined) { + const frame = encodeRedisFrame(value, Date.now()); + const replies = await run(() => client.exec( + newBatch().customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)]), + true, + execOptions, + )); + if (!Array.isArray(replies) || replies.length !== 1) { + throw new DialCacheRedisPayloadError("Invalid DialCache Redis write reply"); + } + validateRedisSetReply(replies[0]); + return true; + } + + const frame = encodeRedisFrame(value, 0); + const stampArgs = [String(cacheTtlMs)]; + // One dispose-guarded operation so the stamp handle cannot be released + // between the batch and its NOSCRIPT recovery. + return await run(async () => { + const replies = await client.exec( + newBatch() + .customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)]) + .customCommand(["EVALSHA", WRITE_TRACKED_STAMP_SHA1, "2", valueKey, watermarkKey, ...stampArgs]), + false, + execOptions, + ); + if (!Array.isArray(replies) || replies.length !== 2) { + throw new DialCacheRedisPayloadError("Invalid DialCache Redis write reply"); + } + const [setReply, rawStamp] = replies as [unknown, unknown]; + // A failed SET is the write outcome even when the stamp settled: the + // stamp may have patched an unrelated frame's placeholder or no-opped. + if (setReply instanceof Error) { + throw setReply; + } + validateRedisSetReply(setReply); + let stampReply: unknown = rawStamp; + if (rawStamp instanceof Error) { + // GLIDE maps the server's NOSCRIPT reply to its own NoScriptError wording. + if (!rawStamp.message.includes("NOSCRIPT") && !rawStamp.message.includes("NoScriptError")) { + throw rawStamp; + } + // GLIDE batches cannot carry Script handles, so a flushed script + // cache falls back to invokeScript, which reloads and re-runs the + // stamp. A late stamp is safe: the placeholder stays unreadable. + stampReply = await client.invokeScript(scripts.writeTrackedStamp, { + keys: [valueKey, watermarkKey], + args: stampArgs, + decoder: glide.Decoder.Bytes, + }); + } + return validateRedisScriptWriteReply(stampReply) === 1; + }); }, async invalidate({ watermarkKey, futureBufferMs }) { const raw = await invoke( diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index dc740b9..ba26faf 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -26,17 +26,23 @@ const INVALID_INVALIDATION_REPLIES: readonly unknown[] = [0, ...INVALID_WRITE_RE interface FakeReplies { readonly get?: unknown; readonly mGet?: unknown; - readonly write?: unknown; - readonly writeTracked?: unknown; + readonly set?: unknown; + readonly stamp?: unknown; readonly invalidate?: unknown; } function fakeClient(replies: FakeReplies = {}) { return { get: vi.fn(async () => Object.hasOwn(replies, "get") ? replies.get : null), - sendCommand: vi.fn(async () => Object.hasOwn(replies, "mGet") ? replies.mGet : [null, null]), - dialcacheWrite: vi.fn(async () => Object.hasOwn(replies, "write") ? replies.write : 1), - dialcacheWriteTracked: vi.fn(async () => Object.hasOwn(replies, "writeTracked") ? replies.writeTracked : 1), + // Serves standalone (args, options) and cluster (firstKey, isReadonly, args, options) shapes. + sendCommand: vi.fn(async (...callArgs: unknown[]) => { + const args = (Array.isArray(callArgs[0]) ? callArgs[0] : callArgs[2]) as Array; + if (args[0] === "SET") { + return Object.hasOwn(replies, "set") ? replies.set : "OK"; + } + return Object.hasOwn(replies, "mGet") ? replies.mGet : [null, null]; + }), + dialcacheWriteTrackedStamp: vi.fn(async () => Object.hasOwn(replies, "stamp") ? replies.stamp : 1), dialcacheInvalidate: vi.fn(async () => Object.hasOwn(replies, "invalidate") ? replies.invalidate : 1), }; } @@ -72,28 +78,17 @@ async function expectProtocolError(operation: Promise, message: string) describe("node-redis adapter", () => { it("provides the expected arguments for every bundled mutation script", () => { - const binary = Buffer.from([0, 0xff]); - expect(Object.keys(dialcacheRedisScripts)).toEqual([ - "dialcacheWrite", - "dialcacheWriteTracked", + "dialcacheWriteTrackedStamp", "dialcacheInvalidate", ]); - expect(dialcacheRedisScripts.dialcacheWrite.transformArguments("plain:value", 1_000, 0, "plain")).toEqual([ - "plain:value", - "1000", - "0", - "plain", - ]); expect( - dialcacheRedisScripts.dialcacheWriteTracked.transformArguments( + dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformArguments( "tracked:{id}:value", "tracked:{id}:watermark", 1_000, - 1, - binary, ), - ).toEqual(["tracked:{id}:value", "tracked:{id}:watermark", "1000", "1", binary]); + ).toEqual(["tracked:{id}:value", "tracked:{id}:watermark", "1000"]); expect( dialcacheRedisScripts.dialcacheInvalidate.transformArguments("tracked:{id}:watermark", 50), ).toEqual(["tracked:{id}:watermark", "50"]); @@ -103,8 +98,8 @@ describe("node-redis adapter", () => { const client = fakeClient({ get: encodeFrame("plain"), mGet: [encodeFrame(Buffer.from([0, 0xff]), { createdAtMs: 2 }), Buffer.from("1")], - write: 1, - writeTracked: 0, + set: "OK", + stamp: 0, invalidate: 1, }); const adapter = createNodeRedisDialCacheClient(client as never); @@ -129,6 +124,175 @@ describe("node-redis adapter", () => { ).resolves.toBeUndefined(); }); + it("writes untracked frames with one native SET", async () => { + const client = fakeClient(); + const adapter = createNodeRedisDialCacheClient(client as never); + const before = Date.now(); + await expect( + adapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" }), + ).resolves.toBe(true); + const after = Date.now(); + + expect(client.dialcacheWriteTrackedStamp).not.toHaveBeenCalled(); + expect(client.sendCommand).toHaveBeenCalledTimes(1); + const [args, options] = client.sendCommand.mock.calls[0] as [Array, unknown]; + expect(args[0]).toBe("SET"); + expect(args[1]).toBe("plain:value"); + expect(args[3]).toBe("PX"); + expect(args[4]).toBe("1000"); + const frame = args[2] as Buffer; + expect(frame[0]).toBe(1); + expect(frame[9]).toBe(0); + expect(frame.subarray(10).toString("utf8")).toBe("plain"); + const createdAtMs = Number(frame.readBigUInt64BE(1)); + expect(createdAtMs).toBeGreaterThanOrEqual(before); + expect(createdAtMs).toBeLessThanOrEqual(after); + expect(options).toMatchObject({ returnBuffers: true }); + }); + + it("pairs a zero-stamped placeholder SET with the stamp script in issue order", async () => { + const order: string[] = []; + const client = fakeClient(); + client.sendCommand.mockImplementation(async () => { + order.push("set"); + return "OK"; + }); + client.dialcacheWriteTrackedStamp.mockImplementation(async () => { + order.push("stamp"); + return 1; + }); + const binary = Buffer.from([0, 0xff]); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect(adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 2_000, + value: binary, + })).resolves.toBe(true); + + expect(order).toEqual(["set", "stamp"]); + const [args] = client.sendCommand.mock.calls[0] as [Array]; + expect(args[0]).toBe("SET"); + expect(args[1]).toBe("tracked:{id}:value"); + expect(args[3]).toBe("PX"); + expect(args[4]).toBe("2000"); + const frame = args[2] as Buffer; + expect(frame[0]).toBe(1); + expect(frame.readBigUInt64BE(1)).toBe(0n); + expect(frame[9]).toBe(1); + expect(frame.subarray(10)).toEqual(binary); + expect(client.dialcacheWriteTrackedStamp).toHaveBeenCalledWith( + "tracked:{id}:value", + "tracked:{id}:watermark", + 2_000, + ); + }); + + it("routes cluster write SETs by the value key", async () => { + const client = fakeCluster(); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect(adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })).resolves.toBe(true); + + const [firstKey, isReadonly, args] = client.sendCommand.mock.calls[0] as [string, boolean, Array]; + expect(firstKey).toBe("tracked:{id}:value"); + expect(isReadonly).toBe(false); + expect(args[0]).toBe("SET"); + expect(args[1]).toBe("tracked:{id}:value"); + }); + + it("accepts SET replies returned as Buffers and rejects everything else", async () => { + await expect( + createNodeRedisDialCacheClient(fakeClient({ set: Buffer.from("OK") }) as never) + .write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" }), + ).resolves.toBe(true); + + for (const reply of ["QUEUED", null, 1, undefined, Buffer.from("NO")]) { + const untracked = createNodeRedisDialCacheClient(fakeClient({ set: reply }) as never); + await expectProtocolError( + Promise.resolve(untracked.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" })), + "Invalid DialCache Redis SET reply; expected OK", + ); + + const tracked = createNodeRedisDialCacheClient(fakeClient({ set: reply }) as never); + await expectProtocolError( + Promise.resolve(tracked.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })), + "Invalid DialCache Redis SET reply; expected OK", + ); + } + }); + + it("rejects out-of-range cacheTtlMs before issuing commands and ceils fractional TTLs", async () => { + const client = fakeClient(); + const adapter = createNodeRedisDialCacheClient(client as never); + for (const cacheTtlMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 31_536_000_001]) { + await expect( + adapter.write({ valueKey: "plain:value", cacheTtlMs, value: "plain" }), + ).rejects.toThrow(RangeError); + await expect( + adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs, + value: "tracked", + }), + ).rejects.toThrow(RangeError); + } + expect(client.sendCommand).not.toHaveBeenCalled(); + expect(client.dialcacheWriteTrackedStamp).not.toHaveBeenCalled(); + + await adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000.1, + value: "tracked", + }); + const [args] = client.sendCommand.mock.calls[0] as [Array]; + expect(args[4]).toBe("1001"); + expect(client.dialcacheWriteTrackedStamp).toHaveBeenCalledWith( + "tracked:{id}:value", + "tracked:{id}:watermark", + 1_001, + ); + }); + + it("surfaces a SET failure as the write error even when the stamp settled", async () => { + const failure = new Error("OOM command not allowed when used memory > 'maxmemory'."); + const client = fakeClient(); + client.sendCommand.mockRejectedValueOnce(failure); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect(adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })).rejects.toBe(failure); + expect(client.dialcacheWriteTrackedStamp).toHaveBeenCalledTimes(1); + + const stampFailure = new Error("ERR invalid DialCache watermark"); + const stampClient = fakeClient(); + stampClient.dialcacheWriteTrackedStamp.mockRejectedValueOnce(stampFailure); + const stampAdapter = createNodeRedisDialCacheClient(stampClient as never); + await expect(stampAdapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })).rejects.toBe(stampFailure); + }); + it("passes the cooperative read signal through node-redis command options", async () => { const client = fakeClient(); const adapter = createNodeRedisDialCacheClient(client as never); @@ -231,13 +395,7 @@ describe("node-redis adapter", () => { const invalidationMessage = "Invalid DialCache Redis invalidate reply; expected integer 1"; for (const reply of INVALID_WRITE_REPLIES) { - const untracked = createNodeRedisDialCacheClient(fakeClient({ write: reply }) as never); - await expectProtocolError( - Promise.resolve(untracked.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" })), - writeMessage, - ); - - const tracked = createNodeRedisDialCacheClient(fakeClient({ writeTracked: reply }) as never); + const tracked = createNodeRedisDialCacheClient(fakeClient({ stamp: reply }) as never); await expectProtocolError( Promise.resolve(tracked.write({ valueKey: "tracked:{id}:value", @@ -262,15 +420,12 @@ describe("node-redis adapter", () => { }); it("validates replies at the public node-redis script transform boundary", () => { - expect(dialcacheRedisScripts.dialcacheWrite.transformReply(0)).toBe(0); - expect(dialcacheRedisScripts.dialcacheWriteTracked.transformReply(1)).toBe(1); + expect(dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(0)).toBe(0); + expect(dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(1)).toBe(1); expect(dialcacheRedisScripts.dialcacheInvalidate.transformReply(1)).toBe(1); for (const reply of INVALID_WRITE_REPLIES) { - expect(() => dialcacheRedisScripts.dialcacheWrite.transformReply(reply as number)).toThrow( - DialCacheRedisProtocolError, - ); - expect(() => dialcacheRedisScripts.dialcacheWriteTracked.transformReply(reply as number)).toThrow( + expect(() => dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(reply as number)).toThrow( DialCacheRedisProtocolError, ); } @@ -300,7 +455,7 @@ describe("node-redis adapter", () => { }); it("surfaces protocol failures through the normal DialCache observability path", async () => { - const redisClient = createNodeRedisDialCacheClient(fakeClient({ write: 2, invalidate: 0 }) as never); + const redisClient = createNodeRedisDialCacheClient(fakeClient({ set: 2, invalidate: 0 }) as never); const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; const metrics = { request: vi.fn(), diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index 67fbfdc..18a9357 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -174,7 +174,7 @@ describe("DialCache Redis protocol on Redis Cluster", () => { if (cluster === undefined) { throw new Error("Redis Cluster did not start"); } - expect(dialcacheRedisScripts.dialcacheWrite.SHA1).not.toBe(dialcacheRedisScripts.dialcacheWriteTracked.SHA1); + expect(dialcacheRedisScripts.dialcacheWriteTrackedStamp.SHA1).not.toBe(dialcacheRedisScripts.dialcacheInvalidate.SHA1); const scriptClient: DialCacheRedisClient = createNodeRedisDialCacheClient(cluster); const dialcache = new DialCache({ namespace: "cluster-cache", @@ -203,6 +203,14 @@ describe("DialCache Redis protocol on Redis Cluster", () => { watermarkKey: "{slot-b}:watermark", }), ).rejects.toThrow(/CROSSSLOT/); + await expect( + scriptClient.write({ + valueKey: "{slot-a}:value", + watermarkKey: "{slot-b}:watermark", + cacheTtlMs: 60_000, + value: "cross", + }), + ).rejects.toThrow(/CROSSSLOT/); }); it("round-trips binary payloads through cluster routing", async () => { diff --git a/test/redis-payload.test.ts b/test/redis-payload.test.ts index d291360..0df70a0 100644 --- a/test/redis-payload.test.ts +++ b/test/redis-payload.test.ts @@ -1,6 +1,7 @@ import { decodeRedisFrame, decodeTrackedRedisFrame, + encodeRedisFrame, } from "../src/redis-protocol.js"; import { DialCacheRedisPayloadEncodingError, @@ -99,6 +100,46 @@ describe("Redis frame decoding", () => { ); }); + it("encodes frames that decode back through both read paths", () => { + const utf8 = encodeRedisFrame("cachéd ✓", 1_000); + expect(utf8[0]).toBe(1); + expect(Number(utf8.readBigUInt64BE(1))).toBe(1_000); + expect(utf8[9]).toBe(0); + expect(decodeRedisFrame(utf8)).toBe("cachéd ✓"); + expect(decodeTrackedRedisFrame(utf8, Buffer.from("999"))).toBe("cachéd ✓"); + + const binaryPayload = Buffer.from([0, 0xff, 0x80]); + const binary = encodeRedisFrame(binaryPayload, 2_000); + expect(binary[9]).toBe(1); + expect(binary).toEqual(encodeFrame(binaryPayload, 1, 2_000)); + expect(decodeRedisFrame(binary)).toEqual(binaryPayload); + + const empty = encodeRedisFrame("", 1); + expect(empty.byteLength).toBe(10); + expect(decodeRedisFrame(empty)).toBe(""); + }); + + it("keeps zero-stamped placeholder frames unreadable on the tracked path", () => { + const placeholder = encodeRedisFrame("pending", 0); + + expect(decodeTrackedRedisFrame(placeholder, null)).toBeNull(); + expect(decodeTrackedRedisFrame(placeholder, Buffer.from("0"))).toBeNull(); + expect(decodeTrackedRedisFrame(placeholder, Buffer.from("1"))).toBeNull(); + expect(decodeRedisFrame(placeholder)).toBe("pending"); + }); + + it("rejects unencodable createdAt timestamps", () => { + for (const createdAtMs of [ + -1, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ]) { + expect(() => encodeRedisFrame("value", createdAtMs)).toThrow(RangeError); + } + }); + it("preserves payload error identity across separately bundled entry points", () => { class SpecializedPayloadError extends DialCacheRedisPayloadError {} class SpecializedEncodingError extends DialCacheRedisPayloadEncodingError {} diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index 62e7096..849a9a2 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -13,8 +13,7 @@ import { } from "../src/index.js"; import { INVALIDATE_CACHE_SCRIPT, - WRITE_CACHE_SCRIPT, - WRITE_TRACKED_CACHE_SCRIPT, + WRITE_TRACKED_STAMP_SCRIPT, } from "../src/internal/redis-scripts.js"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../src/node-redis.js"; import { @@ -57,25 +56,14 @@ const createTestClient = (url: string) => createClient({ url, scripts: dialcache type NodeRedisTestClient = ReturnType; interface RawRedisScriptClient { - write( - valueKey: string, - cacheTtlMs: number, - encoding: number, - payload: string | Buffer, - ): Promise; - writeTracked( - valueKey: string, - watermarkKey: string, - cacheTtlMs: number, - encoding: number, - payload: string | Buffer, - ): Promise; + /** Invoke only the tracked stamp script, as if its paired placeholder SET was lost. */ + stamp(valueKey: string, watermarkKey: string, cacheTtlMs: number): Promise; invalidate(watermarkKey: string, futureBufferMs: number): Promise; } interface RedisAdapterHarness { readonly adapter: DialCacheRedisClient; - /** Exercise Lua argument validation that the semantic adapter cannot represent. */ + /** Exercise Lua argument validation and stamp states the semantic adapter cannot represent. */ readonly raw: RawRedisScriptClient; dispose(): void; } @@ -84,8 +72,7 @@ function createNodeRedisHarness(client: NodeRedisTestClient): RedisAdapterHarnes return { adapter: createNodeRedisDialCacheClient(client), raw: { - write: async (...args) => await client.dialcacheWrite(...args), - writeTracked: async (...args) => await client.dialcacheWriteTracked(...args), + stamp: async (...args) => await client.dialcacheWriteTrackedStamp(...args), invalidate: async (...args) => await client.dialcacheInvalidate(...args), }, dispose: () => undefined, @@ -95,8 +82,7 @@ function createNodeRedisHarness(client: NodeRedisTestClient): RedisAdapterHarnes function createValkeyGlideHarness(client: valkeyGlide.GlideClient): RedisAdapterHarness { const adapter: ValkeyGlideDialCacheClient = createValkeyGlideDialCacheClient(client, valkeyGlide); const rawScripts = { - write: new valkeyGlide.Script(WRITE_CACHE_SCRIPT), - writeTracked: new valkeyGlide.Script(WRITE_TRACKED_CACHE_SCRIPT), + stamp: new valkeyGlide.Script(WRITE_TRACKED_STAMP_SCRIPT), invalidate: new valkeyGlide.Script(INVALIDATE_CACHE_SCRIPT), }; const invoke = async ( @@ -118,20 +104,8 @@ function createValkeyGlideHarness(client: valkeyGlide.GlideClient): RedisAdapter return { adapter, raw: { - write: async (valueKey, cacheTtlMs, encoding, payload) => - await invoke(rawScripts.write, [valueKey], [String(cacheTtlMs), String(encoding), payload]), - writeTracked: async ( - valueKey, - watermarkKey, - cacheTtlMs, - encoding, - payload, - ) => - await invoke( - rawScripts.writeTracked, - [valueKey, watermarkKey], - [String(cacheTtlMs), String(encoding), payload], - ), + stamp: async (valueKey, watermarkKey, cacheTtlMs) => + await invoke(rawScripts.stamp, [valueKey, watermarkKey], [String(cacheTtlMs)]), invalidate: async (watermarkKey, futureBufferMs) => await invoke( rawScripts.invalidate, @@ -1041,7 +1015,12 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { }); expect(metrics.error).not.toHaveBeenCalledWith(expect.objectContaining({ error: "cache_read" })); expect(await admin.type(watermarkKey)).toBe("hash"); - expect(await admin.get(commandOptions({ returnBuffers: true }), valueKey)).toEqual(frame); + // The paired SET lands before the stamp fails on the wrong-type watermark, so + // the original frame is replaced by an unreadable zero-stamped placeholder. + const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); + expect(stored?.[0]).toBe(1); + expect(stored?.readBigUInt64BE(1)).toBe(0n); + await expect(client.adapter.read({ valueKey, watermarkKey })).resolves.toBeNull(); }); it("rejects invalid raw script arguments before mutating Redis", async () => { @@ -1052,26 +1031,30 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { const watermarkKey = "invalid-args:{item:invalid}:watermark"; const notANumber = "not-a-number" as unknown as number; - await expect(client.raw.write(valueKey, 0, 0, "value")).rejects.toThrow("invalid DialCache TTL"); - await expect(client.raw.write(valueKey, notANumber, 0, "value")).rejects.toThrow("invalid DialCache TTL"); - await expect(client.raw.write(valueKey, Number.NaN, 0, "value")).rejects.toThrow("invalid DialCache TTL"); - await expect(client.raw.write(valueKey, Number.POSITIVE_INFINITY, 0, "value")).rejects.toThrow("invalid DialCache TTL"); - await expect(client.raw.write(valueKey, Number.NEGATIVE_INFINITY, 0, "value")).rejects.toThrow("invalid DialCache TTL"); + await expect(client.raw.stamp(valueKey, watermarkKey, 0)).rejects.toThrow("invalid DialCache TTL"); + await expect(client.raw.stamp(valueKey, watermarkKey, notANumber)).rejects.toThrow("invalid DialCache TTL"); + await expect(client.raw.stamp(valueKey, watermarkKey, Number.NaN)).rejects.toThrow("invalid DialCache TTL"); + await expect(client.raw.stamp(valueKey, watermarkKey, Number.POSITIVE_INFINITY)).rejects.toThrow( + "invalid DialCache TTL", + ); + await expect(client.raw.stamp(valueKey, watermarkKey, Number.NEGATIVE_INFINITY)).rejects.toThrow( + "invalid DialCache TTL", + ); await expect( - client.raw.write(valueKey, MAX_SUPPORTED_DURATION_MS + 1, 0, "value"), + client.raw.stamp(valueKey, watermarkKey, MAX_SUPPORTED_DURATION_MS + 1), ).rejects.toThrow("invalid DialCache TTL"); await expect( - client.raw.writeTracked( - valueKey, - watermarkKey, - Number.MAX_SAFE_INTEGER, - 0, - "value", - ), + client.raw.stamp(valueKey, watermarkKey, Number.MAX_SAFE_INTEGER), ).rejects.toThrow("invalid DialCache TTL"); - await expect(client.raw.write(valueKey, 1_000, notANumber, "value")).rejects.toThrow("invalid DialCache payload encoding"); - await expect(client.raw.write(valueKey, 1_000, Number.NaN, "value")).rejects.toThrow("invalid DialCache payload encoding"); - await expect(client.raw.write(valueKey, 1_000, 2, "value")).rejects.toThrow("invalid DialCache payload encoding"); + // The adapters enforce the same TTL domain before issuing any command. + for (const badTtl of [0, notANumber, Number.NaN, Number.POSITIVE_INFINITY, MAX_SUPPORTED_DURATION_MS + 1]) { + await expect( + client.adapter.write({ valueKey, cacheTtlMs: badTtl, value: "value" }), + ).rejects.toThrow(RangeError); + await expect( + client.adapter.write({ valueKey, watermarkKey, cacheTtlMs: badTtl, value: "value" }), + ).rejects.toThrow(RangeError); + } await expect(client.raw.invalidate(watermarkKey, -1)).rejects.toThrow("invalid DialCache future buffer"); await expect(client.raw.invalidate(watermarkKey, notANumber)).rejects.toThrow("invalid DialCache future buffer"); await expect(client.raw.invalidate(watermarkKey, Number.NaN)).rejects.toThrow("invalid DialCache future buffer"); @@ -1097,8 +1080,8 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { } const valueKey = "maximum-args:{item:untracked}:value"; expect( - await client.raw.write(valueKey, MAX_SUPPORTED_DURATION_MS, 0, "value"), - ).toBe(1); + await client.adapter.write({ valueKey, cacheTtlMs: MAX_SUPPORTED_DURATION_MS, value: "value" }), + ).toBe(true); expect(await admin.pTTL(valueKey)).toBeGreaterThan( MAX_SUPPORTED_DURATION_MS - 1_000, ); @@ -1109,14 +1092,13 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { const trackedValueKey = "maximum-args:{item:tracked}:value"; const trackedWatermarkKey = "maximum-args:{item:tracked}:watermark"; expect( - await client.raw.writeTracked( - trackedValueKey, - trackedWatermarkKey, - MAX_SUPPORTED_DURATION_MS, - 0, - "value", - ), - ).toBe(1); + await client.adapter.write({ + valueKey: trackedValueKey, + watermarkKey: trackedWatermarkKey, + cacheTtlMs: MAX_SUPPORTED_DURATION_MS, + value: "value", + }), + ).toBe(true); expect(await admin.pTTL(trackedWatermarkKey)).toBeGreaterThan( MAX_SUPPORTED_DURATION_MS + WATERMARK_TTL_MARGIN_MS - 1_000, ); @@ -1147,15 +1129,20 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { const valueKey = "fractional-args:{item:fractional}:value"; const watermarkKey = "fractional-args:{item:fractional}:watermark"; - expect(await client.raw.write(valueKey, 1_000.1, 0, "value")).toBe(1); + expect(await client.adapter.write({ valueKey, cacheTtlMs: 1_000.1, value: "value" })).toBe(true); expect(await admin.pTTL(valueKey)).toBeGreaterThan(900); expect(await admin.pTTL(valueKey)).toBeLessThanOrEqual(1_001); const trackedValueKey = "fractional-args:{item:tracked}:value"; const trackedWatermarkKey = "fractional-args:{item:tracked}:watermark"; expect( - await client.raw.writeTracked(trackedValueKey, trackedWatermarkKey, 1_000.1, 0, "value"), - ).toBe(1); + await client.adapter.write({ + valueKey: trackedValueKey, + watermarkKey: trackedWatermarkKey, + cacheTtlMs: 1_000.1, + value: "value", + }), + ).toBe(true); expect(await admin.get(trackedWatermarkKey)).toBe("0"); expect(await admin.pTTL(trackedWatermarkKey)).toBeGreaterThan(60_000); expect(await admin.pTTL(trackedWatermarkKey)).toBeLessThanOrEqual(61_001); @@ -1224,21 +1211,28 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { expect(second).toEqual({ id: "bad", calls: 2 }); }); - it("rejects malformed tracked watermark writes without overwriting the cached value", async () => { + it("rejects malformed tracked watermark writes and leaves only an unreadable placeholder", async () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); } const scriptClient = client.adapter; const valueKey = "malformed-write:{item:malformed}:value"; const watermarkKey = "malformed-write:{item:malformed}:watermark"; - expect(await client.raw.write(valueKey, 60_000, 0, "original")).toBe(1); for (const malformed of ["not-a-watermark", "9".repeat(400)]) { await admin.set(watermarkKey, malformed, { PX: 60_000 }); - await expect(client.raw.writeTracked(valueKey, watermarkKey, 60_000, 0, "replacement")).rejects.toThrow( - "invalid DialCache watermark", - ); - expect(await scriptClient.read({ valueKey })).toBe("original"); + await expect(scriptClient.write({ + valueKey, + watermarkKey, + cacheTtlMs: 60_000, + value: "replacement", + })).rejects.toThrow("invalid DialCache watermark"); + // The paired SET lands before the stamp validates the watermark, so the + // tracked path serves nothing and the placeholder stays zero-stamped. + expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull(); + const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); + expect(stored?.readBigUInt64BE(1)).toBe(0n); + await admin.del(valueKey); } }); @@ -1492,6 +1486,56 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { expect(await admin.get(watermarkKey)).toBe("0"); expect(await scriptClient.read({ valueKey, watermarkKey })).toBe("stale"); }); + + it("never serves an unstamped placeholder and stamps it on demand", async () => { + if (client === undefined || admin === undefined) { + throw new Error("Redis test clients did not start"); + } + const valueKey = "placeholder:{item:pending}:value"; + const watermarkKey = "placeholder:{item:pending}:watermark"; + await admin.set(valueKey, encodeFrame("pending", 0, 0), { PX: 60_000 }); + await admin.set(watermarkKey, "0", { PX: 120_000 }); + + expect(await client.adapter.read({ valueKey, watermarkKey })).toBeNull(); + + expect(await client.raw.stamp(valueKey, watermarkKey, 2_000)).toBe(1); + + expect(await client.adapter.read({ valueKey, watermarkKey })).toBe("pending"); + const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); + expect(stored?.readBigUInt64BE(1) ?? 0n).toBeGreaterThan(0n); + }); + + it("refuses to restamp an existing frame after its paired SET was lost", async () => { + if (client === undefined || admin === undefined) { + throw new Error("Redis test clients did not start"); + } + const valueKey = "restamp:{item:fenced}:value"; + const watermarkKey = "restamp:{item:fenced}:watermark"; + // A stale frame fenced by a past invalidation, as left behind when a + // fallback write's SET fails (for example on OOM) but its stamp still runs. + await admin.set(valueKey, encodeFrame("stale", 0, 1_000), { PX: 60_000 }); + await admin.set(watermarkKey, "2000", { PX: 120_000 }); + + expect(await client.raw.stamp(valueKey, watermarkKey, 2_000)).toBe(1); + + const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); + expect(stored?.readBigUInt64BE(1)).toBe(1_000n); + expect(await client.adapter.read({ valueKey, watermarkKey })).toBeNull(); + }); + + it("does not create a value key when stamping after a lost SET", async () => { + if (client === undefined || admin === undefined) { + throw new Error("Redis test clients did not start"); + } + const valueKey = "stamp-missing:{item:lost}:value"; + const watermarkKey = "stamp-missing:{item:lost}:watermark"; + + expect(await client.raw.stamp(valueKey, watermarkKey, 2_000)).toBe(1); + + expect(await admin.exists(valueKey)).toBe(0); + expect(await admin.get(watermarkKey)).toBe("0"); + expect(await admin.pTTL(watermarkKey)).toBeGreaterThan(60_000); + }); }); it("uses one wire format across node-redis and Valkey GLIDE", async () => { diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index ea7bb6f..672f828 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -5,6 +7,7 @@ import { DialCacheRedisPayloadError, DialCacheRedisProtocolError, } from "../src/redis-client.js"; +import { WRITE_TRACKED_STAMP_SCRIPT } from "../src/redis-protocol.js"; import { createValkeyGlideDialCacheClient } from "../src/valkey-glide.js"; const INVALID_WRITE_REPLIES: readonly unknown[] = [ @@ -25,6 +28,7 @@ const INVALID_INVALIDATION_REPLIES: readonly unknown[] = [0, ...INVALID_WRITE_RE const decoderBytes = Symbol("bytes"); const scriptInstances: MockScript[] = []; const batchInstances: MockBatch[] = []; +const clusterBatchInstances: MockClusterBatch[] = []; const standaloneClients = new WeakSet(); const clusterClients = new WeakSet(); @@ -37,10 +41,15 @@ class MockScript { } class MockBatch { + readonly commands: Array> = []; readonly mget = vi.fn((keys: Array) => { this.keys = keys; return this; }); + readonly customCommand = vi.fn((args: Array) => { + this.commands.push(args); + return this; + }); keys: Array | undefined; constructor(readonly isAtomic: boolean) { @@ -48,6 +57,13 @@ class MockBatch { } } +class MockClusterBatch extends MockBatch { + constructor(isAtomic: boolean) { + super(isAtomic); + clusterBatchInstances.push(this); + } +} + function mockClientIdentity(instances: WeakSet) { return { [Symbol.hasInstance](value: unknown): boolean { @@ -61,6 +77,7 @@ function mockClientIdentity(instances: WeakSet) { const mockGlide = { Batch: MockBatch, + ClusterBatch: MockClusterBatch, Decoder: { Bytes: decoderBytes }, GlideClient: mockClientIdentity(standaloneClients), GlideClusterClient: mockClientIdentity(clusterClients), @@ -80,7 +97,10 @@ function createFakeClient(replies: unknown[]) { exec: vi.fn(async ( _batch: MockBatch, _raiseOnError: boolean, - _options: { decoder: typeof decoderBytes }, + _options: { + decoder: typeof decoderBytes; + route?: { type: "primarySlotKey"; key: string }; + }, ) => nextReply()), invokeScript: vi.fn(async (_script: MockScript, _options: InvokeScriptOptions) => nextReply()), }; @@ -137,6 +157,7 @@ describe("Valkey GLIDE adapter", () => { beforeEach(() => { scriptInstances.length = 0; batchInstances.length = 0; + clusterBatchInstances.length = 0; }); it("uses GET and a non-atomic primary MGET batch that preserves caller WATCH state", async () => { @@ -175,7 +196,7 @@ describe("Valkey GLIDE adapter", () => { { decoder: decoderBytes }, ); expect(client.invokeScript).not.toHaveBeenCalled(); - expect(scriptInstances).toHaveLength(3); + expect(scriptInstances).toHaveLength(2); }); it("routes tracked cluster MGET directly to the slot primary", async () => { @@ -255,12 +276,18 @@ describe("Valkey GLIDE adapter", () => { ...mockGlide, Batch: undefined, } as unknown as typeof mockGlide; + const glideWithoutClusterBatch = { + ...mockGlide, + ClusterBatch: undefined, + } as unknown as typeof mockGlide; - expect( - () => createValkeyGlideDialCacheClient(client, glideWithoutBatch), - ).toThrow( - "Valkey GLIDE DialCache requires @valkey/valkey-glide >=2.0.0 with a Batch constructor", - ); + for (const runtime of [glideWithoutBatch, glideWithoutClusterBatch]) { + expect( + () => createValkeyGlideDialCacheClient(client, runtime), + ).toThrow( + "Valkey GLIDE DialCache requires @valkey/valkey-glide >=2.0.0 with Batch and ClusterBatch constructors", + ); + } expect(scriptInstances).toHaveLength(0); }); @@ -281,14 +308,20 @@ describe("Valkey GLIDE adapter", () => { adapter.dispose(); }); - it("passes string and Buffer writes directly to GLIDE", async () => { + it("writes frames through natively batched SET commands", async () => { const binary = Buffer.from([0, 0xff, 0x80]); - const client = fakeClient(1, 0, 1); + const client = fakeClient( + [Buffer.from("OK")], + [Buffer.from("OK"), 0], + 1, + ); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + const before = Date.now(); await expect( adapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "hello" }), ).resolves.toBe(true); + const after = Date.now(); await expect( adapter.write({ valueKey: "tracked:{id}:value", @@ -301,25 +334,188 @@ describe("Valkey GLIDE adapter", () => { adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 100 }), ).resolves.toBeUndefined(); - expect(client.invokeScript).toHaveBeenNthCalledWith( - 1, + expect(batchInstances).toHaveLength(2); + const [untrackedBatch, trackedBatch] = batchInstances; + expect(untrackedBatch?.isAtomic).toBe(false); + expect(untrackedBatch?.commands).toHaveLength(1); + const untrackedSet = untrackedBatch?.commands[0] ?? []; + expect(untrackedSet[0]).toBe("SET"); + expect(untrackedSet[1]).toBe("plain:value"); + expect(untrackedSet[3]).toBe("PX"); + expect(untrackedSet[4]).toBe("1000"); + const untrackedFrame = untrackedSet[2] as Buffer; + expect(untrackedFrame[0]).toBe(1); + expect(untrackedFrame[9]).toBe(0); + expect(untrackedFrame.subarray(10).toString("utf8")).toBe("hello"); + const createdAtMs = Number(untrackedFrame.readBigUInt64BE(1)); + expect(createdAtMs).toBeGreaterThanOrEqual(before); + expect(createdAtMs).toBeLessThanOrEqual(after); + expect(client.exec).toHaveBeenNthCalledWith(1, untrackedBatch, true, { decoder: decoderBytes }); + + expect(trackedBatch?.isAtomic).toBe(false); + expect(trackedBatch?.commands).toHaveLength(2); + const [trackedSet, stamp] = trackedBatch?.commands ?? []; + expect(trackedSet?.[0]).toBe("SET"); + expect(trackedSet?.[1]).toBe("tracked:{id}:value"); + expect(trackedSet?.[3]).toBe("PX"); + expect(trackedSet?.[4]).toBe("2000"); + const trackedFrame = trackedSet?.[2] as Buffer; + expect(trackedFrame[0]).toBe(1); + expect(trackedFrame.readBigUInt64BE(1)).toBe(0n); + expect(trackedFrame[9]).toBe(1); + expect(trackedFrame.subarray(10)).toEqual(binary); + expect(stamp).toEqual([ + "EVALSHA", + createHash("sha1").update(WRITE_TRACKED_STAMP_SCRIPT).digest("hex"), + "2", + "tracked:{id}:value", + "tracked:{id}:watermark", + "2000", + ]); + expect(client.exec).toHaveBeenNthCalledWith(2, trackedBatch, false, { decoder: decoderBytes }); + + expect(client.invokeScript).toHaveBeenCalledTimes(1); + expect(client.invokeScript).toHaveBeenCalledWith( expect.any(MockScript), - { keys: ["plain:value"], args: ["1000", "0", "hello"], decoder: decoderBytes }, + { keys: ["tracked:{id}:watermark"], args: ["100"], decoder: decoderBytes }, ); - expect(client.invokeScript).toHaveBeenNthCalledWith( - 2, - expect.any(MockScript), - { + }); + + it("routes cluster writes through ClusterBatch to the slot primary", async () => { + const client = fakeClusterClient(["OK"], ["OK", 1]); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect( + adapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" }), + ).resolves.toBe(true); + await expect( + adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + }), + ).resolves.toBe(true); + + expect(clusterBatchInstances).toHaveLength(2); + expect(client.exec).toHaveBeenNthCalledWith(1, clusterBatchInstances[0], true, { + decoder: decoderBytes, + route: { type: "primarySlotKey", key: "plain:value" }, + }); + expect(client.exec).toHaveBeenNthCalledWith(2, clusterBatchInstances[1], false, { + decoder: decoderBytes, + route: { type: "primarySlotKey", key: "tracked:{id}:value" }, + }); + }); + + it("falls back to invokeScript when the batched stamp hits NOSCRIPT", async () => { + const noscriptWordings = [ + // Raw server reply wording. + "NOSCRIPT No matching script. Please use EVAL.", + // GLIDE's mapped RequestError wording. + "An error was signalled by the server: - NoScriptError: No matching script.", + ]; + for (const wording of noscriptWordings) { + const client = fakeClient([Buffer.from("OK"), new Error(wording)], 1); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect(adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 2_000, + value: "tracked", + })).resolves.toBe(true); + + expect(client.invokeScript).toHaveBeenCalledTimes(1); + const [script, options] = client.invokeScript.mock.calls[0] ?? []; + expect(script?.code).toBe(WRITE_TRACKED_STAMP_SCRIPT); + expect(options).toEqual({ keys: ["tracked:{id}:value", "tracked:{id}:watermark"], - args: ["2000", "1", binary], + args: ["2000"], decoder: decoderBytes, - }, - ); - expect(client.invokeScript).toHaveBeenNthCalledWith( - 3, - expect.any(MockScript), - { keys: ["tracked:{id}:watermark"], args: ["100"], decoder: decoderBytes }, + }); + adapter.dispose(); + } + }); + + it("rejects out-of-range cacheTtlMs before batching and ceils fractional TTLs", async () => { + const client = fakeClient([Buffer.from("OK"), 1]); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + for (const cacheTtlMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 31_536_000_001]) { + await expect( + adapter.write({ valueKey: "plain:value", cacheTtlMs, value: "plain" }), + ).rejects.toThrow(RangeError); + await expect( + adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs, + value: "tracked", + }), + ).rejects.toThrow(RangeError); + } + expect(client.exec).not.toHaveBeenCalled(); + expect(batchInstances).toHaveLength(0); + + await expect(adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000.1, + value: "tracked", + })).resolves.toBe(true); + const [trackedSet, stamp] = batchInstances[0]?.commands ?? []; + expect(trackedSet?.[4]).toBe("1001"); + expect(stamp?.[5]).toBe("1001"); + adapter.dispose(); + }); + + it("surfaces batched SET and stamp command errors", async () => { + const setFailure = new Error("OOM command not allowed when used memory > 'maxmemory'."); + const setClient = fakeClient([setFailure, 1]); + const setAdapter = createValkeyGlideDialCacheClient(setClient, mockGlide); + await expect(setAdapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })).rejects.toBe(setFailure); + expect(setClient.invokeScript).not.toHaveBeenCalled(); + setAdapter.dispose(); + + const stampFailure = new Error("ERR invalid DialCache watermark"); + const stampClient = fakeClient([Buffer.from("OK"), stampFailure]); + const stampAdapter = createValkeyGlideDialCacheClient(stampClient, mockGlide); + await expect(stampAdapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })).rejects.toBe(stampFailure); + expect(stampClient.invokeScript).not.toHaveBeenCalled(); + stampAdapter.dispose(); + }); + + it("validates write batch envelopes and SET replies", async () => { + const envelopeClient = fakeClient("not-a-batch-reply", [Buffer.from("OK")]); + const envelopeAdapter = createValkeyGlideDialCacheClient(envelopeClient, mockGlide); + await expect( + envelopeAdapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" }), + ).rejects.toBeInstanceOf(DialCacheRedisPayloadError); + await expect(envelopeAdapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })).rejects.toBeInstanceOf(DialCacheRedisPayloadError); + envelopeAdapter.dispose(); + + const setReplyClient = fakeClient(["QUEUED"]); + const setReplyAdapter = createValkeyGlideDialCacheClient(setReplyClient, mockGlide); + await expectProtocolError( + Promise.resolve(setReplyAdapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" })), + "Invalid DialCache Redis SET reply; expected OK", ); + setReplyAdapter.dispose(); }); it("rejects malformed native read and mutation script replies", async () => { @@ -328,7 +524,7 @@ describe("Valkey GLIDE adapter", () => { redisFrame("invalid", { encoding: 2 }), "not-a-batch-reply", [[redisFrame("missing-watermark")]], - "not-an-integer", + [Buffer.from("OK"), "not-an-integer"], null, ); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); @@ -344,7 +540,12 @@ describe("Valkey GLIDE adapter", () => { adapter.read({ valueKey: "bad-pair:{id}:value", watermarkKey: "bad-pair:{id}:watermark" }), ).rejects.toBeInstanceOf(DialCacheRedisPayloadError); await expectProtocolError( - Promise.resolve(adapter.write({ valueKey: "bad-write", cacheTtlMs: 1_000, value: "value" })), + Promise.resolve(adapter.write({ + valueKey: "bad-write:{id}:value", + watermarkKey: "bad-write:{id}:watermark", + cacheTtlMs: 1_000, + value: "value", + })), "Invalid DialCache Redis write reply; expected integer 0 or 1", ); await expectProtocolError( @@ -360,14 +561,10 @@ describe("Valkey GLIDE adapter", () => { const invalidationMessage = "Invalid DialCache Redis invalidate reply; expected integer 1"; for (const reply of INVALID_WRITE_REPLIES) { - const untracked = createValkeyGlideDialCacheClient(fakeClient(reply), mockGlide); - await expectProtocolError( - Promise.resolve(untracked.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" })), - writeMessage, + const tracked = createValkeyGlideDialCacheClient( + fakeClient([Buffer.from("OK"), reply]), + mockGlide, ); - untracked.dispose(); - - const tracked = createValkeyGlideDialCacheClient(fakeClient(reply), mockGlide); await expectProtocolError( Promise.resolve(tracked.write({ valueKey: "tracked:{id}:value", @@ -400,7 +597,7 @@ describe("Valkey GLIDE adapter", () => { adapter.dispose(); adapter.dispose(); - expect(scriptInstances).toHaveLength(3); + expect(scriptInstances).toHaveLength(2); for (const script of scriptInstances) { expect(script.release).toHaveBeenCalledTimes(1); } @@ -448,6 +645,7 @@ describe("Valkey GLIDE adapter", () => { }; const client = fakeClient( [[redisFrame("tracked"), Buffer.from("0")]], + [Buffer.from("OK")], 1, ); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); @@ -457,17 +655,23 @@ describe("Valkey GLIDE adapter", () => { watermarkKey: "module:{instance}:watermark", }); await adapter.write({ valueKey: "module-instance", cacheTtlMs: 1_000, value: "value" }); - - const [batch, , execOptions] = client.exec.mock.calls[0] ?? []; - const [script, options] = client.invokeScript.mock.calls[0] ?? []; - expect(batch).toBeInstanceOf(MockBatch); - expect(batch).not.toBeInstanceOf(otherGlide.Batch); + await adapter.invalidate({ watermarkKey: "module:{instance}:watermark", futureBufferMs: 5 }); + + const [readBatch, , readOptions] = client.exec.mock.calls[0] ?? []; + const [writeBatch, , writeOptions] = client.exec.mock.calls[1] ?? []; + const [script, scriptOptions] = client.invokeScript.mock.calls[0] ?? []; + expect(readBatch).toBeInstanceOf(MockBatch); + expect(readBatch).not.toBeInstanceOf(otherGlide.Batch); + expect(writeBatch).toBeInstanceOf(MockBatch); + expect(writeBatch).not.toBeInstanceOf(otherGlide.Batch); expect(script).toBeInstanceOf(MockScript); expect(script).not.toBeInstanceOf(otherGlide.Script); - expect(execOptions?.decoder).toBe(mockGlide.Decoder.Bytes); - expect(execOptions?.decoder).not.toBe(otherGlide.Decoder.Bytes); - expect(options?.decoder).toBe(mockGlide.Decoder.Bytes); - expect(options?.decoder).not.toBe(otherGlide.Decoder.Bytes); + expect(readOptions?.decoder).toBe(mockGlide.Decoder.Bytes); + expect(readOptions?.decoder).not.toBe(otherGlide.Decoder.Bytes); + expect(writeOptions?.decoder).toBe(mockGlide.Decoder.Bytes); + expect(writeOptions?.decoder).not.toBe(otherGlide.Decoder.Bytes); + expect(scriptOptions?.decoder).toBe(mockGlide.Decoder.Bytes); + expect(scriptOptions?.decoder).not.toBe(otherGlide.Decoder.Bytes); adapter.dispose(); }); }); From 66e4164c2327cca3beda8e75848f94aa692e9d9c Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 7 Aug 2026 22:44:46 -0700 Subject: [PATCH 02/12] fix(redis): pair tracked stamps to their placeholders with a per-write nonce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 8 +- scripts/test-package.mjs | 31 +++++- src/internal/duration.ts | 2 +- src/internal/redis-payload.ts | 65 ++++++++--- src/internal/redis-script-reply.ts | 21 +++- src/internal/redis-scripts.ts | 30 ++++-- src/node-redis.ts | 64 +++++++---- src/redis-client.ts | 39 ++++--- src/redis-protocol.ts | 2 + src/valkey-glide.ts | 101 ++++++++--------- test/node-redis.test.ts | 77 +++++++++++-- test/redis-payload.test.ts | 41 +++++-- test/redis-real.integration.test.ts | 71 ++++++++---- test/valkey-glide.test.ts | 161 ++++++++++++++++++++-------- 14 files changed, 514 insertions(+), 199 deletions(-) diff --git a/README.md b/README.md index 2f71484..70ff5c7 100644 --- a/README.md +++ b/README.md @@ -402,13 +402,13 @@ The node-redis adapter owns no additional resources, so the application closes t Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. -Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` whose frame carries an all-zeros timestamp placeholder, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, patches the placeholder with Redis server time, and maintains the watermark TTL. A placeholder is never readable — tracked reads miss without a watermark and fence `createdAt <= watermark` otherwise — so a delayed or lost stamp degrades to a miss that expires with the value TTL rather than partial state. The stamp only patches an all-zeros timestamp, so it cannot revive an older fenced frame when its paired `SET` failed, and a `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. +Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` of a version-0 placeholder frame carrying a fresh per-write nonce, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, promotes exactly the placeholder carrying its nonce to a served frame with Redis server time, and maintains the watermark TTL. A placeholder is unreadable on both read paths until promoted, so an interleaved or lost stamp degrades to a miss that expires with the value TTL rather than partial state — including briefly blanking a previously readable key the write replaces. The nonce means the stamp can never revive a frame it does not own: if its paired `SET` was rejected, overwritten, or expired, the stamp reports the placeholder gone and the write fails as `cache_write` instead of publishing another write's leftovers. A `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore a clean miss and may be replaced with a valid DialCache frame after the fallback succeeds, while a wrong-type watermark prevents the tracked write from succeeding: its paired `SET` still lands, leaving only an unreadable placeholder until expiry or a later successful write. Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. -For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-running the stamp through GLIDE's `Script`-based `invokeScript`, which reloads it; a late stamp is safe because the placeholder stays unreadable until it lands. Invalidation uses GLIDE's native `Script` lifecycle directly. +For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-running the stamp through GLIDE's `Script`-based `invokeScript`, which reloads it, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation uses GLIDE's native `Script` lifecycle directly. A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`; otherwise the tracked write fails open as a `cache_write` error and leaves an unreadable placeholder or the prior stale value until a later successful cleanup or expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. @@ -426,7 +426,7 @@ Writes, invalidations, async `cacheConfigProvider` calls, and custom serializer #### Serialization -The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. The shared `encodeRedisFrame`, `decodeRedisFrame`, and `decodeTrackedRedisFrame` helpers, the tracked stamp and invalidation Lua sources, and wire constants are available from `dialcache/redis-protocol`, so custom adapters can reuse the bundled adapters' exact framing, miss, and watermark-fencing rules. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed replies, unsupported encodings, and mutation-script reply-domain violations in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. +The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. The shared `encodeRedisFrame`, `encodeTrackedRedisPlaceholder`, `decodeRedisFrame`, and `decodeTrackedRedisFrame` helpers, the tracked stamp and invalidation Lua sources, and wire constants are available from `dialcache/redis-protocol`, so custom adapters can reuse the bundled adapters' exact framing, miss, and watermark-fencing rules. A custom tracked write must pass the stamp script `KEYS = [valueKey, watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`, with `ARGV[1]` equal to the paired `SET`'s `PX` and the nonce from the same `encodeTrackedRedisPlaceholder` call, and must fail the write when the stamp replies `2`. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed replies, unsupported encodings, and mutation-script reply-domain violations in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. Redis values use a compact binary frame: @@ -437,7 +437,7 @@ byte 10 payload encoding (0 = UTF-8, 1 = raw binary) bytes 11... serialized payload ``` -Adapters build frames in the Node process with `encodeRedisFrame`. Untracked frames carry an informational client-clock timestamp that untracked reads never consult; tracked frames are written with an all-zeros placeholder that the stamp script patches to Redis server time using Lua's `struct` library, and adapters decode it with Node's buffer primitives. Redis TTL is authoritative, so expiry metadata is not duplicated in the frame. `payload` is produced by the operation's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; strings are stored as UTF-8 and Buffers are stored byte-for-byte without base64 expansion. Adapters restore the same representation before calling `serializer.load`. +Adapters build frames in the Node process. Untracked frames come from `encodeRedisFrame` and carry an informational client-clock timestamp that untracked reads never consult. Tracked frames start as `encodeTrackedRedisPlaceholder` output — version byte `0`, with a random per-write nonce in the timestamp bytes — which no read path serves; the stamp script verifies the nonce and promotes the frame to version `1` with Redis server time using Lua's `struct` library, and adapters decode it with Node's buffer primitives. Redis TTL is authoritative, so expiry metadata is not duplicated in the frame. `payload` is produced by the operation's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; strings are stored as UTF-8 and Buffers are stored byte-for-byte without base64 expansion. Adapters restore the same representation before calling `serializer.load`. DialCache uses native `JSON.stringify` and `JSON.parse` by default. There is no runtime validation pass, so the default adds no traversal beyond JSON serialization itself. A top-level `undefined` result is supported with an internal sentinel. diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index ffd802c..19578c3 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -53,7 +53,9 @@ import { decodeRedisFrame, decodeTrackedRedisFrame, encodeRedisFrame, + encodeTrackedRedisPlaceholder, WRITE_TRACKED_STAMP_SCRIPT, + type TrackedRedisPlaceholder, } from "dialcache/redis-protocol"; // @ts-expect-error Read Lua sources were removed from the mutation-only Redis protocol. import { READ_CACHE_SCRIPT } from "dialcache/redis-protocol"; @@ -159,11 +161,13 @@ const decodedStaleRedisPayload: string | Buffer | null = decodeTrackedRedisFrame Buffer.from("1"), ); const placeholderRedisFrame: Buffer = encodeRedisFrame("pending", 0); +const trackedRedisPlaceholder: TrackedRedisPlaceholder = encodeTrackedRedisPlaceholder("pending"); const stampScriptSource: string = WRITE_TRACKED_STAMP_SCRIPT; const stampArguments: Array = dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformArguments( "tracked:{id}:value", "tracked:{id}:watermark", 1_000, + trackedRedisPlaceholder.nonce, ); const fallbackTimeoutError = new FallbackTimeoutError("Load", 1_000); const redisReadTimeoutError = new RedisReadTimeoutError("Load", 100); @@ -482,6 +486,7 @@ void READ_TRACKED_CACHE_SCRIPT; void WRITE_CACHE_SCRIPT; void WRITE_TRACKED_CACHE_SCRIPT; void placeholderRedisFrame; +void trackedRedisPlaceholder; void stampScriptSource; void stampArguments; void customRedisClient; @@ -685,7 +690,7 @@ try { console.log("${fallbackTimeoutMarker}"); } try { - nodeRedis.dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(2); + nodeRedis.dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(3); throw new Error("Expected an invalid node-redis script reply to fail"); } catch (error) { if (!(error instanceof root.DialCacheRedisProtocolError)) { @@ -728,6 +733,15 @@ if (redisProtocol.decodeRedisFrame(redisProtocol.encodeRedisFrame("value", 1)) ! if (redisProtocol.decodeTrackedRedisFrame(redisProtocol.encodeRedisFrame("pending", 0), Buffer.from("0")) !== null) { throw new Error("The packed ESM Redis protocol encoder did not produce a fenced placeholder frame"); } +const esmPlaceholder = redisProtocol.encodeTrackedRedisPlaceholder("pending"); +if ( + esmPlaceholder.frame[0] !== 0 + || esmPlaceholder.nonce.byteLength !== 8 + || redisProtocol.decodeRedisFrame(esmPlaceholder.frame) !== null + || redisProtocol.decodeTrackedRedisFrame(esmPlaceholder.frame, Buffer.from("0")) !== null +) { + throw new Error("The packed ESM tracked placeholder must be unreadable until stamped"); +} const esmEmptyFrame = Buffer.alloc(10); esmEmptyFrame[0] = 1; esmEmptyFrame.writeBigUInt64BE(1n, 1); @@ -984,7 +998,7 @@ void (async () => { } })(); try { - nodeRedis.dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(2); + nodeRedis.dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(3); throw new Error("Expected an invalid node-redis script reply to fail"); } catch (error) { if (!(error instanceof root.DialCacheRedisProtocolError)) { @@ -1027,6 +1041,15 @@ if (redisProtocol.decodeRedisFrame(redisProtocol.encodeRedisFrame("value", 1)) ! if (redisProtocol.decodeTrackedRedisFrame(redisProtocol.encodeRedisFrame("pending", 0), Buffer.from("0")) !== null) { throw new Error("The packed CommonJS Redis protocol encoder did not produce a fenced placeholder frame"); } +const cjsPlaceholder = redisProtocol.encodeTrackedRedisPlaceholder("pending"); +if ( + cjsPlaceholder.frame[0] !== 0 + || cjsPlaceholder.nonce.byteLength !== 8 + || redisProtocol.decodeRedisFrame(cjsPlaceholder.frame) !== null + || redisProtocol.decodeTrackedRedisFrame(cjsPlaceholder.frame, Buffer.from("0")) !== null +) { + throw new Error("The packed CommonJS tracked placeholder must be unreadable until stamped"); +} const cjsEmptyFrame = Buffer.alloc(10); cjsEmptyFrame[0] = 1; cjsEmptyFrame.writeBigUInt64BE(1n, 1); @@ -1174,7 +1197,7 @@ const esmFakeGlideClient = { if (options.decoder !== appGlide.Decoder.Bytes) { throw new Error("The ESM adapter did not use the caller-supplied GLIDE byte decoder"); } - return 2; + return 3; }, }; const esmGlideRuntime = { @@ -1234,7 +1257,7 @@ void (async () => { if (options.decoder !== appGlide.Decoder.Bytes) { throw new Error("The CommonJS adapter did not use the caller-supplied GLIDE byte decoder"); } - return 2; + return 3; }, }; const cjsGlideRuntime = { diff --git a/src/internal/duration.ts b/src/internal/duration.ts index b80f76c..c7370a5 100644 --- a/src/internal/duration.ts +++ b/src/internal/duration.ts @@ -26,7 +26,7 @@ export function cacheTtlSecToMs(ttlSec: number): number { * durations upward, so adapters preserve that exact acceptance domain. */ export function ceilSupportedCacheTtlMs(cacheTtlMs: number): number { - const ceiled = Math.ceil(cacheTtlMs); + const ceiled = typeof cacheTtlMs === "number" ? Math.ceil(cacheTtlMs) : Number.NaN; if (!Number.isFinite(ceiled) || ceiled <= 0 || ceiled > MAX_SUPPORTED_DURATION_MS) { throw new RangeError( `DialCache Redis write cacheTtlMs must be a positive duration no greater than ${MAX_SUPPORTED_DURATION_MS} milliseconds`, diff --git a/src/internal/redis-payload.ts b/src/internal/redis-payload.ts index 0e45d51..932715b 100644 --- a/src/internal/redis-payload.ts +++ b/src/internal/redis-payload.ts @@ -1,3 +1,5 @@ +import { randomBytes } from "node:crypto"; + import { DialCacheRedisPayloadEncodingError, DialCacheRedisPayloadError, @@ -7,6 +9,10 @@ import { export const REDIS_FRAME_VERSION = 1; export const REDIS_ENCODING_UTF8 = 0; export const REDIS_ENCODING_BINARY = 1; +/** Version byte of a tracked-write placeholder; no read path serves it. */ +export const REDIS_FRAME_PLACEHOLDER_VERSION = 0; +export const REDIS_FRAME_TIMESTAMP_OFFSET = 1; +export const REDIS_FRAME_TIMESTAMP_BYTES = 8; const REDIS_FRAME_HEADER_BYTES = 9; const REDIS_FRAME_MIN_BYTES = REDIS_FRAME_HEADER_BYTES + 1; @@ -55,22 +61,11 @@ function decodeRedisPayload(raw: Buffer): RedisCachePayload { throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding"); } -/** - * Encode a serializer payload into a DialCache Redis frame. - * - * Untracked writes stamp an informational client-clock `createdAtMs`; - * untracked reads never consult it. Tracked writes must pass zero: an - * all-zeros timestamp is a placeholder that tracked reads can never serve, - * and `WRITE_TRACKED_STAMP_SCRIPT` patches it with server time. - */ -export function encodeRedisFrame(payload: RedisCachePayload, createdAtMs: number): Buffer { - if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) { - throw new RangeError("DialCache frame createdAtMs must be a nonnegative safe integer"); - } +function encodeFrameBytes(payload: RedisCachePayload, version: number, stampBytes: Buffer): Buffer { const payloadBytes = Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(payload, "utf8"); const frame = Buffer.allocUnsafe(REDIS_FRAME_MIN_BYTES + payloadBytes); - frame[0] = REDIS_FRAME_VERSION; - frame.writeBigUInt64BE(BigInt(createdAtMs), 1); + frame[0] = version; + stampBytes.copy(frame, REDIS_FRAME_TIMESTAMP_OFFSET); frame[REDIS_FRAME_HEADER_BYTES] = redisPayloadEncoding(payload); if (Buffer.isBuffer(payload)) { payload.copy(frame, REDIS_FRAME_MIN_BYTES); @@ -80,6 +75,48 @@ export function encodeRedisFrame(payload: RedisCachePayload, createdAtMs: number return frame; } +/** + * Encode a serializer payload into a servable DialCache Redis frame. + * + * Untracked writes stamp an informational client-clock `createdAtMs`; + * untracked reads never consult it. Tracked writes must not use this + * directly — they pair `encodeTrackedRedisPlaceholder` with + * `WRITE_TRACKED_STAMP_SCRIPT` instead. + */ +export function encodeRedisFrame(payload: RedisCachePayload, createdAtMs: number): Buffer { + if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) { + throw new RangeError("DialCache frame createdAtMs must be a nonnegative safe integer"); + } + const timestamp = Buffer.allocUnsafe(REDIS_FRAME_TIMESTAMP_BYTES); + timestamp.writeBigUInt64BE(BigInt(createdAtMs)); + return encodeFrameBytes(payload, REDIS_FRAME_VERSION, timestamp); +} + +export interface TrackedRedisPlaceholder { + /** Version-0 frame that no read path serves until the stamp promotes it. */ + readonly frame: Buffer; + /** Per-write identity passed to `WRITE_TRACKED_STAMP_SCRIPT` as its nonce argument. */ + readonly nonce: Buffer; +} + +/** + * Encode the placeholder frame a tracked write pairs with + * `WRITE_TRACKED_STAMP_SCRIPT`. + * + * The frame carries the placeholder version byte, so both read paths treat it + * as a miss, and a fresh random nonce where a stamped frame carries its + * timestamp. The stamp promotes the frame — patching version and server-time + * timestamp — only when the stored header matches this exact nonce, so it can + * never publish a placeholder left behind by a different write. Mint one + * placeholder per logical write: client-level retries must reuse the same + * frame and nonce so a retried SET re-establishes the placeholder its stamp + * expects. + */ +export function encodeTrackedRedisPlaceholder(payload: RedisCachePayload): TrackedRedisPlaceholder { + const nonce = randomBytes(REDIS_FRAME_TIMESTAMP_BYTES); + return { frame: encodeFrameBytes(payload, REDIS_FRAME_PLACEHOLDER_VERSION, nonce), nonce }; +} + /** * Decode an untracked DialCache frame returned as a Redis bulk string. * Missing, short, and unsupported-version frames are cache misses. Invalid diff --git a/src/internal/redis-script-reply.ts b/src/internal/redis-script-reply.ts index 311cbdd..d341e04 100644 --- a/src/internal/redis-script-reply.ts +++ b/src/internal/redis-script-reply.ts @@ -11,13 +11,28 @@ export function validateRedisSetReply(reply: unknown): void { } } -export function validateRedisScriptWriteReply(reply: unknown): 0 | 1 { - if (reply !== 0 && reply !== 1) { - throw new DialCacheRedisProtocolError("Invalid DialCache Redis write reply; expected integer 0 or 1"); +export function validateRedisScriptWriteReply(reply: unknown): 0 | 1 | 2 { + if (reply !== 0 && reply !== 1 && reply !== 2) { + throw new DialCacheRedisProtocolError("Invalid DialCache Redis write reply; expected integer 0, 1, or 2"); } return reply; } +/** + * Map a validated stamp reply onto the write() boolean contract: 0 (fenced) + * is false, 1 (stamped) is true, and 2 — the paired placeholder was gone — + * fails the write so split pairs surface through the normal fail-open path. + */ +export function resolveTrackedRedisWriteReply(reply: unknown): boolean { + const stamp = validateRedisScriptWriteReply(reply); + if (stamp === 2) { + throw new Error( + "DialCache tracked write lost its placeholder before the stamp; the SET was rejected, overwritten, or expired", + ); + } + return stamp === 1; +} + export function validateRedisScriptInvalidationReply(reply: unknown): 1 { if (reply !== 1) { throw new DialCacheRedisProtocolError("Invalid DialCache Redis invalidate reply; expected integer 1"); diff --git a/src/internal/redis-scripts.ts b/src/internal/redis-scripts.ts index b88ebf1..915064e 100644 --- a/src/internal/redis-scripts.ts +++ b/src/internal/redis-scripts.ts @@ -1,6 +1,13 @@ import { MAX_SUPPORTED_DURATION_MS } from "./duration.js"; +import { + REDIS_FRAME_PLACEHOLDER_VERSION, + REDIS_FRAME_TIMESTAMP_BYTES, + REDIS_FRAME_TIMESTAMP_OFFSET, + REDIS_FRAME_VERSION, +} from "./redis-payload.js"; const WATERMARK_TTL_MARGIN_MS = 60_000; +const PLACEHOLDER_HEADER_END = REDIS_FRAME_TIMESTAMP_OFFSET + REDIS_FRAME_TIMESTAMP_BYTES - 1; const PARSE_WATERMARK_LUA = String.raw`local function parse_watermark(raw) if not string.match(raw, "^%d+$") and not string.match(raw, "^%d+%.%d+$") then @@ -24,6 +31,9 @@ end`; const VALIDATE_STAMP_ARGUMENTS_LUA = String.raw`local cache_ttl_ms = ceil_finite_number(ARGV[1]) if not cache_ttl_ms or cache_ttl_ms <= 0 or cache_ttl_ms > ${MAX_SUPPORTED_DURATION_MS} then return redis.error_reply("ERR invalid DialCache TTL") +end +if string.len(ARGV[2]) ~= ${REDIS_FRAME_TIMESTAMP_BYTES} then + return redis.error_reply("ERR invalid DialCache stamp nonce") end`; const REDIS_TIME_LUA = String.raw`local redis_time = redis.call("TIME") @@ -45,15 +55,21 @@ end if watermark >= now_ms then -- A fenced fallback write removes the placeholder it paired with, along with any - -- stale frame that led to it. Reads that fail before reaching this script cannot - -- benefit from this partial mitigation. + -- stale frame that led to it. The UNLINK stays unconditional: any frame present + -- here is already fenced, and removing a foreign in-flight placeholder only + -- forces that writer's honest reply-2 failure. Reads that fail before reaching + -- this script cannot benefit from this partial mitigation. redis.call("UNLINK", KEYS[1]) return 0 end`, - String.raw`if redis.call("GETRANGE", KEYS[1], 1, 8) == string.rep("\0", 8) then - -- Only stamp an all-zeros placeholder: when the paired SET did not land, - -- restamping an existing frame could unfence a stale value. - redis.call("SETRANGE", KEYS[1], 1, struct.pack(">I8", now_ms)) + String.raw`local stamped = 1 +if redis.call("GETRANGE", KEYS[1], 0, ${PLACEHOLDER_HEADER_END}) == string.char(${REDIS_FRAME_PLACEHOLDER_VERSION}) .. ARGV[2] then + redis.call("SETRANGE", KEYS[1], 0, string.char(${REDIS_FRAME_VERSION}) .. struct.pack(">I8", now_ms)) +else + -- The placeholder this stamp paired with is gone: its SET was rejected, + -- overwritten, or expired. Promoting any other frame could publish a value + -- this write does not own, so leave the key untouched and report 2. + stamped = 2 end`, String.raw`local desired_ttl_ms = cache_ttl_ms + ${WATERMARK_TTL_MARGIN_MS} if not raw_watermark then @@ -66,7 +82,7 @@ else redis.call("PEXPIRE", KEYS[2], desired_ttl_ms) end end`, - "return 1", + "return stamped", ].join("\n\n"); export const INVALIDATE_CACHE_SCRIPT = [ diff --git a/src/node-redis.ts b/src/node-redis.ts index 4931225..5e232df 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -8,9 +8,11 @@ import { decodeRedisFrame, decodeTrackedRedisFrame, encodeRedisFrame, + encodeTrackedRedisPlaceholder, } from "./internal/redis-payload.js"; import { ceilSupportedCacheTtlMs } from "./internal/duration.js"; import { + resolveTrackedRedisWriteReply, validateRedisScriptInvalidationReply, validateRedisScriptWriteReply, validateRedisSetReply, @@ -52,7 +54,7 @@ function defineDialCacheScript, Reply>( export type DialCacheNodeRedisScripts = { readonly dialcacheWriteTrackedStamp: NodeRedisScript< - [valueKey: string, watermarkKey: string, cacheTtlMs: number], + [valueKey: string, watermarkKey: string, cacheTtlMs: number, nonce: Buffer], number >; readonly dialcacheInvalidate: NodeRedisScript< @@ -71,8 +73,9 @@ export const dialcacheRedisScripts: DialCacheNodeRedisScripts = { valueKey: string, watermarkKey: string, cacheTtlMs: number, - ): Array { - return [valueKey, watermarkKey, String(cacheTtlMs)]; + nonce: Buffer, + ): Array { + return [valueKey, watermarkKey, String(cacheTtlMs), nonce]; }, transformReply: writeReply, }), @@ -93,6 +96,7 @@ interface NodeRedisWriteClient { valueKey: string, watermarkKey: string, cacheTtlMs: number, + nonce: Buffer, ): Promise; dialcacheInvalidate(watermarkKey: string, futureBufferMs: number): Promise; } @@ -135,18 +139,27 @@ function validateRedisMGetReply(reply: unknown): [unknown, unknown] { return [reply[0], reply[1]]; } +// Keyed commands route to the slot primary in cluster mode (isReadonly=false), +// so tracked reads observe the latest invalidation watermark even when the +// caller configured node-redis Cluster with useReplicas. +function sendKeyedCommand( + client: NodeRedisClient, + firstKey: string, + args: Array, + options: BufferReplyOptions, +): Promise { + return isNodeRedisClusterClient(client) + ? client.sendCommand(firstKey, false, args, options) + : client.sendCommand(args, options); +} + async function readTracked( client: NodeRedisClient, options: BufferReplyOptions, valueKey: string, watermarkKey: string, ): Promise<[unknown, unknown]> { - const args = ["MGET", valueKey, watermarkKey]; - const raw = isNodeRedisClusterClient(client) - // A tracked read must observe the primary's latest invalidation watermark, - // even when the caller configured node-redis Cluster with useReplicas. - ? await client.sendCommand(valueKey, false, args, options) - : await client.sendCommand(args, options); + const raw = await sendKeyedCommand(client, valueKey, ["MGET", valueKey, watermarkKey], options); return validateRedisMGetReply(raw); } @@ -156,10 +169,12 @@ function sendFrameSet( frame: Buffer, cacheTtlMs: number, ): Promise { - const args: Array = ["SET", valueKey, frame, "PX", String(cacheTtlMs)]; - return isNodeRedisClusterClient(client) - ? client.sendCommand(valueKey, false, args, bufferReplyOptions) - : client.sendCommand(args, bufferReplyOptions); + return sendKeyedCommand( + client, + valueKey, + ["SET", valueKey, frame, "PX", String(cacheTtlMs)], + bufferReplyOptions, + ); } /** @@ -173,6 +188,14 @@ function sendFrameSet( * the client. */ export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCacheRedisClient { + if ( + typeof client.dialcacheWriteTrackedStamp !== "function" + || typeof client.dialcacheInvalidate !== "function" + ) { + throw new TypeError( + "node-redis DialCache requires a client created with scripts: dialcacheRedisScripts", + ); + } return { async read({ valueKey, watermarkKey }, context) { const options: BufferReplyOptions = context === undefined @@ -198,21 +221,24 @@ export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCac ); return true; } + const { frame, nonce } = encodeTrackedRedisPlaceholder(value); // Both commands must enqueue in this synchronous tick so they pipeline // in order; an await between them would allow reordering around them. - const setPromise = sendFrameSet(client, valueKey, encodeRedisFrame(value, 0), cacheTtlMs); - const stampPromise = client.dialcacheWriteTrackedStamp(valueKey, watermarkKey, cacheTtlMs); + const setPromise = sendFrameSet(client, valueKey, frame, cacheTtlMs); + // Observe the SET unconditionally so a synchronous throw before + // allSettled cannot leave its rejection unhandled. + setPromise.catch(() => undefined); + const stampPromise = client.dialcacheWriteTrackedStamp(valueKey, watermarkKey, cacheTtlMs, nonce); const [setResult, stampResult] = await Promise.allSettled([setPromise, stampPromise]); - // A failed SET is the write outcome even when the stamp settled: the - // stamp may have patched an unrelated frame's placeholder or no-opped. + // A failed SET is the write outcome even when the stamp settled. if (setResult.status === "rejected") { throw setResult.reason; } + validateRedisSetReply(setResult.value); if (stampResult.status === "rejected") { throw stampResult.reason; } - validateRedisSetReply(setResult.value); - return validateRedisScriptWriteReply(stampResult.value) === 1; + return resolveTrackedRedisWriteReply(stampResult.value); }, async invalidate({ watermarkKey, futureBufferMs }) { const result = await client.dialcacheInvalidate(watermarkKey, futureBufferMs); diff --git a/src/redis-client.ts b/src/redis-client.ts index b9ff78d..d4db963 100644 --- a/src/redis-client.ts +++ b/src/redis-client.ts @@ -141,24 +141,35 @@ export interface DialCacheRedisClient { */ read(request: RedisReadRequest, context?: RedisReadContext): Awaitable; /** - * Write a DialCache Redis frame produced by `encodeRedisFrame` from - * `dialcache/redis-protocol`, or preserve its exact behavior. + * Write a DialCache Redis frame using the `dialcache/redis-protocol` + * encoders, or preserve their exact behavior. * * Untracked writes are one native `SET valueKey frame PX cacheTtlMs` whose - * frame carries an informational client-clock `createdAtMs`; untracked - * reads never consult it. + * frame comes from `encodeRedisFrame` with an informational client-clock + * `createdAtMs`; untracked reads never consult it. * * Tracked writes issue two commands ordered on one connection without a - * transaction: a native `SET` of a frame whose `createdAtMs` is zero, - * followed by `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the - * watermark, patches the placeholder timestamp with server time, and - * maintains the watermark's existence and TTL. An all-zeros placeholder is - * never readable — tracked reads miss on a missing watermark and fence - * `createdAt <= watermark` otherwise — so an interleaved, delayed, or lost - * stamp degrades to a miss that expires with the value TTL. Implementations - * must not reorder the pair and must surface a SET failure as the write - * error even when the stamp settled. False means invalidation blocked the - * write. + * transaction: a native `SET` of an `encodeTrackedRedisPlaceholder` frame, + * followed by `WRITE_TRACKED_STAMP_SCRIPT` with `KEYS = [valueKey, + * watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`. `ARGV[1]` must equal the + * SET's `PX` — the watermark's lifetime is derived from it — and the nonce + * must be the placeholder's. The script fences against the watermark and + * unlinks the value (reply 0), promotes exactly the placeholder carrying + * its nonce to a served frame with server-time `createdAt` (reply 1), or + * reports the placeholder gone (reply 2); it maintains the watermark's + * existence and TTL in the non-fenced cases. Placeholders are unreadable on + * both read paths, so an interleaved or lost stamp degrades to a miss + * bounded by the value TTL — including briefly blanking a previously + * readable key the write replaces — while a delayed stamp of its own + * placeholder remains subject to the invalidation future buffer, like any + * in-flight write. + * + * Implementations must not reorder the pair, must mint one placeholder per + * logical write so client-level retries stay paired with their stamp, must + * surface a SET failure as the write error even when the stamp settled, and + * must fail the write on reply 2 so split pairs stay observable (a + * client-level SET retry that lands after such a failure can still leave + * the stamped value readable). False means invalidation blocked the write. */ write(request: RedisWriteRequest): Awaitable; /** diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index 01e7eda..b2d93c1 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -6,7 +6,9 @@ export { decodeRedisFrame, decodeTrackedRedisFrame, encodeRedisFrame, + encodeTrackedRedisPlaceholder, REDIS_ENCODING_BINARY, REDIS_ENCODING_UTF8, REDIS_FRAME_VERSION, + type TrackedRedisPlaceholder, } from "./internal/redis-payload.js"; diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index d3989ae..76f5d52 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -1,49 +1,43 @@ -import { createHash } from "node:crypto"; - import { ceilSupportedCacheTtlMs } from "./internal/duration.js"; import { decodeRedisFrame, decodeTrackedRedisFrame, encodeRedisFrame, + encodeTrackedRedisPlaceholder, } from "./internal/redis-payload.js"; import { INVALIDATE_CACHE_SCRIPT, WRITE_TRACKED_STAMP_SCRIPT, } from "./internal/redis-scripts.js"; import { + resolveTrackedRedisWriteReply, validateRedisScriptInvalidationReply, - validateRedisScriptWriteReply, validateRedisSetReply, } from "./internal/redis-script-reply.js"; import { DialCacheRedisPayloadError, type DialCacheRedisClient } from "./redis-client.js"; type ValkeyGlideString = string | Buffer; -// EVALSHA inside a GLIDE batch cannot use a native Script handle, so the -// stamp script's SHA1 is computed once from its exact source bytes. -const WRITE_TRACKED_STAMP_SHA1 = createHash("sha1").update(WRITE_TRACKED_STAMP_SCRIPT).digest("hex"); - interface ValkeyGlideBatch { customCommand(args: ValkeyGlideString[]): ValkeyGlideBatch; mget(keys: ValkeyGlideString[]): ValkeyGlideBatch; } -interface ValkeyGlideClusterReadClient { - customCommand( - args: ValkeyGlideString[], - options: { - decoder: TDecoder; - route: { type: "primarySlotKey"; key: string }; - }, - ): Promise; -} - export interface ValkeyGlideScriptHandle { + /** The SHA1 GLIDE registered the script under; used for batched EVALSHA. */ + getHash(): string; /** Release the native GLIDE script registration. */ release(): void; } export interface ValkeyGlideScriptingClient { + customCommand( + args: ValkeyGlideString[], + options: { + decoder: TDecoder; + route?: { type: "primarySlotKey"; key: string }; + }, + ): Promise; get( key: ValkeyGlideString, options: { decoder: TDecoder }, @@ -149,12 +143,13 @@ export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { * read deadline may return before this adapter's invocation settles. Tracked * standalone reads use a one-command primary batch, while tracked cluster * reads route MGET explicitly to the slot primary, so replica lag cannot hide - * an invalidation watermark. Writes batch a native placeholder SET with an - * EVALSHA of the stamp script — cluster write batches route to the slot - * primary — and a flushed script cache falls back to invokeScript, which - * reloads and re-runs the stamp. Batches are deliberately non-atomic: MGET - * and SET are atomic themselves, an interleaved stamp is safe by design, and - * MULTI/EXEC would consume caller-owned WATCH state. + * an invalidation watermark. Tracked writes batch a native placeholder SET + * with an EVALSHA of the stamp script — cluster write batches route to the + * slot primary — and a flushed script cache falls back to invokeScript, which + * reloads and re-runs the stamp, so the first tracked write against a cold + * script cache pays one extra round trip. Batches are deliberately + * non-atomic: MGET and SET are atomic themselves, an interleaved stamp is + * safe by design, and MULTI/EXEC would consume caller-owned WATCH state. */ export function createValkeyGlideDialCacheClient( client: ValkeyGlideScriptingClient, @@ -165,11 +160,7 @@ export function createValkeyGlideDialCacheClient=2.0.0 with Batch and ClusterBatch constructors", ); } - const clientKind = classifyValkeyGlideClient(client, glide); - const clusterClient = clientKind === "cluster" - ? client as ValkeyGlideScriptingClient - & ValkeyGlideClusterReadClient - : undefined; + const isCluster = classifyValkeyGlideClient(client, glide) === "cluster"; const scripts: DialCacheGlideScripts = { writeTrackedStamp: new glide.Script(WRITE_TRACKED_STAMP_SCRIPT), invalidate: new glide.Script(INVALIDATE_CACHE_SCRIPT), @@ -206,9 +197,9 @@ export function createValkeyGlideDialCacheClient clusterClient.customCommand( + () => client.customCommand( ["MGET", valueKey, watermarkKey], { decoder: glide.Decoder.Bytes, @@ -232,48 +223,42 @@ export function createValkeyGlideDialCacheClient clusterClient !== undefined - ? new glide.ClusterBatch(false) - : new glide.Batch(false); const execOptions: { decoder: TDecoder; route?: { type: "primarySlotKey"; key: string }; - } = clusterClient !== undefined + } = isCluster ? { decoder: glide.Decoder.Bytes, route: { type: "primarySlotKey", key: valueKey } } : { decoder: glide.Decoder.Bytes }; if (watermarkKey === undefined) { const frame = encodeRedisFrame(value, Date.now()); - const replies = await run(() => client.exec( - newBatch().customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)]), - true, - execOptions, + validateRedisSetReply(await run( + () => client.customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)], execOptions), )); - if (!Array.isArray(replies) || replies.length !== 1) { - throw new DialCacheRedisPayloadError("Invalid DialCache Redis write reply"); - } - validateRedisSetReply(replies[0]); return true; } - const frame = encodeRedisFrame(value, 0); - const stampArgs = [String(cacheTtlMs)]; + const { frame, nonce } = encodeTrackedRedisPlaceholder(value); + const stampArgs: ValkeyGlideString[] = [String(cacheTtlMs), nonce]; // One dispose-guarded operation so the stamp handle cannot be released // between the batch and its NOSCRIPT recovery. return await run(async () => { - const replies = await client.exec( - newBatch() - .customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)]) - .customCommand(["EVALSHA", WRITE_TRACKED_STAMP_SHA1, "2", valueKey, watermarkKey, ...stampArgs]), - false, - execOptions, - ); + const batch = (isCluster ? new glide.ClusterBatch(false) : new glide.Batch(false)) + .customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)]) + .customCommand([ + "EVALSHA", + scripts.writeTrackedStamp.getHash(), + "2", + valueKey, + watermarkKey, + ...stampArgs, + ]); + const replies = await client.exec(batch, false, execOptions); if (!Array.isArray(replies) || replies.length !== 2) { throw new DialCacheRedisPayloadError("Invalid DialCache Redis write reply"); } const [setReply, rawStamp] = replies as [unknown, unknown]; - // A failed SET is the write outcome even when the stamp settled: the - // stamp may have patched an unrelated frame's placeholder or no-opped. + // A failed SET is the write outcome even when the stamp settled. if (setReply instanceof Error) { throw setReply; } @@ -284,16 +269,18 @@ export function createValkeyGlideDialCacheClient, message: string) describe("node-redis adapter", () => { it("provides the expected arguments for every bundled mutation script", () => { + const nonce = Buffer.from("01234567"); expect(Object.keys(dialcacheRedisScripts)).toEqual([ "dialcacheWriteTrackedStamp", "dialcacheInvalidate", @@ -87,13 +88,23 @@ describe("node-redis adapter", () => { "tracked:{id}:value", "tracked:{id}:watermark", 1_000, + nonce, ), - ).toEqual(["tracked:{id}:value", "tracked:{id}:watermark", "1000"]); + ).toEqual(["tracked:{id}:value", "tracked:{id}:watermark", "1000", nonce]); expect( dialcacheRedisScripts.dialcacheInvalidate.transformArguments("tracked:{id}:watermark", 50), ).toEqual(["tracked:{id}:watermark", "50"]); }); + it("rejects clients constructed without the DialCache script registrations", () => { + expect( + () => createNodeRedisDialCacheClient({ get: vi.fn(), sendCommand: vi.fn() } as never), + ).toThrow(TypeError); + expect( + () => createNodeRedisDialCacheClient({ get: vi.fn(), sendCommand: vi.fn() } as never), + ).toThrow("requires a client created with scripts: dialcacheRedisScripts"); + }); + it("accepts the exact write and invalidation reply domains", async () => { const client = fakeClient({ get: encodeFrame("plain"), @@ -178,17 +189,52 @@ describe("node-redis adapter", () => { expect(args[3]).toBe("PX"); expect(args[4]).toBe("2000"); const frame = args[2] as Buffer; - expect(frame[0]).toBe(1); - expect(frame.readBigUInt64BE(1)).toBe(0n); + expect(frame[0]).toBe(0); expect(frame[9]).toBe(1); expect(frame.subarray(10)).toEqual(binary); + // The stamp must carry the exact nonce its paired placeholder was minted with. expect(client.dialcacheWriteTrackedStamp).toHaveBeenCalledWith( "tracked:{id}:value", "tracked:{id}:watermark", 2_000, + frame.subarray(1, 9), ); }); + it("fails a tracked write whose placeholder was lost before the stamp", async () => { + const adapter = createNodeRedisDialCacheClient(fakeClient({ stamp: 2 }) as never); + await expect(adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })).rejects.toThrow("DialCache tracked write lost its placeholder before the stamp"); + }); + + it("issues the stamp before the placeholder SET settles", async () => { + const client = fakeClient(); + let resolveSet: ((value: string) => void) | undefined; + client.sendCommand.mockImplementationOnce( + async () => await new Promise((resolve) => { + resolveSet = resolve; + }), + ); + const adapter = createNodeRedisDialCacheClient(client as never); + + const write = adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + }); + // The stamp must already be issued while the SET is still unsettled: an + // await between the pair would leave it uncalled here and hang the write. + expect(client.dialcacheWriteTrackedStamp).toHaveBeenCalledTimes(1); + + resolveSet?.("OK"); + await expect(write).resolves.toBe(true); + }); + it("routes cluster write SETs by the value key", async () => { const client = fakeCluster(); const adapter = createNodeRedisDialCacheClient(client as never); @@ -236,7 +282,8 @@ describe("node-redis adapter", () => { it("rejects out-of-range cacheTtlMs before issuing commands and ceils fractional TTLs", async () => { const client = fakeClient(); const adapter = createNodeRedisDialCacheClient(client as never); - for (const cacheTtlMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 31_536_000_001]) { + const invalidTtls = [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 31_536_000_001, "500" as unknown as number]; + for (const cacheTtlMs of invalidTtls) { await expect( adapter.write({ valueKey: "plain:value", cacheTtlMs, value: "plain" }), ).rejects.toThrow(RangeError); @@ -264,6 +311,7 @@ describe("node-redis adapter", () => { "tracked:{id}:value", "tracked:{id}:watermark", 1_001, + expect.any(Buffer), ); }); @@ -291,6 +339,20 @@ describe("node-redis adapter", () => { cacheTtlMs: 1_000, value: "tracked", })).rejects.toBe(stampFailure); + + // A bad SET reply also wins over a failing stamp, matching the contract. + const combinedClient = fakeClient({ set: "QUEUED" }); + combinedClient.dialcacheWriteTrackedStamp.mockRejectedValueOnce(new Error("ERR stamp")); + const combinedAdapter = createNodeRedisDialCacheClient(combinedClient as never); + await expectProtocolError( + Promise.resolve(combinedAdapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })), + "Invalid DialCache Redis SET reply; expected OK", + ); }); it("passes the cooperative read signal through node-redis command options", async () => { @@ -391,7 +453,7 @@ describe("node-redis adapter", () => { }); it("rejects every out-of-domain reply returned by a node-redis client", async () => { - const writeMessage = "Invalid DialCache Redis write reply; expected integer 0 or 1"; + const writeMessage = "Invalid DialCache Redis write reply; expected integer 0, 1, or 2"; const invalidationMessage = "Invalid DialCache Redis invalidate reply; expected integer 1"; for (const reply of INVALID_WRITE_REPLIES) { @@ -422,6 +484,7 @@ describe("node-redis adapter", () => { it("validates replies at the public node-redis script transform boundary", () => { expect(dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(0)).toBe(0); expect(dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(1)).toBe(1); + expect(dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(2)).toBe(2); expect(dialcacheRedisScripts.dialcacheInvalidate.transformReply(1)).toBe(1); for (const reply of INVALID_WRITE_REPLIES) { diff --git a/test/redis-payload.test.ts b/test/redis-payload.test.ts index 0df70a0..c003af3 100644 --- a/test/redis-payload.test.ts +++ b/test/redis-payload.test.ts @@ -2,6 +2,7 @@ import { decodeRedisFrame, decodeTrackedRedisFrame, encodeRedisFrame, + encodeTrackedRedisPlaceholder, } from "../src/redis-protocol.js"; import { DialCacheRedisPayloadEncodingError, @@ -119,13 +120,41 @@ describe("Redis frame decoding", () => { expect(decodeRedisFrame(empty)).toBe(""); }); - it("keeps zero-stamped placeholder frames unreadable on the tracked path", () => { - const placeholder = encodeRedisFrame("pending", 0); + it("keeps zero-stamped version-1 frames unreadable on the tracked path", () => { + const zeroStamped = encodeRedisFrame("pending", 0); - expect(decodeTrackedRedisFrame(placeholder, null)).toBeNull(); - expect(decodeTrackedRedisFrame(placeholder, Buffer.from("0"))).toBeNull(); - expect(decodeTrackedRedisFrame(placeholder, Buffer.from("1"))).toBeNull(); - expect(decodeRedisFrame(placeholder)).toBe("pending"); + expect(decodeTrackedRedisFrame(zeroStamped, null)).toBeNull(); + expect(decodeTrackedRedisFrame(zeroStamped, Buffer.from("0"))).toBeNull(); + expect(decodeTrackedRedisFrame(zeroStamped, Buffer.from("1"))).toBeNull(); + expect(decodeRedisFrame(zeroStamped)).toBe("pending"); + }); + + it("encodes tracked placeholders that no read path serves", () => { + const { frame, nonce } = encodeTrackedRedisPlaceholder("pending"); + + expect(frame[0]).toBe(0); + expect(nonce.byteLength).toBe(8); + expect(frame.subarray(1, 9)).toEqual(nonce); + expect(frame[9]).toBe(0); + expect(frame.subarray(10).toString("utf8")).toBe("pending"); + expect(decodeRedisFrame(frame)).toBeNull(); + expect(decodeTrackedRedisFrame(frame, null)).toBeNull(); + expect(decodeTrackedRedisFrame(frame, Buffer.from("0"))).toBeNull(); + expect(decodeTrackedRedisFrame(frame, Buffer.from("1"))).toBeNull(); + + const binary = encodeTrackedRedisPlaceholder(Buffer.from([0, 0xff])); + expect(binary.frame[9]).toBe(1); + expect(decodeRedisFrame(binary.frame)).toBeNull(); + }); + + it("gates serving on the version byte even for hostile placeholder nonces", () => { + // A nonce that would decode as a huge timestamp must never beat the + // watermark: version 0 alone keeps the frame a miss on both paths. + const hostile = encodeFrame("pending", 0, 1, 0); + hostile.fill(0xff, 1, 9); + + expect(decodeRedisFrame(hostile)).toBeNull(); + expect(decodeTrackedRedisFrame(hostile, Buffer.from("1"))).toBeNull(); }); it("rejects unencodable createdAt timestamps", () => { diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index 849a9a2..f227c8d 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -15,6 +15,7 @@ import { INVALIDATE_CACHE_SCRIPT, WRITE_TRACKED_STAMP_SCRIPT, } from "../src/internal/redis-scripts.js"; +import { encodeTrackedRedisPlaceholder } from "../src/redis-protocol.js"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../src/node-redis.js"; import { createValkeyGlideDialCacheClient, @@ -56,8 +57,10 @@ const createTestClient = (url: string) => createClient({ url, scripts: dialcache type NodeRedisTestClient = ReturnType; interface RawRedisScriptClient { + /** The SHA1 this adapter's batched or EVALSHA-based stamp dispatch uses. */ + readonly stampScriptSha1: string; /** Invoke only the tracked stamp script, as if its paired placeholder SET was lost. */ - stamp(valueKey: string, watermarkKey: string, cacheTtlMs: number): Promise; + stamp(valueKey: string, watermarkKey: string, cacheTtlMs: number, nonce: Buffer): Promise; invalidate(watermarkKey: string, futureBufferMs: number): Promise; } @@ -72,6 +75,7 @@ function createNodeRedisHarness(client: NodeRedisTestClient): RedisAdapterHarnes return { adapter: createNodeRedisDialCacheClient(client), raw: { + stampScriptSha1: dialcacheRedisScripts.dialcacheWriteTrackedStamp.SHA1, stamp: async (...args) => await client.dialcacheWriteTrackedStamp(...args), invalidate: async (...args) => await client.dialcacheInvalidate(...args), }, @@ -104,8 +108,9 @@ function createValkeyGlideHarness(client: valkeyGlide.GlideClient): RedisAdapter return { adapter, raw: { - stamp: async (valueKey, watermarkKey, cacheTtlMs) => - await invoke(rawScripts.stamp, [valueKey, watermarkKey], [String(cacheTtlMs)]), + stampScriptSha1: rawScripts.stamp.getHash(), + stamp: async (valueKey, watermarkKey, cacheTtlMs, nonce) => + await invoke(rawScripts.stamp, [valueKey, watermarkKey], [String(cacheTtlMs), nonce]), invalidate: async (watermarkKey, futureBufferMs) => await invoke( rawScripts.invalidate, @@ -796,6 +801,15 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { }), ).toBe(true); expect(await scriptClient.read({ valueKey: trackedValueKey, watermarkKey })).toBe("tracked"); + // The recovered write must cache the stamp under the SHA1 the batched + // EVALSHA uses, so later writes take the single-round-trip path. The + // adapter's own dispatch hash must match node-redis's source SHA1; do + // not probe with a throwaway Script here — releasing it would destroy + // the shared per-hash GLIDE script container the live handles rely on. + expect(client.raw.stampScriptSha1).toBe(dialcacheRedisScripts.dialcacheWriteTrackedStamp.SHA1); + expect( + await admin.scriptExists(dialcacheRedisScripts.dialcacheWriteTrackedStamp.SHA1), + ).toEqual([true]); await admin.scriptFlush(); await expect( scriptClient.invalidate({ @@ -1015,11 +1029,10 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { }); expect(metrics.error).not.toHaveBeenCalledWith(expect.objectContaining({ error: "cache_read" })); expect(await admin.type(watermarkKey)).toBe("hash"); - // The paired SET lands before the stamp fails on the wrong-type watermark, so - // the original frame is replaced by an unreadable zero-stamped placeholder. + // The paired SET lands before the stamp fails on the wrong-type watermark, + // so the original frame is replaced by an unreadable version-0 placeholder. const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); - expect(stored?.[0]).toBe(1); - expect(stored?.readBigUInt64BE(1)).toBe(0n); + expect(stored?.[0]).toBe(0); await expect(client.adapter.read({ valueKey, watermarkKey })).resolves.toBeNull(); }); @@ -1031,21 +1044,28 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { const watermarkKey = "invalid-args:{item:invalid}:watermark"; const notANumber = "not-a-number" as unknown as number; - await expect(client.raw.stamp(valueKey, watermarkKey, 0)).rejects.toThrow("invalid DialCache TTL"); - await expect(client.raw.stamp(valueKey, watermarkKey, notANumber)).rejects.toThrow("invalid DialCache TTL"); - await expect(client.raw.stamp(valueKey, watermarkKey, Number.NaN)).rejects.toThrow("invalid DialCache TTL"); - await expect(client.raw.stamp(valueKey, watermarkKey, Number.POSITIVE_INFINITY)).rejects.toThrow( + const nonce = Buffer.alloc(8, 1); + await expect(client.raw.stamp(valueKey, watermarkKey, 0, nonce)).rejects.toThrow("invalid DialCache TTL"); + await expect(client.raw.stamp(valueKey, watermarkKey, notANumber, nonce)).rejects.toThrow("invalid DialCache TTL"); + await expect(client.raw.stamp(valueKey, watermarkKey, Number.NaN, nonce)).rejects.toThrow("invalid DialCache TTL"); + await expect(client.raw.stamp(valueKey, watermarkKey, Number.POSITIVE_INFINITY, nonce)).rejects.toThrow( "invalid DialCache TTL", ); - await expect(client.raw.stamp(valueKey, watermarkKey, Number.NEGATIVE_INFINITY)).rejects.toThrow( + await expect(client.raw.stamp(valueKey, watermarkKey, Number.NEGATIVE_INFINITY, nonce)).rejects.toThrow( "invalid DialCache TTL", ); await expect( - client.raw.stamp(valueKey, watermarkKey, MAX_SUPPORTED_DURATION_MS + 1), + client.raw.stamp(valueKey, watermarkKey, MAX_SUPPORTED_DURATION_MS + 1, nonce), ).rejects.toThrow("invalid DialCache TTL"); await expect( - client.raw.stamp(valueKey, watermarkKey, Number.MAX_SAFE_INTEGER), + client.raw.stamp(valueKey, watermarkKey, Number.MAX_SAFE_INTEGER, nonce), ).rejects.toThrow("invalid DialCache TTL"); + await expect( + client.raw.stamp(valueKey, watermarkKey, 1_000, Buffer.alloc(7, 1)), + ).rejects.toThrow("invalid DialCache stamp nonce"); + await expect( + client.raw.stamp(valueKey, watermarkKey, 1_000, Buffer.alloc(9, 1)), + ).rejects.toThrow("invalid DialCache stamp nonce"); // The adapters enforce the same TTL domain before issuing any command. for (const badTtl of [0, notANumber, Number.NaN, Number.POSITIVE_INFINITY, MAX_SUPPORTED_DURATION_MS + 1]) { await expect( @@ -1228,10 +1248,10 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { value: "replacement", })).rejects.toThrow("invalid DialCache watermark"); // The paired SET lands before the stamp validates the watermark, so the - // tracked path serves nothing and the placeholder stays zero-stamped. + // tracked path serves nothing and the placeholder stays unpromoted. expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull(); const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); - expect(stored?.readBigUInt64BE(1)).toBe(0n); + expect(stored?.[0]).toBe(0); await admin.del(valueKey); } }); @@ -1487,21 +1507,30 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { expect(await scriptClient.read({ valueKey, watermarkKey })).toBe("stale"); }); - it("never serves an unstamped placeholder and stamps it on demand", async () => { + it("never serves an unstamped placeholder and refuses foreign stamps", async () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); } const valueKey = "placeholder:{item:pending}:value"; const watermarkKey = "placeholder:{item:pending}:watermark"; - await admin.set(valueKey, encodeFrame("pending", 0, 0), { PX: 60_000 }); + const { frame, nonce } = encodeTrackedRedisPlaceholder("pending"); + await admin.set(valueKey, frame, { PX: 60_000 }); await admin.set(watermarkKey, "0", { PX: 120_000 }); expect(await client.adapter.read({ valueKey, watermarkKey })).toBeNull(); + expect(await client.adapter.read({ valueKey })).toBeNull(); - expect(await client.raw.stamp(valueKey, watermarkKey, 2_000)).toBe(1); + // A stamp carrying a different write's nonce must not promote this + // placeholder: a leftover from a failed write stays unreadable even + // after later invalidations pass. + expect(await client.raw.stamp(valueKey, watermarkKey, 2_000, Buffer.alloc(8, 0xab))).toBe(2); + expect(await client.adapter.read({ valueKey, watermarkKey })).toBeNull(); + // Only the paired nonce promotes it to a served, server-stamped frame. + expect(await client.raw.stamp(valueKey, watermarkKey, 2_000, nonce)).toBe(1); expect(await client.adapter.read({ valueKey, watermarkKey })).toBe("pending"); const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); + expect(stored?.[0]).toBe(1); expect(stored?.readBigUInt64BE(1) ?? 0n).toBeGreaterThan(0n); }); @@ -1516,7 +1545,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { await admin.set(valueKey, encodeFrame("stale", 0, 1_000), { PX: 60_000 }); await admin.set(watermarkKey, "2000", { PX: 120_000 }); - expect(await client.raw.stamp(valueKey, watermarkKey, 2_000)).toBe(1); + expect(await client.raw.stamp(valueKey, watermarkKey, 2_000, Buffer.alloc(8, 1))).toBe(2); const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); expect(stored?.readBigUInt64BE(1)).toBe(1_000n); @@ -1530,7 +1559,7 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { const valueKey = "stamp-missing:{item:lost}:value"; const watermarkKey = "stamp-missing:{item:lost}:watermark"; - expect(await client.raw.stamp(valueKey, watermarkKey, 2_000)).toBe(1); + expect(await client.raw.stamp(valueKey, watermarkKey, 2_000, Buffer.alloc(8, 2))).toBe(2); expect(await admin.exists(valueKey)).toBe(0); expect(await admin.get(watermarkKey)).toBe("0"); diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index 672f828..68a6456 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -12,7 +12,7 @@ import { createValkeyGlideDialCacheClient } from "../src/valkey-glide.js"; const INVALID_WRITE_REPLIES: readonly unknown[] = [ -1, - 2, + 3, 0.5, Number.NaN, Number.POSITIVE_INFINITY, @@ -23,7 +23,7 @@ const INVALID_WRITE_REPLIES: readonly unknown[] = [ null, undefined, ]; -const INVALID_INVALIDATION_REPLIES: readonly unknown[] = [0, ...INVALID_WRITE_REPLIES]; +const INVALID_INVALIDATION_REPLIES: readonly unknown[] = [0, 2, ...INVALID_WRITE_REPLIES]; const decoderBytes = Symbol("bytes"); const scriptInstances: MockScript[] = []; @@ -34,6 +34,7 @@ const clusterClients = new WeakSet(); class MockScript { readonly release = vi.fn(); + readonly getHash = vi.fn(() => createHash("sha1").update(this.code).digest("hex")); constructor(readonly code: string) { scriptInstances.push(this); @@ -93,6 +94,13 @@ interface InvokeScriptOptions { function createFakeClient(replies: unknown[]) { const nextReply = async (): Promise => replies.shift(); const client = { + customCommand: vi.fn(async ( + _args: Array, + _options: { + decoder: typeof decoderBytes; + route?: { type: "primarySlotKey"; key: string }; + }, + ) => nextReply()), get: vi.fn(async (_key: string | Buffer, _options: { decoder: typeof decoderBytes }) => nextReply()), exec: vi.fn(async ( _batch: MockBatch, @@ -114,19 +122,9 @@ function fakeClient(...replies: unknown[]) { } function fakeClusterClient(...replies: unknown[]) { - const { client, nextReply } = createFakeClient(replies); - const clusterClient = { - ...client, - customCommand: vi.fn(async ( - _args: Array, - _options: { - decoder: typeof decoderBytes; - route: { type: "primarySlotKey"; key: string }; - }, - ) => nextReply()), - }; - clusterClients.add(clusterClient); - return clusterClient; + const client = createFakeClient(replies).client; + clusterClients.add(client); + return client; } function redisFrame( @@ -227,6 +225,7 @@ describe("Valkey GLIDE adapter", () => { it("rejects forwarding wrappers instead of silently treating them as standalone", () => { const directClient = fakeClient(); const forwardingWrapper = { + customCommand: directClient.customCommand, exec: directClient.exec, get: directClient.get, invokeScript: directClient.invokeScript, @@ -308,10 +307,10 @@ describe("Valkey GLIDE adapter", () => { adapter.dispose(); }); - it("writes frames through natively batched SET commands", async () => { + it("writes untracked SETs directly and tracked pairs through a batch", async () => { const binary = Buffer.from([0, 0xff, 0x80]); const client = fakeClient( - [Buffer.from("OK")], + Buffer.from("OK"), [Buffer.from("OK"), 0], 1, ); @@ -334,11 +333,9 @@ describe("Valkey GLIDE adapter", () => { adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 100 }), ).resolves.toBeUndefined(); - expect(batchInstances).toHaveLength(2); - const [untrackedBatch, trackedBatch] = batchInstances; - expect(untrackedBatch?.isAtomic).toBe(false); - expect(untrackedBatch?.commands).toHaveLength(1); - const untrackedSet = untrackedBatch?.commands[0] ?? []; + expect(client.customCommand).toHaveBeenCalledTimes(1); + const [untrackedSet, untrackedOptions] = client.customCommand.mock.calls[0] + ?? [[], undefined]; expect(untrackedSet[0]).toBe("SET"); expect(untrackedSet[1]).toBe("plain:value"); expect(untrackedSet[3]).toBe("PX"); @@ -350,8 +347,10 @@ describe("Valkey GLIDE adapter", () => { const createdAtMs = Number(untrackedFrame.readBigUInt64BE(1)); expect(createdAtMs).toBeGreaterThanOrEqual(before); expect(createdAtMs).toBeLessThanOrEqual(after); - expect(client.exec).toHaveBeenNthCalledWith(1, untrackedBatch, true, { decoder: decoderBytes }); + expect(untrackedOptions).toEqual({ decoder: decoderBytes }); + expect(batchInstances).toHaveLength(1); + const trackedBatch = batchInstances[0]; expect(trackedBatch?.isAtomic).toBe(false); expect(trackedBatch?.commands).toHaveLength(2); const [trackedSet, stamp] = trackedBatch?.commands ?? []; @@ -360,10 +359,10 @@ describe("Valkey GLIDE adapter", () => { expect(trackedSet?.[3]).toBe("PX"); expect(trackedSet?.[4]).toBe("2000"); const trackedFrame = trackedSet?.[2] as Buffer; - expect(trackedFrame[0]).toBe(1); - expect(trackedFrame.readBigUInt64BE(1)).toBe(0n); + expect(trackedFrame[0]).toBe(0); expect(trackedFrame[9]).toBe(1); expect(trackedFrame.subarray(10)).toEqual(binary); + const nonce = trackedFrame.subarray(1, 9); expect(stamp).toEqual([ "EVALSHA", createHash("sha1").update(WRITE_TRACKED_STAMP_SCRIPT).digest("hex"), @@ -371,8 +370,10 @@ describe("Valkey GLIDE adapter", () => { "tracked:{id}:value", "tracked:{id}:watermark", "2000", + nonce, ]); - expect(client.exec).toHaveBeenNthCalledWith(2, trackedBatch, false, { decoder: decoderBytes }); + expect(client.exec).toHaveBeenCalledTimes(1); + expect(client.exec).toHaveBeenCalledWith(trackedBatch, false, { decoder: decoderBytes }); expect(client.invokeScript).toHaveBeenCalledTimes(1); expect(client.invokeScript).toHaveBeenCalledWith( @@ -381,8 +382,22 @@ describe("Valkey GLIDE adapter", () => { ); }); - it("routes cluster writes through ClusterBatch to the slot primary", async () => { - const client = fakeClusterClient(["OK"], ["OK", 1]); + it("fails a tracked write whose placeholder was lost before the stamp", async () => { + const client = fakeClient([Buffer.from("OK"), 2]); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect(adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })).rejects.toThrow("DialCache tracked write lost its placeholder before the stamp"); + expect(client.invokeScript).not.toHaveBeenCalled(); + adapter.dispose(); + }); + + it("routes cluster writes to the slot primary", async () => { + const client = fakeClusterClient("OK", ["OK", 1]); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); await expect( @@ -397,12 +412,13 @@ describe("Valkey GLIDE adapter", () => { }), ).resolves.toBe(true); - expect(clusterBatchInstances).toHaveLength(2); - expect(client.exec).toHaveBeenNthCalledWith(1, clusterBatchInstances[0], true, { + const [, untrackedOptions] = client.customCommand.mock.calls[0] ?? [[], undefined]; + expect(untrackedOptions).toEqual({ decoder: decoderBytes, route: { type: "primarySlotKey", key: "plain:value" }, }); - expect(client.exec).toHaveBeenNthCalledWith(2, clusterBatchInstances[1], false, { + expect(clusterBatchInstances).toHaveLength(1); + expect(client.exec).toHaveBeenCalledWith(clusterBatchInstances[0], false, { decoder: decoderBytes, route: { type: "primarySlotKey", key: "tracked:{id}:value" }, }); @@ -416,6 +432,7 @@ describe("Valkey GLIDE adapter", () => { "An error was signalled by the server: - NoScriptError: No matching script.", ]; for (const wording of noscriptWordings) { + batchInstances.length = 0; const client = fakeClient([Buffer.from("OK"), new Error(wording)], 1); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); @@ -426,12 +443,13 @@ describe("Valkey GLIDE adapter", () => { value: "tracked", })).resolves.toBe(true); + const trackedFrame = batchInstances[0]?.commands[0]?.[2] as Buffer; expect(client.invokeScript).toHaveBeenCalledTimes(1); const [script, options] = client.invokeScript.mock.calls[0] ?? []; expect(script?.code).toBe(WRITE_TRACKED_STAMP_SCRIPT); expect(options).toEqual({ keys: ["tracked:{id}:value", "tracked:{id}:watermark"], - args: ["2000"], + args: ["2000", trackedFrame.subarray(1, 9)], decoder: decoderBytes, }); adapter.dispose(); @@ -441,7 +459,8 @@ describe("Valkey GLIDE adapter", () => { it("rejects out-of-range cacheTtlMs before batching and ceils fractional TTLs", async () => { const client = fakeClient([Buffer.from("OK"), 1]); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); - for (const cacheTtlMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 31_536_000_001]) { + const invalidTtls = [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 31_536_000_001, "500" as unknown as number]; + for (const cacheTtlMs of invalidTtls) { await expect( adapter.write({ valueKey: "plain:value", cacheTtlMs, value: "plain" }), ).rejects.toThrow(RangeError); @@ -454,6 +473,7 @@ describe("Valkey GLIDE adapter", () => { }), ).rejects.toThrow(RangeError); } + expect(client.customCommand).not.toHaveBeenCalled(); expect(client.exec).not.toHaveBeenCalled(); expect(batchInstances).toHaveLength(0); @@ -466,6 +486,7 @@ describe("Valkey GLIDE adapter", () => { const [trackedSet, stamp] = batchInstances[0]?.commands ?? []; expect(trackedSet?.[4]).toBe("1001"); expect(stamp?.[5]).toBe("1001"); + expect(Buffer.isBuffer(stamp?.[6])).toBe(true); adapter.dispose(); }); @@ -496,11 +517,8 @@ describe("Valkey GLIDE adapter", () => { }); it("validates write batch envelopes and SET replies", async () => { - const envelopeClient = fakeClient("not-a-batch-reply", [Buffer.from("OK")]); + const envelopeClient = fakeClient("not-a-batch-reply"); const envelopeAdapter = createValkeyGlideDialCacheClient(envelopeClient, mockGlide); - await expect( - envelopeAdapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" }), - ).rejects.toBeInstanceOf(DialCacheRedisPayloadError); await expect(envelopeAdapter.write({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark", @@ -509,13 +527,27 @@ describe("Valkey GLIDE adapter", () => { })).rejects.toBeInstanceOf(DialCacheRedisPayloadError); envelopeAdapter.dispose(); - const setReplyClient = fakeClient(["QUEUED"]); + const setReplyClient = fakeClient("QUEUED"); const setReplyAdapter = createValkeyGlideDialCacheClient(setReplyClient, mockGlide); await expectProtocolError( Promise.resolve(setReplyAdapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" })), "Invalid DialCache Redis SET reply; expected OK", ); setReplyAdapter.dispose(); + + // A bad SET reply wins over a failing stamp, matching the write contract. + const combinedClient = fakeClient(["QUEUED", new Error("ERR invalid DialCache watermark")]); + const combinedAdapter = createValkeyGlideDialCacheClient(combinedClient, mockGlide); + await expectProtocolError( + Promise.resolve(combinedAdapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })), + "Invalid DialCache Redis SET reply; expected OK", + ); + combinedAdapter.dispose(); }); it("rejects malformed native read and mutation script replies", async () => { @@ -546,7 +578,7 @@ describe("Valkey GLIDE adapter", () => { cacheTtlMs: 1_000, value: "value", })), - "Invalid DialCache Redis write reply; expected integer 0 or 1", + "Invalid DialCache Redis write reply; expected integer 0, 1, or 2", ); await expectProtocolError( Promise.resolve( @@ -557,7 +589,7 @@ describe("Valkey GLIDE adapter", () => { }); it("rejects every out-of-domain write and invalidation reply", async () => { - const writeMessage = "Invalid DialCache Redis write reply; expected integer 0 or 1"; + const writeMessage = "Invalid DialCache Redis write reply; expected integer 0, 1, or 2"; const invalidationMessage = "Invalid DialCache Redis invalidate reply; expected integer 1"; for (const reply of INVALID_WRITE_REPLIES) { @@ -629,6 +661,46 @@ describe("Valkey GLIDE adapter", () => { expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); }); + it("stays busy across the batch and its NOSCRIPT recovery so dispose cannot race", async () => { + const client = fakeClient(); + let resolveExec: ((value: unknown) => void) | undefined; + let resolveFallback: ((value: number) => void) | undefined; + client.exec.mockImplementationOnce( + async () => await new Promise((resolve) => { + resolveExec = resolve; + }), + ); + client.invokeScript.mockImplementationOnce( + async () => await new Promise((resolve) => { + resolveFallback = resolve; + }), + ); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + const write = adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + }); + expect(() => adapter.dispose()).toThrow( + "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", + ); + + resolveExec?.([Buffer.from("OK"), new Error("NOSCRIPT No matching script. Please use EVAL.")]); + await vi.waitFor(() => expect(client.invokeScript).toHaveBeenCalledTimes(1)); + // The fallback is still pending: the stamp handle must stay unreleased. + expect(() => adapter.dispose()).toThrow( + "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", + ); + expect(scriptInstances.every((script) => script.release.mock.calls.length === 0)).toBe(true); + + resolveFallback?.(1); + await expect(write).resolves.toBe(true); + adapter.dispose(); + expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); + }); + it("uses Batch, Script, and Decoder from the supplied GLIDE module instance", async () => { class OtherBatch { mget(): this { @@ -645,7 +717,7 @@ describe("Valkey GLIDE adapter", () => { }; const client = fakeClient( [[redisFrame("tracked"), Buffer.from("0")]], - [Buffer.from("OK")], + [Buffer.from("OK"), 1], 1, ); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); @@ -654,7 +726,12 @@ describe("Valkey GLIDE adapter", () => { valueKey: "module:{instance}:value", watermarkKey: "module:{instance}:watermark", }); - await adapter.write({ valueKey: "module-instance", cacheTtlMs: 1_000, value: "value" }); + await adapter.write({ + valueKey: "module:{instance}:value", + watermarkKey: "module:{instance}:watermark", + cacheTtlMs: 1_000, + value: "value", + }); await adapter.invalidate({ watermarkKey: "module:{instance}:watermark", futureBufferMs: 5 }); const [readBatch, , readOptions] = client.exec.mock.calls[0] ?? []; From c31f2de30f515cee5f3cd40ff1ea8039fe40786e Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 7 Aug 2026 23:00:37 -0700 Subject: [PATCH 03/12] fix(redis): type lost-placeholder failures and tighten the protocol surface 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. --- README.md | 18 ++++---- scripts/test-package.mjs | 60 ++++++++++++++++++++++++++ src/index.ts | 1 + src/internal/redis-payload.ts | 8 ++-- src/internal/redis-script-reply.ts | 7 ++- src/redis-client.ts | 46 ++++++++++++++++---- src/redis-protocol.ts | 7 +-- src/valkey-glide.ts | 24 +++++------ test/node-redis.test.ts | 22 +++++++++- test/redis-cluster.integration.test.ts | 4 ++ test/redis-real.integration.test.ts | 5 ++- test/valkey-glide.test.ts | 7 ++- 12 files changed, 164 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 70ff5c7..e69a631 100644 --- a/README.md +++ b/README.md @@ -398,19 +398,19 @@ The application owns the complete Redis lifecycle. It creates and connects the u Awaiting those public promises does not drain detached shadow work. Shadow scheduling and deadline timers are unreferenced and completion is not guaranteed during shutdown; Redis operations, source reads, serializers, and asynchronous telemetry already started by shadow work remain caller-owned and may still be active. Stop new work before closing their dependencies and accept that an in-flight shadow fill may have been dispatched even if its final outcome is lost during teardown. DialCache does not add a shutdown hook or keep the process alive to deliver best-effort outcomes. -The node-redis adapter owns no additional resources, so the application closes the underlying node-redis client after draining work. The GLIDE adapter owns two native `Script` handles for the tracked write stamp and invalidation, but not the wrapped connection. After outstanding operations finish, call its idempotent `dispose()` before closing GLIDE as shown above; disposal while an adapter operation is in flight throws rather than releasing a live script. +The node-redis adapter owns no additional resources, so the application closes the underlying node-redis client after draining work. The GLIDE adapter owns two native `Script` handles for the tracked write stamp and invalidation, but not the wrapped connection. After outstanding operations finish, call its idempotent `dispose()` before closing GLIDE as shown above; disposal while an adapter operation is in flight throws rather than releasing a live script. On GLIDE 2.0.0, releasing a `Script` handle has been observed to break other live handles for the same script source despite GLIDE's documented reference counting, so adapters sharing one GLIDE module namespace should be disposed together at shutdown, never swapped dispose-after-create. Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. -Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` of a version-0 placeholder frame carrying a fresh per-write nonce, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, promotes exactly the placeholder carrying its nonce to a served frame with Redis server time, and maintains the watermark TTL. A placeholder is unreadable on both read paths until promoted, so an interleaved or lost stamp degrades to a miss that expires with the value TTL rather than partial state — including briefly blanking a previously readable key the write replaces. The nonce means the stamp can never revive a frame it does not own: if its paired `SET` was rejected, overwritten, or expired, the stamp reports the placeholder gone and the write fails as `cache_write` instead of publishing another write's leftovers. A `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. +Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` of a version-0 placeholder frame carrying a fresh per-write nonce, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, promotes exactly the placeholder carrying its nonce to a served frame with Redis server time, and maintains the watermark TTL. A placeholder is unreadable on both read paths until promoted, so an interleaved or lost stamp degrades to a miss that expires with the value TTL rather than partial state — including briefly blanking a previously readable key the write replaces. The nonce means the stamp can never revive a frame it does not own: if its paired `SET` was rejected, overwritten, or expired, the stamp reports the placeholder gone and the write fails with `DialCacheRedisPlaceholderLostError` as a `cache_write` error instead of publishing another write's leftovers. Losing a same-key write race is one such outcome, so `cache_write` carries a benign, self-healing floor that concentrates on hot tracked keys at TTL expiry — size write-error alerts for it, and filter by the error class when triaging. A `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore a clean miss and may be replaced with a valid DialCache frame after the fallback succeeds, while a wrong-type watermark prevents the tracked write from succeeding: its paired `SET` still lands, leaving only an unreadable placeholder until expiry or a later successful write. Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. -For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-running the stamp through GLIDE's `Script`-based `invokeScript`, which reloads it, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation uses GLIDE's native `Script` lifecycle directly. +For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-running the stamp through GLIDE's `Script`-based `invokeScript`, which reloads it, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation uses GLIDE's native `Script` lifecycle directly. -A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`; otherwise the tracked write fails open as a `cache_write` error and leaves an unreadable placeholder or the prior stale value until a later successful cleanup or expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. +A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`; verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. #### Remote read deadlines and async liveness @@ -426,13 +426,15 @@ Writes, invalidations, async `cacheConfigProvider` calls, and custom serializer #### Serialization -The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. The shared `encodeRedisFrame`, `encodeTrackedRedisPlaceholder`, `decodeRedisFrame`, and `decodeTrackedRedisFrame` helpers, the tracked stamp and invalidation Lua sources, and wire constants are available from `dialcache/redis-protocol`, so custom adapters can reuse the bundled adapters' exact framing, miss, and watermark-fencing rules. A custom tracked write must pass the stamp script `KEYS = [valueKey, watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`, with `ARGV[1]` equal to the paired `SET`'s `PX` and the nonce from the same `encodeTrackedRedisPlaceholder` call, and must fail the write when the stamp replies `2`. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed replies, unsupported encodings, and mutation-script reply-domain violations in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. +The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. The shared `encodeRedisFrame`, `encodeTrackedRedisPlaceholder`, `decodeRedisFrame`, and `decodeTrackedRedisFrame` helpers, the `resolveTrackedRedisWriteReply` and `validateRedisSetReply` reply helpers, and the tracked stamp and invalidation Lua sources are available from `dialcache/redis-protocol`, so custom adapters can reuse the bundled adapters' exact framing, miss, watermark-fencing, and reply rules. A custom tracked write must pass the stamp script `KEYS = [valueKey, watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`, ceiling `cacheTtlMs` to an integer used for both the paired `SET`'s `PX` and `ARGV[1]`, with the nonce from the same `encodeTrackedRedisPlaceholder` call; `resolveTrackedRedisWriteReply` maps the reply, failing the write with the root-exported `DialCacheRedisPlaceholderLostError` when the stamp replies `2`. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, `DialCacheRedisProtocolError`, and `DialCacheRedisPlaceholderLostError` classes to distinguish malformed replies, unsupported encodings, reply-domain violations, and lost placeholders in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. Redis values use a compact binary frame: ```text -byte 1 format version -bytes 2-9 Redis-created timestamp in milliseconds (uint64, big-endian) +byte 1 format version: 1 = servable, 0 = unreadable tracked placeholder +bytes 2-9 uint64 big-endian: Redis server time for a promoted tracked frame, + informational client time for an untracked frame, or the random + per-write nonce while a tracked placeholder awaits its stamp byte 10 payload encoding (0 = UTF-8, 1 = raw binary) bytes 11... serialized payload ``` @@ -534,7 +536,7 @@ The detached job uses this bounded algorithm: Here a clean miss means the semantic Redis read returned `null`; it does not include a non-null payload that later fails deserialization. A caller fallback rejection or timeout never becomes accepted `S` and never starts the fill. -Both detached Redis reads use the effective `remoteReadTimeoutMs` and the key's normal protocol. Every clean-miss fill uses the same serializer, TTL, and Redis-time timestamp as an ordinary fill. Tracked `C0` and `C1` reads remain watermark-aware and are routed to primaries by the bundled adapters, while tracked fills also retain the ordinary invalidation watermark. Untracked reads use the ordinary one-key read route, which has no shadow-specific primary guarantee, and untracked fills use the ordinary TTL write without a watermark. Strings compare exactly, Buffers compare by bytes, and string/Buffer pairs compare by their UTF-8 bytes. DialCache does not deserialize `C1`, compare it with `S`, or chase another version. +Both detached Redis reads use the effective `remoteReadTimeoutMs` and the key's normal protocol. Every clean-miss fill uses the same serializer, TTL, and timestamp semantics as an ordinary fill — server time for tracked fills, informational client time for untracked ones. A tracked fill blanks the key with its placeholder before publishing, so a lost or raced stamp can leave a previously readable value unreadable until the value TTL, and `fill_error` includes that benign lost-placeholder outcome. Tracked `C0` and `C1` reads remain watermark-aware and are routed to primaries by the bundled adapters, while tracked fills also retain the ordinary invalidation watermark. Untracked reads use the ordinary one-key read route, which has no shadow-specific primary guarantee, and untracked fills use the ordinary TTL write without a watermark. Strings compare exactly, Buffers compare by bytes, and string/Buffer pairs compare by their UTF-8 bytes. DialCache does not deserialize `C1`, compare it with `S`, or chase another version. The detached scheduler, Redis-read deadline timers, and overall shadow deadline timer are unreferenced, so they do not keep an otherwise idle process alive. Detachment is asynchronous work on the Node event loop, not a worker thread: synchronous source, serializer, or comparator work can still occupy the event loop after the request path has been released. diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 19578c3..1db02e9 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -48,15 +48,24 @@ const rootConsumer = `import { } from "dialcache"; // @ts-expect-error The unused MissingKeyConfigError class was removed instead of deprecated. import { MissingKeyConfigError } from "dialcache"; +import { DialCacheRedisPlaceholderLostError } from "dialcache"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "dialcache/node-redis"; import { decodeRedisFrame, decodeTrackedRedisFrame, encodeRedisFrame, encodeTrackedRedisPlaceholder, + resolveTrackedRedisWriteReply, + validateRedisSetReply, WRITE_TRACKED_STAMP_SCRIPT, type TrackedRedisPlaceholder, } from "dialcache/redis-protocol"; +// @ts-expect-error The codec functions replaced the frame-version wire constant. +import { REDIS_FRAME_VERSION } from "dialcache/redis-protocol"; +// @ts-expect-error The codec functions replaced the UTF-8 encoding wire constant. +import { REDIS_ENCODING_UTF8 } from "dialcache/redis-protocol"; +// @ts-expect-error The codec functions replaced the binary encoding wire constant. +import { REDIS_ENCODING_BINARY } from "dialcache/redis-protocol"; // @ts-expect-error Read Lua sources were removed from the mutation-only Redis protocol. import { READ_CACHE_SCRIPT } from "dialcache/redis-protocol"; // @ts-expect-error Tracked read Lua was removed from the mutation-only Redis protocol. @@ -162,6 +171,9 @@ const decodedStaleRedisPayload: string | Buffer | null = decodeTrackedRedisFrame ); const placeholderRedisFrame: Buffer = encodeRedisFrame("pending", 0); const trackedRedisPlaceholder: TrackedRedisPlaceholder = encodeTrackedRedisPlaceholder("pending"); +const stampReplyResolution: boolean = resolveTrackedRedisWriteReply(1); +const setReplyValidation: void = validateRedisSetReply("OK"); +const placeholderLostError = new DialCacheRedisPlaceholderLostError("lost"); const stampScriptSource: string = WRITE_TRACKED_STAMP_SCRIPT; const stampArguments: Array = dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformArguments( "tracked:{id}:value", @@ -487,6 +499,12 @@ void WRITE_CACHE_SCRIPT; void WRITE_TRACKED_CACHE_SCRIPT; void placeholderRedisFrame; void trackedRedisPlaceholder; +void stampReplyResolution; +void setReplyValidation; +void placeholderLostError; +void REDIS_FRAME_VERSION; +void REDIS_ENCODING_UTF8; +void REDIS_ENCODING_BINARY; void stampScriptSource; void stampArguments; void customRedisClient; @@ -742,6 +760,27 @@ if ( ) { throw new Error("The packed ESM tracked placeholder must be unreadable until stamped"); } +if ( + "REDIS_FRAME_VERSION" in redisProtocol + || "REDIS_ENCODING_UTF8" in redisProtocol + || "REDIS_ENCODING_BINARY" in redisProtocol +) { + throw new Error("The removed wire constants must not be exported by the packed ESM Redis protocol entry"); +} +if ( + redisProtocol.resolveTrackedRedisWriteReply(1) !== true + || redisProtocol.resolveTrackedRedisWriteReply(0) !== false +) { + throw new Error("The packed ESM stamp reply resolver did not map replies 0 and 1"); +} +try { + redisProtocol.resolveTrackedRedisWriteReply(2); + throw new Error("Expected a lost-placeholder stamp reply to fail"); +} catch (error) { + if (!(error instanceof root.DialCacheRedisPlaceholderLostError)) { + throw new Error("The lost-placeholder error does not match the root ESM export"); + } +} const esmEmptyFrame = Buffer.alloc(10); esmEmptyFrame[0] = 1; esmEmptyFrame.writeBigUInt64BE(1n, 1); @@ -1050,6 +1089,27 @@ if ( ) { throw new Error("The packed CommonJS tracked placeholder must be unreadable until stamped"); } +if ( + "REDIS_FRAME_VERSION" in redisProtocol + || "REDIS_ENCODING_UTF8" in redisProtocol + || "REDIS_ENCODING_BINARY" in redisProtocol +) { + throw new Error("The removed wire constants must not be exported by the packed CommonJS Redis protocol entry"); +} +if ( + redisProtocol.resolveTrackedRedisWriteReply(1) !== true + || redisProtocol.resolveTrackedRedisWriteReply(0) !== false +) { + throw new Error("The packed CommonJS stamp reply resolver did not map replies 0 and 1"); +} +try { + redisProtocol.resolveTrackedRedisWriteReply(2); + throw new Error("Expected a lost-placeholder stamp reply to fail"); +} catch (error) { + if (!(error instanceof root.DialCacheRedisPlaceholderLostError)) { + throw new Error("The lost-placeholder error does not match the root CommonJS export"); + } +} const cjsEmptyFrame = Buffer.alloc(10); cjsEmptyFrame[0] = 1; cjsEmptyFrame.writeBigUInt64BE(1n, 1); diff --git a/src/index.ts b/src/index.ts index bfa5a63..3275ac8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ export type { DialCacheKeyInit } from "./key.js"; export { DialCacheRedisPayloadEncodingError, DialCacheRedisPayloadError, + DialCacheRedisPlaceholderLostError, DialCacheRedisProtocolError, } from "./redis-client.js"; export type { RedisConfig } from "./internal/redis-cache.js"; diff --git a/src/internal/redis-payload.ts b/src/internal/redis-payload.ts index 932715b..fcd4974 100644 --- a/src/internal/redis-payload.ts +++ b/src/internal/redis-payload.ts @@ -7,14 +7,14 @@ import { } from "../redis-client.js"; export const REDIS_FRAME_VERSION = 1; -export const REDIS_ENCODING_UTF8 = 0; -export const REDIS_ENCODING_BINARY = 1; +const REDIS_ENCODING_UTF8 = 0; +const REDIS_ENCODING_BINARY = 1; /** Version byte of a tracked-write placeholder; no read path serves it. */ export const REDIS_FRAME_PLACEHOLDER_VERSION = 0; export const REDIS_FRAME_TIMESTAMP_OFFSET = 1; export const REDIS_FRAME_TIMESTAMP_BYTES = 8; -const REDIS_FRAME_HEADER_BYTES = 9; +const REDIS_FRAME_HEADER_BYTES = REDIS_FRAME_TIMESTAMP_OFFSET + REDIS_FRAME_TIMESTAMP_BYTES; const REDIS_FRAME_MIN_BYTES = REDIS_FRAME_HEADER_BYTES + 1; function validateRedisBulkStringReply(raw: unknown): Buffer | null { @@ -148,7 +148,7 @@ export function decodeTrackedRedisFrame( if (watermark === null) { return null; } - const createdAtMs = Number(frame.readBigUInt64BE(1)); + const createdAtMs = Number(frame.readBigUInt64BE(REDIS_FRAME_TIMESTAMP_OFFSET)); return createdAtMs <= watermark ? null : decodeRedisPayload(frame.subarray(REDIS_FRAME_HEADER_BYTES)); diff --git a/src/internal/redis-script-reply.ts b/src/internal/redis-script-reply.ts index d341e04..b018aab 100644 --- a/src/internal/redis-script-reply.ts +++ b/src/internal/redis-script-reply.ts @@ -1,4 +1,7 @@ -import { DialCacheRedisProtocolError } from "../redis-client.js"; +import { + DialCacheRedisPlaceholderLostError, + DialCacheRedisProtocolError, +} from "../redis-client.js"; export function validateRedisSetReply(reply: unknown): void { const text = typeof reply === "string" @@ -26,7 +29,7 @@ export function validateRedisScriptWriteReply(reply: unknown): 0 | 1 | 2 { export function resolveTrackedRedisWriteReply(reply: unknown): boolean { const stamp = validateRedisScriptWriteReply(reply); if (stamp === 2) { - throw new Error( + throw new DialCacheRedisPlaceholderLostError( "DialCache tracked write lost its placeholder before the stamp; the SET was rejected, overwritten, or expired", ); } diff --git a/src/redis-client.ts b/src/redis-client.ts index d4db963..bfab12d 100644 --- a/src/redis-client.ts +++ b/src/redis-client.ts @@ -3,6 +3,7 @@ import type { Awaitable } from "./config.js"; const redisPayloadErrorBrand = Symbol.for("dialcache.DialCacheRedisPayloadError"); const redisPayloadEncodingErrorBrand = Symbol.for("dialcache.DialCacheRedisPayloadEncodingError"); const redisProtocolErrorBrand = Symbol.for("dialcache.DialCacheRedisProtocolError"); +const redisPlaceholderLostErrorBrand = Symbol.for("dialcache.DialCacheRedisPlaceholderLostError"); export class DialCacheRedisPayloadError extends Error { static [Symbol.hasInstance](value: unknown): boolean { @@ -58,6 +59,31 @@ export class DialCacheRedisProtocolError extends Error { } } +/** + * A tracked write's stamp found no placeholder carrying its nonce: the paired + * SET was rejected, overwritten by a concurrent writer, expired, or removed + * by a fenced write. The value was not published, and DialCache suppresses the + * corresponding process-local publication. Same-key write contention produces + * a benign floor of these, concentrated on hot keys at TTL expiry. + */ +export class DialCacheRedisPlaceholderLostError extends Error { + static [Symbol.hasInstance](value: unknown): boolean { + if (this !== DialCacheRedisPlaceholderLostError) { + return Function.prototype[Symbol.hasInstance].call(this, value); + } + return typeof value === "object" + && value !== null + && Object.getOwnPropertyDescriptor(value, redisPlaceholderLostErrorBrand)?.value === true; + } + + constructor(message: string) { + super(message); + this.name = "DialCacheRedisPlaceholderLostError"; + // CJS adapter subpaths are separate bundles; a global symbol preserves root-export instanceof checks. + Object.defineProperty(this, redisPlaceholderLostErrorBrand, { value: true }); + } +} + /** Serialized cache data, independent of any Redis client or wire framing. */ export type RedisCachePayload = string | Buffer; @@ -151,9 +177,10 @@ export interface DialCacheRedisClient { * Tracked writes issue two commands ordered on one connection without a * transaction: a native `SET` of an `encodeTrackedRedisPlaceholder` frame, * followed by `WRITE_TRACKED_STAMP_SCRIPT` with `KEYS = [valueKey, - * watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`. `ARGV[1]` must equal the - * SET's `PX` — the watermark's lifetime is derived from it — and the nonce - * must be the placeholder's. The script fences against the watermark and + * watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`. Ceil `cacheTtlMs` to an + * integer and pass that same value as both the SET's `PX` and `ARGV[1]` — + * `PX` rejects fractions and the watermark's lifetime is derived from + * `ARGV[1]` — and the nonce must be the placeholder's. The script fences against the watermark and * unlinks the value (reply 0), promotes exactly the placeholder carrying * its nonce to a served frame with server-time `createdAt` (reply 1), or * reports the placeholder gone (reply 2); it maintains the watermark's @@ -165,11 +192,14 @@ export interface DialCacheRedisClient { * in-flight write. * * Implementations must not reorder the pair, must mint one placeholder per - * logical write so client-level retries stay paired with their stamp, must - * surface a SET failure as the write error even when the stamp settled, and - * must fail the write on reply 2 so split pairs stay observable (a - * client-level SET retry that lands after such a failure can still leave - * the stamped value readable). False means invalidation blocked the write. + * logical write so client-level retries stay paired with their stamp, and + * must surface a SET failure as the write error even when the stamp settled + * (in that case the stamp may have promoted the landed SET, leaving the + * value readable despite the reported failure). Reply 2 must fail the write + * with `DialCacheRedisPlaceholderLostError` so split pairs stay observable; + * after reply 2 the key holds another writer's frame or an unreadable + * placeholder, never this write's value. False means invalidation blocked + * the write. */ write(request: RedisWriteRequest): Awaitable; /** diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index b2d93c1..12dba2b 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -7,8 +7,9 @@ export { decodeTrackedRedisFrame, encodeRedisFrame, encodeTrackedRedisPlaceholder, - REDIS_ENCODING_BINARY, - REDIS_ENCODING_UTF8, - REDIS_FRAME_VERSION, type TrackedRedisPlaceholder, } from "./internal/redis-payload.js"; +export { + resolveTrackedRedisWriteReply, + validateRedisSetReply, +} from "./internal/redis-script-reply.js"; diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 76f5d52..8c0fc53 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -133,7 +133,11 @@ export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { /** * Wrap a caller-owned GLIDE connection. The returned adapter owns only its * two mutation Script handles and preserves the connection's - * `requestTimeout`. Pass the same GLIDE module namespace used to create the + * `requestTimeout`. On GLIDE 2.0.0, releasing any Script handle for a source + * has been observed to break other live handles for that same source despite + * the documented reference counting, so adapters sharing one GLIDE module + * namespace must be disposed together after draining, never swapped + * dispose-after-create. Pass the same GLIDE module namespace used to create the * client so native Batch and Script objects come from that client's runtime. * Only direct GlideClient and GlideClusterClient instances are accepted; * wrappers should implement DialCacheRedisClient directly. @@ -180,14 +184,6 @@ export function createValkeyGlideDialCacheClient => run( - () => client.invokeScript(script, { keys, args, decoder: glide.Decoder.Bytes }), - ); - return { async read({ valueKey, watermarkKey }) { if (watermarkKey === undefined) { @@ -284,11 +280,11 @@ export function createValkeyGlideDialCacheClient client.invokeScript(scripts.invalidate, { + keys: [watermarkKey], + args: [String(futureBufferMs)], + decoder: glide.Decoder.Bytes, + })); validateRedisScriptInvalidationReply(raw); }, dispose() { diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index 4b05016..62f5226 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -4,6 +4,7 @@ import { CacheLayer, DialCache, DialCacheKeyConfig, + DialCacheRedisPlaceholderLostError, DialCacheRedisProtocolError, } from "../src/index.js"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../src/node-redis.js"; @@ -103,6 +104,21 @@ describe("node-redis adapter", () => { expect( () => createNodeRedisDialCacheClient({ get: vi.fn(), sendCommand: vi.fn() } as never), ).toThrow("requires a client created with scripts: dialcacheRedisScripts"); + // Partial registration must fail just as loudly as none. + expect( + () => createNodeRedisDialCacheClient({ + get: vi.fn(), + sendCommand: vi.fn(), + dialcacheWriteTrackedStamp: vi.fn(), + } as never), + ).toThrow(TypeError); + expect( + () => createNodeRedisDialCacheClient({ + get: vi.fn(), + sendCommand: vi.fn(), + dialcacheInvalidate: vi.fn(), + } as never), + ).toThrow(TypeError); }); it("accepts the exact write and invalidation reply domains", async () => { @@ -203,12 +219,14 @@ describe("node-redis adapter", () => { it("fails a tracked write whose placeholder was lost before the stamp", async () => { const adapter = createNodeRedisDialCacheClient(fakeClient({ stamp: 2 }) as never); - await expect(adapter.write({ + const write = adapter.write({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark", cacheTtlMs: 1_000, value: "tracked", - })).rejects.toThrow("DialCache tracked write lost its placeholder before the stamp"); + }); + await expect(write).rejects.toThrow("DialCache tracked write lost its placeholder before the stamp"); + await expect(write).rejects.toBeInstanceOf(DialCacheRedisPlaceholderLostError); }); it("issues the stamp before the placeholder SET settles", async () => { diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index 18a9357..055ebad 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -123,6 +123,9 @@ describe("DialCache Redis protocol on Redis Cluster", () => { keyType: "item_id", useCase: "ClusterSlots", cacheKey: (id) => id, + // Tracked, so the pre-flush pass loads the stamp script on every master + // and the post-flush pass proves a genuine per-node NOSCRIPT reload. + trackForInvalidation: true, defaultConfig: remoteOnly, }); @@ -147,6 +150,7 @@ describe("DialCache Redis protocol on Redis Cluster", () => { keyType: "item_id", useCase: "ClusterSlots", cacheKey: (id) => id, + trackForInvalidation: true, defaultConfig: remoteOnly, }); const second = await recoveryDialcache.enable(async () => await Promise.all(ids.map(recoverValue))); diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index f227c8d..a43193e 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -804,8 +804,9 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { // The recovered write must cache the stamp under the SHA1 the batched // EVALSHA uses, so later writes take the single-round-trip path. The // adapter's own dispatch hash must match node-redis's source SHA1; do - // not probe with a throwaway Script here — releasing it would destroy - // the shared per-hash GLIDE script container the live handles rely on. + // not probe with a throwaway Script here — on GLIDE 2.0.0, releasing a + // same-source handle was observed to break the live handles' reloads + // despite the documented reference counting. expect(client.raw.stampScriptSha1).toBe(dialcacheRedisScripts.dialcacheWriteTrackedStamp.SHA1); expect( await admin.scriptExists(dialcacheRedisScripts.dialcacheWriteTrackedStamp.SHA1), diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index 68a6456..5c9b485 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { DialCacheRedisPayloadEncodingError, DialCacheRedisPayloadError, + DialCacheRedisPlaceholderLostError, DialCacheRedisProtocolError, } from "../src/redis-client.js"; import { WRITE_TRACKED_STAMP_SCRIPT } from "../src/redis-protocol.js"; @@ -386,12 +387,14 @@ describe("Valkey GLIDE adapter", () => { const client = fakeClient([Buffer.from("OK"), 2]); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); - await expect(adapter.write({ + const write = adapter.write({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark", cacheTtlMs: 1_000, value: "tracked", - })).rejects.toThrow("DialCache tracked write lost its placeholder before the stamp"); + }); + await expect(write).rejects.toThrow("DialCache tracked write lost its placeholder before the stamp"); + await expect(write).rejects.toBeInstanceOf(DialCacheRedisPlaceholderLostError); expect(client.invokeScript).not.toHaveBeenCalled(); adapter.dispose(); }); From 2f39250215586bdd20d8af82d500353142ca8528 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 7 Aug 2026 23:19:10 -0700 Subject: [PATCH 04/12] fix(redis): brand disjointness coverage and a self-contained protocol entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 2 +- scripts/test-package.mjs | 11 +++++++++++ src/internal/redis-payload.ts | 6 +++--- src/internal/redis-scripts.ts | 5 ++--- src/redis-protocol.ts | 7 +++++++ test/node-redis.test.ts | 29 +++++++++++++++++++++++++++++ 6 files changed, 53 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e69a631..645beff 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,7 @@ The node-redis adapter owns no additional resources, so the application closes t Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. -Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` of a version-0 placeholder frame carrying a fresh per-write nonce, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, promotes exactly the placeholder carrying its nonce to a served frame with Redis server time, and maintains the watermark TTL. A placeholder is unreadable on both read paths until promoted, so an interleaved or lost stamp degrades to a miss that expires with the value TTL rather than partial state — including briefly blanking a previously readable key the write replaces. The nonce means the stamp can never revive a frame it does not own: if its paired `SET` was rejected, overwritten, or expired, the stamp reports the placeholder gone and the write fails with `DialCacheRedisPlaceholderLostError` as a `cache_write` error instead of publishing another write's leftovers. Losing a same-key write race is one such outcome, so `cache_write` carries a benign, self-healing floor that concentrates on hot tracked keys at TTL expiry — size write-error alerts for it, and filter by the error class when triaging. A `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. +Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` of a version-0 placeholder frame carrying a fresh per-write nonce, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, promotes exactly the placeholder carrying its nonce to a served frame with Redis server time, and maintains the watermark TTL. A placeholder is unreadable on both read paths until promoted, so an interleaved or lost stamp degrades to a miss that expires with the value TTL rather than partial state — including briefly blanking a previously readable key the write replaces. The nonce means the stamp can never revive a frame it does not own: if its paired `SET` was rejected, overwritten, or expired, the stamp reports the placeholder gone and the write fails with `DialCacheRedisPlaceholderLostError` as a `cache_write` error instead of publishing another write's leftovers. Losing a same-key write race is one such outcome, so `cache_write` carries a benign, self-healing floor that concentrates on hot tracked keys at TTL expiry — size write-error alerts for it. The `cache_write` metric itself stays one bounded counter; the error's class and name distinguish the lost-placeholder case in logs and in application `catch` blocks. A `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore a clean miss and may be replaced with a valid DialCache frame after the fallback succeeds, while a wrong-type watermark prevents the tracked write from succeeding: its paired `SET` still lands, leaving only an unreadable placeholder until expiry or a later successful write. diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 1db02e9..7469e4b 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -781,6 +781,17 @@ try { throw new Error("The lost-placeholder error does not match the root ESM export"); } } +// ESM chunk splitting shares one class instance across entries, so also +// prove the brand itself: a hand-branded foreign Error must satisfy the +// root export's Symbol.hasInstance. +const esmBrandedLost = Object.defineProperty( + new Error("lost"), + Symbol.for("dialcache.DialCacheRedisPlaceholderLostError"), + { value: true }, +); +if (!(esmBrandedLost instanceof root.DialCacheRedisPlaceholderLostError)) { + throw new Error("The ESM lost-placeholder brand did not satisfy instanceof"); +} const esmEmptyFrame = Buffer.alloc(10); esmEmptyFrame[0] = 1; esmEmptyFrame.writeBigUInt64BE(1n, 1); diff --git a/src/internal/redis-payload.ts b/src/internal/redis-payload.ts index fcd4974..40d4e55 100644 --- a/src/internal/redis-payload.ts +++ b/src/internal/redis-payload.ts @@ -11,10 +11,10 @@ const REDIS_ENCODING_UTF8 = 0; const REDIS_ENCODING_BINARY = 1; /** Version byte of a tracked-write placeholder; no read path serves it. */ export const REDIS_FRAME_PLACEHOLDER_VERSION = 0; -export const REDIS_FRAME_TIMESTAMP_OFFSET = 1; +const REDIS_FRAME_TIMESTAMP_OFFSET = 1; export const REDIS_FRAME_TIMESTAMP_BYTES = 8; -const REDIS_FRAME_HEADER_BYTES = REDIS_FRAME_TIMESTAMP_OFFSET + REDIS_FRAME_TIMESTAMP_BYTES; +export const REDIS_FRAME_HEADER_BYTES = REDIS_FRAME_TIMESTAMP_OFFSET + REDIS_FRAME_TIMESTAMP_BYTES; const REDIS_FRAME_MIN_BYTES = REDIS_FRAME_HEADER_BYTES + 1; function validateRedisBulkStringReply(raw: unknown): Buffer | null { @@ -45,7 +45,7 @@ function parseRedisWatermark(raw: Buffer | null): number | null { return Number.isFinite(watermark) ? watermark : null; } -export function redisPayloadEncoding(value: RedisCachePayload): number { +function redisPayloadEncoding(value: RedisCachePayload): number { return Buffer.isBuffer(value) ? REDIS_ENCODING_BINARY : REDIS_ENCODING_UTF8; } diff --git a/src/internal/redis-scripts.ts b/src/internal/redis-scripts.ts index 915064e..1fcfcd7 100644 --- a/src/internal/redis-scripts.ts +++ b/src/internal/redis-scripts.ts @@ -1,13 +1,12 @@ import { MAX_SUPPORTED_DURATION_MS } from "./duration.js"; import { + REDIS_FRAME_HEADER_BYTES, REDIS_FRAME_PLACEHOLDER_VERSION, REDIS_FRAME_TIMESTAMP_BYTES, - REDIS_FRAME_TIMESTAMP_OFFSET, REDIS_FRAME_VERSION, } from "./redis-payload.js"; const WATERMARK_TTL_MARGIN_MS = 60_000; -const PLACEHOLDER_HEADER_END = REDIS_FRAME_TIMESTAMP_OFFSET + REDIS_FRAME_TIMESTAMP_BYTES - 1; const PARSE_WATERMARK_LUA = String.raw`local function parse_watermark(raw) if not string.match(raw, "^%d+$") and not string.match(raw, "^%d+%.%d+$") then @@ -63,7 +62,7 @@ if watermark >= now_ms then return 0 end`, String.raw`local stamped = 1 -if redis.call("GETRANGE", KEYS[1], 0, ${PLACEHOLDER_HEADER_END}) == string.char(${REDIS_FRAME_PLACEHOLDER_VERSION}) .. ARGV[2] then +if redis.call("GETRANGE", KEYS[1], 0, ${REDIS_FRAME_HEADER_BYTES - 1}) == string.char(${REDIS_FRAME_PLACEHOLDER_VERSION}) .. ARGV[2] then redis.call("SETRANGE", KEYS[1], 0, string.char(${REDIS_FRAME_VERSION}) .. struct.pack(">I8", now_ms)) else -- The placeholder this stamp paired with is gone: its SET was rejected, diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index 12dba2b..aed5be0 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -13,3 +13,10 @@ export { resolveTrackedRedisWriteReply, validateRedisSetReply, } from "./internal/redis-script-reply.js"; +// The classes those helpers throw, so this subpath is self-contained for +// custom adapters; the shared brand keeps them instanceof-compatible with +// the root exports. +export { + DialCacheRedisPlaceholderLostError, + DialCacheRedisProtocolError, +} from "./redis-client.js"; diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index 62f5226..96bf4d9 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -535,6 +535,35 @@ describe("node-redis adapter", () => { expect(falselyBranded).not.toBeInstanceOf(DialCacheRedisProtocolError); }); + it("keeps placeholder-lost errors branded and disjoint from protocol errors", () => { + class SpecializedPlaceholderLostError extends DialCacheRedisPlaceholderLostError {} + + const baseError = new DialCacheRedisPlaceholderLostError("base"); + const specializedError = new SpecializedPlaceholderLostError("specialized"); + const crossBundleError = Object.defineProperty( + new Error("lost"), + Symbol.for("dialcache.DialCacheRedisPlaceholderLostError"), + { value: true }, + ); + const falselyBranded = Object.defineProperty( + {}, + Symbol.for("dialcache.DialCacheRedisPlaceholderLostError"), + { value: false }, + ); + + expect(baseError).toBeInstanceOf(DialCacheRedisPlaceholderLostError); + expect(baseError).not.toBeInstanceOf(SpecializedPlaceholderLostError); + expect(specializedError).toBeInstanceOf(SpecializedPlaceholderLostError); + expect(specializedError).toBeInstanceOf(DialCacheRedisPlaceholderLostError); + expect(crossBundleError).toBeInstanceOf(DialCacheRedisPlaceholderLostError); + expect(falselyBranded).not.toBeInstanceOf(DialCacheRedisPlaceholderLostError); + // The benign race-loser class must stay disjoint from operational + // protocol failures, or filtering one silently swallows the other. + expect(baseError).not.toBeInstanceOf(DialCacheRedisProtocolError); + expect(new DialCacheRedisProtocolError("operational")) + .not.toBeInstanceOf(DialCacheRedisPlaceholderLostError); + }); + it("surfaces protocol failures through the normal DialCache observability path", async () => { const redisClient = createNodeRedisDialCacheClient(fakeClient({ set: 2, invalidate: 0 }) as never); const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; From 7bba68e03620cf2a2ba6c01bed34ed3b71c11b16 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 7 Aug 2026 23:51:25 -0700 Subject: [PATCH 05/12] fix(redis): drop the stamp Script handle and the redis-protocol error re-exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 4 ++-- scripts/test-package.mjs | 23 +++++++++++++++------ src/redis-protocol.ts | 7 ------- src/valkey-glide.ts | 33 ++++++++++++++++-------------- test/valkey-glide.test.ts | 42 ++++++++++++++++++++++----------------- 5 files changed, 61 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 645beff..1a62c4d 100644 --- a/README.md +++ b/README.md @@ -398,7 +398,7 @@ The application owns the complete Redis lifecycle. It creates and connects the u Awaiting those public promises does not drain detached shadow work. Shadow scheduling and deadline timers are unreferenced and completion is not guaranteed during shutdown; Redis operations, source reads, serializers, and asynchronous telemetry already started by shadow work remain caller-owned and may still be active. Stop new work before closing their dependencies and accept that an in-flight shadow fill may have been dispatched even if its final outcome is lost during teardown. DialCache does not add a shutdown hook or keep the process alive to deliver best-effort outcomes. -The node-redis adapter owns no additional resources, so the application closes the underlying node-redis client after draining work. The GLIDE adapter owns two native `Script` handles for the tracked write stamp and invalidation, but not the wrapped connection. After outstanding operations finish, call its idempotent `dispose()` before closing GLIDE as shown above; disposal while an adapter operation is in flight throws rather than releasing a live script. On GLIDE 2.0.0, releasing a `Script` handle has been observed to break other live handles for the same script source despite GLIDE's documented reference counting, so adapters sharing one GLIDE module namespace should be disposed together at shutdown, never swapped dispose-after-create. +The node-redis adapter owns no additional resources, so the application closes the underlying node-redis client after draining work. The GLIDE adapter owns one native `Script` handle for invalidation, but not the wrapped connection. After outstanding operations finish, call its idempotent `dispose()` before closing GLIDE as shown above; disposal while an adapter operation is in flight throws rather than releasing a live script. On GLIDE 2.0.0, releasing a `Script` handle has been observed to break other live handles for the same script source despite GLIDE's documented reference counting, so adapters sharing one GLIDE module namespace should be disposed together at shutdown, never swapped dispose-after-create. Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. @@ -408,7 +408,7 @@ Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. -For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-running the stamp through GLIDE's `Script`-based `invokeScript`, which reloads it, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation uses GLIDE's native `Script` lifecycle directly. +For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation uses GLIDE's native `Script` lifecycle directly. A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`; verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 7469e4b..64e3103 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -1121,6 +1121,17 @@ try { throw new Error("The lost-placeholder error does not match the root CommonJS export"); } } +// Keep the brand coverage bundler-independent: a hand-branded foreign Error +// must satisfy the root export's Symbol.hasInstance even if CJS ever shares +// chunks the way ESM does. +const cjsBrandedLost = Object.defineProperty( + new Error("lost"), + Symbol.for("dialcache.DialCacheRedisPlaceholderLostError"), + { value: true }, +); +if (!(cjsBrandedLost instanceof root.DialCacheRedisPlaceholderLostError)) { + throw new Error("The CommonJS lost-placeholder brand did not satisfy instanceof"); +} const cjsEmptyFrame = Buffer.alloc(10); cjsEmptyFrame[0] = 1; cjsEmptyFrame.writeBigUInt64BE(1n, 1); @@ -1261,9 +1272,9 @@ const esmFakeGlideClient = { } return ["OK", new Error("NOSCRIPT No matching script. Please use EVAL.")]; }, - invokeScript: async (script, options) => { - if (!(script instanceof appGlide.Script) || script instanceof otherGlide.Script) { - throw new Error("The ESM adapter did not use the caller-supplied GLIDE Script constructor"); + customCommand: async (args, options) => { + if (args[0] !== "EVAL") { + throw new Error("The ESM adapter's NOSCRIPT recovery must resend the stamp source via EVAL"); } if (options.decoder !== appGlide.Decoder.Bytes) { throw new Error("The ESM adapter did not use the caller-supplied GLIDE byte decoder"); @@ -1321,9 +1332,9 @@ void (async () => { } return ["OK", new Error("NOSCRIPT No matching script. Please use EVAL.")]; }, - invokeScript: async (script, options) => { - if (!(script instanceof appGlide.Script) || script instanceof otherGlide.Script) { - throw new Error("The CommonJS adapter did not use the caller-supplied GLIDE Script constructor"); + customCommand: async (args, options) => { + if (args[0] !== "EVAL") { + throw new Error("The CommonJS adapter's NOSCRIPT recovery must resend the stamp source via EVAL"); } if (options.decoder !== appGlide.Decoder.Bytes) { throw new Error("The CommonJS adapter did not use the caller-supplied GLIDE byte decoder"); diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index aed5be0..12dba2b 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -13,10 +13,3 @@ export { resolveTrackedRedisWriteReply, validateRedisSetReply, } from "./internal/redis-script-reply.js"; -// The classes those helpers throw, so this subpath is self-contained for -// custom adapters; the shared brand keeps them instanceof-compatible with -// the root exports. -export { - DialCacheRedisPlaceholderLostError, - DialCacheRedisProtocolError, -} from "./redis-client.js"; diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 8c0fc53..2495d75 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { ceilSupportedCacheTtlMs } from "./internal/duration.js"; import { decodeRedisFrame, @@ -18,14 +20,17 @@ import { DialCacheRedisPayloadError, type DialCacheRedisClient } from "./redis-c type ValkeyGlideString = string | Buffer; +// Redis caches EVAL'd sources under sha1(source), so this digest is by +// definition the one the batched EVALSHA must use and the one the EVAL +// fallback repopulates. +const WRITE_TRACKED_STAMP_SHA1 = createHash("sha1").update(WRITE_TRACKED_STAMP_SCRIPT).digest("hex"); + interface ValkeyGlideBatch { customCommand(args: ValkeyGlideString[]): ValkeyGlideBatch; mget(keys: ValkeyGlideString[]): ValkeyGlideBatch; } export interface ValkeyGlideScriptHandle { - /** The SHA1 GLIDE registered the script under; used for batched EVALSHA. */ - getHash(): string; /** Release the native GLIDE script registration. */ release(): void; } @@ -82,7 +87,6 @@ export interface ValkeyGlideRuntime { - readonly writeTrackedStamp: TScript; readonly invalidate: TScript; } @@ -132,7 +136,7 @@ export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { /** * Wrap a caller-owned GLIDE connection. The returned adapter owns only its - * two mutation Script handles and preserves the connection's + * invalidation Script handle and preserves the connection's * `requestTimeout`. On GLIDE 2.0.0, releasing any Script handle for a source * has been observed to break other live handles for that same source despite * the documented reference counting, so adapters sharing one GLIDE module @@ -166,7 +170,6 @@ export function createValkeyGlideDialCacheClient = { - writeTrackedStamp: new glide.Script(WRITE_TRACKED_STAMP_SCRIPT), invalidate: new glide.Script(INVALIDATE_CACHE_SCRIPT), }; let disposed = false; @@ -236,14 +239,14 @@ export function createValkeyGlideDialCacheClient { const batch = (isCluster ? new glide.ClusterBatch(false) : new glide.Batch(false)) .customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)]) .customCommand([ "EVALSHA", - scripts.writeTrackedStamp.getHash(), + WRITE_TRACKED_STAMP_SHA1, "2", valueKey, watermarkKey, @@ -268,13 +271,13 @@ export function createValkeyGlideDialCacheClient(); class MockScript { readonly release = vi.fn(); - readonly getHash = vi.fn(() => createHash("sha1").update(this.code).digest("hex")); constructor(readonly code: string) { scriptInstances.push(this); @@ -195,7 +194,7 @@ describe("Valkey GLIDE adapter", () => { { decoder: decoderBytes }, ); expect(client.invokeScript).not.toHaveBeenCalled(); - expect(scriptInstances).toHaveLength(2); + expect(scriptInstances).toHaveLength(1); }); it("routes tracked cluster MGET directly to the slot primary", async () => { @@ -395,7 +394,8 @@ describe("Valkey GLIDE adapter", () => { }); await expect(write).rejects.toThrow("DialCache tracked write lost its placeholder before the stamp"); await expect(write).rejects.toBeInstanceOf(DialCacheRedisPlaceholderLostError); - expect(client.invokeScript).not.toHaveBeenCalled(); + // Reply 2 is a settled outcome, not a recovery trigger. + expect(client.customCommand).not.toHaveBeenCalled(); adapter.dispose(); }); @@ -427,7 +427,7 @@ describe("Valkey GLIDE adapter", () => { }); }); - it("falls back to invokeScript when the batched stamp hits NOSCRIPT", async () => { + it("falls back to EVAL by source when the batched stamp hits NOSCRIPT", async () => { const noscriptWordings = [ // Raw server reply wording. "NOSCRIPT No matching script. Please use EVAL.", @@ -447,14 +447,20 @@ describe("Valkey GLIDE adapter", () => { })).resolves.toBe(true); const trackedFrame = batchInstances[0]?.commands[0]?.[2] as Buffer; - expect(client.invokeScript).toHaveBeenCalledTimes(1); - const [script, options] = client.invokeScript.mock.calls[0] ?? []; - expect(script?.code).toBe(WRITE_TRACKED_STAMP_SCRIPT); - expect(options).toEqual({ - keys: ["tracked:{id}:value", "tracked:{id}:watermark"], - args: ["2000", trackedFrame.subarray(1, 9)], - decoder: decoderBytes, - }); + expect(client.customCommand).toHaveBeenCalledTimes(1); + expect(client.customCommand).toHaveBeenCalledWith( + [ + "EVAL", + WRITE_TRACKED_STAMP_SCRIPT, + "2", + "tracked:{id}:value", + "tracked:{id}:watermark", + "2000", + trackedFrame.subarray(1, 9), + ], + { decoder: decoderBytes }, + ); + expect(client.invokeScript).not.toHaveBeenCalled(); adapter.dispose(); } }); @@ -503,7 +509,7 @@ describe("Valkey GLIDE adapter", () => { cacheTtlMs: 1_000, value: "tracked", })).rejects.toBe(setFailure); - expect(setClient.invokeScript).not.toHaveBeenCalled(); + expect(setClient.customCommand).not.toHaveBeenCalled(); setAdapter.dispose(); const stampFailure = new Error("ERR invalid DialCache watermark"); @@ -515,7 +521,7 @@ describe("Valkey GLIDE adapter", () => { cacheTtlMs: 1_000, value: "tracked", })).rejects.toBe(stampFailure); - expect(stampClient.invokeScript).not.toHaveBeenCalled(); + expect(stampClient.customCommand).not.toHaveBeenCalled(); stampAdapter.dispose(); }); @@ -632,7 +638,7 @@ describe("Valkey GLIDE adapter", () => { adapter.dispose(); adapter.dispose(); - expect(scriptInstances).toHaveLength(2); + expect(scriptInstances).toHaveLength(1); for (const script of scriptInstances) { expect(script.release).toHaveBeenCalledTimes(1); } @@ -673,7 +679,7 @@ describe("Valkey GLIDE adapter", () => { resolveExec = resolve; }), ); - client.invokeScript.mockImplementationOnce( + client.customCommand.mockImplementationOnce( async () => await new Promise((resolve) => { resolveFallback = resolve; }), @@ -691,8 +697,8 @@ describe("Valkey GLIDE adapter", () => { ); resolveExec?.([Buffer.from("OK"), new Error("NOSCRIPT No matching script. Please use EVAL.")]); - await vi.waitFor(() => expect(client.invokeScript).toHaveBeenCalledTimes(1)); - // The fallback is still pending: the stamp handle must stay unreleased. + await vi.waitFor(() => expect(client.customCommand).toHaveBeenCalledTimes(1)); + // The EVAL recovery is still pending: the write must stay in flight. expect(() => adapter.dispose()).toThrow( "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", ); From ab7034744cd3fd4d9769646849bea355e9e6f486 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 8 Aug 2026 00:06:24 -0700 Subject: [PATCH 06/12] fix(redis): align GLIDE docs with the EVAL recovery and pin its cluster 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. --- README.md | 6 ++--- src/valkey-glide.ts | 25 +++++++----------- test/redis-real.integration.test.ts | 15 +++-------- test/valkey-glide.test.ts | 39 ++++++++++++++++++++++++++--- 4 files changed, 51 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 1a62c4d..8125268 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,7 @@ The node-redis adapter owns no additional resources, so the application closes t Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. -Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` of a version-0 placeholder frame carrying a fresh per-write nonce, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, promotes exactly the placeholder carrying its nonce to a served frame with Redis server time, and maintains the watermark TTL. A placeholder is unreadable on both read paths until promoted, so an interleaved or lost stamp degrades to a miss that expires with the value TTL rather than partial state — including briefly blanking a previously readable key the write replaces. The nonce means the stamp can never revive a frame it does not own: if its paired `SET` was rejected, overwritten, or expired, the stamp reports the placeholder gone and the write fails with `DialCacheRedisPlaceholderLostError` as a `cache_write` error instead of publishing another write's leftovers. Losing a same-key write race is one such outcome, so `cache_write` carries a benign, self-healing floor that concentrates on hot tracked keys at TTL expiry — size write-error alerts for it. The `cache_write` metric itself stays one bounded counter; the error's class and name distinguish the lost-placeholder case in logs and in application `catch` blocks. A `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. +Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` of a version-0 placeholder frame carrying a fresh per-write nonce, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, promotes exactly the placeholder carrying its nonce to a served frame with Redis server time, and maintains the watermark TTL. A placeholder is unreadable on both read paths until promoted, so an interleaved or lost stamp degrades to a miss that expires with the value TTL rather than partial state — including briefly blanking a previously readable key the write replaces. The nonce means the stamp can never revive a frame it does not own: if its paired `SET` was rejected, overwritten, or expired, the stamp reports the placeholder gone and the write fails with `DialCacheRedisPlaceholderLostError` as a `cache_write` error instead of publishing another write's leftovers. Losing a same-key write race is one such outcome, so `cache_write` carries a benign, self-healing floor that concentrates on hot tracked keys at TTL expiry — size write-error alerts for it. The `cache_write` metric itself stays one bounded counter; the error's class and name distinguish the lost-placeholder case in logs and in application `catch` blocks. Each occurrence also emits one warn through the configured logger (the default is `console`), so fleets expecting hot-key write contention should supply a logger that rate-limits or filters that class. A `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore a clean miss and may be replaced with a valid DialCache frame after the fallback succeeds, while a wrong-type watermark prevents the tracked write from succeeding: its paired `SET` still lands, leaving only an unreadable placeholder until expiry or a later successful write. @@ -410,7 +410,7 @@ Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an ex For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation uses GLIDE's native `Script` lifecycle directly. -A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`; verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. +A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`, and must allow the client to issue `EVAL` — both adapters recover a flushed script cache by re-sending the stamp source, not via `SCRIPT LOAD`; verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. #### Remote read deadlines and async liveness @@ -641,7 +641,7 @@ Tracked writes create a baseline watermark and extend its TTL to at least the va `futureBufferMs` must be a nonnegative safe integer no greater than 31,536,000,000 (a fixed 365-day duration). The default is zero, but zero provides no stale-publication protection once Redis time advances. Every production invalidation should pass a named, application-owned nonzero value based on that application's measured or conservatively bounded timings; there is no universally safe library value. -Size the buffer to cover the maximum expected negative clock skew between promotion-eligible Redis nodes plus the complete interval in which stale data could still reach the Redis write: source visibility or replication lag, the full remaining tail of any fallback that may already have observed the pre-mutation value, `serializer.dump`, Redis client queue and network latency, the placeholder write and the stamp script that assigns its server timestamp, and a safety margin. Invalidate only after the source mutation commits. Underestimating this interval can allow a delayed stale fallback to repopulate Redis after the watermark window ends. Overestimating it lengthens the tracked Redis miss/write-suppression window described above, increasing fallback load and, until write-side cleanup succeeds, stale-payload transfer and read-timeout risk without publishing stale values. A larger buffer does not delay or suppress returning fallback values to callers. +Size the buffer to cover the maximum expected negative clock skew between promotion-eligible Redis nodes plus the complete interval in which stale data could still reach the Redis write: source visibility or replication lag, the full remaining tail of any fallback that may already have observed the pre-mutation value, `serializer.dump`, Redis client queue and network latency, the placeholder write and the stamp script that assigns its server timestamp, and a safety margin. Invalidate only after the source mutation commits. Underestimating this interval can allow a delayed stale fallback to repopulate Redis after the watermark window ends. Overestimating it lengthens the tracked Redis miss/write-suppression window described above, increasing fallback load and, until write-side cleanup succeeds, stale-payload transfer and read-timeout risk without publishing stale values. Each fenced write inside the window also stores its full placeholder payload before the stamp unlinks it, so a long buffer on a hot large-value key adds allocator, replication, and AOF churn the previous fence-before-store protocol never paid. A larger buffer does not delay or suppress returning fallback values to callers. This is a timing contract rather than a cancellation or acquisition fence: the buffer prevents stale fallback results from passing that tracked Redis write only while the configured window remains active, and it does not force a fallback to read from an authoritative source. diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 2495d75..fa739af 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -86,10 +86,6 @@ export interface ValkeyGlideRuntime { - readonly invalidate: TScript; -} - function matchesValkeyGlideIdentity( identity: unknown, name: "GlideClient" | "GlideClusterClient", @@ -130,7 +126,7 @@ function classifyValkeyGlideClient( } export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { - /** Release the adapter-owned GLIDE Script handles. Does not close the wrapped GLIDE client. */ + /** Release the adapter-owned invalidation Script handle. Does not close the wrapped GLIDE client. */ dispose(): void; } @@ -145,7 +141,7 @@ export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { * client so native Batch and Script objects come from that client's runtime. * Only direct GlideClient and GlideClusterClient instances are accepted; * wrappers should implement DialCacheRedisClient directly. - * Callers dispose the handles after draining work, then close GLIDE. A request + * Callers dispose the handle after draining work, then close GLIDE. A request * timeout bounds client waiting but is not server-side command cancellation. * GLIDE's current command API has no per-invocation signal, so DialCache's core * read deadline may return before this adapter's invocation settles. Tracked @@ -153,9 +149,10 @@ export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { * reads route MGET explicitly to the slot primary, so replica lag cannot hide * an invalidation watermark. Tracked writes batch a native placeholder SET * with an EVALSHA of the stamp script — cluster write batches route to the - * slot primary — and a flushed script cache falls back to invokeScript, which - * reloads and re-runs the stamp, so the first tracked write against a cold - * script cache pays one extra round trip. Batches are deliberately + * slot primary — and recovers a flushed script cache by re-sending the stamp + * as EVAL with its source, which the server caches under the same SHA1, so + * the first tracked write against a cold script cache pays one extra round + * trip. Batches are deliberately * non-atomic: MGET and SET are atomic themselves, an interleaved stamp is * safe by design, and MULTI/EXEC would consume caller-owned WATCH state. */ @@ -169,9 +166,7 @@ export function createValkeyGlideDialCacheClient = { - invalidate: new glide.Script(INVALIDATE_CACHE_SCRIPT), - }; + const invalidateScript: TScript = new glide.Script(INVALIDATE_CACHE_SCRIPT); let disposed = false; let activeOperations = 0; @@ -283,7 +278,7 @@ export function createValkeyGlideDialCacheClient client.invokeScript(scripts.invalidate, { + const raw = await run(() => client.invokeScript(invalidateScript, { keys: [watermarkKey], args: [String(futureBufferMs)], decoder: glide.Decoder.Bytes, @@ -298,9 +293,7 @@ export function createValkeyGlideDialCacheClient createClient({ url, scripts: dialcache type NodeRedisTestClient = ReturnType; interface RawRedisScriptClient { - /** The SHA1 this adapter's batched or EVALSHA-based stamp dispatch uses. */ - readonly stampScriptSha1: string; /** Invoke only the tracked stamp script, as if its paired placeholder SET was lost. */ stamp(valueKey: string, watermarkKey: string, cacheTtlMs: number, nonce: Buffer): Promise; invalidate(watermarkKey: string, futureBufferMs: number): Promise; @@ -75,7 +73,6 @@ function createNodeRedisHarness(client: NodeRedisTestClient): RedisAdapterHarnes return { adapter: createNodeRedisDialCacheClient(client), raw: { - stampScriptSha1: dialcacheRedisScripts.dialcacheWriteTrackedStamp.SHA1, stamp: async (...args) => await client.dialcacheWriteTrackedStamp(...args), invalidate: async (...args) => await client.dialcacheInvalidate(...args), }, @@ -108,7 +105,6 @@ function createValkeyGlideHarness(client: valkeyGlide.GlideClient): RedisAdapter return { adapter, raw: { - stampScriptSha1: rawScripts.stamp.getHash(), stamp: async (valueKey, watermarkKey, cacheTtlMs, nonce) => await invoke(rawScripts.stamp, [valueKey, watermarkKey], [String(cacheTtlMs), nonce]), invalidate: async (watermarkKey, futureBufferMs) => @@ -801,13 +797,10 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { }), ).toBe(true); expect(await scriptClient.read({ valueKey: trackedValueKey, watermarkKey })).toBe("tracked"); - // The recovered write must cache the stamp under the SHA1 the batched - // EVALSHA uses, so later writes take the single-round-trip path. The - // adapter's own dispatch hash must match node-redis's source SHA1; do - // not probe with a throwaway Script here — on GLIDE 2.0.0, releasing a - // same-source handle was observed to break the live handles' reloads - // despite the documented reference counting. - expect(client.raw.stampScriptSha1).toBe(dialcacheRedisScripts.dialcacheWriteTrackedStamp.SHA1); + // The recovered write must cache the stamp under sha1(source) — the + // digest node-redis registers and the GLIDE batch dispatches — so later + // writes take the single-round-trip path. (The unit suites pin each + // adapter's dispatched digest to an independently computed sha1.) expect( await admin.scriptExists(dialcacheRedisScripts.dialcacheWriteTrackedStamp.SHA1), ).toEqual([true]); diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index eff2dc2..38c9310 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -8,7 +8,7 @@ import { DialCacheRedisPlaceholderLostError, DialCacheRedisProtocolError, } from "../src/redis-client.js"; -import { WRITE_TRACKED_STAMP_SCRIPT } from "../src/redis-protocol.js"; +import { INVALIDATE_CACHE_SCRIPT, WRITE_TRACKED_STAMP_SCRIPT } from "../src/redis-protocol.js"; import { createValkeyGlideDialCacheClient } from "../src/valkey-glide.js"; const INVALID_WRITE_REPLIES: readonly unknown[] = [ @@ -112,17 +112,17 @@ function createFakeClient(replies: unknown[]) { ) => nextReply()), invokeScript: vi.fn(async (_script: MockScript, _options: InvokeScriptOptions) => nextReply()), }; - return { client, nextReply }; + return client; } function fakeClient(...replies: unknown[]) { - const client = createFakeClient(replies).client; + const client = createFakeClient(replies); standaloneClients.add(client); return client; } function fakeClusterClient(...replies: unknown[]) { - const client = createFakeClient(replies).client; + const client = createFakeClient(replies); clusterClients.add(client); return client; } @@ -427,6 +427,36 @@ describe("Valkey GLIDE adapter", () => { }); }); + it("routes the EVAL recovery to the slot primary on cluster", async () => { + const noscript = new Error("NOSCRIPT No matching script. Please use EVAL."); + const client = fakeClusterClient([Buffer.from("OK"), noscript], 1); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect(adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 2_000, + value: "tracked", + })).resolves.toBe(true); + + const trackedFrame = clusterBatchInstances[0]?.commands[0]?.[2] as Buffer; + expect(client.customCommand).toHaveBeenCalledWith( + [ + "EVAL", + WRITE_TRACKED_STAMP_SCRIPT, + "2", + "tracked:{id}:value", + "tracked:{id}:watermark", + "2000", + trackedFrame.subarray(1, 9), + ], + { + decoder: decoderBytes, + route: { type: "primarySlotKey", key: "tracked:{id}:value" }, + }, + ); + }); + it("falls back to EVAL by source when the batched stamp hits NOSCRIPT", async () => { const noscriptWordings = [ // Raw server reply wording. @@ -639,6 +669,7 @@ describe("Valkey GLIDE adapter", () => { adapter.dispose(); expect(scriptInstances).toHaveLength(1); + expect(scriptInstances[0]?.code).toBe(INVALIDATE_CACHE_SCRIPT); for (const script of scriptInstances) { expect(script.release).toHaveBeenCalledTimes(1); } From ed522fb4218940cb2f423909f611e4ad33a7e424 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 8 Aug 2026 10:34:18 -0700 Subject: [PATCH 07/12] feat(redis): make the GLIDE adapter stateless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 16 +- scripts/test-package.mjs | 20 +-- src/valkey-glide.ts | 253 ++++++++++++---------------- test/redis-real.integration.test.ts | 8 +- test/valkey-glide.test.ts | 166 +++++------------- 5 files changed, 168 insertions(+), 295 deletions(-) diff --git a/README.md b/README.md index 8125268..48a2707 100644 --- a/README.md +++ b/README.md @@ -378,27 +378,27 @@ const dialcache = new DialCache({ }); function shutdown(): void { - // After draining request-path calls and invalidations, release scripts before closing GLIDE. - // Detached shadow work is best-effort and has no drain handle. - redisClient.dispose(); + // After draining request-path calls and invalidations, close GLIDE; the + // adapter is stateless. Detached shadow work is best-effort and has no + // drain handle. glideClient.close(); } ``` Pass the same GLIDE 2.x module namespace that created the client. The adapter uses that namespace's `GlideClient` and `GlideClusterClient` identities, -`Batch` and `Script` constructors, and `Decoder.Bytes` without importing a -GLIDE runtime itself. The helper accepts a direct official client instance and +`Batch` and `ClusterBatch` constructors, and `Decoder.Bytes` without importing +a GLIDE runtime itself. The helper accepts a direct official client instance and fails during construction when the client came from another module instance or is hidden behind a forwarding wrapper, because it cannot safely infer that wrapper's topology. Custom wrappers can implement `DialCacheRedisClient` directly. -The application owns the complete Redis lifecycle. It creates and connects the underlying client and passes the semantic adapter to DialCache. During shutdown, stop starting DialCache-backed work and await every promise returned by a cached function, `getOrLoad()`, or `invalidateRemote()`, including calls still running fallbacks that may later write Redis. A read that crossed DialCache's wait deadline may still be active inside the client, so use client-native telemetry and shutdown controls to drain or terminate that work before disposing adapter-owned resources and closing the connection. DialCache only borrows `redis.client`; it has no close or drain method and never disposes or closes caller resources. +The application owns the complete Redis lifecycle. It creates and connects the underlying client and passes the semantic adapter to DialCache. During shutdown, stop starting DialCache-backed work and await every promise returned by a cached function, `getOrLoad()`, or `invalidateRemote()`, including calls still running fallbacks that may later write Redis. A read that crossed DialCache's wait deadline may still be active inside the client, so use client-native telemetry and shutdown controls to drain or terminate that work before closing the connection. DialCache only borrows `redis.client`; it has no close or drain method and never disposes or closes caller resources. Awaiting those public promises does not drain detached shadow work. Shadow scheduling and deadline timers are unreferenced and completion is not guaranteed during shutdown; Redis operations, source reads, serializers, and asynchronous telemetry already started by shadow work remain caller-owned and may still be active. Stop new work before closing their dependencies and accept that an in-flight shadow fill may have been dispatched even if its final outcome is lost during teardown. DialCache does not add a shutdown hook or keep the process alive to deliver best-effort outcomes. -The node-redis adapter owns no additional resources, so the application closes the underlying node-redis client after draining work. The GLIDE adapter owns one native `Script` handle for invalidation, but not the wrapped connection. After outstanding operations finish, call its idempotent `dispose()` before closing GLIDE as shown above; disposal while an adapter operation is in flight throws rather than releasing a live script. On GLIDE 2.0.0, releasing a `Script` handle has been observed to break other live handles for the same script source despite GLIDE's documented reference counting, so adapters sharing one GLIDE module namespace should be disposed together at shutdown, never swapped dispose-after-create. +Neither adapter owns additional resources: both dispatch their mutation scripts by source SHA1 and hold no native handles, so the application simply closes the underlying client after draining work. Applications that construct their own GLIDE `Script` objects should know that on GLIDE 2.0.0, releasing a handle has been observed to break other live handles for the same script source despite GLIDE's documented reference counting. Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. @@ -408,7 +408,7 @@ Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. -For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation uses GLIDE's native `Script` lifecycle directly. +For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation dispatches the same way on both adapters: `EVALSHA` by the script's source SHA1, recovered with `EVAL`. A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`, and must allow the client to issue `EVAL` — both adapters recover a flushed script cache by re-sending the stamp source, not via `SCRIPT LOAD`; verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 64e3103..f6b261a 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -567,11 +567,15 @@ import { createPrometheusDialCacheMetrics, type PrometheusMetricsOptions, } from "dialcache/prometheus"; +import { type DialCacheRedisClient } from "dialcache"; import { createValkeyGlideDialCacheClient, - type ValkeyGlideDialCacheClient, type ValkeyGlideRuntime, } from "dialcache/valkey-glide"; +// @ts-expect-error The stateless GLIDE adapter removed its dispose wrapper type. +import { type ValkeyGlideDialCacheClient } from "dialcache/valkey-glide"; +// @ts-expect-error The handle-free GLIDE adapter removed the Script handle type. +import { type ValkeyGlideScriptHandle } from "dialcache/valkey-glide"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "dialcache/node-redis"; import { Registry, type OpenMetricsContentType } from "prom-client"; @@ -584,7 +588,7 @@ const openMetricsRegistry = new Registry(); openMetricsRegistry.setContentType(Registry.OPENMETRICS_CONTENT_TYPE); const openMetricsAdapter = new PrometheusDialCacheMetrics({ registry: openMetricsRegistry, prefix: "open_" }); const registryIsRequired: {} extends Pick ? false : true = true; -const glideRedisClient: ValkeyGlideDialCacheClient | undefined = undefined; +const glideRedisClient: DialCacheRedisClient | undefined = undefined; const standaloneNodeRedisClient = createRedisClient({ scripts: dialcacheRedisScripts }); const clusterNodeRedisClient = createRedisCluster({ rootNodes: [{ url: "redis://127.0.0.1:6379" }], @@ -592,12 +596,12 @@ const clusterNodeRedisClient = createRedisCluster({ }); const standaloneNodeRedisAdapter = createNodeRedisDialCacheClient(standaloneNodeRedisClient); const clusterNodeRedisAdapter = createNodeRedisDialCacheClient(clusterNodeRedisClient); -const glideRuntime: ValkeyGlideRuntime = valkeyGlide; +const glideRuntime: ValkeyGlideRuntime = valkeyGlide; declare const standaloneGlideClient: valkeyGlide.GlideClient; declare const clusterGlideClient: valkeyGlide.GlideClusterClient; -const standaloneGlideAdapter = createValkeyGlideDialCacheClient(standaloneGlideClient, glideRuntime); -const clusterGlideAdapter = createValkeyGlideDialCacheClient(clusterGlideClient, glideRuntime); -// @ts-expect-error The caller's GLIDE runtime is required for native Script ownership. +const standaloneGlideAdapter: DialCacheRedisClient = createValkeyGlideDialCacheClient(standaloneGlideClient, glideRuntime); +const clusterGlideAdapter: DialCacheRedisClient = createValkeyGlideDialCacheClient(clusterGlideClient, glideRuntime); +// @ts-expect-error The caller's GLIDE runtime is required for native Batch ownership. createValkeyGlideDialCacheClient(standaloneGlideClient); const dogStatsD = new StatsD({ mock: true }); const compatibleDogStatsD: DatadogDogStatsDClient = dogStatsD; @@ -1300,8 +1304,6 @@ try { if (!(error instanceof root.DialCacheRedisProtocolError)) { throw new Error("The GLIDE protocol error does not match the root ESM export"); } -} finally { - adapter.dispose(); }`, ], { cwd: workspace }, @@ -1360,8 +1362,6 @@ void (async () => { if (!(error instanceof root.DialCacheRedisProtocolError)) { throw new Error("The GLIDE protocol error does not match the root CommonJS export"); } - } finally { - adapter.dispose(); } })();`, ], diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index fa739af..9f7a616 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -20,22 +20,23 @@ import { DialCacheRedisPayloadError, type DialCacheRedisClient } from "./redis-c type ValkeyGlideString = string | Buffer; -// Redis caches EVAL'd sources under sha1(source), so this digest is by -// definition the one the batched EVALSHA must use and the one the EVAL -// fallback repopulates. +// Redis caches EVAL'd sources under sha1(source), so these digests are by +// definition the ones the EVALSHA dispatches must use and the ones the EVAL +// recoveries repopulate. const WRITE_TRACKED_STAMP_SHA1 = createHash("sha1").update(WRITE_TRACKED_STAMP_SCRIPT).digest("hex"); +const INVALIDATE_CACHE_SHA1 = createHash("sha1").update(INVALIDATE_CACHE_SCRIPT).digest("hex"); + +// GLIDE maps the server's NOSCRIPT reply to its own NoScriptError wording. +function isNoScriptError(error: Error): boolean { + return error.message.includes("NOSCRIPT") || error.message.includes("NoScriptError"); +} interface ValkeyGlideBatch { customCommand(args: ValkeyGlideString[]): ValkeyGlideBatch; mget(keys: ValkeyGlideString[]): ValkeyGlideBatch; } -export interface ValkeyGlideScriptHandle { - /** Release the native GLIDE script registration. */ - release(): void; -} - -export interface ValkeyGlideScriptingClient { +export interface ValkeyGlideScriptingClient { customCommand( args: ValkeyGlideString[], options: { @@ -55,21 +56,13 @@ export interface ValkeyGlideScriptingClient { route?: { type: "primarySlotKey"; key: string }; }, ): Promise; - invokeScript( - script: TScript, - options: { - keys: ValkeyGlideString[]; - args: ValkeyGlideString[]; - decoder: TDecoder; - }, - ): Promise; } interface ValkeyGlideClientIdentity { readonly [Symbol.hasInstance]: (value: unknown) => boolean; } -export interface ValkeyGlideRuntime { +export interface ValkeyGlideRuntime { /** The Batch constructor exported by the same GLIDE module instance as the client. */ readonly Batch: new (isAtomic: boolean) => ValkeyGlideBatch; /** The ClusterBatch constructor exported by the same GLIDE module instance as the client. */ @@ -78,8 +71,6 @@ export interface ValkeyGlideRuntime TScript; /** The Decoder enum exported by the same GLIDE module instance as the client. */ readonly Decoder: { readonly Bytes: TDecoder; @@ -101,9 +92,9 @@ function matchesValkeyGlideIdentity( return (identity as ValkeyGlideClientIdentity)[Symbol.hasInstance](client); } -function classifyValkeyGlideClient( - client: ValkeyGlideScriptingClient, - glide: ValkeyGlideRuntime, +function classifyValkeyGlideClient( + client: ValkeyGlideScriptingClient, + glide: ValkeyGlideRuntime, ): "standalone" | "cluster" { const isStandalone = matchesValkeyGlideIdentity(glide.GlideClient, "GlideClient", client); const isCluster = matchesValkeyGlideIdentity( @@ -125,90 +116,62 @@ function classifyValkeyGlideClient( return isCluster ? "cluster" : "standalone"; } -export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { - /** Release the adapter-owned invalidation Script handle. Does not close the wrapped GLIDE client. */ - dispose(): void; -} - /** - * Wrap a caller-owned GLIDE connection. The returned adapter owns only its - * invalidation Script handle and preserves the connection's - * `requestTimeout`. On GLIDE 2.0.0, releasing any Script handle for a source - * has been observed to break other live handles for that same source despite - * the documented reference counting, so adapters sharing one GLIDE module - * namespace must be disposed together after draining, never swapped - * dispose-after-create. Pass the same GLIDE module namespace used to create the - * client so native Batch and Script objects come from that client's runtime. + * Wrap a caller-owned GLIDE connection. The returned adapter is stateless — + * it owns no native handles and needs no disposal — and preserves the + * connection's `requestTimeout`. Pass the same GLIDE module namespace used to + * create the client so native Batch objects come from that client's runtime. * Only direct GlideClient and GlideClusterClient instances are accepted; - * wrappers should implement DialCacheRedisClient directly. - * Callers dispose the handle after draining work, then close GLIDE. A request + * wrappers should implement DialCacheRedisClient directly. A request * timeout bounds client waiting but is not server-side command cancellation. * GLIDE's current command API has no per-invocation signal, so DialCache's core * read deadline may return before this adapter's invocation settles. Tracked * standalone reads use a one-command primary batch, while tracked cluster * reads route MGET explicitly to the slot primary, so replica lag cannot hide - * an invalidation watermark. Tracked writes batch a native placeholder SET - * with an EVALSHA of the stamp script — cluster write batches route to the - * slot primary — and recovers a flushed script cache by re-sending the stamp - * as EVAL with its source, which the server caches under the same SHA1, so - * the first tracked write against a cold script cache pays one extra round - * trip. Batches are deliberately + * an invalidation watermark. Both mutation scripts dispatch as EVALSHA by + * their source SHA1 and recover a flushed script cache by re-sending the + * source as EVAL — which the server caches under that same SHA1 — so the + * first mutation against a cold script cache pays one extra round trip. + * Tracked writes batch a native placeholder SET with the stamp EVALSHA; + * cluster write batches route to the slot primary. Batches are deliberately * non-atomic: MGET and SET are atomic themselves, an interleaved stamp is * safe by design, and MULTI/EXEC would consume caller-owned WATCH state. */ -export function createValkeyGlideDialCacheClient( - client: ValkeyGlideScriptingClient, - glide: ValkeyGlideRuntime, -): ValkeyGlideDialCacheClient { +export function createValkeyGlideDialCacheClient( + client: ValkeyGlideScriptingClient, + glide: ValkeyGlideRuntime, +): DialCacheRedisClient { if (typeof glide.Batch !== "function" || typeof glide.ClusterBatch !== "function") { throw new Error( "Valkey GLIDE DialCache requires @valkey/valkey-glide >=2.0.0 with Batch and ClusterBatch constructors", ); } const isCluster = classifyValkeyGlideClient(client, glide) === "cluster"; - const invalidateScript: TScript = new glide.Script(INVALIDATE_CACHE_SCRIPT); - let disposed = false; - let activeOperations = 0; - - const run = async (operation: () => Promise): Promise => { - if (disposed) { - throw new Error("Valkey GLIDE DialCache client is disposed"); - } - activeOperations += 1; - try { - return await operation(); - } finally { - activeOperations -= 1; - } - }; return { async read({ valueKey, watermarkKey }) { if (watermarkKey === undefined) { - const raw = await run( - () => client.get(valueKey, { decoder: glide.Decoder.Bytes }), - ); + const raw = await client.get(valueKey, { decoder: glide.Decoder.Bytes }); return decodeRedisFrame(raw); } - const pair = isCluster - ? await run( - () => client.customCommand( - ["MGET", valueKey, watermarkKey], - { - decoder: glide.Decoder.Bytes, - route: { type: "primarySlotKey", key: valueKey }, - }, - ), - ) - : await run(async () => { - const batch = new glide.Batch(false).mget([valueKey, watermarkKey]); - const raw = await client.exec(batch, true, { decoder: glide.Decoder.Bytes }); - if (!Array.isArray(raw) || raw.length !== 1) { - throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload reply"); - } - return raw[0]; - }); + let pair: unknown; + if (isCluster) { + pair = await client.customCommand( + ["MGET", valueKey, watermarkKey], + { + decoder: glide.Decoder.Bytes, + route: { type: "primarySlotKey", key: valueKey }, + }, + ); + } else { + const batch = new glide.Batch(false).mget([valueKey, watermarkKey]); + const raw = await client.exec(batch, true, { decoder: glide.Decoder.Bytes }); + if (!Array.isArray(raw) || raw.length !== 1) { + throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload reply"); + } + pair = raw[0]; + } if (!Array.isArray(pair) || pair.length !== 2) { throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload reply"); } @@ -226,74 +189,78 @@ export function createValkeyGlideDialCacheClient client.customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)], execOptions), - )); + validateRedisSetReply( + await client.customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)], execOptions), + ); return true; } const { frame, nonce } = encodeTrackedRedisPlaceholder(value); const stampArgs: ValkeyGlideString[] = [String(cacheTtlMs), nonce]; - // One dispose-guarded operation covering the batch and its NOSCRIPT - // recovery, so in-flight accounting spans the whole logical write. - return await run(async () => { - const batch = (isCluster ? new glide.ClusterBatch(false) : new glide.Batch(false)) - .customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)]) - .customCommand([ - "EVALSHA", - WRITE_TRACKED_STAMP_SHA1, - "2", - valueKey, - watermarkKey, - ...stampArgs, - ]); - const replies = await client.exec(batch, false, execOptions); - if (!Array.isArray(replies) || replies.length !== 2) { - throw new DialCacheRedisPayloadError("Invalid DialCache Redis write reply"); - } - const [setReply, rawStamp] = replies as [unknown, unknown]; - // A failed SET is the write outcome even when the stamp settled. - if (setReply instanceof Error) { - throw setReply; - } - validateRedisSetReply(setReply); - let stampReply: unknown = rawStamp; - if (rawStamp instanceof Error) { - // GLIDE maps the server's NOSCRIPT reply to its own NoScriptError wording. - if (!rawStamp.message.includes("NOSCRIPT") && !rawStamp.message.includes("NoScriptError")) { - throw rawStamp; - } - // Only NOSCRIPT proves the batched stamp never executed, so only it - // is retried: after any other error a re-run could find its own - // frame already promoted and misreport the write as a lost - // placeholder. EVAL resends the source, the server caches it under - // the same SHA1 the batched EVALSHA uses, and the nonce keeps the - // late stamp paired to this write. - stampReply = await client.customCommand( - ["EVAL", WRITE_TRACKED_STAMP_SCRIPT, "2", valueKey, watermarkKey, ...stampArgs], - execOptions, - ); + const batch = (isCluster ? new glide.ClusterBatch(false) : new glide.Batch(false)) + .customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)]) + .customCommand([ + "EVALSHA", + WRITE_TRACKED_STAMP_SHA1, + "2", + valueKey, + watermarkKey, + ...stampArgs, + ]); + const replies = await client.exec(batch, false, execOptions); + if (!Array.isArray(replies) || replies.length !== 2) { + throw new DialCacheRedisPayloadError("Invalid DialCache Redis write reply"); + } + const [setReply, rawStamp] = replies as [unknown, unknown]; + // A failed SET is the write outcome even when the stamp settled. + if (setReply instanceof Error) { + throw setReply; + } + validateRedisSetReply(setReply); + let stampReply: unknown = rawStamp; + if (rawStamp instanceof Error) { + if (!isNoScriptError(rawStamp)) { + throw rawStamp; } - return resolveTrackedRedisWriteReply(stampReply); - }); + // Only NOSCRIPT proves the batched stamp never executed, so only it + // is retried: after any other error a re-run could find its own + // frame already promoted and misreport the write as a lost + // placeholder. EVAL resends the source, the server caches it under + // the same SHA1 the batched EVALSHA uses, and the nonce keeps the + // late stamp paired to this write. + stampReply = await client.customCommand( + ["EVAL", WRITE_TRACKED_STAMP_SCRIPT, "2", valueKey, watermarkKey, ...stampArgs], + execOptions, + ); + } + return resolveTrackedRedisWriteReply(stampReply); }, async invalidate({ watermarkKey, futureBufferMs }) { - const raw = await run(() => client.invokeScript(invalidateScript, { - keys: [watermarkKey], - args: [String(futureBufferMs)], - decoder: glide.Decoder.Bytes, - })); - validateRedisScriptInvalidationReply(raw); - }, - dispose() { - if (disposed) { - return; - } - if (activeOperations > 0) { - throw new Error("Cannot dispose Valkey GLIDE DialCache client while operations are in flight"); + const invalidateArgs: ValkeyGlideString[] = [String(futureBufferMs)]; + const options: { + decoder: TDecoder; + route?: { type: "primarySlotKey"; key: string }; + } = isCluster + ? { decoder: glide.Decoder.Bytes, route: { type: "primarySlotKey", key: watermarkKey } } + : { decoder: glide.Decoder.Bytes }; + let raw: unknown; + try { + raw = await client.customCommand( + ["EVALSHA", INVALIDATE_CACHE_SHA1, "1", watermarkKey, ...invalidateArgs], + options, + ); + } catch (error) { + if (!(error instanceof Error) || !isNoScriptError(error)) { + throw error; + } + // NOSCRIPT proves the invalidation never executed; EVAL re-sends the + // source, which the server caches under the same SHA1 for later calls. + raw = await client.customCommand( + ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", watermarkKey, ...invalidateArgs], + options, + ); } - disposed = true; - invalidateScript.release(); + validateRedisScriptInvalidationReply(raw); }, }; } diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index 6ce0a50..8da7402 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -17,10 +17,7 @@ import { } from "../src/internal/redis-scripts.js"; import { encodeTrackedRedisPlaceholder } from "../src/redis-protocol.js"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../src/node-redis.js"; -import { - createValkeyGlideDialCacheClient, - type ValkeyGlideDialCacheClient, -} from "../src/valkey-glide.js"; +import { createValkeyGlideDialCacheClient } from "../src/valkey-glide.js"; const engines = [ { name: "Redis 6.2", image: "redis:6.2-alpine" }, @@ -81,7 +78,7 @@ function createNodeRedisHarness(client: NodeRedisTestClient): RedisAdapterHarnes } function createValkeyGlideHarness(client: valkeyGlide.GlideClient): RedisAdapterHarness { - const adapter: ValkeyGlideDialCacheClient = createValkeyGlideDialCacheClient(client, valkeyGlide); + const adapter = createValkeyGlideDialCacheClient(client, valkeyGlide); const rawScripts = { stamp: new valkeyGlide.Script(WRITE_TRACKED_STAMP_SCRIPT), invalidate: new valkeyGlide.Script(INVALIDATE_CACHE_SCRIPT), @@ -115,7 +112,6 @@ function createValkeyGlideHarness(client: valkeyGlide.GlideClient): RedisAdapter ), }, dispose() { - adapter.dispose(); for (const script of Object.values(rawScripts)) { script.release(); } diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index 38c9310..5d47f2d 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -27,20 +27,11 @@ const INVALID_WRITE_REPLIES: readonly unknown[] = [ const INVALID_INVALIDATION_REPLIES: readonly unknown[] = [0, 2, ...INVALID_WRITE_REPLIES]; const decoderBytes = Symbol("bytes"); -const scriptInstances: MockScript[] = []; const batchInstances: MockBatch[] = []; const clusterBatchInstances: MockClusterBatch[] = []; const standaloneClients = new WeakSet(); const clusterClients = new WeakSet(); -class MockScript { - readonly release = vi.fn(); - - constructor(readonly code: string) { - scriptInstances.push(this); - } -} - class MockBatch { readonly commands: Array> = []; readonly mget = vi.fn((keys: Array) => { @@ -82,15 +73,8 @@ const mockGlide = { Decoder: { Bytes: decoderBytes }, GlideClient: mockClientIdentity(standaloneClients), GlideClusterClient: mockClientIdentity(clusterClients), - Script: MockScript, }; -interface InvokeScriptOptions { - keys: Array; - args: Array; - decoder: typeof decoderBytes; -} - function createFakeClient(replies: unknown[]) { const nextReply = async (): Promise => replies.shift(); const client = { @@ -110,7 +94,6 @@ function createFakeClient(replies: unknown[]) { route?: { type: "primarySlotKey"; key: string }; }, ) => nextReply()), - invokeScript: vi.fn(async (_script: MockScript, _options: InvokeScriptOptions) => nextReply()), }; return client; } @@ -153,7 +136,6 @@ async function expectProtocolError(operation: Promise, message: string) describe("Valkey GLIDE adapter", () => { beforeEach(() => { - scriptInstances.length = 0; batchInstances.length = 0; clusterBatchInstances.length = 0; }); @@ -193,8 +175,7 @@ describe("Valkey GLIDE adapter", () => { true, { decoder: decoderBytes }, ); - expect(client.invokeScript).not.toHaveBeenCalled(); - expect(scriptInstances).toHaveLength(1); + expect(client.customCommand).not.toHaveBeenCalled(); }); it("routes tracked cluster MGET directly to the slot primary", async () => { @@ -228,7 +209,6 @@ describe("Valkey GLIDE adapter", () => { customCommand: directClient.customCommand, exec: directClient.exec, get: directClient.get, - invokeScript: directClient.invokeScript, }; expect( @@ -237,7 +217,6 @@ describe("Valkey GLIDE adapter", () => { "Valkey GLIDE DialCache requires a direct GlideClient or GlideClusterClient instance " + "from the supplied runtime; wrappers should implement DialCacheRedisClient directly", ); - expect(scriptInstances).toHaveLength(0); }); it("rejects a direct client from a different GLIDE module instance", () => { @@ -254,7 +233,6 @@ describe("Valkey GLIDE adapter", () => { "Valkey GLIDE DialCache requires a direct GlideClient or GlideClusterClient instance " + "from the supplied runtime; wrappers should implement DialCacheRedisClient directly", ); - expect(scriptInstances).toHaveLength(0); }); it("rejects an ambiguous client identity before allocating scripts", () => { @@ -266,7 +244,6 @@ describe("Valkey GLIDE adapter", () => { ).toThrow( "Invalid Valkey GLIDE runtime: client matches both GlideClient and GlideClusterClient", ); - expect(scriptInstances).toHaveLength(0); }); it("requires GLIDE 2.x Batch support before allocating scripts", () => { @@ -287,7 +264,6 @@ describe("Valkey GLIDE adapter", () => { "Valkey GLIDE DialCache requires @valkey/valkey-glide >=2.0.0 with Batch and ClusterBatch constructors", ); } - expect(scriptInstances).toHaveLength(0); }); it("preserves GLIDE invocation options when given a core read context", async () => { @@ -304,7 +280,6 @@ describe("Valkey GLIDE adapter", () => { "plain:value", { decoder: decoderBytes }, ); - adapter.dispose(); }); it("writes untracked SETs directly and tracked pairs through a batch", async () => { @@ -333,7 +308,6 @@ describe("Valkey GLIDE adapter", () => { adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 100 }), ).resolves.toBeUndefined(); - expect(client.customCommand).toHaveBeenCalledTimes(1); const [untrackedSet, untrackedOptions] = client.customCommand.mock.calls[0] ?? [[], undefined]; expect(untrackedSet[0]).toBe("SET"); @@ -375,10 +349,18 @@ describe("Valkey GLIDE adapter", () => { expect(client.exec).toHaveBeenCalledTimes(1); expect(client.exec).toHaveBeenCalledWith(trackedBatch, false, { decoder: decoderBytes }); - expect(client.invokeScript).toHaveBeenCalledTimes(1); - expect(client.invokeScript).toHaveBeenCalledWith( - expect.any(MockScript), - { keys: ["tracked:{id}:watermark"], args: ["100"], decoder: decoderBytes }, + // Call 1 is the untracked SET; invalidation dispatches by its source SHA1. + expect(client.customCommand).toHaveBeenCalledTimes(2); + expect(client.customCommand).toHaveBeenNthCalledWith( + 2, + [ + "EVALSHA", + createHash("sha1").update(INVALIDATE_CACHE_SCRIPT).digest("hex"), + "1", + "tracked:{id}:watermark", + "100", + ], + { decoder: decoderBytes }, ); }); @@ -396,11 +378,10 @@ describe("Valkey GLIDE adapter", () => { await expect(write).rejects.toBeInstanceOf(DialCacheRedisPlaceholderLostError); // Reply 2 is a settled outcome, not a recovery trigger. expect(client.customCommand).not.toHaveBeenCalled(); - adapter.dispose(); }); - it("routes cluster writes to the slot primary", async () => { - const client = fakeClusterClient("OK", ["OK", 1]); + it("routes cluster writes and invalidations to the slot primary", async () => { + const client = fakeClusterClient("OK", ["OK", 1], 1); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); await expect( @@ -414,6 +395,9 @@ describe("Valkey GLIDE adapter", () => { value: "tracked", }), ).resolves.toBe(true); + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 25 }), + ).resolves.toBeUndefined(); const [, untrackedOptions] = client.customCommand.mock.calls[0] ?? [[], undefined]; expect(untrackedOptions).toEqual({ @@ -425,6 +409,11 @@ describe("Valkey GLIDE adapter", () => { decoder: decoderBytes, route: { type: "primarySlotKey", key: "tracked:{id}:value" }, }); + const [, invalidateOptions] = client.customCommand.mock.calls[1] ?? [[], undefined]; + expect(invalidateOptions).toEqual({ + decoder: decoderBytes, + route: { type: "primarySlotKey", key: "tracked:{id}:watermark" }, + }); }); it("routes the EVAL recovery to the slot primary on cluster", async () => { @@ -490,8 +479,6 @@ describe("Valkey GLIDE adapter", () => { ], { decoder: decoderBytes }, ); - expect(client.invokeScript).not.toHaveBeenCalled(); - adapter.dispose(); } }); @@ -526,7 +513,6 @@ describe("Valkey GLIDE adapter", () => { expect(trackedSet?.[4]).toBe("1001"); expect(stamp?.[5]).toBe("1001"); expect(Buffer.isBuffer(stamp?.[6])).toBe(true); - adapter.dispose(); }); it("surfaces batched SET and stamp command errors", async () => { @@ -540,7 +526,6 @@ describe("Valkey GLIDE adapter", () => { value: "tracked", })).rejects.toBe(setFailure); expect(setClient.customCommand).not.toHaveBeenCalled(); - setAdapter.dispose(); const stampFailure = new Error("ERR invalid DialCache watermark"); const stampClient = fakeClient([Buffer.from("OK"), stampFailure]); @@ -552,7 +537,6 @@ describe("Valkey GLIDE adapter", () => { value: "tracked", })).rejects.toBe(stampFailure); expect(stampClient.customCommand).not.toHaveBeenCalled(); - stampAdapter.dispose(); }); it("validates write batch envelopes and SET replies", async () => { @@ -564,7 +548,6 @@ describe("Valkey GLIDE adapter", () => { cacheTtlMs: 1_000, value: "tracked", })).rejects.toBeInstanceOf(DialCacheRedisPayloadError); - envelopeAdapter.dispose(); const setReplyClient = fakeClient("QUEUED"); const setReplyAdapter = createValkeyGlideDialCacheClient(setReplyClient, mockGlide); @@ -572,7 +555,6 @@ describe("Valkey GLIDE adapter", () => { Promise.resolve(setReplyAdapter.write({ valueKey: "plain:value", cacheTtlMs: 1_000, value: "plain" })), "Invalid DialCache Redis SET reply; expected OK", ); - setReplyAdapter.dispose(); // A bad SET reply wins over a failing stamp, matching the write contract. const combinedClient = fakeClient(["QUEUED", new Error("ERR invalid DialCache watermark")]); @@ -586,7 +568,6 @@ describe("Valkey GLIDE adapter", () => { })), "Invalid DialCache Redis SET reply; expected OK", ); - combinedAdapter.dispose(); }); it("rejects malformed native read and mutation script replies", async () => { @@ -645,7 +626,6 @@ describe("Valkey GLIDE adapter", () => { })), writeMessage, ); - tracked.dispose(); } for (const reply of INVALID_INVALIDATION_REPLIES) { @@ -657,103 +637,36 @@ describe("Valkey GLIDE adapter", () => { })), invalidationMessage, ); - adapter.dispose(); - } - }); - - it("releases every script exactly once and rejects later operations", async () => { - const client = fakeClient(); - const adapter = createValkeyGlideDialCacheClient(client, mockGlide); - - adapter.dispose(); - adapter.dispose(); - - expect(scriptInstances).toHaveLength(1); - expect(scriptInstances[0]?.code).toBe(INVALIDATE_CACHE_SCRIPT); - for (const script of scriptInstances) { - expect(script.release).toHaveBeenCalledTimes(1); } - await expect(adapter.read({ valueKey: "disposed" })).rejects.toThrow("Valkey GLIDE DialCache client is disposed"); - expect(client.get).not.toHaveBeenCalled(); - expect(client.exec).not.toHaveBeenCalled(); - expect(client.invokeScript).not.toHaveBeenCalled(); - }); - - it("does not release scripts while a native read is in flight", async () => { - let resolveRead: ((value: Buffer) => void) | undefined; - const client = fakeClient(); - client.get.mockImplementationOnce( - async () => await new Promise((resolve) => { - resolveRead = resolve; - }), - ); - const adapter = createValkeyGlideDialCacheClient(client, mockGlide); - - const read = adapter.read({ valueKey: "in-flight" }); - expect(() => adapter.dispose()).toThrow( - "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", - ); - expect(scriptInstances.every((script) => script.release.mock.calls.length === 0)).toBe(true); - - resolveRead?.(redisFrame("done")); - await expect(read).resolves.toBe("done"); - adapter.dispose(); - expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); }); - it("stays busy across the batch and its NOSCRIPT recovery so dispose cannot race", async () => { - const client = fakeClient(); - let resolveExec: ((value: unknown) => void) | undefined; - let resolveFallback: ((value: number) => void) | undefined; - client.exec.mockImplementationOnce( - async () => await new Promise((resolve) => { - resolveExec = resolve; - }), - ); - client.customCommand.mockImplementationOnce( - async () => await new Promise((resolve) => { - resolveFallback = resolve; - }), + it("recovers a flushed invalidation script with EVAL by source", async () => { + const client = fakeClient(1); + client.customCommand.mockRejectedValueOnce( + new Error("An error was signalled by the server: - NoScriptError: No matching script."), ); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); - const write = adapter.write({ - valueKey: "tracked:{id}:value", - watermarkKey: "tracked:{id}:watermark", - cacheTtlMs: 1_000, - value: "tracked", - }); - expect(() => adapter.dispose()).toThrow( - "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", - ); + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).resolves.toBeUndefined(); - resolveExec?.([Buffer.from("OK"), new Error("NOSCRIPT No matching script. Please use EVAL.")]); - await vi.waitFor(() => expect(client.customCommand).toHaveBeenCalledTimes(1)); - // The EVAL recovery is still pending: the write must stay in flight. - expect(() => adapter.dispose()).toThrow( - "Cannot dispose Valkey GLIDE DialCache client while operations are in flight", + expect(client.customCommand).toHaveBeenNthCalledWith( + 2, + ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", "tracked:{id}:watermark", "50"], + { decoder: decoderBytes }, ); - expect(scriptInstances.every((script) => script.release.mock.calls.length === 0)).toBe(true); - - resolveFallback?.(1); - await expect(write).resolves.toBe(true); - adapter.dispose(); - expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); }); - it("uses Batch, Script, and Decoder from the supplied GLIDE module instance", async () => { + it("uses Batch and Decoder from the supplied GLIDE module instance", async () => { class OtherBatch { mget(): this { return this; } } - class OtherScript { - readonly release = vi.fn(); - } const otherGlide = { Batch: OtherBatch, Decoder: { Bytes: Symbol("other-bytes") }, - Script: OtherScript, }; const client = fakeClient( [[redisFrame("tracked"), Buffer.from("0")]], @@ -776,19 +689,16 @@ describe("Valkey GLIDE adapter", () => { const [readBatch, , readOptions] = client.exec.mock.calls[0] ?? []; const [writeBatch, , writeOptions] = client.exec.mock.calls[1] ?? []; - const [script, scriptOptions] = client.invokeScript.mock.calls[0] ?? []; + const [, invalidateOptions] = client.customCommand.mock.calls[0] ?? []; expect(readBatch).toBeInstanceOf(MockBatch); expect(readBatch).not.toBeInstanceOf(otherGlide.Batch); expect(writeBatch).toBeInstanceOf(MockBatch); expect(writeBatch).not.toBeInstanceOf(otherGlide.Batch); - expect(script).toBeInstanceOf(MockScript); - expect(script).not.toBeInstanceOf(otherGlide.Script); expect(readOptions?.decoder).toBe(mockGlide.Decoder.Bytes); expect(readOptions?.decoder).not.toBe(otherGlide.Decoder.Bytes); expect(writeOptions?.decoder).toBe(mockGlide.Decoder.Bytes); expect(writeOptions?.decoder).not.toBe(otherGlide.Decoder.Bytes); - expect(scriptOptions?.decoder).toBe(mockGlide.Decoder.Bytes); - expect(scriptOptions?.decoder).not.toBe(otherGlide.Decoder.Bytes); - adapter.dispose(); + expect(invalidateOptions?.decoder).toBe(mockGlide.Decoder.Bytes); + expect(invalidateOptions?.decoder).not.toBe(otherGlide.Decoder.Bytes); }); }); From d1c8229cdc1d30499d3c472d0076308207d21128 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 8 Aug 2026 12:10:30 -0700 Subject: [PATCH 08/12] fix(redis): harden script recovery and release notes after the safety review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 4 +- release.config.mjs | 5 +- src/redis-protocol.ts | 1 + src/valkey-glide.ts | 55 +++++++++-------- test/redis-cluster.integration.test.ts | 83 ++++++++++++++++++++++++++ test/valkey-glide.test.ts | 52 +++++++++++----- 6 files changed, 157 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 48a2707..f29f779 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,7 @@ Neither adapter owns additional resources: both dispatch their mutation scripts Reads use native `GET` for untracked entries and one atomic `MGET` for each tracked value-and-watermark pair. The adapters validate and decode the returned frame in the Node process. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. -Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` of a version-0 placeholder frame carrying a fresh per-write nonce, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, promotes exactly the placeholder carrying its nonce to a served frame with Redis server time, and maintains the watermark TTL. A placeholder is unreadable on both read paths until promoted, so an interleaved or lost stamp degrades to a miss that expires with the value TTL rather than partial state — including briefly blanking a previously readable key the write replaces. The nonce means the stamp can never revive a frame it does not own: if its paired `SET` was rejected, overwritten, or expired, the stamp reports the placeholder gone and the write fails with `DialCacheRedisPlaceholderLostError` as a `cache_write` error instead of publishing another write's leftovers. Losing a same-key write race is one such outcome, so `cache_write` carries a benign, self-healing floor that concentrates on hot tracked keys at TTL expiry — size write-error alerts for it. The `cache_write` metric itself stays one bounded counter; the error's class and name distinguish the lost-placeholder case in logs and in application `catch` blocks. Each occurrence also emits one warn through the configured logger (the default is `console`), so fleets expecting hot-key write contention should supply a logger that rate-limits or filters that class. A `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. +Writes are native too, so the payload never crosses the Redis-to-Lua boundary. An untracked write is one `SET` of a client-encoded frame. A tracked write pipelines two ordered commands on one connection: a `SET` of a version-0 placeholder frame carrying a fresh per-write nonce, then the small payload-free `WRITE_TRACKED_STAMP_SCRIPT`, which fences against the watermark, promotes exactly the placeholder carrying its nonce to a served frame with Redis server time, and maintains the watermark TTL. A placeholder is unreadable on both read paths until promoted, so an interleaved or lost stamp degrades to a miss that expires with the value TTL rather than partial state — including briefly blanking a previously readable key the write replaces. The nonce means the stamp can never revive a frame it does not own: if its paired `SET` was rejected, overwritten, or expired, the stamp reports the placeholder gone and the write fails with `DialCacheRedisPlaceholderLostError` as a `cache_write` error instead of publishing another write's leftovers. Losing a same-key write race is one such outcome, so `cache_write` carries a benign, self-healing floor that concentrates on hot tracked keys at TTL expiry — size write-error alerts for it. The `cache_write` metric itself stays one bounded counter; the error's class and name distinguish the lost-placeholder case in logs, and in the `catch` blocks of code that calls an adapter's `write()` directly — DialCache's own request paths absorb it fail-open rather than rethrowing to callers. Each occurrence also emits one warn through the configured logger (the default is `console`), so fleets expecting hot-key write contention should supply a logger that rate-limits or filters that class. A `SET` failure is the write's outcome even when the stamp settled. The pair is deliberately not a `MULTI`/`EXEC` transaction, which would consume caller-owned `WATCH` state. Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces `WRONGTYPE`; tracked `MGET` represents a wrong-type member as a missing value. A wrong-type tracked value is therefore a clean miss and may be replaced with a valid DialCache frame after the fallback succeeds, while a wrong-type watermark prevents the tracked write from succeeding: its paired `SET` still lands, leaving only an unreadable placeholder until expiry or a later successful write. @@ -410,7 +410,7 @@ Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an ex For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation dispatches the same way on both adapters: `EVALSHA` by the script's source SHA1, recovered with `EVAL`. -A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must also allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`, and must allow the client to issue `EVAL` — both adapters recover a flushed script cache by re-sending the stamp source, not via `SCRIPT LOAD`; verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. +A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must allow the client to issue `EVALSHA` (the steady-state dispatch for both mutation scripts) and `EVAL` (both adapters recover a flushed script cache by re-sending script sources, never via `SCRIPT LOAD`), and must allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`; verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. #### Remote read deadlines and async liveness diff --git a/release.config.mjs b/release.config.mjs index 56e6794..1ae9d5d 100644 --- a/release.config.mjs +++ b/release.config.mjs @@ -10,7 +10,10 @@ export default { // A version PR records the selected version without selecting a new // release itself. Earlier commits still determine the release type. { type: "release", release: false }, - { breaking: true, release: "major" }, + // Pre-1.0 MVP policy: breaking changes release as minors so their + // BREAKING CHANGE footers still drive full release notes without + // forcing 1.0.0. Restore "major" here when cutting 1.0.0. + { breaking: true, release: "minor" }, { type: "feat", release: "minor" }, { type: "fix", release: "patch" }, { type: "perf", release: "patch" }, diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index 12dba2b..a4cc18a 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -11,5 +11,6 @@ export { } from "./internal/redis-payload.js"; export { resolveTrackedRedisWriteReply, + validateRedisScriptInvalidationReply, validateRedisSetReply, } from "./internal/redis-script-reply.js"; diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 9f7a616..5723ef4 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -26,9 +26,10 @@ type ValkeyGlideString = string | Buffer; const WRITE_TRACKED_STAMP_SHA1 = createHash("sha1").update(WRITE_TRACKED_STAMP_SCRIPT).digest("hex"); const INVALIDATE_CACHE_SHA1 = createHash("sha1").update(INVALIDATE_CACHE_SCRIPT).digest("hex"); -// GLIDE maps the server's NOSCRIPT reply to its own NoScriptError wording. +// Matches the server's raw NOSCRIPT reply and GLIDE's mapped NoScriptError +// wording, case-insensitively so message-format drift cannot blind it. function isNoScriptError(error: Error): boolean { - return error.message.includes("NOSCRIPT") || error.message.includes("NoScriptError"); + return error.message.toLowerCase().includes("noscript"); } interface ValkeyGlideBatch { @@ -147,6 +148,14 @@ export function createValkeyGlideDialCacheClient( ); } const isCluster = classifyValkeyGlideClient(client, glide) === "cluster"; + // Keyed dispatch options: cluster commands pin the slot primary; standalone + // commands carry only the byte decoder. + const keyedOptions = (key: string): { + decoder: TDecoder; + route?: { type: "primarySlotKey"; key: string }; + } => isCluster + ? { decoder: glide.Decoder.Bytes, route: { type: "primarySlotKey", key } } + : { decoder: glide.Decoder.Bytes }; return { async read({ valueKey, watermarkKey }) { @@ -159,10 +168,7 @@ export function createValkeyGlideDialCacheClient( if (isCluster) { pair = await client.customCommand( ["MGET", valueKey, watermarkKey], - { - decoder: glide.Decoder.Bytes, - route: { type: "primarySlotKey", key: valueKey }, - }, + keyedOptions(valueKey), ); } else { const batch = new glide.Batch(false).mget([valueKey, watermarkKey]); @@ -180,12 +186,7 @@ export function createValkeyGlideDialCacheClient( async write(request) { const { valueKey, watermarkKey, value } = request; const cacheTtlMs = ceilSupportedCacheTtlMs(request.cacheTtlMs); - const execOptions: { - decoder: TDecoder; - route?: { type: "primarySlotKey"; key: string }; - } = isCluster - ? { decoder: glide.Decoder.Bytes, route: { type: "primarySlotKey", key: valueKey } } - : { decoder: glide.Decoder.Bytes }; + const execOptions = keyedOptions(valueKey); if (watermarkKey === undefined) { const frame = encodeRedisFrame(value, Date.now()); @@ -237,12 +238,7 @@ export function createValkeyGlideDialCacheClient( }, async invalidate({ watermarkKey, futureBufferMs }) { const invalidateArgs: ValkeyGlideString[] = [String(futureBufferMs)]; - const options: { - decoder: TDecoder; - route?: { type: "primarySlotKey"; key: string }; - } = isCluster - ? { decoder: glide.Decoder.Bytes, route: { type: "primarySlotKey", key: watermarkKey } } - : { decoder: glide.Decoder.Bytes }; + const options = keyedOptions(watermarkKey); let raw: unknown; try { raw = await client.customCommand( @@ -250,15 +246,22 @@ export function createValkeyGlideDialCacheClient( options, ); } catch (error) { - if (!(error instanceof Error) || !isNoScriptError(error)) { - throw error; + // Any rejection is retried once with the source: the invalidation + // script is idempotent (the watermark only advances and its TTL only + // widens), so a duplicate run after an ambiguous failure is harmless, + // and EVAL self-heals both a flushed script cache and an + // EVALSHA-rejecting proxy without depending on error wording. + try { + raw = await client.customCommand( + ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", watermarkKey, ...invalidateArgs], + options, + ); + } catch (retryError) { + if (retryError instanceof Error && (retryError as { cause?: unknown }).cause === undefined) { + (retryError as { cause?: unknown }).cause = error; + } + throw retryError; } - // NOSCRIPT proves the invalidation never executed; EVAL re-sends the - // source, which the server caches under the same SHA1 for later calls. - raw = await client.customCommand( - ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", watermarkKey, ...invalidateArgs], - options, - ); } validateRedisScriptInvalidationReply(raw); }, diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index 055ebad..908e108 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -1,3 +1,4 @@ +import * as valkeyGlide from "@valkey/valkey-glide"; import { commandOptions, createCluster, type RedisClusterOptions } from "redis"; import { GenericContainer, @@ -10,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { CacheLayer, DialCache, DialCacheKeyConfig, type DialCacheRedisClient } from "../src/index.js"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../src/node-redis.js"; +import { createValkeyGlideDialCacheClient } from "../src/valkey-glide.js"; const remoteOnly = new DialCacheKeyConfig({ ttlSec: { [CacheLayer.REMOTE]: 60 }, @@ -37,6 +39,7 @@ describe("DialCache Redis protocol on Redis Cluster", () => { let network: StartedNetwork | undefined; let containers: Array = []; let cluster: ReturnType | undefined; + let glideCluster: valkeyGlide.GlideClusterClient | undefined; beforeAll(async () => { const startedNetwork = await new Network().start(); @@ -99,9 +102,27 @@ describe("DialCache Redis protocol on Redis Cluster", () => { }); cluster.on("error", () => undefined); await cluster.connect(); + + // GLIDE has no nodeAddressMap: it must reach the cluster's announced + // container IPs directly. Those are host-routable on Linux (CI) but not + // under Docker Desktop, so probe with a short timeout and let the GLIDE + // assertions skip locally instead of failing. + try { + glideCluster = await valkeyGlide.GlideClusterClient.createClient({ + addresses: containers.map((container) => ({ + host: container.getIpAddress(networkName), + port: 6379, + })), + requestTimeout: 5_000, + advancedConfiguration: { connectionTimeout: 2_000 }, + }); + } catch { + glideCluster = undefined; + } }); afterAll(async () => { + glideCluster?.close(); await cluster?.quit(); await Promise.all(containers.map(async (container) => await container.stop())); await network?.stop(); @@ -246,4 +267,66 @@ describe("DialCache Redis protocol on Redis Cluster", () => { ).toBe(true); expect(await scriptClient.read({ valueKey: trackedValueKey, watermarkKey })).toEqual(trackedPayload); }); + + it("runs GLIDE tracked mutations against the real cluster", async (ctx) => { + if (glideCluster === undefined) { + return ctx.skip(); + } + const adapter = createValkeyGlideDialCacheClient(glideCluster, valkeyGlide); + const valueKey = "glide-cluster:{item:tracked}:value"; + const watermarkKey = "glide-cluster:{item:tracked}:watermark"; + + expect( + await adapter.write({ valueKey, watermarkKey, cacheTtlMs: 60_000, value: "glide" }), + ).toBe(true); + expect(await adapter.read({ valueKey, watermarkKey })).toBe("glide"); + + await adapter.invalidate({ watermarkKey, futureBufferMs: 0 }); + await new Promise((resolve) => setTimeout(resolve, 2)); + expect(await adapter.read({ valueKey, watermarkKey })).toBeNull(); + expect( + await adapter.write({ valueKey, watermarkKey, cacheTtlMs: 60_000, value: "glide-2" }), + ).toBe(true); + expect(await adapter.read({ valueKey, watermarkKey })).toBe("glide-2"); + + const untrackedKey = "glide-cluster:{item:untracked}:value"; + expect(await adapter.write({ valueKey: untrackedKey, cacheTtlMs: 60_000, value: "plain" })).toBe(true); + expect(await adapter.read({ valueKey: untrackedKey })).toBe("plain"); + + await expect(adapter.write({ + valueKey: "{glide-a}:value", + watermarkKey: "{glide-b}:watermark", + cacheTtlMs: 60_000, + value: "cross", + })).rejects.toThrow(/CROSSSLOT/i); + }); + + it("recovers GLIDE cluster mutations after SCRIPT FLUSH on every master", async (ctx) => { + if (glideCluster === undefined || cluster === undefined) { + return ctx.skip(); + } + const activeCluster = cluster; + const flushAllMasters = async (): Promise => { + await Promise.all( + activeCluster.masters.map(async (master) => { + const nodeClient = await activeCluster.nodeClient(master); + await nodeClient.scriptFlush(); + }), + ); + }; + const adapter = createValkeyGlideDialCacheClient(glideCluster, valkeyGlide); + const valueKey = "glide-flush:{item:tracked}:value"; + const watermarkKey = "glide-flush:{item:tracked}:watermark"; + + await flushAllMasters(); + expect( + await adapter.write({ valueKey, watermarkKey, cacheTtlMs: 60_000, value: "recovered" }), + ).toBe(true); + expect(await adapter.read({ valueKey, watermarkKey })).toBe("recovered"); + + await flushAllMasters(); + await expect(adapter.invalidate({ watermarkKey, futureBufferMs: 0 })).resolves.toBeUndefined(); + await new Promise((resolve) => setTimeout(resolve, 2)); + expect(await adapter.read({ valueKey, watermarkKey })).toBeNull(); + }); }); diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index 5d47f2d..1340407 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -452,6 +452,8 @@ describe("Valkey GLIDE adapter", () => { "NOSCRIPT No matching script. Please use EVAL.", // GLIDE's mapped RequestError wording. "An error was signalled by the server: - NoScriptError: No matching script.", + // Case drift must not blind the stamp's recovery either. + "noscript no matching script", ]; for (const wording of noscriptWordings) { batchInstances.length = 0; @@ -640,22 +642,44 @@ describe("Valkey GLIDE adapter", () => { } }); - it("recovers a flushed invalidation script with EVAL by source", async () => { - const client = fakeClient(1); - client.customCommand.mockRejectedValueOnce( - new Error("An error was signalled by the server: - NoScriptError: No matching script."), - ); - const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + it("retries any invalidation rejection once with EVAL by source", async () => { + // NOSCRIPT is the common trigger, but the retry deliberately covers every + // rejection: the invalidation script is idempotent, and an + // EVALSHA-rejecting proxy must self-heal rather than fail every call. + for (const wording of [ + "An error was signalled by the server: - NoScriptError: No matching script.", + "NOPERM this user has no permissions to run the 'evalsha' command", + ]) { + const client = fakeClient(1); + client.customCommand.mockRejectedValueOnce(new Error(wording)); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); - await expect( - adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), - ).resolves.toBeUndefined(); + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).resolves.toBeUndefined(); - expect(client.customCommand).toHaveBeenNthCalledWith( - 2, - ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", "tracked:{id}:watermark", "50"], - { decoder: decoderBytes }, - ); + expect(client.customCommand).toHaveBeenNthCalledWith( + 2, + ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", "tracked:{id}:watermark", "50"], + { decoder: decoderBytes }, + ); + } + }); + + it("chains the original rejection when the invalidation retry also fails", async () => { + const first = new Error("read ECONNRESET"); + const second = new Error("ERR invalid DialCache future buffer"); + const client = fakeClient(); + client.customCommand.mockRejectedValueOnce(first).mockRejectedValueOnce(second); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + const invalidation = adapter.invalidate({ + watermarkKey: "tracked:{id}:watermark", + futureBufferMs: 50, + }); + await expect(invalidation).rejects.toBe(second); + await expect(invalidation).rejects.toMatchObject({ cause: first }); + expect(client.customCommand).toHaveBeenCalledTimes(2); }); it("uses Batch and Decoder from the supplied GLIDE module instance", async () => { From d30a397e9d9406e15f070e876b1d24ed3dbd0491 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 8 Aug 2026 13:10:21 -0700 Subject: [PATCH 09/12] test(redis): pin compressed payloads riding the tracked placeholder protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/redis-real.integration.test.ts | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index 5fcd4e8..35e19d1 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -273,6 +273,46 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { expect(storedSmall?.subarray(10).toString("utf8")).toBe(JSON.stringify(firstSmall)); }); + it("round-trips compressed payloads through tracked writes and invalidation", async () => { + if (client === undefined || admin === undefined) { + throw new Error("Redis test clients did not start"); + } + // Compression envelopes and the tracked placeholder protocol were built + // in separate branches; this pins their combination: a zstd payload + // rides an unreadable nonce placeholder, gets promoted by the stamp, + // and stays fenceable by the watermark. + const scriptClient: DialCacheRedisClient = client.adapter; + const namespace = "real-compression-tracked"; + const dialcache = new DialCache({ namespace, redis: { client: scriptClient, readTimeoutMs: 10_000 } }); + let calls = 0; + const getLarge = dialcache.cached( + async (id: string) => ({ id, calls: ++calls, blob: "tracked dialcache payload ".repeat(1_024) }), + { + keyType: "item_id", + useCase: "RealCompressionTracked", + cacheKey: (id) => id, + trackForInvalidation: true, + defaultConfig: remoteOnly, + }, + ); + + const first = await dialcache.enable(async () => await getLarge("big")); + const second = await dialcache.enable(async () => await getLarge("big")); + expect(second).toEqual(first); + expect(calls).toBe(1); + + const valueKey = `{${namespace}:item_id:big}#RealCompressionTracked:dialcache-frame-v1`; + const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); + expect(stored?.[0]).toBe(1); + expect(stored?.[9]).toBe(1); + expect(stored?.[10]).toBe(MARKER_ZSTD_UTF8); + + await dialcache.invalidateRemote("item_id", "big"); + await new Promise((resolve) => setTimeout(resolve, 2)); + const refreshed = await dialcache.enable(async () => await getLarge("big")); + expect(refreshed).toEqual({ ...first, calls: 2 }); + }); + it("escapes envelope-colliding binary serializer output on the wire and round-trips it", async () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); From f2bd523fa64ff787d4eb7ea2b8564d4a074909f7 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 8 Aug 2026 15:20:29 -0700 Subject: [PATCH 10/12] fix(redis): invalidation retry parity and post-merge review hardening - 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 --- AGENTS.md | 6 +- README.md | 10 +-- release.config.mjs | 5 +- scripts/test-package.mjs | 56 +++++++++++++ src/internal/duration.ts | 8 +- src/node-redis.ts | 51 ++++++++++-- src/redis-client.ts | 9 +- src/redis-protocol.ts | 16 ++-- src/valkey-glide.ts | 4 +- test/node-redis.test.ts | 110 +++++++++++++++++++++++++ test/redis-cluster.integration.test.ts | 10 ++- test/redis-payload.test.ts | 12 +++ test/redis-real.integration.test.ts | 15 +++- test/valkey-glide.test.ts | 22 ++++- 14 files changed, 298 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e456fad..3578d44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,11 @@ src/ key.ts # Structured cache keys and Redis hash tags metrics.ts # Backend-neutral metrics adapter contract prometheus.ts # Optional Prometheus adapter + datadog.ts # Optional Datadog (DogStatsD) adapter redis-client.ts # Client-independent semantic Redis interface - node-redis.ts # node-redis adapter and script registration - redis-protocol.ts # Public frame codec and Lua protocol exports + node-redis.ts # node-redis adapter and script registration + valkey-glide.ts # Valkey GLIDE adapter (standalone and cluster) + redis-protocol.ts # Public frame codec and Lua protocol exports serializer.ts # Serializer contract and JSON implementation internal/ # Cache layers, runtime config, payload compression, and mutation Lua scripts test/ # Unit and Redis integration tests diff --git a/README.md b/README.md index d1d8166..31c15bb 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ async function shutdown(): Promise { } ``` -`redis.client` is required when Redis is configured and accepts the semantic `DialCacheRedisClient` interface. `redis.readTimeoutMs` is optional and sets the instance default for remote reads; omit it to use 50 ms. Create and connect the underlying client before constructing `DialCache`. Node-redis users should register the supplied mutation scripts and wrap their client with `createNodeRedisDialCacheClient` as shown above; the adapter performs reads with native commands. The helper requires node-redis's promise API and does not support `legacyMode`, whose callback surface and `.v4` view do not expose the complete native-command-plus-custom-script contract together. +`redis.client` is required when Redis is configured and accepts the semantic `DialCacheRedisClient` interface. `redis.readTimeoutMs` is optional and sets the instance default for remote reads; omit it to use 50 ms. Create and connect the underlying client before constructing `DialCache`. Node-redis users should register the supplied mutation scripts and wrap their client with `createNodeRedisDialCacheClient` as shown above; the adapter performs reads with native commands. The registered `dialcache*` methods are DialCache's wiring, not a write API: they return raw script replies — the stamp's `2` means the placeholder was lost, not success — so code invoking them directly must map stamp replies through `resolveTrackedRedisWriteReply` from `dialcache/redis-protocol`. The helper requires node-redis's promise API and does not support `legacyMode`, whose callback surface and `.v4` view do not expose the complete native-command-plus-custom-script contract together. Valkey GLIDE users pass an already-created standalone or cluster client and its module namespace to the GLIDE adapter: @@ -410,9 +410,9 @@ Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. -For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation dispatches the same way on both adapters: `EVALSHA` by the script's source SHA1, recovered with `EVAL`. +For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation dispatches as `EVALSHA` by the script's source SHA1 on both adapters, and both retry a rejected dispatch once by re-sending the source as `EVAL`: the invalidation script is idempotent — its watermark only advances and its TTL only widens — so a duplicate execution after an ambiguous failure is harmless, and the retry heals a flushed script cache and an `EVALSHA`-rejecting proxy without depending on error wording. Reply-domain violations are deterministic and are not retried. When the retry also fails, the surfaced error is the retry's, with the original rejection attached as its `cause`; a failing invalidation can therefore consume two of the client's per-command budgets (GLIDE's `requestTimeout`; node-redis's own `NOSCRIPT` recovery may add one more round trip in between) before rejecting. -A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must allow the client to issue `EVALSHA` (the steady-state dispatch for both mutation scripts) and `EVAL` (both adapters recover a flushed script cache by re-sending script sources, never via `SCRIPT LOAD`), and must allow the stamp script to invoke `UNLINK`, `GETRANGE`, and `SETRANGE`; verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. +A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must allow the client to issue the native `GET`, `MGET`, and `SET` commands — `SET` newly carries every write, where the previous protocol wrote only through scripts — plus `EVALSHA` (the steady-state dispatch for both mutation scripts) and `EVAL` (both adapters recover a flushed script cache by re-sending script sources, never via `SCRIPT LOAD`). Server versions differ on whether script-invoked commands are also checked against the invoking user, so grant what the mutation scripts invoke as well: `TIME`, `GET`, `SET`, and `PTTL` (both scripts), plus the stamp's `PEXPIRE`, `UNLINK`, `GETRANGE`, and `SETRANGE`. Verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. #### Remote read deadlines and async liveness @@ -428,7 +428,7 @@ Writes, invalidations, async `cacheConfigProvider` calls, and custom serializer #### Serialization -The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. The shared `encodeRedisFrame`, `encodeTrackedRedisPlaceholder`, `decodeRedisFrame`, and `decodeTrackedRedisFrame` helpers, the `resolveTrackedRedisWriteReply` and `validateRedisSetReply` reply helpers, and the tracked stamp and invalidation Lua sources are available from `dialcache/redis-protocol`, so custom adapters can reuse the bundled adapters' exact framing, miss, watermark-fencing, and reply rules. A custom tracked write must pass the stamp script `KEYS = [valueKey, watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`, ceiling `cacheTtlMs` to an integer used for both the paired `SET`'s `PX` and `ARGV[1]`, with the nonce from the same `encodeTrackedRedisPlaceholder` call; `resolveTrackedRedisWriteReply` maps the reply, failing the write with the root-exported `DialCacheRedisPlaceholderLostError` when the stamp replies `2`. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, `DialCacheRedisProtocolError`, and `DialCacheRedisPlaceholderLostError` classes to distinguish malformed replies, unsupported encodings, reply-domain violations, and lost placeholders in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. +The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose client commands or wire encodings. The shared `encodeRedisFrame`, `encodeTrackedRedisPlaceholder`, `decodeRedisFrame`, and `decodeTrackedRedisFrame` helpers, the `resolveTrackedRedisWriteReply`, `validateRedisSetReply`, and `validateRedisScriptInvalidationReply` reply helpers, the `ceilSupportedCacheTtlMs` TTL guard, and the tracked stamp and invalidation Lua sources are available from `dialcache/redis-protocol`, so custom adapters can reuse the bundled adapters' exact framing, miss, watermark-fencing, TTL-domain, and reply rules. A custom tracked write must pass the stamp script `KEYS = [valueKey, watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`, running `cacheTtlMs` through `ceilSupportedCacheTtlMs` and using the result for both the paired `SET`'s `PX` and `ARGV[1]` (the stamp script re-validates the same domain server-side as defense in depth), with the nonce from the same `encodeTrackedRedisPlaceholder` call; `resolveTrackedRedisWriteReply` maps the reply, failing the write with the root-exported `DialCacheRedisPlaceholderLostError` when the stamp replies `2`. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, `DialCacheRedisProtocolError`, and `DialCacheRedisPlaceholderLostError` classes to distinguish malformed replies, unsupported encodings, reply-domain violations, and lost placeholders in logs. DialCache records bounded `cache_read`, `cache_write`, or `invalidation` metrics by failure site. Redis values use a compact binary frame: @@ -904,7 +904,7 @@ The command builds `dist` before reporting ten scenarios: sequential request-loc ### Releasing -Publishing starts by manually running the `Release` workflow from current `main`. After the package checks pass, Semantic Release selects the next version from Conventional Commits since the highest stable `vX.Y.Z` tag. Breaking changes bump major, `feat` bumps minor, and every other normal PR-title type (`fix`, `perf`, `docs`, `style`, `refactor`, `test`, `build`, `chore`, `ci`, and `revert`) bumps patch. The highest required bump wins. +Publishing starts by manually running the `Release` workflow from current `main`. After the package checks pass, Semantic Release selects the next version from Conventional Commits since the highest stable `vX.Y.Z` tag. While the package is pre-1.0, breaking changes bump minor — their `BREAKING CHANGE:` footers still drive full release notes without forcing 1.0.0 — `feat` bumps minor, and every other normal PR-title type (`fix`, `perf`, `docs`, `style`, `refactor`, `test`, `build`, `chore`, `ci`, and `revert`) bumps patch. The highest required bump wins. Major bumps return when 1.0.0 is cut; `release.config.mjs` implements this table and must change together with this section. The workflow opens a `release: ` PR whose only change is the matching `package.json` version. `release` is a reserved Conventional Commit type configured not to request another release, so the version-control commit does not cause an extra bump. GitHub marks workflow runs for a PR opened with `GITHUB_TOKEN` as approval-required; approve those runs, review the PR, and squash-merge it normally through the protected branch. diff --git a/release.config.mjs b/release.config.mjs index 1ae9d5d..350564a 100644 --- a/release.config.mjs +++ b/release.config.mjs @@ -10,9 +10,8 @@ export default { // A version PR records the selected version without selecting a new // release itself. Earlier commits still determine the release type. { type: "release", release: false }, - // Pre-1.0 MVP policy: breaking changes release as minors so their - // BREAKING CHANGE footers still drive full release notes without - // forcing 1.0.0. Restore "major" here when cutting 1.0.0. + // Pre-1.0 policy — see README "Releasing", which owns this table. + // Restore "major" here when cutting 1.0.0. { breaking: true, release: "minor" }, { type: "feat", release: "minor" }, { type: "fix", release: "patch" }, diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 4da61cb..872d932 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -55,11 +55,13 @@ import { MissingKeyConfigError } from "dialcache"; import { DialCacheRedisPlaceholderLostError } from "dialcache"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "dialcache/node-redis"; import { + ceilSupportedCacheTtlMs, decodeRedisFrame, decodeTrackedRedisFrame, encodeRedisFrame, encodeTrackedRedisPlaceholder, resolveTrackedRedisWriteReply, + validateRedisScriptInvalidationReply, validateRedisSetReply, WRITE_TRACKED_STAMP_SCRIPT, type TrackedRedisPlaceholder, @@ -177,6 +179,8 @@ const placeholderRedisFrame: Buffer = encodeRedisFrame("pending", 0); const trackedRedisPlaceholder: TrackedRedisPlaceholder = encodeTrackedRedisPlaceholder("pending"); const stampReplyResolution: boolean = resolveTrackedRedisWriteReply(1); const setReplyValidation: void = validateRedisSetReply("OK"); +const invalidationReplyValidation: 1 = validateRedisScriptInvalidationReply(1); +const ceiledCacheTtlMs: number = ceilSupportedCacheTtlMs(1_000.5); const placeholderLostError = new DialCacheRedisPlaceholderLostError("lost"); const stampScriptSource: string = WRITE_TRACKED_STAMP_SCRIPT; const stampArguments: Array = dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformArguments( @@ -826,6 +830,32 @@ try { throw new Error("The lost-placeholder error does not match the root ESM export"); } } +if (redisProtocol.validateRedisScriptInvalidationReply(1) !== 1) { + throw new Error("The packed ESM invalidation reply validator must accept reply 1"); +} +for (const invalidInvalidationReply of [0, 2]) { + try { + redisProtocol.validateRedisScriptInvalidationReply(invalidInvalidationReply); + throw new Error("Expected an out-of-domain invalidation reply to fail"); + } catch (error) { + if (!(error instanceof root.DialCacheRedisProtocolError)) { + throw new Error("The invalidation reply error does not match the root ESM export"); + } + } +} +if (redisProtocol.ceilSupportedCacheTtlMs(1_000.5) !== 1_001) { + throw new Error("The packed ESM TTL guard did not ceil a fractional cacheTtlMs"); +} +for (const invalidCacheTtlMs of [0, 31_536_000_001]) { + try { + redisProtocol.ceilSupportedCacheTtlMs(invalidCacheTtlMs); + throw new Error("Expected an out-of-domain cacheTtlMs to fail"); + } catch (error) { + if (!(error instanceof RangeError)) { + throw new Error("The packed ESM TTL guard must reject out-of-domain durations with RangeError"); + } + } +} // ESM chunk splitting shares one class instance across entries, so also // prove the brand itself: a hand-branded foreign Error must satisfy the // root export's Symbol.hasInstance. @@ -1166,6 +1196,32 @@ try { throw new Error("The lost-placeholder error does not match the root CommonJS export"); } } +if (redisProtocol.validateRedisScriptInvalidationReply(1) !== 1) { + throw new Error("The packed CommonJS invalidation reply validator must accept reply 1"); +} +for (const invalidInvalidationReply of [0, 2]) { + try { + redisProtocol.validateRedisScriptInvalidationReply(invalidInvalidationReply); + throw new Error("Expected an out-of-domain invalidation reply to fail"); + } catch (error) { + if (!(error instanceof root.DialCacheRedisProtocolError)) { + throw new Error("The invalidation reply error does not match the root CommonJS export"); + } + } +} +if (redisProtocol.ceilSupportedCacheTtlMs(1_000.5) !== 1_001) { + throw new Error("The packed CommonJS TTL guard did not ceil a fractional cacheTtlMs"); +} +for (const invalidCacheTtlMs of [0, 31_536_000_001]) { + try { + redisProtocol.ceilSupportedCacheTtlMs(invalidCacheTtlMs); + throw new Error("Expected an out-of-domain cacheTtlMs to fail"); + } catch (error) { + if (!(error instanceof RangeError)) { + throw new Error("The packed CommonJS TTL guard must reject out-of-domain durations with RangeError"); + } + } +} // Keep the brand coverage bundler-independent: a hand-branded foreign Error // must satisfy the root export's Symbol.hasInstance even if CJS ever shares // chunks the way ESM does. diff --git a/src/internal/duration.ts b/src/internal/duration.ts index c7370a5..bd83c86 100644 --- a/src/internal/duration.ts +++ b/src/internal/duration.ts @@ -21,9 +21,11 @@ export function cacheTtlSecToMs(ttlSec: number): number { } /** - * Validate and ceil an adapter-level write TTL. Native SET PX requires an - * integer, and the Lua write validation this replaces rounded fractional - * durations upward, so adapters preserve that exact acceptance domain. + * Validate and ceil an adapter-level write TTL to the protocol's acceptance + * domain: fractional milliseconds round up, and the result must be a + * positive integer no greater than 365 days. Native SET PX requires an + * integer, and the stamp script re-checks the same domain server-side as + * defense in depth for adapters that skip this guard. */ export function ceilSupportedCacheTtlMs(cacheTtlMs: number): number { const ceiled = typeof cacheTtlMs === "number" ? Math.ceil(cacheTtlMs) : Number.NaN; diff --git a/src/node-redis.ts b/src/node-redis.ts index 5e232df..34ce4f2 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -19,6 +19,7 @@ import { } from "./internal/redis-script-reply.js"; import { DialCacheRedisPayloadError, + DialCacheRedisProtocolError, type DialCacheRedisClient, } from "./redis-client.js"; @@ -52,6 +53,14 @@ function defineDialCacheScript, Reply>( return defineScript(config); } +/** + * DialCache's client wiring, not a write API: the registered methods return + * raw script replies. `dialcacheWriteTrackedStamp` replies `0 | 1 | 2`, and + * `2` means the placeholder was lost — not success. Code invoking these + * methods directly must map stamp replies through + * `resolveTrackedRedisWriteReply` from `dialcache/redis-protocol`, which + * throws `DialCacheRedisPlaceholderLostError` on `2`. + */ export type DialCacheNodeRedisScripts = { readonly dialcacheWriteTrackedStamp: NodeRedisScript< [valueKey: string, watermarkKey: string, cacheTtlMs: number, nonce: Buffer], @@ -63,6 +72,7 @@ export type DialCacheNodeRedisScripts = { >; }; +/** See {@link DialCacheNodeRedisScripts}: wiring for the adapter, not a direct write API. */ export const dialcacheRedisScripts: DialCacheNodeRedisScripts = { dialcacheWriteTrackedStamp: defineDialCacheScript({ SCRIPT: WRITE_TRACKED_STAMP_SCRIPT, @@ -183,9 +193,12 @@ function sendFrameSet( * supported. Aborting after dispatch does not unsend a command or prove the * server stopped executing it. Tracked writes enqueue their placeholder SET * and stamp script in one synchronous tick, so node-redis pipelines them in - * order on one connection (per slot node in cluster mode). The caller remains - * responsible for finite native command budgets, draining work, and closing - * the client. + * order on one connection (per slot node in cluster mode). Invalidation + * retries any rejected dispatch once by re-sending the script source as + * EVAL — the script is idempotent, so a duplicate run is harmless — with the + * original rejection preserved on the surfaced error's `cause`. The caller + * remains responsible for finite native command budgets, draining work, and + * closing the client. */ export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCacheRedisClient { if ( @@ -241,8 +254,36 @@ export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCac return resolveTrackedRedisWriteReply(stampResult.value); }, async invalidate({ watermarkKey, futureBufferMs }) { - const result = await client.dialcacheInvalidate(watermarkKey, futureBufferMs); - validateRedisScriptInvalidationReply(result); + let raw: unknown; + try { + raw = await client.dialcacheInvalidate(watermarkKey, futureBufferMs); + } catch (error) { + // The registered transformReply validates inside the returned + // promise, so a reply-domain violation surfaces here as a rejection; + // it is deterministic and must not be retried. Any other rejection + // is retried once with the source: the invalidation script is + // idempotent (the watermark only advances and its TTL only widens), + // so a duplicate run after an ambiguous failure is harmless, and + // EVAL self-heals both a flushed script cache and an + // EVALSHA-rejecting proxy without depending on error wording. + if (error instanceof DialCacheRedisProtocolError) { + throw error; + } + try { + raw = await sendKeyedCommand( + client, + watermarkKey, + ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", watermarkKey, String(futureBufferMs)], + bufferReplyOptions, + ); + } catch (retryError) { + if (retryError instanceof Error && retryError.cause === undefined) { + retryError.cause = error; + } + throw retryError; + } + } + validateRedisScriptInvalidationReply(raw); }, }; } diff --git a/src/redis-client.ts b/src/redis-client.ts index bfab12d..93f0c1e 100644 --- a/src/redis-client.ts +++ b/src/redis-client.ts @@ -177,10 +177,11 @@ export interface DialCacheRedisClient { * Tracked writes issue two commands ordered on one connection without a * transaction: a native `SET` of an `encodeTrackedRedisPlaceholder` frame, * followed by `WRITE_TRACKED_STAMP_SCRIPT` with `KEYS = [valueKey, - * watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`. Ceil `cacheTtlMs` to an - * integer and pass that same value as both the SET's `PX` and `ARGV[1]` — - * `PX` rejects fractions and the watermark's lifetime is derived from - * `ARGV[1]` — and the nonce must be the placeholder's. The script fences against the watermark and + * watermarkKey]` and `ARGV = [cacheTtlMs, nonce]`. Run `cacheTtlMs` through + * `ceilSupportedCacheTtlMs` (exported by `dialcache/redis-protocol`) and + * pass the result as both the SET's `PX` and `ARGV[1]` — `PX` rejects + * fractions and the watermark's lifetime is derived from `ARGV[1]` — and + * the nonce must be the placeholder's. The script fences against the watermark and * unlinks the value (reply 0), promotes exactly the placeholder carrying * its nonce to a served frame with server-time `createdAt` (reply 1), or * reports the placeholder gone (reply 2); it maintains the watermark's diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index 13b18af..faf2785 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -1,14 +1,16 @@ /** * Public frame protocol surface for adapter authors and out-of-band tooling. * - * These exports describe the frame header (version, createdAt, encoding) and - * decode a frame into its payload bytes. The payload region past the header - * is opaque at this layer: entries written by DialCache releases with payload - * compression may begin with - * a compression envelope byte (0x00 escape, 0x01/0x02 zstd; see the README - * Compression section), which DialCache core interprets above the adapter. - * Adapters must never decompress or otherwise rewrite payload bytes. + * These exports describe the frame header (version, createdAt, encoding), + * decode a frame into its payload bytes, and guard the write-TTL acceptance + * domain. The payload region past the header is opaque at this layer: + * entries written by DialCache releases with payload compression may begin + * with a compression envelope byte (0x00 escape, 0x01/0x02 zstd; see the + * README Compression section), which DialCache core interprets above the + * adapter. Adapters must never decompress or otherwise rewrite payload + * bytes. */ +export { ceilSupportedCacheTtlMs } from "./internal/duration.js"; export { INVALIDATE_CACHE_SCRIPT, WRITE_TRACKED_STAMP_SCRIPT, diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 5723ef4..4e3c476 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -257,8 +257,8 @@ export function createValkeyGlideDialCacheClient( options, ); } catch (retryError) { - if (retryError instanceof Error && (retryError as { cause?: unknown }).cause === undefined) { - (retryError as { cause?: unknown }).cause = error; + if (retryError instanceof Error && retryError.cause === undefined) { + retryError.cause = error; } throw retryError; } diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index 96bf4d9..6e4c301 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -8,6 +8,7 @@ import { DialCacheRedisProtocolError, } from "../src/index.js"; import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../src/node-redis.js"; +import { INVALIDATE_CACHE_SCRIPT } from "../src/redis-protocol.js"; const INVALID_WRITE_REPLIES: readonly unknown[] = [ -1, @@ -28,6 +29,7 @@ interface FakeReplies { readonly get?: unknown; readonly mGet?: unknown; readonly set?: unknown; + readonly eval?: unknown; readonly stamp?: unknown; readonly invalidate?: unknown; } @@ -41,6 +43,9 @@ function fakeClient(replies: FakeReplies = {}) { if (args[0] === "SET") { return Object.hasOwn(replies, "set") ? replies.set : "OK"; } + if (args[0] === "EVAL") { + return Object.hasOwn(replies, "eval") ? replies.eval : 1; + } return Object.hasOwn(replies, "mGet") ? replies.mGet : [null, null]; }), dialcacheWriteTrackedStamp: vi.fn(async () => Object.hasOwn(replies, "stamp") ? replies.stamp : 1), @@ -499,6 +504,111 @@ describe("node-redis adapter", () => { } }); + it("dispatches invalidation once and sends no EVAL when the registered script resolves", async () => { + const client = fakeClient(); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).resolves.toBeUndefined(); + + expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(1); + expect(client.sendCommand).not.toHaveBeenCalled(); + }); + + it("retries a rejected invalidation dispatch once with EVAL by source", async () => { + const client = fakeClient(); + client.dialcacheInvalidate.mockRejectedValueOnce( + new Error("NOPERM this user has no permissions to run the 'evalsha' command"), + ); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).resolves.toBeUndefined(); + + expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(1); + expect(client.sendCommand).toHaveBeenCalledTimes(1); + const [args] = client.sendCommand.mock.calls[0] as [Array]; + expect(args).toEqual([ + "EVAL", + INVALIDATE_CACHE_SCRIPT, + "1", + "tracked:{id}:watermark", + "50", + ]); + }); + + it("routes the invalidation EVAL retry through the cluster keyed overload", async () => { + const client = fakeCluster(); + client.dialcacheInvalidate.mockRejectedValueOnce(new Error("NOPERM evalsha denied")); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).resolves.toBeUndefined(); + + expect(client.sendCommand).toHaveBeenCalledTimes(1); + const [firstKey, isReadonly, args] = client.sendCommand.mock.calls[0] as [ + string, + boolean, + Array, + ]; + expect(firstKey).toBe("tracked:{id}:watermark"); + expect(isReadonly).toBe(false); + expect(args[0]).toBe("EVAL"); + }); + + it("surfaces the invalidation retry rejection with the original attached as cause", async () => { + const client = fakeClient(); + const original = new Error("NOPERM evalsha denied"); + const retryFailure = new Error("NOPERM eval denied"); + client.dialcacheInvalidate.mockRejectedValueOnce(original); + client.sendCommand.mockRejectedValueOnce(retryFailure); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).rejects.toBe(retryFailure); + + expect(retryFailure.cause).toBe(original); + expect(client.sendCommand).toHaveBeenCalledTimes(1); + }); + + it("preserves a pre-existing cause on the invalidation retry rejection", async () => { + const client = fakeClient(); + const retryFailure = new Error("wrapped transport failure", { cause: "socket closed" }); + client.dialcacheInvalidate.mockRejectedValueOnce(new Error("NOPERM evalsha denied")); + client.sendCommand.mockRejectedValueOnce(retryFailure); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).rejects.toBe(retryFailure); + + expect(retryFailure.cause).toBe("socket closed"); + }); + + it("does not retry an invalidation reply-domain violation", async () => { + // The registered transformReply validates inside the returned promise on + // a real client, so a domain violation arrives as a rejection; it is + // deterministic and must surface without a second dispatch. + const client = fakeClient(); + client.dialcacheInvalidate.mockRejectedValueOnce( + new DialCacheRedisProtocolError("Invalid DialCache Redis invalidate reply; expected integer 1"), + ); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expectProtocolError( + Promise.resolve(adapter.invalidate({ + watermarkKey: "tracked:{id}:watermark", + futureBufferMs: 50, + })), + "Invalid DialCache Redis invalidate reply; expected integer 1", + ); + expect(client.sendCommand).not.toHaveBeenCalled(); + }); + it("validates replies at the public node-redis script transform boundary", () => { expect(dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(0)).toBe(0); expect(dialcacheRedisScripts.dialcacheWriteTrackedStamp.transformReply(1)).toBe(1); diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index 908e108..f98597e 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -106,7 +106,8 @@ describe("DialCache Redis protocol on Redis Cluster", () => { // GLIDE has no nodeAddressMap: it must reach the cluster's announced // container IPs directly. Those are host-routable on Linux (CI) but not // under Docker Desktop, so probe with a short timeout and let the GLIDE - // assertions skip locally instead of failing. + // assertions skip locally instead of failing. CI must fail closed: a + // silent skip there would drop the only GLIDE cluster coverage. try { glideCluster = await valkeyGlide.GlideClusterClient.createClient({ addresses: containers.map((container) => ({ @@ -116,8 +117,11 @@ describe("DialCache Redis protocol on Redis Cluster", () => { requestTimeout: 5_000, advancedConfiguration: { connectionTimeout: 2_000 }, }); - } catch { - glideCluster = undefined; + } catch (error) { + if (process.env.CI !== undefined) { + throw error; + } + console.warn("GLIDE cluster client unavailable; skipping GLIDE cluster assertions", error); } }); diff --git a/test/redis-payload.test.ts b/test/redis-payload.test.ts index c003af3..144b172 100644 --- a/test/redis-payload.test.ts +++ b/test/redis-payload.test.ts @@ -147,6 +147,18 @@ describe("Redis frame decoding", () => { expect(decodeRedisFrame(binary.frame)).toBeNull(); }); + it("mints a distinct nonce for every placeholder", () => { + // The stamp promotes only the placeholder carrying its own nonce, so + // nonce uniqueness is what keeps concurrent same-key writes disjoint. + const mints = Array.from({ length: 32 }, () => encodeTrackedRedisPlaceholder("pending")); + const nonces = new Set(mints.map(({ nonce }) => nonce.toString("hex"))); + + expect(nonces.size).toBe(32); + for (const { frame, nonce } of mints) { + expect(frame.subarray(1, 9)).toEqual(nonce); + } + }); + it("gates serving on the version byte even for hostile placeholder nonces", () => { // A nonce that would decode as a huge timestamp must never beat the // watermark: version 0 alone keeps the frame a miss on both paths. diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index 35e19d1..a63a781 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -308,9 +308,22 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { expect(stored?.[10]).toBe(MARKER_ZSTD_UTF8); await dialcache.invalidateRemote("item_id", "big"); - await new Promise((resolve) => setTimeout(resolve, 2)); + // Leave the zero-buffer watermark clearly in the past so the refill's + // stamp cannot land inside the fence window and blank the entry. + await new Promise((resolve) => setTimeout(resolve, 25)); const refreshed = await dialcache.enable(async () => await getLarge("big")); expect(refreshed).toEqual({ ...first, calls: 2 }); + + // The refill must be a published, servable zstd frame: a third read + // serves it from Redis without reloading, and the stored bytes carry a + // promoted version byte with the envelope intact after the stamp. + const third = await dialcache.enable(async () => await getLarge("big")); + expect(third).toEqual(refreshed); + expect(calls).toBe(2); + const restored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); + expect(restored?.[0]).toBe(1); + expect(restored?.[9]).toBe(1); + expect(restored?.[10]).toBe(MARKER_ZSTD_UTF8); }); it("escapes envelope-colliding binary serializer output on the wire and round-trips it", async () => { diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index 1340407..dfefec1 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -76,7 +76,12 @@ const mockGlide = { }; function createFakeClient(replies: unknown[]) { - const nextReply = async (): Promise => replies.shift(); + const nextReply = async (): Promise => { + if (replies.length === 0) { + throw new Error("fake GLIDE reply queue exhausted; queue every expected dispatch"); + } + return replies.shift(); + }; const client = { customCommand: vi.fn(async ( _args: Array, @@ -658,6 +663,7 @@ describe("Valkey GLIDE adapter", () => { adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), ).resolves.toBeUndefined(); + expect(client.customCommand).toHaveBeenCalledTimes(2); expect(client.customCommand).toHaveBeenNthCalledWith( 2, ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", "tracked:{id}:watermark", "50"], @@ -682,6 +688,20 @@ describe("Valkey GLIDE adapter", () => { expect(client.customCommand).toHaveBeenCalledTimes(2); }); + it("preserves a pre-existing cause on the invalidation retry rejection", async () => { + const first = new Error("read ECONNRESET"); + const second = new Error("wrapped transport failure", { cause: "socket closed" }); + const client = fakeClient(); + client.customCommand.mockRejectedValueOnce(first).mockRejectedValueOnce(second); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).rejects.toBe(second); + + expect(second.cause).toBe("socket closed"); + }); + it("uses Batch and Decoder from the supplied GLIDE module instance", async () => { class OtherBatch { mget(): this { From 4fe78c86e5f429634c54dcd71e365104cecc7dcb Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 8 Aug 2026 16:10:06 -0700 Subject: [PATCH 11/12] fix(redis): stop mutating shared client errors and pin the retry seams - 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 --- AGENTS.md | 6 +++ README.md | 6 +-- scripts/test-package.mjs | 26 ++++++++++++- src/node-redis.ts | 38 ++++++++++--------- src/redis-protocol.ts | 19 ++++++---- src/valkey-glide.ts | 8 ++++ test/node-redis.test.ts | 52 +++++++++++++++++++++----- test/redis-cluster.integration.test.ts | 16 ++++++-- test/redis-real.integration.test.ts | 5 ++- test/valkey-glide.test.ts | 30 +++++++++++++++ 10 files changed, 161 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3578d44..47fcada 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,9 @@ DialCache is a TypeScript caching library with explicit request-scoped enablemen ```text src/ + index.ts # Public root entry point (barrel) dialcache.ts # Main DialCache API and cached-function wrapper + errors.ts # Public error classes config.ts # Public configuration and rollout types context.ts # AsyncLocalStorage-based enabled context key.ts # Structured cache keys and Redis hash tags @@ -34,6 +36,10 @@ test/ # Unit and Redis integration tests - Cache plumbing fails open; explicit maintenance operations surface mutation failures. - Tracked Redis values and invalidation watermarks share a Redis Cluster hash tag. - Tracked reads run on primaries so replica lag cannot hide invalidation. +- A tracked write's placeholder frame (version byte 0) is unreadable on both + read paths until the stamp script promotes it, and the stamp promotes only + the placeholder carrying its own per-write nonce. +- A SET failure is the tracked write's outcome even when the stamp settled. - Local entries are process-local and are not synchronously invalidated across instances. ## Conventions diff --git a/README.md b/README.md index 31c15bb..8beeec5 100644 --- a/README.md +++ b/README.md @@ -410,7 +410,7 @@ Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. -For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation dispatches as `EVALSHA` by the script's source SHA1 on both adapters, and both retry a rejected dispatch once by re-sending the source as `EVAL`: the invalidation script is idempotent — its watermark only advances and its TTL only widens — so a duplicate execution after an ambiguous failure is harmless, and the retry heals a flushed script cache and an `EVALSHA`-rejecting proxy without depending on error wording. Reply-domain violations are deterministic and are not retried. When the retry also fails, the surfaced error is the retry's, with the original rejection attached as its `cause`; a failing invalidation can therefore consume two of the client's per-command budgets (GLIDE's `requestTimeout`; node-redis's own `NOSCRIPT` recovery may add one more round trip in between) before rejecting. +For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation dispatches as `EVALSHA` by the script's source SHA1 on both adapters, and both retry a rejected dispatch once by re-sending the source as `EVAL`: the invalidation script is idempotent — its watermark only advances and its TTL only widens — so a duplicate execution after an ambiguous failure is harmless, and the retry heals a flushed script cache and an `EVALSHA`-rejecting proxy without depending on error wording. Reply-domain violations are deterministic and are not retried. When the retry also fails, the surfaced error is the retry's. On GLIDE the original rejection is attached as the retry error's `cause` unless it already carries one, and a failing invalidation is bounded by roughly two `requestTimeout` windows. On node-redis the retry rejection surfaces unmodified and the original is discarded — the library rejects every command flushed by a single disconnect with one shared error instance, so the adapter never mutates it — and no per-command deadline exists: `disableOfflineQueue`, `commandsQueueMaxLength`, and `reconnectStrategy` bound queueing and dispatch (the setup snippet above disables the offline queue, which makes a disconnected retry fail fast instead of waiting for reconnect), but a command already written to a hung connection has no reply deadline. Its own `NOSCRIPT` recovery may also add one round trip before the adapter's retry. A retry that heals is indistinguishable from a first-attempt success in DialCache's metrics and logs; a fleet stuck permanently healing (an `EVALSHA`-rejecting proxy, an ACL granting `EVAL` but not `EVALSHA`) shows up only server-side, as `INFO commandstats` `cmdstat_eval` calls approaching `cmdstat_evalsha`. A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must allow the client to issue the native `GET`, `MGET`, and `SET` commands — `SET` newly carries every write, where the previous protocol wrote only through scripts — plus `EVALSHA` (the steady-state dispatch for both mutation scripts) and `EVAL` (both adapters recover a flushed script cache by re-sending script sources, never via `SCRIPT LOAD`). Server versions differ on whether script-invoked commands are also checked against the invoking user, so grant what the mutation scripts invoke as well: `TIME`, `GET`, `SET`, and `PTTL` (both scripts), plus the stamp's `PEXPIRE`, `UNLINK`, `GETRANGE`, and `SETRANGE`. Verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. @@ -424,7 +424,7 @@ Same-key followers share the leader's remaining remote-read budget. The timer co The bundled node-redis adapter passes the signal through per-command options, which can remove queued work where supported. Aborting after dispatch does not unsend a command or prove that Redis stopped executing it. GLIDE's current adapter commands have no per-invocation signal, so a read may continue after DialCache has fallen back. Keep client-native connection, retry, queue, and response budgets in place; they bound underlying resource lifetime while DialCache's deadline bounds caller wait time. -Writes, invalidations, async `cacheConfigProvider` calls, and custom serializer methods still need finite application-owned budgets. Do not put mutations behind a bare `Promise.race`: rejecting the outer promise neither removes queued work nor proves whether a dispatched mutation executed. +Writes, invalidations, async `cacheConfigProvider` calls, and custom serializer methods still need finite application-owned budgets. Client support differs: GLIDE's `requestTimeout` bounds every command's reply wait, while node-redis has no per-command deadline — its queue and reconnect controls bound admission and dispatch only (see the invalidation-retry paragraph above), so bound node-redis mutations at the connection layer rather than per call. Do not put mutations behind a bare `Promise.race`: rejecting the outer promise neither removes queued work nor proves whether a dispatched mutation executed. #### Serialization @@ -876,7 +876,7 @@ The `error` label reports where an operation failed rather than copying the thro | `config_resolution` | Runtime or layer configuration, or ramp resolution, failed | | `cache_read` | A local-cache or Redis read failed | | `cache_read_timeout` | A Redis read exceeded its effective remote-read deadline | -| `cache_write` | A local-cache or Redis write failed | +| `cache_write` | A local-cache or Redis write failed; tracked Redis writes add a benign self-healing floor under same-key contention (see [Redis-backed TTL cache](#redis-backed-ttl-cache)) | | `serialization_load` | Deserializing a Redis payload failed | | `serialization_dump` | Serializing a value for Redis failed | | `compression` | zstd compression failed while preparing a Redis write | diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 872d932..5c4e486 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -726,6 +726,14 @@ const nodeRedis = await import("dialcache/node-redis"); await import("dialcache/valkey-glide"); await import("dialcache/datadog"); const redisProtocol = await import("dialcache/redis-protocol"); +// Each bundle embeds its own copy of the Lua sources; a divergence forks the +// protocol (different SHA1s) without failing any behavioral test. +if ( + nodeRedis.dialcacheRedisScripts.dialcacheWriteTrackedStamp.SCRIPT !== redisProtocol.WRITE_TRACKED_STAMP_SCRIPT + || nodeRedis.dialcacheRedisScripts.dialcacheInvalidate.SCRIPT !== redisProtocol.INVALIDATE_CACHE_SCRIPT +) { + throw new Error("The packed ESM node-redis Lua sources diverged from the redis-protocol entry"); +} const fallbackTimeoutError = new root.FallbackTimeoutError("PackageRuntime", 1000); if (!(fallbackTimeoutError instanceof root.DialCacheError) || fallbackTimeoutError.timeoutMs !== 1000) { throw new Error("The root ESM fallback-timeout error export is invalid"); @@ -1090,6 +1098,14 @@ const nodeRedis = require("dialcache/node-redis"); require("dialcache/valkey-glide"); require("dialcache/datadog"); const redisProtocol = require("dialcache/redis-protocol"); +// CommonJS bundles duplicate the Lua sources per entry point; a divergence +// forks the protocol (different SHA1s) without failing any behavioral test. +if ( + nodeRedis.dialcacheRedisScripts.dialcacheWriteTrackedStamp.SCRIPT !== redisProtocol.WRITE_TRACKED_STAMP_SCRIPT + || nodeRedis.dialcacheRedisScripts.dialcacheInvalidate.SCRIPT !== redisProtocol.INVALIDATE_CACHE_SCRIPT +) { + throw new Error("The packed CommonJS node-redis Lua sources diverged from the redis-protocol entry"); +} const fallbackTimeoutError = new root.FallbackTimeoutError("PackageRuntime", 1000); if (!(fallbackTimeoutError instanceof root.DialCacheError) || fallbackTimeoutError.timeoutMs !== 1000) { throw new Error("The root CommonJS fallback-timeout error export is invalid"); @@ -1358,7 +1374,7 @@ const appGlide = await import("@valkey/valkey-glide"); const otherGlide = await import("dialcache-test-glide"); await import("dialcache/datadog"); await import("dialcache/prometheus"); -await import("dialcache/redis-protocol"); +const redisProtocol = await import("dialcache/redis-protocol"); await import("dialcache/node-redis"); if (appGlide.Script === otherGlide.Script) { throw new Error("The package test requires two distinct GLIDE module instances"); @@ -1377,6 +1393,9 @@ const esmFakeGlideClient = { if (args[0] !== "EVAL") { throw new Error("The ESM adapter's NOSCRIPT recovery must resend the stamp source via EVAL"); } + if (args[1] !== redisProtocol.WRITE_TRACKED_STAMP_SCRIPT) { + throw new Error("The ESM GLIDE bundle's embedded stamp source diverged from the redis-protocol entry"); + } if (options.decoder !== appGlide.Decoder.Bytes) { throw new Error("The ESM adapter did not use the caller-supplied GLIDE byte decoder"); } @@ -1415,7 +1434,7 @@ const appGlide = require("@valkey/valkey-glide"); const otherGlide = require("dialcache-test-glide"); require("dialcache/datadog"); require("dialcache/prometheus"); -require("dialcache/redis-protocol"); +const redisProtocol = require("dialcache/redis-protocol"); require("dialcache/node-redis"); void (async () => { if (appGlide.Script === otherGlide.Script) { @@ -1435,6 +1454,9 @@ void (async () => { if (args[0] !== "EVAL") { throw new Error("The CommonJS adapter's NOSCRIPT recovery must resend the stamp source via EVAL"); } + if (args[1] !== redisProtocol.WRITE_TRACKED_STAMP_SCRIPT) { + throw new Error("The CommonJS GLIDE bundle's embedded stamp source diverged from the redis-protocol entry"); + } if (options.decoder !== appGlide.Decoder.Bytes) { throw new Error("The CommonJS adapter did not use the caller-supplied GLIDE byte decoder"); } diff --git a/src/node-redis.ts b/src/node-redis.ts index 34ce4f2..4c80358 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -194,11 +194,15 @@ function sendFrameSet( * server stopped executing it. Tracked writes enqueue their placeholder SET * and stamp script in one synchronous tick, so node-redis pipelines them in * order on one connection (per slot node in cluster mode). Invalidation - * retries any rejected dispatch once by re-sending the script source as - * EVAL — the script is idempotent, so a duplicate run is harmless — with the - * original rejection preserved on the surfaced error's `cause`. The caller - * remains responsible for finite native command budgets, draining work, and - * closing the client. + * retries any dispatch rejection other than a reply-domain violation once by + * re-sending the script source as EVAL — the script is idempotent, so a + * duplicate run is harmless — and a failed retry surfaces unmodified, with + * the original rejection discarded. node-redis has no per-command deadline: + * `disableOfflineQueue`, `commandsQueueMaxLength`, and `reconnectStrategy` + * bound queueing and dispatch, not the reply wait, so with the offline queue + * enabled a retry issued during a disconnect can wait until reconnect. The + * caller remains responsible for finite native command budgets, draining + * work, and closing the client. */ export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCacheRedisClient { if ( @@ -269,19 +273,17 @@ export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCac if (error instanceof DialCacheRedisProtocolError) { throw error; } - try { - raw = await sendKeyedCommand( - client, - watermarkKey, - ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", watermarkKey, String(futureBufferMs)], - bufferReplyOptions, - ); - } catch (retryError) { - if (retryError instanceof Error && retryError.cause === undefined) { - retryError.cause = error; - } - throw retryError; - } + // A failed retry surfaces unmodified, discarding this original + // rejection: node-redis rejects every command flushed by a single + // disconnect with one shared error instance — the same object its + // "error" listeners and every other in-flight caller receive — so + // the adapter never mutates a rejection it did not construct. + raw = await sendKeyedCommand( + client, + watermarkKey, + ["EVAL", INVALIDATE_CACHE_SCRIPT, "1", watermarkKey, String(futureBufferMs)], + bufferReplyOptions, + ); } validateRedisScriptInvalidationReply(raw); }, diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index faf2785..0afaeb6 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -1,14 +1,17 @@ /** * Public frame protocol surface for adapter authors and out-of-band tooling. * - * These exports describe the frame header (version, createdAt, encoding), - * decode a frame into its payload bytes, and guard the write-TTL acceptance - * domain. The payload region past the header is opaque at this layer: - * entries written by DialCache releases with payload compression may begin - * with a compression envelope byte (0x00 escape, 0x01/0x02 zstd; see the - * README Compression section), which DialCache core interprets above the - * adapter. Adapters must never decompress or otherwise rewrite payload - * bytes. + * These exports encode frames and mint tracked placeholders (use + * `encodeRedisFrame` and `encodeTrackedRedisPlaceholder` rather than + * reimplementing them — see the latter's JSDoc for the nonce contract), + * decode a frame into its payload bytes, resolve and validate mutation + * replies, guard the write-TTL acceptance domain, and carry the tracked + * stamp and invalidation Lua sources the bundled adapters dispatch. The + * payload region past the header is opaque at this layer: entries written by + * DialCache releases with payload compression may begin with a compression + * envelope byte (0x00 escape, 0x01/0x02 zstd; see the README Compression + * section), which DialCache core interprets above the adapter. Adapters must + * never decompress or otherwise rewrite payload bytes. */ export { ceilSupportedCacheTtlMs } from "./internal/duration.js"; export { diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 4e3c476..065024d 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -137,6 +137,11 @@ function classifyValkeyGlideClient( * cluster write batches route to the slot primary. Batches are deliberately * non-atomic: MGET and SET are atomic themselves, an interleaved stamp is * safe by design, and MULTI/EXEC would consume caller-owned WATCH state. + * Recovery differs by script: the stamp is retried only on NOSCRIPT, while + * invalidation retries any rejection once with EVAL by source. When that + * retry also fails, the original rejection is attached as the retry error's + * `cause` unless it already carries one — safe here because GLIDE constructs + * a fresh error object per rejection. */ export function createValkeyGlideDialCacheClient( client: ValkeyGlideScriptingClient, @@ -257,6 +262,9 @@ export function createValkeyGlideDialCacheClient( options, ); } catch (retryError) { + // Mutating the rejection is safe on GLIDE only: it constructs a + // fresh error per rejection, so no other caller holds this object + // (node-redis shares flush errors and its adapter never mutates). if (retryError instanceof Error && retryError.cause === undefined) { retryError.cause = error; } diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index 6e4c301..0360781 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -529,7 +529,7 @@ describe("node-redis adapter", () => { expect(client.dialcacheInvalidate).toHaveBeenCalledTimes(1); expect(client.sendCommand).toHaveBeenCalledTimes(1); - const [args] = client.sendCommand.mock.calls[0] as [Array]; + const [args, options] = client.sendCommand.mock.calls[0] as [Array, object]; expect(args).toEqual([ "EVAL", INVALIDATE_CACHE_SCRIPT, @@ -537,6 +537,8 @@ describe("node-redis adapter", () => { "tracked:{id}:watermark", "50", ]); + expect(options).toMatchObject({ returnBuffers: true }); + expect(Object.keys(options)).toEqual(["returnBuffers"]); }); it("routes the invalidation EVAL retry through the cluster keyed overload", async () => { @@ -549,17 +551,19 @@ describe("node-redis adapter", () => { ).resolves.toBeUndefined(); expect(client.sendCommand).toHaveBeenCalledTimes(1); - const [firstKey, isReadonly, args] = client.sendCommand.mock.calls[0] as [ + const [firstKey, isReadonly, args, options] = client.sendCommand.mock.calls[0] as [ string, boolean, Array, + object, ]; expect(firstKey).toBe("tracked:{id}:watermark"); expect(isReadonly).toBe(false); expect(args[0]).toBe("EVAL"); + expect(options).toMatchObject({ returnBuffers: true }); }); - it("surfaces the invalidation retry rejection with the original attached as cause", async () => { + it("surfaces the invalidation retry rejection unmodified", async () => { const client = fakeClient(); const original = new Error("NOPERM evalsha denied"); const retryFailure = new Error("NOPERM eval denied"); @@ -571,22 +575,52 @@ describe("node-redis adapter", () => { adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), ).rejects.toBe(retryFailure); - expect(retryFailure.cause).toBe(original); + expect(retryFailure.cause).toBeUndefined(); expect(client.sendCommand).toHaveBeenCalledTimes(1); }); - it("preserves a pre-existing cause on the invalidation retry rejection", async () => { + it("never mutates a flush-shared error instance rejecting both dispatches", async () => { + // node-redis rejects every command flushed by one disconnect with a + // single shared error object; the adapter must not write to it. + const client = fakeClient(); + const shared = new Error("socket torn down"); + client.dialcacheInvalidate.mockRejectedValueOnce(shared); + client.sendCommand.mockRejectedValueOnce(shared); + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).rejects.toBe(shared); + + expect(shared.cause).toBeUndefined(); + }); + + it("passes a non-Error invalidation retry rejection through as-is", async () => { const client = fakeClient(); - const retryFailure = new Error("wrapped transport failure", { cause: "socket closed" }); client.dialcacheInvalidate.mockRejectedValueOnce(new Error("NOPERM evalsha denied")); - client.sendCommand.mockRejectedValueOnce(retryFailure); + client.sendCommand.mockRejectedValueOnce("socket closed"); const adapter = createNodeRedisDialCacheClient(client as never); await expect( adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), - ).rejects.toBe(retryFailure); + ).rejects.toBe("socket closed"); + }); + + it("validates the invalidation retry reply through the shared validator", async () => { + // The retry bypasses the registered transformReply, so the trailing + // validator is the only guard on this path. + const client = fakeClient({ eval: 0 }); + client.dialcacheInvalidate.mockRejectedValueOnce(new Error("NOPERM evalsha denied")); + const adapter = createNodeRedisDialCacheClient(client as never); - expect(retryFailure.cause).toBe("socket closed"); + await expectProtocolError( + Promise.resolve(adapter.invalidate({ + watermarkKey: "tracked:{id}:watermark", + futureBufferMs: 50, + })), + "Invalid DialCache Redis invalidate reply; expected integer 1", + ); + expect(client.sendCommand).toHaveBeenCalledTimes(1); }); it("does not retry an invalidation reply-domain violation", async () => { diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index f98597e..32c1a09 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -118,8 +118,13 @@ describe("DialCache Redis protocol on Redis Cluster", () => { advancedConfiguration: { connectionTimeout: 2_000 }, }); } catch (error) { - if (process.env.CI !== undefined) { - throw error; + const ci = process.env.CI; + if (ci !== undefined && ci !== "" && ci !== "0" && ci !== "false") { + throw new Error( + "GLIDE cluster client unavailable on CI, so the only GLIDE cluster coverage would " + + "silently skip; commonly the cluster's announced container IPs are not host-routable", + { cause: error }, + ); } console.warn("GLIDE cluster client unavailable; skipping GLIDE cluster assertions", error); } @@ -221,6 +226,8 @@ describe("DialCache Redis protocol on Redis Cluster", () => { const before = await dialcache.enable(async () => await getUser("123")); version = 2; await dialcache.invalidateRemote("user_id", "123"); + // Small margin is fine here: the assertion is served by the source + // fallback whether or not the refill write beats the fence. await new Promise((resolve) => setTimeout(resolve, 2)); const after = await dialcache.enable(async () => await getUser("123")); @@ -286,7 +293,9 @@ describe("DialCache Redis protocol on Redis Cluster", () => { expect(await adapter.read({ valueKey, watermarkKey })).toBe("glide"); await adapter.invalidate({ watermarkKey, futureBufferMs: 0 }); - await new Promise((resolve) => setTimeout(resolve, 2)); + // The follow-up write's stamp is fenced unless server time passes the + // zero-buffer watermark; the read-null below holds at any margin. + await new Promise((resolve) => setTimeout(resolve, 25)); expect(await adapter.read({ valueKey, watermarkKey })).toBeNull(); expect( await adapter.write({ valueKey, watermarkKey, cacheTtlMs: 60_000, value: "glide-2" }), @@ -330,7 +339,6 @@ describe("DialCache Redis protocol on Redis Cluster", () => { await flushAllMasters(); await expect(adapter.invalidate({ watermarkKey, futureBufferMs: 0 })).resolves.toBeUndefined(); - await new Promise((resolve) => setTimeout(resolve, 2)); expect(await adapter.read({ valueKey, watermarkKey })).toBeNull(); }); }); diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index a63a781..798e2b0 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -1322,7 +1322,10 @@ describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { version = 2; const cached = await dialcache.enable(async () => await getUser("123")); await dialcache.invalidateRemote("user_id", "123"); - await new Promise((resolve) => setTimeout(resolve, 2)); + // The refill's stamp is fenced unless server time passes the + // zero-buffer watermark; the afterScriptFlush read needs that write to + // have been published (calls must stay 2). + await new Promise((resolve) => setTimeout(resolve, 25)); const refreshed = await dialcache.enable(async () => await getUser("123")); await admin.scriptFlush(); const afterScriptFlush = await dialcache.enable(async () => await getUser("123")); diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index dfefec1..7f72afc 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -702,6 +702,36 @@ describe("Valkey GLIDE adapter", () => { expect(second.cause).toBe("socket closed"); }); + it("passes a non-Error invalidation retry rejection through without decoration", async () => { + // The cause attachment must guard on instanceof Error: assigning to a + // primitive rejection would throw a TypeError and mask the failure. + const client = fakeClient(); + client.customCommand + .mockRejectedValueOnce(new Error("read ECONNRESET")) + .mockRejectedValueOnce("socket closed"); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }), + ).rejects.toBe("socket closed"); + }); + + it("validates the invalidation retry reply through the shared validator", async () => { + // The retry reply has no other guard; a non-1 integer must still fail. + const client = fakeClient(0); + client.customCommand.mockRejectedValueOnce(new Error("NOPERM evalsha denied")); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expectProtocolError( + Promise.resolve(adapter.invalidate({ + watermarkKey: "tracked:{id}:watermark", + futureBufferMs: 50, + })), + "Invalid DialCache Redis invalidate reply; expected integer 1", + ); + expect(client.customCommand).toHaveBeenCalledTimes(2); + }); + it("uses Batch and Decoder from the supplied GLIDE module instance", async () => { class OtherBatch { mget(): this { From 13964a11e31965b8d65b919a2791e9347dcbe523 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 8 Aug 2026 16:46:03 -0700 Subject: [PATCH 12/12] fix(redis): correct the healed-retry telemetry recipe and close the last 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 --- AGENTS.md | 4 +-- README.md | 2 +- scripts/test-package.mjs | 60 +++++++++++++++++++++++++++++++++++++-- src/valkey-glide.ts | 3 +- test/node-redis.test.ts | 8 ++++-- test/valkey-glide.test.ts | 5 +++- 6 files changed, 71 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47fcada..acc2626 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,14 +10,14 @@ DialCache is a TypeScript caching library with explicit request-scoped enablemen src/ index.ts # Public root entry point (barrel) dialcache.ts # Main DialCache API and cached-function wrapper - errors.ts # Public error classes + errors.ts # Public core error classes (DialCacheError hierarchy) config.ts # Public configuration and rollout types context.ts # AsyncLocalStorage-based enabled context key.ts # Structured cache keys and Redis hash tags metrics.ts # Backend-neutral metrics adapter contract prometheus.ts # Optional Prometheus adapter datadog.ts # Optional Datadog (DogStatsD) adapter - redis-client.ts # Client-independent semantic Redis interface + redis-client.ts # Client-independent semantic Redis interface and its public error classes node-redis.ts # node-redis adapter and script registration valkey-glide.ts # Valkey GLIDE adapter (standalone and cluster) redis-protocol.ts # Public frame codec and Lua protocol exports diff --git a/README.md b/README.md index 8beeec5..0cbd5a2 100644 --- a/README.md +++ b/README.md @@ -410,7 +410,7 @@ Native commands retain Redis's wrong-type behavior. An untracked `GET` surfaces Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an explicit primary route in cluster mode; in standalone mode it sends `MGET` through a one-command non-atomic batch because direct read commands follow the client's replica-read preference. Standalone batches use the primary, and `MGET` itself provides the atomic snapshot without consuming caller-owned `WATCH` state. The GLIDE helper distinguishes those modes from the direct client's runtime identity and rejects ambiguous clients instead of silently choosing a route. -For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation dispatches as `EVALSHA` by the script's source SHA1 on both adapters, and both retry a rejected dispatch once by re-sending the source as `EVAL`: the invalidation script is idempotent — its watermark only advances and its TTL only widens — so a duplicate execution after an ambiguous failure is harmless, and the retry heals a flushed script cache and an `EVALSHA`-rejecting proxy without depending on error wording. Reply-domain violations are deterministic and are not retried. When the retry also fails, the surfaced error is the retry's. On GLIDE the original rejection is attached as the retry error's `cause` unless it already carries one, and a failing invalidation is bounded by roughly two `requestTimeout` windows. On node-redis the retry rejection surfaces unmodified and the original is discarded — the library rejects every command flushed by a single disconnect with one shared error instance, so the adapter never mutates it — and no per-command deadline exists: `disableOfflineQueue`, `commandsQueueMaxLength`, and `reconnectStrategy` bound queueing and dispatch (the setup snippet above disables the offline queue, which makes a disconnected retry fail fast instead of waiting for reconnect), but a command already written to a hung connection has no reply deadline. Its own `NOSCRIPT` recovery may also add one round trip before the adapter's retry. A retry that heals is indistinguishable from a first-attempt success in DialCache's metrics and logs; a fleet stuck permanently healing (an `EVALSHA`-rejecting proxy, an ACL granting `EVAL` but not `EVALSHA`) shows up only server-side, as `INFO commandstats` `cmdstat_eval` calls approaching `cmdstat_evalsha`. +For the stamp and invalidation scripts, node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` after `NOSCRIPT`; its cluster client routes commands by their first key and performs that fallback on the selected shard. That retry likewise extends the unreadable-placeholder gap of a tracked write by one round trip on a cold script cache. The GLIDE adapter batches the tracked write's `SET` with an `EVALSHA` of the stamp script — routing cluster write batches to the slot primary — and recovers from a flushed script cache by re-sending the stamp as `EVAL` with its source, which the server caches under the same SHA1, so the first tracked write against a cold script cache pays one extra round trip. A late stamp stays paired to its own placeholder through the nonce; if the placeholder is gone by then, the write fails rather than publishing. Invalidation dispatches as `EVALSHA` by the script's source SHA1 on both adapters, and both retry a rejected dispatch once by re-sending the source as `EVAL`: the invalidation script is idempotent — its watermark only advances and its TTL only widens — so a duplicate execution after an ambiguous failure is harmless, and the retry heals a flushed script cache and an `EVALSHA`-rejecting proxy without depending on error wording. Reply-domain violations are deterministic and are not retried. When the retry also fails, the surfaced error is the retry's. On GLIDE the original rejection is attached as the retry error's `cause` unless it already carries one, and a failing invalidation is bounded by roughly two `requestTimeout` windows. On node-redis the retry rejection surfaces unmodified and the original is discarded — the library rejects every command flushed by a single disconnect with one shared error instance, so the adapter never mutates it — and no per-command deadline exists: `disableOfflineQueue`, `commandsQueueMaxLength`, and `reconnectStrategy` bound queueing and dispatch (the setup snippet above disables the offline queue, which makes a disconnected retry fail fast instead of waiting for reconnect), but a command already written to a hung connection has no reply deadline. Its own `NOSCRIPT` recovery may also add one round trip before the adapter's retry. A retry that heals is indistinguishable from a first-attempt success in DialCache's metrics and logs. The genuinely silent regime is invalidation-dispatch healing: watch server-side `INFO commandstats` for `cmdstat_eval` calls rising in step with invalidation volume while `cmdstat_evalsha` stays flat (a proxy rejecting `EVALSHA` before it reaches Redis) or accrues `rejected_calls` (an ACL denial). A sustained stamp fault is loud by contrast — the ACL paragraph below describes its amplitude. A tracked write rejected by an active future watermark uses `UNLINK` to remove the value key — the placeholder it just stored, along with any logically stale frame — without synchronously freeing it on Redis's command path. The mutation protocol therefore requires a server that implements `UNLINK` (Redis 4.0 or later, or a compatible Valkey release). Command-restricted Redis ACLs must allow the client to issue the native `GET`, `MGET`, and `SET` commands — `SET` newly carries every write, where the previous protocol wrote only through scripts — plus `EVALSHA` (the steady-state dispatch for both mutation scripts) and `EVAL` (both adapters recover a flushed script cache by re-sending script sources, never via `SCRIPT LOAD`). Server versions differ on whether script-invoked commands are also checked against the invoking user, so grant what the mutation scripts invoke as well: `TIME`, `GET`, `SET`, and `PTTL` (both scripts), plus the stamp's `PEXPIRE`, `UNLINK`, `GETRANGE`, and `SETRANGE`. Verify those grants before upgrading, because the failure amplitude of a persistent stamp fault changed. A sustained stamp failure (denied command, a proxy rejecting `EVALSHA`) still lands every paired `SET`, so each tracked write replaces the last served value with an unreadable placeholder while also suppressing process-local publication — within one TTL horizon the source absorbs full traffic, where the previous protocol degraded to serving stale values until expiry. DialCache's integration matrix covers Redis 6.2 and Valkey 8. diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 5c4e486..90f8702 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -1418,8 +1418,36 @@ try { throw new Error("Expected an invalid GLIDE script reply to fail"); } catch (error) { if (!(error instanceof root.DialCacheRedisProtocolError)) { - throw new Error("The GLIDE protocol error does not match the root ESM export"); + throw new Error("The GLIDE protocol error does not match the root ESM export", { cause: error }); } +} +const esmInvalidationDispatches = []; +const esmFakeInvalidationClient = { + customCommand: async (args) => { + esmInvalidationDispatches.push(args); + if (esmInvalidationDispatches.length === 1) { + throw new Error("packed invalidation dispatch rejected"); + } + return 1; + }, +}; +const esmInvalidationRuntime = { + ...appGlide, + GlideClient: { [Symbol.hasInstance]: (value) => value === esmFakeInvalidationClient }, + GlideClusterClient: { [Symbol.hasInstance]: () => false }, +}; +await glide + .createValkeyGlideDialCacheClient(esmFakeInvalidationClient, esmInvalidationRuntime) + .invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }); +if ( + esmInvalidationDispatches.length !== 2 + || esmInvalidationDispatches[0][0] !== "EVALSHA" + || esmInvalidationDispatches[1][0] !== "EVAL" +) { + throw new Error("The ESM GLIDE invalidation retry did not dispatch EVALSHA then EVAL"); +} +if (esmInvalidationDispatches[1][1] !== redisProtocol.INVALIDATE_CACHE_SCRIPT) { + throw new Error("The ESM GLIDE bundle's embedded invalidation source diverged from the redis-protocol entry"); }`, ], { cwd: workspace }, @@ -1479,9 +1507,37 @@ void (async () => { throw new Error("Expected an invalid GLIDE script reply to fail"); } catch (error) { if (!(error instanceof root.DialCacheRedisProtocolError)) { - throw new Error("The GLIDE protocol error does not match the root CommonJS export"); + throw new Error("The GLIDE protocol error does not match the root CommonJS export", { cause: error }); } } + const cjsInvalidationDispatches = []; + const cjsFakeInvalidationClient = { + customCommand: async (args) => { + cjsInvalidationDispatches.push(args); + if (cjsInvalidationDispatches.length === 1) { + throw new Error("packed invalidation dispatch rejected"); + } + return 1; + }, + }; + const cjsInvalidationRuntime = { + ...appGlide, + GlideClient: { [Symbol.hasInstance]: (value) => value === cjsFakeInvalidationClient }, + GlideClusterClient: { [Symbol.hasInstance]: () => false }, + }; + await glide + .createValkeyGlideDialCacheClient(cjsFakeInvalidationClient, cjsInvalidationRuntime) + .invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 50 }); + if ( + cjsInvalidationDispatches.length !== 2 + || cjsInvalidationDispatches[0][0] !== "EVALSHA" + || cjsInvalidationDispatches[1][0] !== "EVAL" + ) { + throw new Error("The CommonJS GLIDE invalidation retry did not dispatch EVALSHA then EVAL"); + } + if (cjsInvalidationDispatches[1][1] !== redisProtocol.INVALIDATE_CACHE_SCRIPT) { + throw new Error("The CommonJS GLIDE bundle's embedded invalidation source diverged from the redis-protocol entry"); + } })();`, ], { cwd: workspace }, diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index 065024d..7b33915 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -140,8 +140,7 @@ function classifyValkeyGlideClient( * Recovery differs by script: the stamp is retried only on NOSCRIPT, while * invalidation retries any rejection once with EVAL by source. When that * retry also fails, the original rejection is attached as the retry error's - * `cause` unless it already carries one — safe here because GLIDE constructs - * a fresh error object per rejection. + * `cause` unless it already carries one. */ export function createValkeyGlideDialCacheClient( client: ValkeyGlideScriptingClient, diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index 0360781..b91cc8e 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -579,9 +579,11 @@ describe("node-redis adapter", () => { expect(client.sendCommand).toHaveBeenCalledTimes(1); }); - it("never mutates a flush-shared error instance rejecting both dispatches", async () => { - // node-redis rejects every command flushed by one disconnect with a - // single shared error object; the adapter must not write to it. + it("never writes to the rejection even when one instance rejects both dispatches", async () => { + // node-redis flush rejections are shared with every other in-flight + // caller and the client's "error" listeners. The same-instance fixture + // is a deliberate over-approximation: even if one object surfaced on + // both dispatches, the adapter writes nothing to it. const client = fakeClient(); const shared = new Error("socket torn down"); client.dialcacheInvalidate.mockRejectedValueOnce(shared); diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index 7f72afc..ae2426b 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -636,7 +636,8 @@ describe("Valkey GLIDE adapter", () => { } for (const reply of INVALID_INVALIDATION_REPLIES) { - const adapter = createValkeyGlideDialCacheClient(fakeClient(reply), mockGlide); + const client = fakeClient(reply); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); await expectProtocolError( Promise.resolve(adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", @@ -644,6 +645,8 @@ describe("Valkey GLIDE adapter", () => { })), invalidationMessage, ); + // A reply-domain violation is deterministic and must never be retried. + expect(client.customCommand).toHaveBeenCalledTimes(1); } });