diff --git a/AGENTS.md b/AGENTS.md index b8a5cfe..acc2626 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,17 +8,21 @@ 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 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 - redis-client.ts # Client-independent semantic Redis interface - node-redis.ts # node-redis adapter and script registration - redis-protocol.ts # Public Lua protocol exports + datadog.ts # Optional Datadog (DogStatsD) adapter + 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 serializer.ts # Serializer contract and JSON implementation - internal/ # Cache layers, runtime config, payload compression, and Lua scripts + internal/ # Cache layers, runtime config, payload compression, and mutation Lua scripts test/ # Unit and Redis integration tests ``` @@ -32,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 5466998..0cbd5a2 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: @@ -380,37 +380,39 @@ 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 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. +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. -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` 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. 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. 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 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 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 @@ -422,22 +424,24 @@ 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 -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`, `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: ```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 (optionally zstd-compressed; see Compression) ``` -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`. Payloads stored raw keep their exact serialized bytes: strings are stored as UTF-8 and Buffers byte-for-byte without base64 expansion, except that binary output beginning with a [compression envelope byte](#compression) (`0x00`–`0x02`) gains a one-byte escape prefix on the wire. Payloads at or above the compression threshold may instead be stored as a zstd envelope (see [Compression](#compression)), so wire bytes for large values are not the serializer's output. Adapters return the frame payload as-is; the envelope — including restoring a compressed string's representation before `serializer.load` — is interpreted by the core above them. +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`. Payloads stored raw keep their exact serialized bytes: strings are stored as UTF-8 and Buffers byte-for-byte without base64 expansion, except that binary output beginning with a [compression envelope byte](#compression) (`0x00`–`0x02`) gains a one-byte escape prefix on the wire. Payloads at or above the compression threshold may instead be stored as a zstd envelope (see [Compression](#compression)), so wire bytes for large values are not the serializer's output. Adapters return the frame payload as-is; the envelope — including restoring a compressed string's representation before `serializer.load` — is interpreted by the core above them. 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. @@ -558,7 +562,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. @@ -653,7 +657,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. @@ -663,7 +667,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. 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. @@ -872,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 | @@ -900,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 56e6794..350564a 100644 --- a/release.config.mjs +++ b/release.config.mjs @@ -10,7 +10,9 @@ 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 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" }, { type: "perf", release: "patch" }, diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index ac9620b..90f8702 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -52,12 +52,34 @@ 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 } from "dialcache/redis-protocol"; +import { + ceilSupportedCacheTtlMs, + decodeRedisFrame, + decodeTrackedRedisFrame, + encodeRedisFrame, + encodeTrackedRedisPlaceholder, + resolveTrackedRedisWriteReply, + validateRedisScriptInvalidationReply, + 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. 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, @@ -153,6 +175,20 @@ const decodedStaleRedisPayload: string | Buffer | null = decodeTrackedRedisFrame emptyRedisFrame, Buffer.from("1"), ); +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( + "tracked:{id}:value", + "tracked:{id}:watermark", + 1_000, + trackedRedisPlaceholder.nonce, +); const fallbackTimeoutError = new FallbackTimeoutError("Load", 1_000); const redisReadTimeoutError = new RedisReadTimeoutError("Load", 100); const coalescingState: CoalescingState = cache.getCoalescingState(); @@ -497,8 +533,25 @@ 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 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; const globalSerializer: Serializer = { dump: () => "global", @@ -559,11 +612,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"; @@ -576,7 +633,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" }], @@ -584,12 +641,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; @@ -669,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"); @@ -700,7 +765,7 @@ try { console.log("${fallbackTimeoutMarker}"); } try { - nodeRedis.dialcacheRedisScripts.dialcacheWrite.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)) { @@ -722,6 +787,94 @@ 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 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"); +} +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"); + } +} +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. +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); @@ -945,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"); @@ -978,7 +1139,7 @@ void (async () => { } })(); try { - nodeRedis.dialcacheRedisScripts.dialcacheWrite.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)) { @@ -1000,6 +1161,94 @@ 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 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"); +} +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"); + } +} +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. +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); @@ -1125,20 +1374,32 @@ 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"); } const esmFakeGlideClient = { - 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"); + 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.")]; + }, + 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 (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"); } - return 2; + return 3; }, }; const esmGlideRuntime = { @@ -1148,14 +1409,45 @@ 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)) { - 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 }); } -} finally { - adapter.dispose(); +} +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 }, @@ -1170,21 +1462,33 @@ 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) { throw new Error("The package test requires two distinct GLIDE module instances"); } const cjsFakeGlideClient = { - 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"); + 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 2; + return ["OK", new Error("NOSCRIPT No matching script. Please use EVAL.")]; + }, + 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 (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"); + } + return 3; }, }; const cjsGlideRuntime = { @@ -1194,14 +1498,45 @@ 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)) { - 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 }); } - } finally { - adapter.dispose(); + } + 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"); } })();`, ], diff --git a/src/index.ts b/src/index.ts index 116f80c..8f27a09 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,6 +42,7 @@ export type { DialCacheKeyInit } from "./key.js"; export { DialCacheRedisPayloadEncodingError, DialCacheRedisPayloadError, + DialCacheRedisPlaceholderLostError, DialCacheRedisProtocolError, } from "./redis-client.js"; export type { CompressionConfig } from "./internal/compression.js"; diff --git a/src/internal/duration.ts b/src/internal/duration.ts index 06cfa8f..bd83c86 100644 --- a/src/internal/duration.ts +++ b/src/internal/duration.ts @@ -20,6 +20,23 @@ export function cacheTtlSecToMs(ttlSec: number): number { return ttlSec * 1_000; } +/** + * 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; + 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..40d4e55 100644 --- a/src/internal/redis-payload.ts +++ b/src/internal/redis-payload.ts @@ -1,15 +1,20 @@ +import { randomBytes } from "node:crypto"; + import { DialCacheRedisPayloadEncodingError, DialCacheRedisPayloadError, type RedisCachePayload, } from "../redis-client.js"; -import { - REDIS_ENCODING_BINARY, - REDIS_ENCODING_UTF8, - REDIS_FRAME_VERSION, -} from "./redis-scripts.js"; -const REDIS_FRAME_HEADER_BYTES = 9; +export const REDIS_FRAME_VERSION = 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; +const REDIS_FRAME_TIMESTAMP_OFFSET = 1; +export const REDIS_FRAME_TIMESTAMP_BYTES = 8; + +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 { @@ -40,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; } @@ -56,6 +61,62 @@ function decodeRedisPayload(raw: Buffer): RedisCachePayload { throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding"); } +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] = 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); + } else { + frame.write(payload, REDIS_FRAME_MIN_BYTES, "utf8"); + } + 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 @@ -87,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 d156bc7..b018aab 100644 --- a/src/internal/redis-script-reply.ts +++ b/src/internal/redis-script-reply.ts @@ -1,12 +1,41 @@ -import { DialCacheRedisProtocolError } from "../redis-client.js"; +import { + DialCacheRedisPlaceholderLostError, + DialCacheRedisProtocolError, +} from "../redis-client.js"; -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 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 | 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 DialCacheRedisPlaceholderLostError( + "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 5d725cb..1fcfcd7 100644 --- a/src/internal/redis-scripts.ts +++ b/src/internal/redis-scripts.ts @@ -1,8 +1,10 @@ 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; +import { + REDIS_FRAME_HEADER_BYTES, + REDIS_FRAME_PLACEHOLDER_VERSION, + REDIS_FRAME_TIMESTAMP_BYTES, + REDIS_FRAME_VERSION, +} from "./redis-payload.js"; const WATERMARK_TTL_MARGIN_MS = 60_000; @@ -25,36 +27,21 @@ 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") +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") 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 +53,23 @@ 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. 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`, - WRITE_FRAME_LUA, + String.raw`local stamped = 1 +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, + -- 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 redis.call("SET", KEYS[2], "0", "PX", desired_ttl_ms) @@ -83,7 +81,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 5fd2f5a..4c80358 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -2,20 +2,24 @@ 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, + encodeTrackedRedisPlaceholder, } from "./internal/redis-payload.js"; +import { ceilSupportedCacheTtlMs } from "./internal/duration.js"; import { + resolveTrackedRedisWriteReply, validateRedisScriptInvalidationReply, validateRedisScriptWriteReply, + validateRedisSetReply, } from "./internal/redis-script-reply.js"; import { DialCacheRedisPayloadError, + DialCacheRedisProtocolError, type DialCacheRedisClient, } from "./redis-client.js"; @@ -49,19 +53,17 @@ 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 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, nonce: Buffer], number >; readonly dialcacheInvalidate: NodeRedisScript< @@ -70,24 +72,10 @@ export type DialCacheNodeRedisScripts = { >; }; +/** See {@link DialCacheNodeRedisScripts}: wiring for the adapter, not a direct write API. */ 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 +83,9 @@ export const dialcacheRedisScripts: DialCacheNodeRedisScripts = { valueKey: string, watermarkKey: string, cacheTtlMs: number, - encoding: number, - payload: string | Buffer, + nonce: Buffer, ): Array { - return [valueKey, watermarkKey, String(cacheTtlMs), String(encoding), payload]; + return [valueKey, watermarkKey, String(cacheTtlMs), nonce]; }, transformReply: writeReply, }), @@ -115,13 +102,11 @@ 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, + nonce: Buffer, ): Promise; dialcacheInvalidate(watermarkKey: string, futureBufferMs: number): Promise; } @@ -129,7 +114,7 @@ interface NodeRedisWriteClient { interface NodeRedisStandaloneClient extends NodeRedisWriteClient { get(options: BufferReplyOptions, valueKey: string): Promise; sendCommand( - args: Array, + args: Array, options: BufferReplyOptions, ): Promise; } @@ -141,7 +126,7 @@ interface NodeRedisClusterClient extends NodeRedisWriteClient { sendCommand( firstKey: string, isReadonly: false, - args: Array, + args: Array, options: BufferReplyOptions, ): Promise; } @@ -164,29 +149,70 @@ 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); } +function sendFrameSet( + client: NodeRedisClient, + valueKey: string, + frame: Buffer, + cacheTtlMs: number, +): Promise { + return sendKeyedCommand( + client, + valueKey, + ["SET", valueKey, frame, "PX", String(cacheTtlMs)], + 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). Invalidation + * 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 ( + 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 @@ -204,22 +230,62 @@ 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; + } + 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, 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. + if (setResult.status === "rejected") { + throw setResult.reason; + } + validateRedisSetReply(setResult.value); + if (stampResult.status === "rejected") { + throw stampResult.reason; + } + 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; + } + // 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-client.ts b/src/redis-client.ts index aab67c8..93f0c1e 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; @@ -140,7 +166,42 @@ 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 using the `dialcache/redis-protocol` + * encoders, or preserve their exact behavior. + * + * Untracked writes are one native `SET valueKey frame PX cacheTtlMs` whose + * 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 an `encodeTrackedRedisPlaceholder` frame, + * followed by `WRITE_TRACKED_STAMP_SCRIPT` with `KEYS = [valueKey, + * 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 + * 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, 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; /** * Advance the watermark monotonically after the source mutation commits. diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index 9606653..0afaeb6 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -1,23 +1,32 @@ /** * 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 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 { 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, + encodeTrackedRedisPlaceholder, + type TrackedRedisPlaceholder, } 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 0918faf..7b33915 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -1,41 +1,50 @@ +import { createHash } from "node:crypto"; + +import { ceilSupportedCacheTtlMs } from "./internal/duration.js"; import { decodeRedisFrame, decodeTrackedRedisFrame, - redisPayloadEncoding, + encodeRedisFrame, + encodeTrackedRedisPlaceholder, } 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 { + resolveTrackedRedisWriteReply, validateRedisScriptInvalidationReply, - validateRedisScriptWriteReply, + validateRedisSetReply, } from "./internal/redis-script-reply.js"; import { DialCacheRedisPayloadError, type DialCacheRedisClient } from "./redis-client.js"; type ValkeyGlideString = string | Buffer; +// 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"); + +// 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.toLowerCase().includes("noscript"); +} + interface ValkeyGlideBatch { + customCommand(args: ValkeyGlideString[]): ValkeyGlideBatch; mget(keys: ValkeyGlideString[]): ValkeyGlideBatch; } -interface ValkeyGlideClusterReadClient { +export interface ValkeyGlideScriptingClient { customCommand( args: ValkeyGlideString[], options: { decoder: TDecoder; - route: { type: "primarySlotKey"; key: string }; + route?: { type: "primarySlotKey"; key: string }; }, ): Promise; -} - -export interface ValkeyGlideScriptHandle { - /** Release the native GLIDE script registration. */ - release(): void; -} - -export interface ValkeyGlideScriptingClient { get( key: ValkeyGlideString, options: { decoder: TDecoder }, @@ -43,14 +52,9 @@ export interface ValkeyGlideScriptingClient { exec( batch: ValkeyGlideBatch, raiseOnError: boolean, - options: { decoder: TDecoder }, - ): Promise; - invokeScript( - script: TScript, options: { - keys: ValkeyGlideString[]; - args: ValkeyGlideString[]; decoder: TDecoder; + route?: { type: "primarySlotKey"; key: string }; }, ): Promise; } @@ -59,27 +63,21 @@ 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. */ + 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. */ readonly GlideClusterClient: ValkeyGlideClientIdentity; - /** The Script constructor exported by the same GLIDE module instance as the client. */ - readonly Script: new (source: string) => TScript; /** The Decoder enum exported by the same GLIDE module instance as the client. */ readonly Decoder: { readonly Bytes: TDecoder; }; } -interface DialCacheGlideScripts { - readonly write: TScript; - readonly writeTracked: TScript; - readonly invalidate: TScript; -} - function matchesValkeyGlideIdentity( identity: unknown, name: "GlideClient" | "GlideClusterClient", @@ -95,9 +93,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( @@ -119,132 +117,160 @@ function classifyValkeyGlideClient( return isCluster ? "cluster" : "standalone"; } -export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { - /** Release the adapter-owned GLIDE Script handles. Does not close the wrapped GLIDE client. */ - dispose(): void; -} - /** - * Wrap a caller-owned GLIDE connection. The returned adapter owns only its - * three 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. + * 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 handles 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. The standalone batch is deliberately non-atomic: - * MGET itself is atomic, and MULTI/EXEC would consume caller-owned WATCH state. + * 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. + * 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. */ -export function createValkeyGlideDialCacheClient( - client: ValkeyGlideScriptingClient, - glide: ValkeyGlideRuntime, -): ValkeyGlideDialCacheClient { - if (typeof glide.Batch !== "function") { +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 a Batch constructor", + "Valkey GLIDE DialCache requires @valkey/valkey-glide >=2.0.0 with Batch and ClusterBatch constructors", ); } - const clientKind = classifyValkeyGlideClient(client, glide); - const clusterClient = clientKind === "cluster" - ? client as ValkeyGlideScriptingClient - & ValkeyGlideClusterReadClient - : undefined; - const scripts: DialCacheGlideScripts = { - write: new glide.Script(WRITE_CACHE_SCRIPT), - writeTracked: new glide.Script(WRITE_TRACKED_CACHE_SCRIPT), - invalidate: 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; - } - }; - - const invoke = async ( - script: TScript, - keys: ValkeyGlideString[], - args: ValkeyGlideString[] = [], - ): Promise => run( - () => client.invokeScript(script, { keys, args, decoder: glide.Decoder.Bytes }), - ); + 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 }) { 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 = clusterClient !== undefined - ? await run( - () => clusterClient.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], + keyedOptions(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"); } return decodeTrackedRedisFrame(pair[0], pair[1]); }, async write(request) { - const { valueKey, watermarkKey, cacheTtlMs, value } = request; - const encoding = redisPayloadEncoding(value); - const raw = watermarkKey === undefined - ? await invoke(scripts.write, [valueKey], [String(cacheTtlMs), String(encoding), value]) - : await invoke( - scripts.writeTracked, - [valueKey, watermarkKey], - [String(cacheTtlMs), String(encoding), value], - ); - return validateRedisScriptWriteReply(raw) === 1; - }, - async invalidate({ watermarkKey, futureBufferMs }) { - const raw = await invoke( - scripts.invalidate, - [watermarkKey], - [String(futureBufferMs)], - ); - validateRedisScriptInvalidationReply(raw); - }, - dispose() { - if (disposed) { - return; + const { valueKey, watermarkKey, value } = request; + const cacheTtlMs = ceilSupportedCacheTtlMs(request.cacheTtlMs); + const execOptions = keyedOptions(valueKey); + + if (watermarkKey === undefined) { + const frame = encodeRedisFrame(value, Date.now()); + validateRedisSetReply( + await client.customCommand(["SET", valueKey, frame, "PX", String(cacheTtlMs)], execOptions), + ); + return true; } - if (activeOperations > 0) { - throw new Error("Cannot dispose Valkey GLIDE DialCache client while operations are in flight"); + + const { frame, nonce } = encodeTrackedRedisPlaceholder(value); + const stampArgs: ValkeyGlideString[] = [String(cacheTtlMs), nonce]; + 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; } - disposed = true; - for (const script of Object.values(scripts)) { - script.release(); + validateRedisSetReply(setReply); + let stampReply: unknown = rawStamp; + if (rawStamp instanceof Error) { + if (!isNoScriptError(rawStamp)) { + 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, + ); } + return resolveTrackedRedisWriteReply(stampReply); + }, + async invalidate({ watermarkKey, futureBufferMs }) { + const invalidateArgs: ValkeyGlideString[] = [String(futureBufferMs)]; + const options = keyedOptions(watermarkKey); + let raw: unknown; + try { + raw = await client.customCommand( + ["EVALSHA", INVALIDATE_CACHE_SHA1, "1", watermarkKey, ...invalidateArgs], + options, + ); + } catch (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) { + // 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; + } + throw retryError; + } + } + validateRedisScriptInvalidationReply(raw); }, }; } diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index dc740b9..b91cc8e 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -4,13 +4,15 @@ import { CacheLayer, DialCache, DialCacheKeyConfig, + DialCacheRedisPlaceholderLostError, 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, - 2, + 3, 0.5, Number.NaN, Number.POSITIVE_INFINITY, @@ -21,22 +23,32 @@ 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]; interface FakeReplies { readonly get?: unknown; readonly mGet?: unknown; - readonly write?: unknown; - readonly writeTracked?: unknown; + readonly set?: unknown; + readonly eval?: 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"; + } + 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), dialcacheInvalidate: vi.fn(async () => Object.hasOwn(replies, "invalidate") ? replies.invalidate : 1), }; } @@ -72,39 +84,54 @@ 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]); - + const nonce = Buffer.from("01234567"); 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, + nonce, ), - ).toEqual(["tracked:{id}:value", "tracked:{id}:watermark", "1000", "1", binary]); + ).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"); + // 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 () => { 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 +156,228 @@ 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(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); + const write = adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + }); + 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 () => { + 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); + + 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); + 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); + 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, + expect.any(Buffer), + ); + }); + + 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); + + // 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 () => { const client = fakeClient(); const adapter = createNodeRedisDialCacheClient(client as never); @@ -227,17 +476,11 @@ 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) { - 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", @@ -261,16 +504,155 @@ 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, options] = client.sendCommand.mock.calls[0] as [Array, object]; + expect(args).toEqual([ + "EVAL", + INVALIDATE_CACHE_SCRIPT, + "1", + "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 () => { + 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, 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 unmodified", 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).toBeUndefined(); + expect(client.sendCommand).toHaveBeenCalledTimes(1); + }); + + 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); + 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(); + client.dialcacheInvalidate.mockRejectedValueOnce(new Error("NOPERM evalsha denied")); + client.sendCommand.mockRejectedValueOnce("socket closed"); + const adapter = createNodeRedisDialCacheClient(client as never); + + 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 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); + + 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 () => { + // 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.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.dialcacheWriteTrackedStamp.transformReply(2)).toBe(2); 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, ); } @@ -299,8 +681,37 @@ 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({ 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..32c1a09 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,36 @@ 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. 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) => ({ + host: container.getIpAddress(networkName), + port: 6379, + })), + requestTimeout: 5_000, + advancedConfiguration: { connectionTimeout: 2_000 }, + }); + } catch (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); + } }); afterAll(async () => { + glideCluster?.close(); await cluster?.quit(); await Promise.all(containers.map(async (container) => await container.stop())); await network?.stop(); @@ -123,6 +153,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 +180,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))); @@ -174,7 +208,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", @@ -192,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")); @@ -203,6 +239,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 () => { @@ -234,4 +278,67 @@ 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 }); + // 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" }), + ).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(); + expect(await adapter.read({ valueKey, watermarkKey })).toBeNull(); + }); }); diff --git a/test/redis-payload.test.ts b/test/redis-payload.test.ts index d291360..144b172 100644 --- a/test/redis-payload.test.ts +++ b/test/redis-payload.test.ts @@ -1,6 +1,8 @@ import { decodeRedisFrame, decodeTrackedRedisFrame, + encodeRedisFrame, + encodeTrackedRedisPlaceholder, } from "../src/redis-protocol.js"; import { DialCacheRedisPayloadEncodingError, @@ -99,6 +101,86 @@ 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 version-1 frames unreadable on the tracked path", () => { + const zeroStamped = encodeRedisFrame("pending", 0); + + 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("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. + 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", () => { + 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 d5508ae..798e2b0 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -16,14 +16,11 @@ import { MARKER_ESCAPED_RAW, MARKER_ZSTD_UTF8 } from "../src/internal/compressio import { markerCollidingSerializer, type Row } from "./marker-colliding-serializer.js"; import { INVALIDATE_CACHE_SCRIPT, - WRITE_CACHE_SCRIPT, - WRITE_TRACKED_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, - 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" }, @@ -60,25 +57,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, nonce: Buffer): 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; } @@ -87,8 +73,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, @@ -96,10 +81,9 @@ function createNodeRedisHarness(client: NodeRedisTestClient): RedisAdapterHarnes } function createValkeyGlideHarness(client: valkeyGlide.GlideClient): RedisAdapterHarness { - const adapter: ValkeyGlideDialCacheClient = createValkeyGlideDialCacheClient(client, valkeyGlide); + const adapter = 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 ( @@ -121,20 +105,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, nonce) => + await invoke(rawScripts.stamp, [valueKey, watermarkKey], [String(cacheTtlMs), nonce]), invalidate: async (watermarkKey, futureBufferMs) => await invoke( rawScripts.invalidate, @@ -143,7 +115,6 @@ function createValkeyGlideHarness(client: valkeyGlide.GlideClient): RedisAdapter ), }, dispose() { - adapter.dispose(); for (const script of Object.values(rawScripts)) { script.release(); } @@ -302,6 +273,59 @@ 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"); + // 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 () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); @@ -905,6 +929,13 @@ 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 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]); await admin.scriptFlush(); await expect( scriptClient.invalidate({ @@ -1124,7 +1155,11 @@ 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 version-0 placeholder. + const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); + expect(stored?.[0]).toBe(0); + await expect(client.adapter.read({ valueKey, watermarkKey })).resolves.toBeNull(); }); it("rejects invalid raw script arguments before mutating Redis", async () => { @@ -1135,26 +1170,37 @@ 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"); + 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, nonce)).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, nonce), ).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, nonce), ).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"); + 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( + 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"); @@ -1180,8 +1226,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, ); @@ -1192,14 +1238,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, ); @@ -1230,15 +1275,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); @@ -1272,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")); @@ -1307,21 +1360,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 unpromoted. + expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull(); + const stored = await admin.get(commandOptions({ returnBuffers: true }), valueKey); + expect(stored?.[0]).toBe(0); + await admin.del(valueKey); } }); @@ -1575,6 +1635,65 @@ 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 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"; + 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(); + + // 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); + }); + + 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, Buffer.alloc(8, 1))).toBe(2); + + 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, Buffer.alloc(8, 2))).toBe(2); + + 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..ae2426b 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -1,15 +1,19 @@ +import { createHash } from "node:crypto"; + import { beforeEach, describe, expect, it, vi } from "vitest"; import { DialCacheRedisPayloadEncodingError, DialCacheRedisPayloadError, + DialCacheRedisPlaceholderLostError, DialCacheRedisProtocolError, } from "../src/redis-client.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[] = [ -1, - 2, + 3, 0.5, Number.NaN, Number.POSITIVE_INFINITY, @@ -20,27 +24,24 @@ 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[] = []; 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) => { 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 +49,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,52 +69,50 @@ function mockClientIdentity(instances: WeakSet) { const mockGlide = { Batch: MockBatch, + ClusterBatch: MockClusterBatch, 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 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, + _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, _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()), }; - 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, 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); + clusterClients.add(client); + return client; } function redisFrame( @@ -135,8 +141,8 @@ async function expectProtocolError(operation: Promise, message: string) 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 () => { @@ -174,8 +180,7 @@ describe("Valkey GLIDE adapter", () => { true, { decoder: decoderBytes }, ); - expect(client.invokeScript).not.toHaveBeenCalled(); - expect(scriptInstances).toHaveLength(3); + expect(client.customCommand).not.toHaveBeenCalled(); }); it("routes tracked cluster MGET directly to the slot primary", async () => { @@ -206,9 +211,9 @@ 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, }; expect( @@ -217,7 +222,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", () => { @@ -234,7 +238,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", () => { @@ -246,7 +249,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", () => { @@ -255,13 +257,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", - ); - expect(scriptInstances).toHaveLength(0); + 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", + ); + } }); it("preserves GLIDE invocation options when given a core read context", async () => { @@ -278,17 +285,22 @@ describe("Valkey GLIDE adapter", () => { "plain:value", { decoder: decoderBytes }, ); - adapter.dispose(); }); - it("passes string and Buffer writes directly to GLIDE", async () => { + it("writes untracked SETs directly and tracked pairs through a batch", 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,24 +313,267 @@ describe("Valkey GLIDE adapter", () => { adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 100 }), ).resolves.toBeUndefined(); - expect(client.invokeScript).toHaveBeenNthCalledWith( - 1, - expect.any(MockScript), - { keys: ["plain:value"], args: ["1000", "0", "hello"], decoder: decoderBytes }, - ); - expect(client.invokeScript).toHaveBeenNthCalledWith( + 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"); + 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(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 ?? []; + 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(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"), + "2", + "tracked:{id}:value", + "tracked:{id}:watermark", + "2000", + nonce, + ]); + expect(client.exec).toHaveBeenCalledTimes(1); + expect(client.exec).toHaveBeenCalledWith(trackedBatch, false, { decoder: decoderBytes }); + + // Call 1 is the untracked SET; invalidation dispatches by its source SHA1. + expect(client.customCommand).toHaveBeenCalledTimes(2); + expect(client.customCommand).toHaveBeenNthCalledWith( 2, - expect.any(MockScript), + [ + "EVALSHA", + createHash("sha1").update(INVALIDATE_CACHE_SCRIPT).digest("hex"), + "1", + "tracked:{id}:watermark", + "100", + ], + { decoder: decoderBytes }, + ); + }); + + 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); + + const write = adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + }); + await expect(write).rejects.toThrow("DialCache tracked write lost its placeholder before the stamp"); + await expect(write).rejects.toBeInstanceOf(DialCacheRedisPlaceholderLostError); + // Reply 2 is a settled outcome, not a recovery trigger. + expect(client.customCommand).not.toHaveBeenCalled(); + }); + + 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( + 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); + await expect( + adapter.invalidate({ watermarkKey: "tracked:{id}:watermark", futureBufferMs: 25 }), + ).resolves.toBeUndefined(); + + const [, untrackedOptions] = client.customCommand.mock.calls[0] ?? [[], undefined]; + expect(untrackedOptions).toEqual({ + decoder: decoderBytes, + route: { type: "primarySlotKey", key: "plain:value" }, + }); + expect(clusterBatchInstances).toHaveLength(1); + expect(client.exec).toHaveBeenCalledWith(clusterBatchInstances[0], false, { + 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 () => { + 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), + ], { - keys: ["tracked:{id}:value", "tracked:{id}:watermark"], - args: ["2000", "1", binary], decoder: decoderBytes, + route: { type: "primarySlotKey", key: "tracked:{id}:value" }, }, ); - expect(client.invokeScript).toHaveBeenNthCalledWith( - 3, - expect.any(MockScript), - { keys: ["tracked:{id}:watermark"], args: ["100"], decoder: decoderBytes }, + }); + + 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.", + // 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; + 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); + + const trackedFrame = batchInstances[0]?.commands[0]?.[2] as Buffer; + 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 }, + ); + } + }); + + 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); + 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); + await expect( + adapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs, + value: "tracked", + }), + ).rejects.toThrow(RangeError); + } + expect(client.customCommand).not.toHaveBeenCalled(); + 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"); + expect(Buffer.isBuffer(stamp?.[6])).toBe(true); + }); + + 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.customCommand).not.toHaveBeenCalled(); + + 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.customCommand).not.toHaveBeenCalled(); + }); + + it("validates write batch envelopes and SET replies", async () => { + const envelopeClient = fakeClient("not-a-batch-reply"); + const envelopeAdapter = createValkeyGlideDialCacheClient(envelopeClient, mockGlide); + await expect(envelopeAdapter.write({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + cacheTtlMs: 1_000, + value: "tracked", + })).rejects.toBeInstanceOf(DialCacheRedisPayloadError); + + 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", + ); + + // 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", ); }); @@ -328,7 +583,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,8 +599,13 @@ 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" })), - "Invalid DialCache Redis write reply; expected integer 0 or 1", + 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, 1, or 2", ); await expectProtocolError( Promise.resolve( @@ -356,18 +616,14 @@ 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) { - 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", @@ -377,11 +633,11 @@ describe("Valkey GLIDE adapter", () => { })), writeMessage, ); - tracked.dispose(); } 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", @@ -389,65 +645,109 @@ describe("Valkey GLIDE adapter", () => { })), invalidationMessage, ); - adapter.dispose(); + // A reply-domain violation is deterministic and must never be retried. + expect(client.customCommand).toHaveBeenCalledTimes(1); } }); - it("releases every script exactly once and rejects later operations", async () => { + 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(); + + expect(client.customCommand).toHaveBeenCalledTimes(2); + 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); - adapter.dispose(); - adapter.dispose(); + 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); + }); - expect(scriptInstances).toHaveLength(3); - 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("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("does not release scripts while a native read is in flight", async () => { - let resolveRead: ((value: Buffer) => void) | undefined; + 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.get.mockImplementationOnce( - async () => await new Promise((resolve) => { - resolveRead = resolve; - }), - ); + client.customCommand + .mockRejectedValueOnce(new Error("read ECONNRESET")) + .mockRejectedValueOnce("socket closed"); 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); + 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); - resolveRead?.(redisFrame("done")); - await expect(read).resolves.toBe("done"); - adapter.dispose(); - expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); + 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, 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")]], + [Buffer.from("OK"), 1], 1, ); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); @@ -456,18 +756,26 @@ describe("Valkey GLIDE adapter", () => { valueKey: "module:{instance}:value", 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); - 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); - adapter.dispose(); + 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] ?? []; + const [writeBatch, , writeOptions] = client.exec.mock.calls[1] ?? []; + 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(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(invalidateOptions?.decoder).toBe(mockGlide.Decoder.Bytes); + expect(invalidateOptions?.decoder).not.toBe(otherGlide.Decoder.Bytes); }); });