diff --git a/README.md b/README.md index aba4a7b..e34fa77 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ pnpm add dialcache # Choose a Redis client when using the remote layer: pnpm add redis@~4.7.1 # or -pnpm add @valkey/valkey-glide +pnpm add @valkey/valkey-glide@^2.0.0 # Add a metrics client only when using its adapter: pnpm add prom-client@^15.1.3 # or @@ -324,7 +324,7 @@ The limit counts entries rather than estimating JavaScript object memory. Recent ### Redis-backed TTL cache -The Redis layer supports standalone Redis, Valkey, and Redis Cluster. Register DialCache's native node-redis scripts when creating the client, then pass that client to DialCache: +The Redis layer supports standalone Redis, Valkey, and Redis Cluster. Register DialCache's bundled node-redis scripts when creating the client, then pass that client to DialCache: ```ts import { createClient } from "redis"; @@ -356,7 +356,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 scripts and wrap their client with `createNodeRedisDialCacheClient` as shown above. +`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. Valkey GLIDE users pass an already-created standalone or cluster client and its module namespace to the GLIDE adapter: @@ -385,18 +385,30 @@ function shutdown(): void { } ``` -Pass the same module namespace that created the client. DialCache uses its -`Script` constructor and `Decoder.Bytes` value without importing a GLIDE runtime -itself, so linked workspaces and applications with another installed GLIDE -version cannot accidentally mix native script handles. +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 +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. 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 five native `Script` handles but not the wrapped connection. After outstanding operations finish, call its idempotent `dispose()` before closing GLIDE as shown above; disposal while an adapter operation is in flight throws rather than releasing a live script. +The node-redis adapter owns no additional resources, so the application closes the underlying node-redis client after draining work. The GLIDE adapter owns 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. -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 scripts from their declared keys. Tracked reads are deliberately routed to primaries so a lagging replica cannot hide an invalidation watermark. +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. + +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. + +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. #### Remote read deadlines and async liveness @@ -406,13 +418,13 @@ When the deadline expires, DialCache aborts the optional `RedisReadContext.signa Same-key followers share the leader's remaining remote-read budget. The timer covers only the semantic Redis read, not config resolution, serializer load, fallback work, Redis writes, or invalidation. `fallbackTimeoutMs` starts separately when the source fallback begins. -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 script API has no per-invocation signal, so its invocation 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. +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. #### 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. Distinct untracked/tracked read and write Lua sources, the invalidation source, and wire constants are available from `dialcache/redis-protocol`. Custom adapters can throw the root-exported `DialCacheRedisPayloadError`, `DialCacheRedisPayloadEncodingError`, and `DialCacheRedisProtocolError` classes to distinguish malformed payloads, unsupported encodings, and Lua 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 `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. Redis values use a compact binary frame: @@ -423,7 +435,7 @@ byte 10 payload encoding (0 = UTF-8, 1 = raw binary) bytes 11... serialized payload ``` -Redis's Lua `struct` library packs and unpacks the timestamp. Redis TTL is authoritative, so expiry metadata is not duplicated in the frame. `payload` is produced by the operation's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; strings are stored as UTF-8 and Buffers are stored byte-for-byte without base64 expansion. Adapters restore the same representation before calling `serializer.load`. +The Redis write scripts use Lua's `struct` library to pack the timestamp; adapters decode it with Node's buffer primitives. Redis TTL is authoritative, so expiry metadata is not duplicated in the frame. `payload` is produced by the operation's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; strings are stored as UTF-8 and Buffers are stored byte-for-byte without base64 expansion. Adapters restore the same representation before calling `serializer.load`. 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. @@ -615,7 +627,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. If its fallback then reaches the tracked Redis write, Redis rejects the write and DialCache also suppresses the corresponding process-local population; the fallback value still returns to its caller. 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, 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. 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. @@ -625,7 +637,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 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, 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. This is a timing contract rather than a cancellation or acquisition fence: the buffer prevents stale fallback results from passing that tracked Redis write only while the configured window remains active, and it does not force a fallback to read from an authoritative source. diff --git a/package.json b/package.json index 99604c2..4952b8f 100644 --- a/package.json +++ b/package.json @@ -100,7 +100,7 @@ "lru-cache": "^11.5.2" }, "devDependencies": { - "@valkey/valkey-glide": "^2.4.2", + "@valkey/valkey-glide": "2.0.0", "@vitest/coverage-v8": "^4.0.14", "hot-shots": "^17.0.0", "prom-client": "^15.1.3", @@ -111,10 +111,14 @@ "vitest": "^4.0.14" }, "peerDependencies": { + "@valkey/valkey-glide": "^2.0.0", "prom-client": "^15.1.3", "redis": "~4.7.1" }, "peerDependenciesMeta": { + "@valkey/valkey-glide": { + "optional": true + }, "prom-client": { "optional": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f668556..47b5450 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: version: 11.5.2 devDependencies: '@valkey/valkey-glide': - specifier: ^2.4.2 - version: 2.4.2 + specifier: 2.0.0 + version: 2.0.0 '@vitest/coverage-v8': specifier: ^4.0.14 version: 4.1.10(vitest@4.1.10) @@ -620,42 +620,42 @@ packages: '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} - '@valkey/valkey-glide-darwin-arm64@2.4.2': - resolution: {integrity: sha512-ILJt4/nGWvzWbrNTUFCvOmUnUoLhOHDKAdCQBTTZtt61JRv4z1ctS8kSkHXtdiwWYCPZIsqjjIsb43HdujkuKQ==} + '@valkey/valkey-glide-darwin-arm64@2.0.0': + resolution: {integrity: sha512-kiVne6nFqB/NetXarsmuuYyVB0fRf7fxfrLHW/cTh5u2eNCiti1BQqdIBPAW0q0PHsDIOvTB64dEjZnWQ8DjUQ==} cpu: [arm64] os: [darwin] - '@valkey/valkey-glide-darwin-x64@2.4.2': - resolution: {integrity: sha512-qd68iRRAdQ3maWJMgf44JE6v4dk9S5mCWXcRwBa6BzjqzLNoqiHa13lf+YOzaEj4EQEPKz6leBIfQEqIs1ZNMw==} + '@valkey/valkey-glide-darwin-x64@2.0.0': + resolution: {integrity: sha512-8ubNHqBLNgeQviVgZp+W8kFe5V6FSci5Gia9/+djPTb3zXgpAailo9myJqmh2RL6pX3B0M5xtw+6F9WhnOo7Ww==} cpu: [x64] os: [darwin] - '@valkey/valkey-glide-linux-arm64-gnu@2.4.2': - resolution: {integrity: sha512-zzstOJvpXHHZSnwBdkk3J0fz8v3+HjPGa/4PktldBTR5VZ3ZfS5+HFuXdRR6/nNpww5fcTQ0KRYdAONUWDiWsw==} + '@valkey/valkey-glide-linux-arm64-gnu@2.0.0': + resolution: {integrity: sha512-ikqvMeqLoEN7JAYTcD2/m7NRFWgEmIhKQgm2PWiu3yprBQnqc083UCrZfgMk/Ey1duhewC7UwKE33aIPKk4XTA==} cpu: [arm64] os: [linux] libc: [glibc] - '@valkey/valkey-glide-linux-arm64-musl@2.4.2': - resolution: {integrity: sha512-Li7htG1mAikPRN2v9IggIuFED6zGjMRlcYS+42qbNeXRjyYG/V4M4Y0TjAhS2qte6jRqpgCbuyEQSIVt5JSWEQ==} + '@valkey/valkey-glide-linux-arm64-musl@2.0.0': + resolution: {integrity: sha512-gltqidXIDL1m3ucABYX7p80te9GGglqYjeixSRUTLmrUtoA6GnPg3fWF4GybH9Q3tjxz8B/m38mz+VJJZ7fIWA==} cpu: [arm64] os: [linux] libc: [musl] - '@valkey/valkey-glide-linux-x64-gnu@2.4.2': - resolution: {integrity: sha512-3cJfKpJSzXgsVwhNdeQcHO73L5K42V1iZaLGpLsXPwtuEO8olSu/tslN24AYgjwTM3QghOQb0k66x6JonB+3rA==} + '@valkey/valkey-glide-linux-x64-gnu@2.0.0': + resolution: {integrity: sha512-WwPLuj/WCCbnQEIbCaugyPK8uDte1YatZhT8Lc8RCPM2UfcKdnKlS8g/2oytojiH5kkAupxUqFsP//9os3DYQg==} cpu: [x64] os: [linux] libc: [glibc] - '@valkey/valkey-glide-linux-x64-musl@2.4.2': - resolution: {integrity: sha512-YBPVVW84VSOFfIME6jqE6DAQKiV688oWkhjNmakJ/xm4Pxae78QWNBI0s2Rbwpfy7PQ59gJ/ZfwPVbMHOXp/vw==} + '@valkey/valkey-glide-linux-x64-musl@2.0.0': + resolution: {integrity: sha512-hFn8zOicEn2D16LaO5pw9QUzOPi67Jj4hJwC/rUUmtmpRnbLN2hqWfxK00/+1k5MBJ8K1764ObvXm4wIY3kehQ==} cpu: [x64] os: [linux] libc: [musl] - '@valkey/valkey-glide@2.4.2': - resolution: {integrity: sha512-ugE9hxB+0QoXhix2PgmtJZqWAEFqS8iJsWRqn8dgqdBrOwKUv9LidWuEzF/WBGyYZDj9Ji/g8CvaskA2fHZvTw==} + '@valkey/valkey-glide@2.0.0': + resolution: {integrity: sha512-nJCeRCXgqb7fMEu2dmrdbqOAr/OVpkx2u3dfr+eJuv0zSb/vcanaIH1VZUJAEFMHfvP7WSUfsI6rxZVjJ+/xxQ==} engines: {node: '>=16'} '@vitest/coverage-v8@4.1.10': @@ -2090,35 +2090,35 @@ snapshots: dependencies: '@types/node': 18.19.130 - '@valkey/valkey-glide-darwin-arm64@2.4.2': + '@valkey/valkey-glide-darwin-arm64@2.0.0': optional: true - '@valkey/valkey-glide-darwin-x64@2.4.2': + '@valkey/valkey-glide-darwin-x64@2.0.0': optional: true - '@valkey/valkey-glide-linux-arm64-gnu@2.4.2': + '@valkey/valkey-glide-linux-arm64-gnu@2.0.0': optional: true - '@valkey/valkey-glide-linux-arm64-musl@2.4.2': + '@valkey/valkey-glide-linux-arm64-musl@2.0.0': optional: true - '@valkey/valkey-glide-linux-x64-gnu@2.4.2': + '@valkey/valkey-glide-linux-x64-gnu@2.0.0': optional: true - '@valkey/valkey-glide-linux-x64-musl@2.4.2': + '@valkey/valkey-glide-linux-x64-musl@2.0.0': optional: true - '@valkey/valkey-glide@2.4.2': + '@valkey/valkey-glide@2.0.0': dependencies: long: 5.3.2 protobufjs: 7.6.5 optionalDependencies: - '@valkey/valkey-glide-darwin-arm64': 2.4.2 - '@valkey/valkey-glide-darwin-x64': 2.4.2 - '@valkey/valkey-glide-linux-arm64-gnu': 2.4.2 - '@valkey/valkey-glide-linux-arm64-musl': 2.4.2 - '@valkey/valkey-glide-linux-x64-gnu': 2.4.2 - '@valkey/valkey-glide-linux-x64-musl': 2.4.2 + '@valkey/valkey-glide-darwin-arm64': 2.0.0 + '@valkey/valkey-glide-darwin-x64': 2.0.0 + '@valkey/valkey-glide-linux-arm64-gnu': 2.0.0 + '@valkey/valkey-glide-linux-arm64-musl': 2.0.0 + '@valkey/valkey-glide-linux-x64-gnu': 2.0.0 + '@valkey/valkey-glide-linux-x64-musl': 2.0.0 '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 6c6c023..0256b34 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -48,8 +48,12 @@ const rootConsumer = `import { } from "dialcache"; // @ts-expect-error The unused MissingKeyConfigError class was removed instead of deprecated. import { MissingKeyConfigError } from "dialcache"; -import { createNodeRedisDialCacheClient } from "dialcache/node-redis"; +import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "dialcache/node-redis"; +import { decodeRedisFrame, decodeTrackedRedisFrame } 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"; import { DatadogDialCacheMetrics, createDatadogDialCacheMetrics, @@ -137,6 +141,14 @@ const datadogClassAdapter = new DatadogDialCacheMetrics(datadogOptions); const missingObservationType: DatadogMetricsOptions = { client: dogStatsDClient }; const cache = new DialCache({ namespace: "consumer-cache", metrics }); const redisProtocolError = new DialCacheRedisProtocolError("Invalid DialCache Redis write reply"); +const emptyRedisFrame = Buffer.alloc(10); +emptyRedisFrame[0] = 1; +emptyRedisFrame.writeBigUInt64BE(1n, 1); +const decodedEmptyRedisPayload: string | Buffer | null = decodeRedisFrame(emptyRedisFrame); +const decodedStaleRedisPayload: string | Buffer | null = decodeTrackedRedisFrame( + emptyRedisFrame, + Buffer.from("1"), +); const fallbackTimeoutError = new FallbackTimeoutError("Load", 1_000); const redisReadTimeoutError = new RedisReadTimeoutError("Load", 100); const coalescingState: CoalescingState = cache.getCoalescingState(); @@ -438,7 +450,14 @@ void disabledOverlay; void metricErrorKinds; void unboundedErrorKind; void createNodeRedisDialCacheClient; +void decodedEmptyRedisPayload; +void decodedStaleRedisPayload; +// @ts-expect-error Native reads removed the legacy node-redis registration. +void dialcacheRedisScripts.dialcacheRead; +// @ts-expect-error Native tracked reads removed the legacy node-redis registration. +void dialcacheRedisScripts.dialcacheReadTracked; void READ_CACHE_SCRIPT; +void READ_TRACKED_CACHE_SCRIPT; void customRedisClient; const globalSerializer: Serializer = { dump: () => "global", @@ -486,6 +505,7 @@ void missingObservationType; const integrationConsumer = `import * as valkeyGlide from "@valkey/valkey-glide"; import { DialCache } from "dialcache"; import StatsD from "hot-shots"; +import { createClient as createRedisClient, createCluster as createRedisCluster } from "redis"; import { DatadogDialCacheMetrics, createDatadogDialCacheMetrics, @@ -503,6 +523,7 @@ import { type ValkeyGlideDialCacheClient, type ValkeyGlideRuntime, } from "dialcache/valkey-glide"; +import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "dialcache/node-redis"; import { Registry, type OpenMetricsContentType } from "prom-client"; const registry = new Registry(); @@ -515,6 +536,13 @@ 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 standaloneNodeRedisClient = createRedisClient({ scripts: dialcacheRedisScripts }); +const clusterNodeRedisClient = createRedisCluster({ + rootNodes: [{ url: "redis://127.0.0.1:6379" }], + scripts: dialcacheRedisScripts, +}); +const standaloneNodeRedisAdapter = createNodeRedisDialCacheClient(standaloneNodeRedisClient); +const clusterNodeRedisAdapter = createNodeRedisDialCacheClient(clusterNodeRedisClient); const glideRuntime: ValkeyGlideRuntime = valkeyGlide; declare const standaloneGlideClient: valkeyGlide.GlideClient; declare const clusterGlideClient: valkeyGlide.GlideClusterClient; @@ -544,6 +572,8 @@ void classAdapter; void openMetricsAdapter; void registryIsRequired; void glideRedisClient; +void standaloneNodeRedisAdapter; +void clusterNodeRedisAdapter; void standaloneGlideAdapter; void clusterGlideAdapter; void datadogClassAdapter; @@ -597,7 +627,7 @@ try { const nodeRedis = await import("dialcache/node-redis"); await import("dialcache/valkey-glide"); await import("dialcache/datadog"); -await import("dialcache/redis-protocol"); +const redisProtocol = await import("dialcache/redis-protocol"); 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"); @@ -639,6 +669,45 @@ try { if ("MissingKeyConfigError" in root) { throw new Error("The removed MissingKeyConfigError class must not be exported from the root ESM entry"); } +if ( + "dialcacheRead" in nodeRedis.dialcacheRedisScripts + || "dialcacheReadTracked" in nodeRedis.dialcacheRedisScripts +) { + throw new Error("The removed read scripts must not be registered by the packed ESM node-redis entry"); +} +if ( + "READ_CACHE_SCRIPT" in redisProtocol + || "READ_TRACKED_CACHE_SCRIPT" in redisProtocol +) { + throw new Error("The removed read scripts must not be exported by the packed ESM Redis protocol entry"); +} +const esmEmptyFrame = Buffer.alloc(10); +esmEmptyFrame[0] = 1; +esmEmptyFrame.writeBigUInt64BE(1n, 1); +if (redisProtocol.decodeRedisFrame(esmEmptyFrame) !== "") { + throw new Error("The packed ESM Redis protocol decoder did not preserve an empty UTF-8 payload"); +} +if (redisProtocol.decodeTrackedRedisFrame(esmEmptyFrame, Buffer.from("1")) !== null) { + throw new Error("The packed ESM Redis protocol decoder did not reject a stale tracked frame"); +} +try { + redisProtocol.decodeRedisFrame("not binary"); + throw new Error("Expected the packed ESM Redis protocol decoder to reject a non-binary reply"); +} catch (error) { + if (!(error instanceof root.DialCacheRedisPayloadError)) { + throw new Error("The Redis protocol payload error does not match the root ESM export"); + } +} +const esmInvalidEncodingFrame = Buffer.from(esmEmptyFrame); +esmInvalidEncodingFrame[9] = 2; +try { + redisProtocol.decodeRedisFrame(esmInvalidEncodingFrame); + throw new Error("Expected the packed ESM Redis protocol decoder to reject an unsupported encoding"); +} catch (error) { + if (!(error instanceof root.DialCacheRedisPayloadEncodingError)) { + throw new Error("The Redis protocol encoding error does not match the root ESM export"); + } +} const esmDisabledOverlay = root.DialCacheKeyConfig.disabled(); if ( esmDisabledOverlay.requestLocal !== false @@ -834,7 +903,7 @@ console.log("${shadowPayloadReleaseMarker}");`, const nodeRedis = require("dialcache/node-redis"); require("dialcache/valkey-glide"); require("dialcache/datadog"); -require("dialcache/redis-protocol"); +const redisProtocol = require("dialcache/redis-protocol"); 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"); @@ -878,6 +947,45 @@ try { if ("MissingKeyConfigError" in root) { throw new Error("The removed MissingKeyConfigError class must not be exported from the root CommonJS entry"); } +if ( + "dialcacheRead" in nodeRedis.dialcacheRedisScripts + || "dialcacheReadTracked" in nodeRedis.dialcacheRedisScripts +) { + throw new Error("The removed read scripts must not be registered by the packed CommonJS node-redis entry"); +} +if ( + "READ_CACHE_SCRIPT" in redisProtocol + || "READ_TRACKED_CACHE_SCRIPT" in redisProtocol +) { + throw new Error("The removed read scripts must not be exported by the packed CommonJS Redis protocol entry"); +} +const cjsEmptyFrame = Buffer.alloc(10); +cjsEmptyFrame[0] = 1; +cjsEmptyFrame.writeBigUInt64BE(1n, 1); +if (redisProtocol.decodeRedisFrame(cjsEmptyFrame) !== "") { + throw new Error("The packed CommonJS Redis protocol decoder did not preserve an empty UTF-8 payload"); +} +if (redisProtocol.decodeTrackedRedisFrame(cjsEmptyFrame, Buffer.from("1")) !== null) { + throw new Error("The packed CommonJS Redis protocol decoder did not reject a stale tracked frame"); +} +try { + redisProtocol.decodeRedisFrame("not binary"); + throw new Error("Expected the packed CommonJS Redis protocol decoder to reject a non-binary reply"); +} catch (error) { + if (!(error instanceof root.DialCacheRedisPayloadError)) { + throw new Error("The Redis protocol payload error does not match the root CommonJS export"); + } +} +const cjsInvalidEncodingFrame = Buffer.from(cjsEmptyFrame); +cjsInvalidEncodingFrame[9] = 2; +try { + redisProtocol.decodeRedisFrame(cjsInvalidEncodingFrame); + throw new Error("Expected the packed CommonJS Redis protocol decoder to reject an unsupported encoding"); +} catch (error) { + if (!(error instanceof root.DialCacheRedisPayloadEncodingError)) { + throw new Error("The Redis protocol encoding error does not match the root CommonJS export"); + } +} const cjsDisabledOverlay = root.DialCacheKeyConfig.disabled(); if ( cjsDisabledOverlay.requestLocal !== false @@ -952,7 +1060,7 @@ void (async () => { "redis@~4.7.1", "typescript@5.9.3", "prom-client@^15.1.3", - "@valkey/valkey-glide@2.2.10", + "@valkey/valkey-glide@2.0.0", "dialcache-test-glide@npm:@valkey/valkey-glide@2.4.2", "hot-shots@^17.0.0", ], @@ -981,7 +1089,7 @@ await import("dialcache/node-redis"); if (appGlide.Script === otherGlide.Script) { throw new Error("The package test requires two distinct GLIDE module instances"); } -const adapter = glide.createValkeyGlideDialCacheClient({ +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"); @@ -991,7 +1099,13 @@ const adapter = glide.createValkeyGlideDialCacheClient({ } return 2; }, -}, appGlide); +}; +const esmGlideRuntime = { + ...appGlide, + GlideClient: { [Symbol.hasInstance]: (value) => value === esmFakeGlideClient }, + GlideClusterClient: { [Symbol.hasInstance]: () => false }, +}; +const adapter = glide.createValkeyGlideDialCacheClient(esmFakeGlideClient, esmGlideRuntime); try { await adapter.write({ valueKey: "value", cacheTtlMs: 1_000, value: "payload" }); throw new Error("Expected an invalid GLIDE script reply to fail"); @@ -1021,7 +1135,7 @@ void (async () => { if (appGlide.Script === otherGlide.Script) { throw new Error("The package test requires two distinct GLIDE module instances"); } - const adapter = glide.createValkeyGlideDialCacheClient({ + 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"); @@ -1031,7 +1145,13 @@ void (async () => { } return 2; }, - }, appGlide); + }; + const cjsGlideRuntime = { + ...appGlide, + GlideClient: { [Symbol.hasInstance]: (value) => value === cjsFakeGlideClient }, + GlideClusterClient: { [Symbol.hasInstance]: () => false }, + }; + const adapter = glide.createValkeyGlideDialCacheClient(cjsFakeGlideClient, cjsGlideRuntime); try { await adapter.write({ valueKey: "value", cacheTtlMs: 1_000, value: "payload" }); throw new Error("Expected an invalid GLIDE script reply to fail"); diff --git a/src/internal/redis-payload.ts b/src/internal/redis-payload.ts index dccfdea..1210678 100644 --- a/src/internal/redis-payload.ts +++ b/src/internal/redis-payload.ts @@ -3,17 +3,48 @@ import { DialCacheRedisPayloadError, type RedisCachePayload, } from "../redis-client.js"; -import { REDIS_ENCODING_BINARY, REDIS_ENCODING_UTF8 } from "./redis-scripts.js"; +import { + REDIS_ENCODING_BINARY, + REDIS_ENCODING_UTF8, + REDIS_FRAME_VERSION, +} from "./redis-scripts.js"; -export function redisPayloadEncoding(value: RedisCachePayload): number { - return Buffer.isBuffer(value) ? REDIS_ENCODING_BINARY : REDIS_ENCODING_UTF8; +const REDIS_FRAME_HEADER_BYTES = 9; +const REDIS_FRAME_MIN_BYTES = REDIS_FRAME_HEADER_BYTES + 1; + +function validateRedisBulkStringReply(raw: unknown): Buffer | null { + if (raw === null || Buffer.isBuffer(raw)) { + return raw; + } + throw new DialCacheRedisPayloadError( + "Invalid DialCache Redis read reply; expected a bulk string or null", + ); } -export function decodeRedisPayload(raw: Buffer): RedisCachePayload { - if (raw.length === 0) { - throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload"); +function isSupportedRedisFrame(raw: Buffer | null): raw is Buffer { + return raw !== null + && raw.length >= REDIS_FRAME_MIN_BYTES + && raw[0] === REDIS_FRAME_VERSION; +} + +function parseRedisWatermark(raw: Buffer | null): number | null { + if (raw === null) { + return null; + } + const text = raw.toString("utf8"); + const match = /^[0-9]+(?:\.[0-9]+)?/.exec(text); + if (match?.[0].length !== text.length) { + return null; } + const watermark = Number(text); + return Number.isFinite(watermark) ? watermark : null; +} +export function redisPayloadEncoding(value: RedisCachePayload): number { + return Buffer.isBuffer(value) ? REDIS_ENCODING_BINARY : REDIS_ENCODING_UTF8; +} + +function decodeRedisPayload(raw: Buffer): RedisCachePayload { const encoding = raw[0]; const payload = raw.subarray(1); if (encoding === REDIS_ENCODING_UTF8) { @@ -24,3 +55,40 @@ export function decodeRedisPayload(raw: Buffer): RedisCachePayload { } throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding"); } + +/** + * Decode an untracked DialCache frame returned as a Redis bulk string. + * Missing, short, and unsupported-version frames are cache misses. Invalid + * runtime reply types and unsupported payload encodings throw typed errors. + */ +export function decodeRedisFrame(raw: unknown): RedisCachePayload | null { + const frame = validateRedisBulkStringReply(raw); + return isSupportedRedisFrame(frame) + ? decodeRedisPayload(frame.subarray(REDIS_FRAME_HEADER_BYTES)) + : null; +} + +/** + * Decode a tracked DialCache frame against a watermark from the same atomic, + * authoritative snapshot. Missing or malformed state and frames created at or + * before the watermark are cache misses. Invalid runtime reply types and + * unsupported payload encodings throw typed errors. + */ +export function decodeTrackedRedisFrame( + raw: unknown, + rawWatermark: unknown, +): RedisCachePayload | null { + const frame = validateRedisBulkStringReply(raw); + const watermarkFrame = validateRedisBulkStringReply(rawWatermark); + if (!isSupportedRedisFrame(frame)) { + return null; + } + const watermark = parseRedisWatermark(watermarkFrame); + if (watermark === null) { + return null; + } + const createdAtMs = Number(frame.readBigUInt64BE(1)); + return createdAtMs <= watermark + ? null + : decodeRedisPayload(frame.subarray(REDIS_FRAME_HEADER_BYTES)); +} diff --git a/src/internal/redis-scripts.ts b/src/internal/redis-scripts.ts index 891c0ac..5d725cb 100644 --- a/src/internal/redis-scripts.ts +++ b/src/internal/redis-scripts.ts @@ -25,17 +25,6 @@ const CEIL_FINITE_NUMBER_LUA = String.raw`local function ceil_finite_number(raw) return math.ceil(value) end`; -const READ_FRAME_LUA = String.raw`local value = redis.call("GET", KEYS[1]) -if not value or string.len(value) < 10 then - return false -end - -if string.byte(value, 1) ~= ${REDIS_FRAME_VERSION} then - return false -end`; - -const RETURN_PAYLOAD_LUA = String.raw`return string.sub(value, 10)`; - const VALIDATE_WRITE_ARGUMENTS_LUA = String.raw`local cache_ttl_ms = ceil_finite_number(ARGV[1]) local encoding = tonumber(ARGV[2]) if not cache_ttl_ms or cache_ttl_ms <= 0 or cache_ttl_ms > ${MAX_SUPPORTED_DURATION_MS} then @@ -54,28 +43,6 @@ const WRITE_FRAME_LUA = String.raw`local frame = string.char(${REDIS_FRAME_VERSI .. ARGV[3] redis.call("SET", KEYS[1], frame, "PX", cache_ttl_ms)`; -export const READ_CACHE_SCRIPT = [READ_FRAME_LUA, RETURN_PAYLOAD_LUA].join("\n\n"); - -export const READ_TRACKED_CACHE_SCRIPT = [ - PARSE_WATERMARK_LUA, - READ_FRAME_LUA, - String.raw`local raw_watermark = redis.call("GET", KEYS[2]) -if not raw_watermark then - return false -end - -local watermark = parse_watermark(raw_watermark) -if not watermark then - return false -end - -local created_at = struct.unpack(">I8", string.sub(value, 2, 9)) -if created_at <= watermark then - return false -end`, - RETURN_PAYLOAD_LUA, -].join("\n\n"); - export const WRITE_CACHE_SCRIPT = [ CEIL_FINITE_NUMBER_LUA, VALIDATE_WRITE_ARGUMENTS_LUA, @@ -99,6 +66,9 @@ 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. + redis.call("UNLINK", KEYS[1]) return 0 end`, WRITE_FRAME_LUA, diff --git a/src/node-redis.ts b/src/node-redis.ts index 2093b8f..5fd2f5a 100644 --- a/src/node-redis.ts +++ b/src/node-redis.ts @@ -2,17 +2,22 @@ import { commandOptions, defineScript } from "redis"; import { INVALIDATE_CACHE_SCRIPT, - READ_CACHE_SCRIPT, - READ_TRACKED_CACHE_SCRIPT, WRITE_CACHE_SCRIPT, WRITE_TRACKED_CACHE_SCRIPT, } from "./internal/redis-scripts.js"; -import { decodeRedisPayload, redisPayloadEncoding } from "./internal/redis-payload.js"; +import { + decodeRedisFrame, + decodeTrackedRedisFrame, + redisPayloadEncoding, +} from "./internal/redis-payload.js"; import { validateRedisScriptInvalidationReply, validateRedisScriptWriteReply, } from "./internal/redis-script-reply.js"; -import type { DialCacheRedisClient } from "./redis-client.js"; +import { + DialCacheRedisPayloadError, + type DialCacheRedisClient, +} from "./redis-client.js"; type BufferReplyOptions = ReturnType< typeof commandOptions<{ @@ -22,7 +27,6 @@ type BufferReplyOptions = ReturnType< >; // Redis bulk strings are binary data; decoding them as UTF-8 would corrupt arbitrary serializer output. const bufferReplyOptions: BufferReplyOptions = commandOptions({ returnBuffers: true }); -const readReply = (reply: string | null): string | null => reply; const writeReply = (reply: number): number => validateRedisScriptWriteReply(reply); const invalidationReply = (reply: number): number => validateRedisScriptInvalidationReply(reply); type NodeRedisArgument = string | Buffer; @@ -46,8 +50,6 @@ function defineDialCacheScript, Reply>( } export type DialCacheNodeRedisScripts = { - readonly dialcacheRead: NodeRedisScript<[valueKey: string], string | null>; - readonly dialcacheReadTracked: NodeRedisScript<[valueKey: string, watermarkKey: string], string | null>; readonly dialcacheWrite: NodeRedisScript< [valueKey: string, cacheTtlMs: number, encoding: number, payload: string | Buffer], number @@ -69,27 +71,6 @@ export type DialCacheNodeRedisScripts = { }; export const dialcacheRedisScripts: DialCacheNodeRedisScripts = { - dialcacheRead: defineDialCacheScript({ - SCRIPT: READ_CACHE_SCRIPT, - NUMBER_OF_KEYS: 1, - FIRST_KEY_INDEX: 0, - IS_READ_ONLY: true, - transformArguments(valueKey: string): Array { - return [valueKey]; - }, - transformReply: readReply, - }), - dialcacheReadTracked: defineDialCacheScript({ - SCRIPT: READ_TRACKED_CACHE_SCRIPT, - NUMBER_OF_KEYS: 2, - FIRST_KEY_INDEX: 0, - // Replica lag must not hide a newly-written invalidation watermark. - IS_READ_ONLY: false, - transformArguments(valueKey: string, watermarkKey: string): Array { - return [valueKey, watermarkKey]; - }, - transformReply: readReply, - }), dialcacheWrite: defineDialCacheScript({ SCRIPT: WRITE_CACHE_SCRIPT, NUMBER_OF_KEYS: 1, @@ -133,13 +114,7 @@ export const dialcacheRedisScripts: DialCacheNodeRedisScripts = { }), }; -interface NodeRedisScriptClient { - dialcacheRead(options: BufferReplyOptions, valueKey: string): Promise; - dialcacheReadTracked( - options: BufferReplyOptions, - valueKey: string, - watermarkKey: string, - ): Promise; +interface NodeRedisWriteClient { dialcacheWrite(valueKey: string, cacheTtlMs: number, encoding: number, payload: string | Buffer): Promise; dialcacheWriteTracked( valueKey: string, @@ -151,6 +126,59 @@ interface NodeRedisScriptClient { dialcacheInvalidate(watermarkKey: string, futureBufferMs: number): Promise; } +interface NodeRedisStandaloneClient extends NodeRedisWriteClient { + get(options: BufferReplyOptions, valueKey: string): Promise; + sendCommand( + args: Array, + options: BufferReplyOptions, + ): Promise; +} + +interface NodeRedisClusterClient extends NodeRedisWriteClient { + /** Public node-redis Cluster topology view, used only to distinguish its sendCommand overload. */ + readonly masters: ReadonlyArray; + get(options: BufferReplyOptions, valueKey: string): Promise; + sendCommand( + firstKey: string, + isReadonly: false, + args: Array, + options: BufferReplyOptions, + ): Promise; +} + +type NodeRedisClient = NodeRedisStandaloneClient | NodeRedisClusterClient; + +function isNodeRedisClusterClient(client: NodeRedisClient): client is NodeRedisClusterClient { + return "masters" in client && Array.isArray(client.masters); +} + +function validateRedisMGetReply(reply: unknown): [unknown, unknown] { + if ( + !Array.isArray(reply) + || reply.length !== 2 + ) { + throw new DialCacheRedisPayloadError( + "Invalid DialCache Redis tracked read reply; expected an array with two entries", + ); + } + return [reply[0], reply[1]]; +} + +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); + return validateRedisMGetReply(raw); +} + /** * 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 @@ -158,16 +186,22 @@ interface NodeRedisScriptClient { * server stopped executing it. The caller remains responsible for finite * native command budgets, draining work, and closing the client. */ -export function createNodeRedisDialCacheClient(client: NodeRedisScriptClient): DialCacheRedisClient { +export function createNodeRedisDialCacheClient(client: NodeRedisClient): DialCacheRedisClient { return { async read({ valueKey, watermarkKey }, context) { const options: BufferReplyOptions = context === undefined ? bufferReplyOptions : commandOptions({ returnBuffers: true, signal: context.signal }); - const raw = watermarkKey === undefined - ? await client.dialcacheRead(options, valueKey) - : await client.dialcacheReadTracked(options, valueKey, watermarkKey); - return raw === null ? null : decodeRedisPayload(raw); + if (watermarkKey === undefined) { + return decodeRedisFrame(await client.get(options, valueKey)); + } + const [rawValue, rawWatermark] = await readTracked( + client, + options, + valueKey, + watermarkKey, + ); + return decodeTrackedRedisFrame(rawValue, rawWatermark); }, async write(request) { const { valueKey, watermarkKey, cacheTtlMs, value } = request; diff --git a/src/redis-client.ts b/src/redis-client.ts index 2bf18d6..aab67c8 100644 --- a/src/redis-client.ts +++ b/src/redis-client.ts @@ -1,18 +1,42 @@ import type { Awaitable } from "./config.js"; +const redisPayloadErrorBrand = Symbol.for("dialcache.DialCacheRedisPayloadError"); +const redisPayloadEncodingErrorBrand = Symbol.for("dialcache.DialCacheRedisPayloadEncodingError"); const redisProtocolErrorBrand = Symbol.for("dialcache.DialCacheRedisProtocolError"); export class DialCacheRedisPayloadError extends Error { + static [Symbol.hasInstance](value: unknown): boolean { + if (this !== DialCacheRedisPayloadError) { + return Function.prototype[Symbol.hasInstance].call(this, value); + } + return typeof value === "object" + && value !== null + && Object.getOwnPropertyDescriptor(value, redisPayloadErrorBrand)?.value === true; + } + constructor(message: string) { super(message); this.name = "DialCacheRedisPayloadError"; + // CJS adapter subpaths are separate bundles; a global symbol preserves root-export instanceof checks. + Object.defineProperty(this, redisPayloadErrorBrand, { value: true }); } } export class DialCacheRedisPayloadEncodingError extends Error { + static [Symbol.hasInstance](value: unknown): boolean { + if (this !== DialCacheRedisPayloadEncodingError) { + return Function.prototype[Symbol.hasInstance].call(this, value); + } + return typeof value === "object" + && value !== null + && Object.getOwnPropertyDescriptor(value, redisPayloadEncodingErrorBrand)?.value === true; + } + constructor(message: string) { super(message); this.name = "DialCacheRedisPayloadEncodingError"; + // CJS adapter subpaths are separate bundles; a global symbol preserves root-export instanceof checks. + Object.defineProperty(this, redisPayloadEncodingErrorBrand, { value: true }); } } @@ -94,7 +118,20 @@ export interface RedisInvalidationRequest { */ export interface DialCacheRedisClient { /** - * Atomically read and validate a value against its watermark when tracked. + * Read a DialCache Redis frame and return its decoded serializer payload. + * Implementations must use `decodeRedisFrame` / `decodeTrackedRedisFrame` + * from `dialcache/redis-protocol`, or preserve their exact behavior. + * + * Raw values are Redis bulk strings (`Buffer`) or null. A missing value, a + * frame shorter than the version/timestamp/encoding header, or an + * unsupported frame version is a cache miss. A tracked read also misses + * when its watermark is missing, is not a finite unsigned decimal, or is + * greater than or equal to the frame's creation time. In other words, + * `createdAt <= watermark` is fenced. Unsupported payload encodings and + * non-bulk runtime replies are payload protocol errors rather than misses. + * + * Tracked implementations must read the value and watermark atomically from + * one authoritative snapshot; replica lag must not hide an invalidation. * * A non-null payload is transferred to DialCache. A returned Buffer must * remain stable and must not be mutated, pooled, or reused after this method diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index 5a9c23b..e726ae4 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -1,10 +1,12 @@ export { INVALIDATE_CACHE_SCRIPT, - READ_CACHE_SCRIPT, - READ_TRACKED_CACHE_SCRIPT, REDIS_ENCODING_BINARY, REDIS_ENCODING_UTF8, REDIS_FRAME_VERSION, WRITE_CACHE_SCRIPT, WRITE_TRACKED_CACHE_SCRIPT, } from "./internal/redis-scripts.js"; +export { + decodeRedisFrame, + decodeTrackedRedisFrame, +} from "./internal/redis-payload.js"; diff --git a/src/valkey-glide.ts b/src/valkey-glide.ts index c63485c..0918faf 100644 --- a/src/valkey-glide.ts +++ b/src/valkey-glide.ts @@ -1,8 +1,10 @@ -import { decodeRedisPayload, redisPayloadEncoding } from "./internal/redis-payload.js"; +import { + decodeRedisFrame, + decodeTrackedRedisFrame, + redisPayloadEncoding, +} from "./internal/redis-payload.js"; import { INVALIDATE_CACHE_SCRIPT, - READ_CACHE_SCRIPT, - READ_TRACKED_CACHE_SCRIPT, WRITE_CACHE_SCRIPT, WRITE_TRACKED_CACHE_SCRIPT, } from "./internal/redis-scripts.js"; @@ -14,12 +16,35 @@ import { DialCacheRedisPayloadError, type DialCacheRedisClient } from "./redis-c type ValkeyGlideString = string | Buffer; +interface ValkeyGlideBatch { + mget(keys: ValkeyGlideString[]): ValkeyGlideBatch; +} + +interface ValkeyGlideClusterReadClient { + customCommand( + args: ValkeyGlideString[], + options: { + decoder: TDecoder; + 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 }, + ): Promise; + exec( + batch: ValkeyGlideBatch, + raiseOnError: boolean, + options: { decoder: TDecoder }, + ): Promise; invokeScript( script: TScript, options: { @@ -30,7 +55,17 @@ export interface ValkeyGlideScriptingClient { ): Promise; } +interface ValkeyGlideClientIdentity { + readonly [Symbol.hasInstance]: (value: unknown) => boolean; +} + export interface ValkeyGlideRuntime { + /** The Batch constructor exported by the same GLIDE module instance as the client. */ + readonly Batch: 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. */ @@ -40,13 +75,50 @@ export interface ValkeyGlideRuntime { - readonly read: TScript; - readonly readTracked: TScript; readonly write: TScript; readonly writeTracked: TScript; readonly invalidate: TScript; } +function matchesValkeyGlideIdentity( + identity: unknown, + name: "GlideClient" | "GlideClusterClient", + client: unknown, +): boolean { + if ( + identity === null + || (typeof identity !== "object" && typeof identity !== "function") + || typeof (identity as ValkeyGlideClientIdentity)[Symbol.hasInstance] !== "function" + ) { + throw new Error(`Invalid Valkey GLIDE runtime: ${name} must support Symbol.hasInstance`); + } + return (identity as ValkeyGlideClientIdentity)[Symbol.hasInstance](client); +} + +function classifyValkeyGlideClient( + client: ValkeyGlideScriptingClient, + glide: ValkeyGlideRuntime, +): "standalone" | "cluster" { + const isStandalone = matchesValkeyGlideIdentity(glide.GlideClient, "GlideClient", client); + const isCluster = matchesValkeyGlideIdentity( + glide.GlideClusterClient, + "GlideClusterClient", + client, + ); + if (isStandalone && isCluster) { + throw new Error( + "Invalid Valkey GLIDE runtime: client matches both GlideClient and GlideClusterClient", + ); + } + if (!isStandalone && !isCluster) { + throw new Error( + "Valkey GLIDE DialCache requires a direct GlideClient or GlideClusterClient instance " + + "from the supplied runtime; wrappers should implement DialCacheRedisClient directly", + ); + } + 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; @@ -54,56 +126,93 @@ export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient { /** * Wrap a caller-owned GLIDE connection. The returned adapter owns only its - * Script handles and preserves the connection's `requestTimeout`. Pass the - * same GLIDE module namespace used to create the client so native Script - * handles are registered with that client's runtime. Callers dispose the - * handles after draining work, then close GLIDE. A request timeout bounds - * client waiting but is not server-side command cancellation. GLIDE's current - * script API has no per-invocation signal, so DialCache's core read deadline - * may return before this adapter's invocation settles. + * 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. + * 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 + * 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. */ export function createValkeyGlideDialCacheClient( client: ValkeyGlideScriptingClient, glide: ValkeyGlideRuntime, ): ValkeyGlideDialCacheClient { + if (typeof glide.Batch !== "function") { + throw new Error( + "Valkey GLIDE DialCache requires @valkey/valkey-glide >=2.0.0 with a Batch constructor", + ); + } + const clientKind = classifyValkeyGlideClient(client, glide); + const clusterClient = clientKind === "cluster" + ? client as ValkeyGlideScriptingClient + & ValkeyGlideClusterReadClient + : undefined; const scripts: DialCacheGlideScripts = { - read: new glide.Script(READ_CACHE_SCRIPT), - readTracked: new glide.Script(READ_TRACKED_CACHE_SCRIPT), 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 activeInvocations = 0; + let activeOperations = 0; - const invoke = async ( - script: TScript, - keys: ValkeyGlideString[], - args: ValkeyGlideString[] = [], - ): Promise => { + const run = async (operation: () => Promise): Promise => { if (disposed) { throw new Error("Valkey GLIDE DialCache client is disposed"); } - activeInvocations += 1; + activeOperations += 1; try { - return await client.invokeScript(script, { keys, args, decoder: glide.Decoder.Bytes }); + return await operation(); } finally { - activeInvocations -= 1; + activeOperations -= 1; } }; + const invoke = async ( + script: TScript, + keys: ValkeyGlideString[], + args: ValkeyGlideString[] = [], + ): Promise => run( + () => client.invokeScript(script, { keys, args, decoder: glide.Decoder.Bytes }), + ); + return { async read({ valueKey, watermarkKey }) { - const raw = watermarkKey === undefined - ? await invoke(scripts.read, [valueKey]) - : await invoke(scripts.readTracked, [valueKey, watermarkKey]); - if (raw === null) { - return null; + if (watermarkKey === undefined) { + const raw = await run( + () => client.get(valueKey, { decoder: glide.Decoder.Bytes }), + ); + return decodeRedisFrame(raw); } - if (!Buffer.isBuffer(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]; + }); + if (!Array.isArray(pair) || pair.length !== 2) { throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload reply"); } - return decodeRedisPayload(raw); + return decodeTrackedRedisFrame(pair[0], pair[1]); }, async write(request) { const { valueKey, watermarkKey, cacheTtlMs, value } = request; @@ -129,7 +238,7 @@ export function createValkeyGlideDialCacheClient 0) { + if (activeOperations > 0) { throw new Error("Cannot dispose Valkey GLIDE DialCache client while operations are in flight"); } disposed = true; diff --git a/test/node-redis.test.ts b/test/node-redis.test.ts index 0447429..dc740b9 100644 --- a/test/node-redis.test.ts +++ b/test/node-redis.test.ts @@ -24,8 +24,8 @@ const INVALID_WRITE_REPLIES: readonly unknown[] = [ const INVALID_INVALIDATION_REPLIES: readonly unknown[] = [0, ...INVALID_WRITE_REPLIES]; interface FakeReplies { - readonly read?: Buffer | null; - readonly readTracked?: Buffer | null; + readonly get?: unknown; + readonly mGet?: unknown; readonly write?: unknown; readonly writeTracked?: unknown; readonly invalidate?: unknown; @@ -33,14 +33,32 @@ interface FakeReplies { function fakeClient(replies: FakeReplies = {}) { return { - dialcacheRead: vi.fn(async () => Object.hasOwn(replies, "read") ? replies.read : null), - dialcacheReadTracked: vi.fn(async () => Object.hasOwn(replies, "readTracked") ? replies.readTracked : null), + 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), dialcacheInvalidate: vi.fn(async () => Object.hasOwn(replies, "invalidate") ? replies.invalidate : 1), }; } +function fakeCluster(replies: FakeReplies = {}) { + return { + ...fakeClient(replies), + masters: [], + }; +} + +function encodeFrame( + payload: string | Buffer, + { createdAtMs = 1, encoding = Buffer.isBuffer(payload) ? 1 : 0 } = {}, +): Buffer { + const header = Buffer.alloc(10); + header[0] = 1; + header.writeBigUInt64BE(BigInt(createdAtMs), 1); + header[9] = encoding; + return Buffer.concat([header, Buffer.isBuffer(payload) ? payload : Buffer.from(payload)]); +} + async function expectProtocolError(operation: Promise, message: string): Promise { let rejection: unknown; try { @@ -53,16 +71,14 @@ async function expectProtocolError(operation: Promise, message: string) } describe("node-redis adapter", () => { - it("provides the expected arguments for every bundled script", () => { + it("provides the expected arguments for every bundled mutation script", () => { const binary = Buffer.from([0, 0xff]); - expect(dialcacheRedisScripts.dialcacheRead.transformArguments("plain:value")).toEqual(["plain:value"]); - expect( - dialcacheRedisScripts.dialcacheReadTracked.transformArguments( - "tracked:{id}:value", - "tracked:{id}:watermark", - ), - ).toEqual(["tracked:{id}:value", "tracked:{id}:watermark"]); + expect(Object.keys(dialcacheRedisScripts)).toEqual([ + "dialcacheWrite", + "dialcacheWriteTracked", + "dialcacheInvalidate", + ]); expect(dialcacheRedisScripts.dialcacheWrite.transformArguments("plain:value", 1_000, 0, "plain")).toEqual([ "plain:value", "1000", @@ -85,8 +101,8 @@ describe("node-redis adapter", () => { it("accepts the exact write and invalidation reply domains", async () => { const client = fakeClient({ - read: Buffer.from([0, ...Buffer.from("plain")]), - readTracked: Buffer.from([1, 0, 0xff]), + get: encodeFrame("plain"), + mGet: [encodeFrame(Buffer.from([0, 0xff]), { createdAtMs: 2 }), Buffer.from("1")], write: 1, writeTracked: 0, invalidate: 1, @@ -125,17 +141,91 @@ describe("node-redis adapter", () => { context, ); - expect(client.dialcacheRead).toHaveBeenCalledWith( + expect(client.get).toHaveBeenCalledWith( expect.objectContaining({ returnBuffers: true, signal: controller.signal }), "plain:value", ); - expect(client.dialcacheReadTracked).toHaveBeenCalledWith( + expect(client.sendCommand).toHaveBeenCalledWith( + ["MGET", "tracked:{id}:value", "tracked:{id}:watermark"], expect.objectContaining({ returnBuffers: true, signal: controller.signal }), + ); + }); + + it("forces tracked Cluster MGET reads to the primary", async () => { + const client = fakeCluster({ + mGet: [encodeFrame("tracked", { createdAtMs: 2 }), Buffer.from("1")], + }); + const adapter = createNodeRedisDialCacheClient(client as never); + const controller = new AbortController(); + + await expect(adapter.read( + { valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark" }, + { timeoutMs: 25, signal: controller.signal }, + )).resolves.toBe("tracked"); + + expect(client.sendCommand).toHaveBeenCalledWith( "tracked:{id}:value", - "tracked:{id}:watermark", + false, + ["MGET", "tracked:{id}:value", "tracked:{id}:watermark"], + expect.objectContaining({ returnBuffers: true, signal: controller.signal }), + ); + }); + + it("does not mistake unrelated standalone metadata for the Cluster topology marker", async () => { + const client = { + ...fakeClient({ + mGet: [encodeFrame("tracked", { createdAtMs: 2 }), Buffer.from("1")], + }), + masters: "application metadata", + }; + const adapter = createNodeRedisDialCacheClient(client as never); + + await expect(adapter.read({ + valueKey: "tracked:{id}:value", + watermarkKey: "tracked:{id}:watermark", + })).resolves.toBe("tracked"); + + expect(client.sendCommand).toHaveBeenCalledWith( + ["MGET", "tracked:{id}:value", "tracked:{id}:watermark"], + expect.objectContaining({ returnBuffers: true }), ); }); + it("rejects malformed native read reply shapes", async () => { + await expect( + createNodeRedisDialCacheClient(fakeClient({ get: "not-bytes" }) as never) + .read({ valueKey: "plain:value" }), + ).rejects.toMatchObject({ + name: "DialCacheRedisPayloadError", + message: "Invalid DialCache Redis read reply; expected a bulk string or null", + }); + + const malformedMGetEnvelopes: readonly unknown[] = [ + null, + [null], + [null, null, null], + ]; + for (const reply of malformedMGetEnvelopes) { + await expect( + createNodeRedisDialCacheClient(fakeClient({ mGet: reply }) as never) + .read({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark" }), + ).rejects.toMatchObject({ + name: "DialCacheRedisPayloadError", + message: "Invalid DialCache Redis tracked read reply; expected an array with two entries", + }); + } + + for (const reply of [["not-bytes", null], [null, 0]]) { + await expect( + createNodeRedisDialCacheClient(fakeClient({ mGet: reply }) as never) + .read({ valueKey: "tracked:{id}:value", watermarkKey: "tracked:{id}:watermark" }), + ).rejects.toMatchObject({ + name: "DialCacheRedisPayloadError", + message: "Invalid DialCache Redis read reply; expected a bulk string or null", + }); + } + }); + 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 invalidationMessage = "Invalid DialCache Redis invalidate reply; expected integer 1"; diff --git a/test/redis-cluster.integration.test.ts b/test/redis-cluster.integration.test.ts index 1a5f721..67fbfdc 100644 --- a/test/redis-cluster.integration.test.ts +++ b/test/redis-cluster.integration.test.ts @@ -33,7 +33,7 @@ async function waitForCluster(container: StartedTestContainer): Promise { throw new Error("Redis Cluster did not become ready"); } -describe("DialCache Lua protocol on Redis Cluster", () => { +describe("DialCache Redis protocol on Redis Cluster", () => { let network: StartedNetwork | undefined; let containers: Array = []; let cluster: ReturnType | undefined; @@ -107,7 +107,7 @@ describe("DialCache Lua protocol on Redis Cluster", () => { await network?.stop(); }); - it("routes scripts across slots and reloads them per node", async () => { + it("routes cache operations across slots and reloads mutation scripts per node", async () => { if (cluster === undefined) { throw new Error("Redis Cluster did not start"); } @@ -140,7 +140,7 @@ describe("DialCache Lua protocol on Redis Cluster", () => { }), ); const recoveryDialcache = new DialCache({ - namespace: "cluster-cache", + namespace: "cluster-cache-recovery", redis: { client: scriptClient, readTimeoutMs: 10_000 }, }); const recoverValue = recoveryDialcache.cached(async (id: string) => ({ id, calls: ++calls }), { @@ -150,20 +150,30 @@ describe("DialCache Lua protocol on Redis Cluster", () => { defaultConfig: remoteOnly, }); const second = await recoveryDialcache.enable(async () => await Promise.all(ids.map(recoverValue))); + const sizesAfterRecovery = await Promise.all( + activeCluster.masters.map(async (master) => { + const client = await activeCluster.nodeClient(master); + return await client.dbSize(); + }), + ); + const callsAfterRecovery = calls; + const third = await recoveryDialcache.enable(async () => await Promise.all(ids.map(recoverValue))); expect(first.map(({ id }) => id)).toEqual(ids); - expect(calls).toBe(30); expect(sizesBeforeFlush.every((size) => size > 0)).toBe(true); - expect(second).toEqual(first); + expect( + sizesAfterRecovery.every((size, index) => size > (sizesBeforeFlush[index] ?? Number.POSITIVE_INFINITY)), + ).toBe(true); + expect(second.map(({ id }) => id)).toEqual(ids); + expect(third).toEqual(second); + expect(callsAfterRecovery).toBe(60); + expect(calls).toBe(callsAfterRecovery); }); it("keeps tracked keys colocated and rejects mismatched hash tags", async () => { if (cluster === undefined) { throw new Error("Redis Cluster did not start"); } - expect(dialcacheRedisScripts.dialcacheRead.IS_READ_ONLY).toBe(true); - expect(dialcacheRedisScripts.dialcacheReadTracked.IS_READ_ONLY).toBe(false); - expect(dialcacheRedisScripts.dialcacheRead.SHA1).not.toBe(dialcacheRedisScripts.dialcacheReadTracked.SHA1); expect(dialcacheRedisScripts.dialcacheWrite.SHA1).not.toBe(dialcacheRedisScripts.dialcacheWriteTracked.SHA1); const scriptClient: DialCacheRedisClient = createNodeRedisDialCacheClient(cluster); const dialcache = new DialCache({ @@ -187,10 +197,15 @@ describe("DialCache Lua protocol on Redis Cluster", () => { expect(before).toEqual({ id: "123", version: 1 }); expect(after).toEqual({ id: "123", version: 2 }); - await expect(cluster.dialcacheReadTracked("{slot-a}:value", "{slot-b}:watermark")).rejects.toThrow(/CROSSSLOT/); + await expect( + scriptClient.read({ + valueKey: "{slot-a}:value", + watermarkKey: "{slot-b}:watermark", + }), + ).rejects.toThrow(/CROSSSLOT/); }); - it("round-trips binary payloads through cluster script routing", async () => { + it("round-trips binary payloads through cluster routing", async () => { if (cluster === undefined) { throw new Error("Redis Cluster did not start"); } diff --git a/test/redis-payload.test.ts b/test/redis-payload.test.ts new file mode 100644 index 0000000..d291360 --- /dev/null +++ b/test/redis-payload.test.ts @@ -0,0 +1,145 @@ +import { + decodeRedisFrame, + decodeTrackedRedisFrame, +} from "../src/redis-protocol.js"; +import { + DialCacheRedisPayloadEncodingError, + DialCacheRedisPayloadError, +} from "../src/redis-client.js"; + +function encodeFrame( + payload: string | Buffer, + encoding = 0, + createdAtMs = 1_000, + version = 1, +): Buffer { + const timestamp = Buffer.alloc(8); + timestamp.writeBigUInt64BE(BigInt(createdAtMs)); + return Buffer.concat([ + Buffer.from([version]), + timestamp, + Buffer.from([encoding]), + Buffer.from(payload), + ]); +} + +describe("Redis frame decoding", () => { + it("decodes UTF-8 and binary payloads without copying binary data", () => { + expect(decodeRedisFrame(encodeFrame("cached"))).toBe("cached"); + + const frame = encodeFrame(Buffer.from([0, 0xff, 0x80]), 1); + const decoded = decodeRedisFrame(frame); + expect(decoded).toEqual(Buffer.from([0, 0xff, 0x80])); + expect(Buffer.isBuffer(decoded)).toBe(true); + if (!Buffer.isBuffer(decoded)) { + throw new Error("Expected a binary Redis payload"); + } + expect(decoded.buffer).toBe(frame.buffer); + expect(decoded.byteOffset).toBe(frame.byteOffset + 10); + expect(decoded.byteLength).toBe(frame.byteLength - 10); + }); + + it("treats missing, short, and unsupported frames as misses", () => { + expect(decodeRedisFrame(null)).toBeNull(); + expect(decodeRedisFrame(Buffer.alloc(9))).toBeNull(); + expect(decodeRedisFrame(encodeFrame("cached", 0, 1_000, 2))).toBeNull(); + }); + + it("rejects unsupported payload encodings after validating the frame", () => { + expect(() => decodeRedisFrame(encodeFrame("cached", 2))).toThrow( + DialCacheRedisPayloadEncodingError, + ); + }); + + it("rejects non-bulk-string runtime replies at the shared decoder boundary", () => { + const invalidReplies: readonly unknown[] = [undefined, "not-bytes", 0, {}, []]; + + for (const reply of invalidReplies) { + expect(() => decodeRedisFrame(reply)).toThrow(DialCacheRedisPayloadError); + expect(() => decodeTrackedRedisFrame(reply, null)).toThrow(DialCacheRedisPayloadError); + expect(() => decodeTrackedRedisFrame(null, reply)).toThrow(DialCacheRedisPayloadError); + } + }); + + it("validates tracked frames against integer and fractional watermarks", () => { + const frame = encodeFrame("cached", 0, 1_000); + + expect(decodeTrackedRedisFrame(frame, Buffer.from("999"))).toBe("cached"); + expect(decodeTrackedRedisFrame(frame, Buffer.from("999.5"))).toBe("cached"); + expect(decodeTrackedRedisFrame(frame, Buffer.from("1000"))).toBeNull(); + expect(decodeTrackedRedisFrame(frame, Buffer.from("1000.5"))).toBeNull(); + }); + + it("treats missing, malformed, and non-finite watermarks as misses", () => { + const frame = encodeFrame("cached", 0, 1_000); + + for (const watermark of [ + null, + Buffer.from(""), + Buffer.from("-1"), + Buffer.from("1."), + Buffer.from(".1"), + Buffer.from("1e2"), + Buffer.from("1\n"), + Buffer.from("9".repeat(400)), + ]) { + expect(decodeTrackedRedisFrame(frame, watermark)).toBeNull(); + } + }); + + it("validates tracked frame and watermark state before payload encoding", () => { + const malformedPayload = encodeFrame("cached", 2, 1_000); + + expect(decodeTrackedRedisFrame(null, Buffer.from("0"))).toBeNull(); + expect(decodeTrackedRedisFrame(Buffer.alloc(9), Buffer.from("0"))).toBeNull(); + expect(decodeTrackedRedisFrame(malformedPayload, null)).toBeNull(); + expect(decodeTrackedRedisFrame(malformedPayload, Buffer.from("1000"))).toBeNull(); + expect(() => decodeTrackedRedisFrame(malformedPayload, Buffer.from("999"))).toThrow( + DialCacheRedisPayloadEncodingError, + ); + }); + + it("preserves payload error identity across separately bundled entry points", () => { + class SpecializedPayloadError extends DialCacheRedisPayloadError {} + class SpecializedEncodingError extends DialCacheRedisPayloadEncodingError {} + + const payloadError = new DialCacheRedisPayloadError("payload"); + const encodingError = new DialCacheRedisPayloadEncodingError("encoding"); + const specializedPayloadError = new SpecializedPayloadError("specialized payload"); + const specializedEncodingError = new SpecializedEncodingError("specialized encoding"); + const crossBundlePayloadError = Object.defineProperty( + new Error("payload"), + Symbol.for("dialcache.DialCacheRedisPayloadError"), + { value: true }, + ); + const crossBundleEncodingError = Object.defineProperty( + new Error("encoding"), + Symbol.for("dialcache.DialCacheRedisPayloadEncodingError"), + { value: true }, + ); + const falselyBrandedPayloadError = Object.defineProperty( + new Error("payload"), + Symbol.for("dialcache.DialCacheRedisPayloadError"), + { value: false }, + ); + const falselyBrandedEncodingError = Object.defineProperty( + new Error("encoding"), + Symbol.for("dialcache.DialCacheRedisPayloadEncodingError"), + { value: false }, + ); + + expect(payloadError).toBeInstanceOf(DialCacheRedisPayloadError); + expect(payloadError).not.toBeInstanceOf(SpecializedPayloadError); + expect(specializedPayloadError).toBeInstanceOf(SpecializedPayloadError); + expect(specializedPayloadError).toBeInstanceOf(DialCacheRedisPayloadError); + expect(crossBundlePayloadError).toBeInstanceOf(DialCacheRedisPayloadError); + expect(falselyBrandedPayloadError).not.toBeInstanceOf(DialCacheRedisPayloadError); + + expect(encodingError).toBeInstanceOf(DialCacheRedisPayloadEncodingError); + expect(encodingError).not.toBeInstanceOf(SpecializedEncodingError); + expect(specializedEncodingError).toBeInstanceOf(SpecializedEncodingError); + expect(specializedEncodingError).toBeInstanceOf(DialCacheRedisPayloadEncodingError); + expect(crossBundleEncodingError).toBeInstanceOf(DialCacheRedisPayloadEncodingError); + expect(falselyBrandedEncodingError).not.toBeInstanceOf(DialCacheRedisPayloadEncodingError); + }); +}); diff --git a/test/redis-real.integration.test.ts b/test/redis-real.integration.test.ts index eafe6d9..62e7096 100644 --- a/test/redis-real.integration.test.ts +++ b/test/redis-real.integration.test.ts @@ -154,7 +154,7 @@ function encodeFrame(payload: string | Buffer, encoding: number, createdAtMs = D return Buffer.concat([Buffer.from([version]), timestamp, Buffer.from([encoding]), Buffer.from(payload)]); } -describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { +describe.each(engines)("DialCache Redis protocol on $name", ({ image }) => { let container: StartedTestContainer | undefined; // This connection controls and inspects server state; cache operations use the selected adapter harness. let admin: NodeRedisTestClient | undefined; @@ -799,7 +799,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { }); }); - it("recovers every Lua script after SCRIPT FLUSH", async () => { + it("reloads every mutation script after SCRIPT FLUSH", async () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); } @@ -808,7 +808,6 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { await admin.scriptFlush(); expect(await scriptClient.write({ valueKey, cacheTtlMs: 60_000, value: "untracked" })).toBe(true); - await admin.scriptFlush(); expect(await scriptClient.read({ valueKey })).toBe("untracked"); const trackedValueKey = "script-recovery:{item:tracked}:value"; @@ -822,7 +821,6 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { value: "tracked", }), ).toBe(true); - await admin.scriptFlush(); expect(await scriptClient.read({ valueKey: trackedValueKey, watermarkKey })).toBe("tracked"); await admin.scriptFlush(); await expect( @@ -831,6 +829,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { futureBufferMs: 0, }), ).resolves.toBeUndefined(); + expect(await scriptClient.read({ valueKey: trackedValueKey, watermarkKey })).toBeNull(); }); it("treats every invalid read frame and watermark state as a miss", async () => { @@ -865,6 +864,186 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { expect(await scriptClient.read({ valueKey, watermarkKey })).toBe("tracked"); }); + it("records a stale tracked frame as a remote miss without a read error", async () => { + if (client === undefined || admin === undefined) { + throw new Error("Redis test clients did not start"); + } + const scriptClient = client.adapter; + + const namespace = "stale-miss-metrics"; + const useCase = "TrackedStaleMissMetrics"; + const id = "stale"; + const staleValueKey = `{${namespace}:item_id:${id}}#${useCase}:dialcache-frame-v1`; + const staleWatermarkKey = `{${namespace}:item_id:${id}}#watermark`; + await admin.set(staleValueKey, encodeFrame("stale", 0, 1_000)); + await admin.set(staleWatermarkKey, "1000"); + + const metrics = { + request: vi.fn(), + miss: vi.fn(), + disabled: vi.fn(), + error: vi.fn(), + invalidation: vi.fn(), + coalesced: vi.fn(), + observeGet: vi.fn(), + observeFallback: vi.fn(), + observeSerialization: vi.fn(), + observeSize: vi.fn(), + } satisfies DialCacheMetricsAdapter; + const dialcache = new DialCache({ + namespace, + redis: { client: scriptClient, readTimeoutMs: 10_000 }, + metrics, + }); + const fallback = vi.fn(async () => ({ source: "fallback" })); + const getValue = dialcache.cached(fallback, { + keyType: "item_id", + useCase, + cacheKey: () => id, + trackForInvalidation: true, + defaultConfig: remoteOnly, + }); + const labels = { + cacheNamespace: namespace, + useCase, + keyType: "item_id", + layer: CacheLayer.REMOTE, + } as const; + + await expect(dialcache.enable(async () => await getValue())).resolves.toEqual({ source: "fallback" }); + + expect(fallback).toHaveBeenCalledOnce(); + expect(metrics.request).toHaveBeenCalledOnce(); + expect(metrics.request).toHaveBeenCalledWith(labels); + expect(metrics.miss).toHaveBeenCalledOnce(); + expect(metrics.miss).toHaveBeenCalledWith(labels); + expect(metrics.observeGet).toHaveBeenCalledOnce(); + expect(metrics.observeGet).toHaveBeenCalledWith(labels, expect.any(Number)); + expect(metrics.observeFallback).toHaveBeenCalledOnce(); + expect(metrics.observeFallback).toHaveBeenCalledWith(labels, expect.any(Number)); + expect(metrics.error).not.toHaveBeenCalled(); + }); + + it("uses native wrong-type semantics and repairs tracked value keys", async () => { + if (client === undefined || admin === undefined) { + throw new Error("Redis test clients did not start"); + } + const scriptClient = client.adapter; + const valueKey = "wrong-type:{item:read}:value"; + const watermarkKey = "wrong-type:{item:read}:watermark"; + + await admin.hSet(valueKey, "field", "value"); + await admin.set(watermarkKey, "0"); + await expect(scriptClient.read({ valueKey })).rejects.toThrow(/WRONGTYPE/); + await expect(scriptClient.read({ valueKey, watermarkKey })).resolves.toBeNull(); + + await admin.del([valueKey, watermarkKey]); + await admin.set(valueKey, encodeFrame("cached", 0, 1_000)); + await admin.hSet(watermarkKey, "field", "value"); + await expect(scriptClient.read({ valueKey, watermarkKey })).resolves.toBeNull(); + + const namespace = "wrong-type-repair"; + const repairValueKey = `{${namespace}:item_id:repair}#WrongTypeRepair:dialcache-frame-v1`; + const repairWatermarkKey = `{${namespace}:item_id:repair}#watermark`; + await admin.hSet(repairValueKey, "field", "value"); + await admin.set(repairWatermarkKey, "0"); + + let sourceCalls = 0; + const dialcache = new DialCache({ + namespace, + redis: { client: scriptClient, readTimeoutMs: 10_000 }, + }); + const getValue = dialcache.cached(async (id: string) => ({ id, calls: ++sourceCalls }), { + keyType: "item_id", + useCase: "WrongTypeRepair", + cacheKey: (id) => id, + trackForInvalidation: true, + defaultConfig: remoteOnly, + }); + + const repaired = await dialcache.enable(async () => await getValue("repair")); + const cached = await dialcache.enable(async () => await getValue("repair")); + + expect(repaired).toEqual({ id: "repair", calls: 1 }); + expect(cached).toEqual(repaired); + expect(sourceCalls).toBe(1); + expect(await admin.type(repairValueKey)).toBe("string"); + }); + + it("fails open repeatedly when a tracked watermark has the wrong Redis type", async () => { + if (client === undefined || admin === undefined) { + throw new Error("Redis test clients did not start"); + } + const namespace = "wrong-type-watermark"; + const useCase = "WrongTypeWatermark"; + const id = "broken"; + const valueKey = `{${namespace}:item_id:${id}}#${useCase}:dialcache-frame-v1`; + const watermarkKey = `{${namespace}:item_id:${id}}#watermark`; + const frame = encodeFrame("cached", 0, 1_000); + await admin.set(valueKey, frame, { PX: 60_000 }); + await admin.hSet(watermarkKey, "field", "value"); + + const metrics = { + request: vi.fn(), + miss: vi.fn(), + disabled: vi.fn(), + error: vi.fn(), + invalidation: vi.fn(), + coalesced: vi.fn(), + observeGet: vi.fn(), + observeFallback: vi.fn(), + observeSerialization: vi.fn(), + observeSize: vi.fn(), + } satisfies DialCacheMetricsAdapter; + const dialcache = new DialCache({ + namespace, + redis: { client: client.adapter, readTimeoutMs: 10_000 }, + logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, + metrics, + }); + let sourceCalls = 0; + const getValue = dialcache.cached(async () => ({ source: "fallback", calls: ++sourceCalls }), { + keyType: "item_id", + useCase, + cacheKey: () => id, + trackForInvalidation: true, + defaultConfig: remoteOnly, + }); + const labels = { + cacheNamespace: namespace, + useCase, + keyType: "item_id", + layer: CacheLayer.REMOTE, + } as const; + + await expect(dialcache.enable(async () => await getValue())).resolves.toEqual({ + source: "fallback", + calls: 1, + }); + await expect(dialcache.enable(async () => await getValue())).resolves.toEqual({ + source: "fallback", + calls: 2, + }); + + expect(sourceCalls).toBe(2); + expect(metrics.request).toHaveBeenCalledTimes(2); + expect(metrics.miss).toHaveBeenCalledTimes(2); + expect(metrics.error).toHaveBeenCalledTimes(2); + expect(metrics.error).toHaveBeenNthCalledWith(1, { + ...labels, + error: "cache_write", + inFallback: false, + }); + expect(metrics.error).toHaveBeenNthCalledWith(2, { + ...labels, + error: "cache_write", + inFallback: false, + }); + 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); + }); + it("rejects invalid raw script arguments before mutating Redis", async () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); @@ -990,7 +1169,7 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { expect(await admin.pTTL(watermarkKey)).toBeLessThanOrEqual(60_101); }); - it("invalidates tracked entries and recovers after SCRIPT FLUSH", async () => { + it("keeps native reads working after SCRIPT FLUSH", async () => { if (client === undefined || admin === undefined) { throw new Error("Redis test clients did not start"); } @@ -1273,8 +1452,14 @@ describe.each(engines)("DialCache Lua protocol on $name", ({ image }) => { await scriptClient.invalidate({ watermarkKey, futureBufferMs: 100 }); expect(await scriptClient.read({ valueKey, watermarkKey })).toBeNull(); + const watermarkBeforeBlockedWrite = await admin.get(watermarkKey); + const watermarkTtlBeforeBlockedWrite = await admin.pTTL(watermarkKey); expect(await scriptClient.write({ ...writeRequest, value: "blocked" })).toBe(false); - expect(await scriptClient.read({ valueKey })).toBe("cached"); + expect(await scriptClient.read({ valueKey })).toBeNull(); + expect(await admin.get(watermarkKey)).toBe(watermarkBeforeBlockedWrite); + const watermarkTtlAfterBlockedWrite = await admin.pTTL(watermarkKey); + expect(watermarkTtlAfterBlockedWrite).toBeGreaterThan(watermarkTtlBeforeBlockedWrite - 1_000); + expect(watermarkTtlAfterBlockedWrite).toBeLessThanOrEqual(watermarkTtlBeforeBlockedWrite); const ttlBeforeRead = await admin.pTTL(watermarkKey); await scriptClient.read({ valueKey, watermarkKey }); expect(await admin.pTTL(watermarkKey)).toBeLessThanOrEqual(ttlBeforeRead); diff --git a/test/valkey-glide.test.ts b/test/valkey-glide.test.ts index cb7d405..ea7bb6f 100644 --- a/test/valkey-glide.test.ts +++ b/test/valkey-glide.test.ts @@ -24,6 +24,9 @@ const INVALID_INVALIDATION_REPLIES: readonly unknown[] = [0, ...INVALID_WRITE_RE const decoderBytes = Symbol("bytes"); const scriptInstances: MockScript[] = []; +const batchInstances: MockBatch[] = []; +const standaloneClients = new WeakSet(); +const clusterClients = new WeakSet(); class MockScript { readonly release = vi.fn(); @@ -33,8 +36,34 @@ class MockScript { } } +class MockBatch { + readonly mget = vi.fn((keys: Array) => { + this.keys = keys; + return this; + }); + keys: Array | undefined; + + constructor(readonly isAtomic: boolean) { + batchInstances.push(this); + } +} + +function mockClientIdentity(instances: WeakSet) { + return { + [Symbol.hasInstance](value: unknown): boolean { + if ((typeof value !== "object" || value === null) && typeof value !== "function") { + return false; + } + return instances.has(value); + }, + }; +} + const mockGlide = { + Batch: MockBatch, Decoder: { Bytes: decoderBytes }, + GlideClient: mockClientIdentity(standaloneClients), + GlideClusterClient: mockClientIdentity(clusterClients), Script: MockScript, }; @@ -44,10 +73,53 @@ interface InvokeScriptOptions { decoder: typeof decoderBytes; } +function createFakeClient(replies: unknown[]) { + const nextReply = async (): Promise => replies.shift(); + const client = { + get: vi.fn(async (_key: string | Buffer, _options: { decoder: typeof decoderBytes }) => nextReply()), + exec: vi.fn(async ( + _batch: MockBatch, + _raiseOnError: boolean, + _options: { decoder: typeof decoderBytes }, + ) => nextReply()), + invokeScript: vi.fn(async (_script: MockScript, _options: InvokeScriptOptions) => nextReply()), + }; + return { client, nextReply }; +} + function fakeClient(...replies: unknown[]) { - return { - invokeScript: vi.fn(async (_script: MockScript, _options: InvokeScriptOptions) => replies.shift()), + const client = createFakeClient(replies).client; + 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; +} + +function redisFrame( + payload: string | Buffer, + options: { createdAtMs?: number; encoding?: number } = {}, +): Buffer { + const bytes = Buffer.isBuffer(payload) ? payload : Buffer.from(payload); + const frame = Buffer.alloc(10 + bytes.length); + frame[0] = 1; + frame.writeBigUInt64BE(BigInt(options.createdAtMs ?? 1_000), 1); + frame[9] = options.encoding ?? (Buffer.isBuffer(payload) ? 1 : 0); + bytes.copy(frame, 10); + return frame; } async function expectProtocolError(operation: Promise, message: string): Promise { @@ -64,10 +136,15 @@ async function expectProtocolError(operation: Promise, message: string) describe("Valkey GLIDE adapter", () => { beforeEach(() => { scriptInstances.length = 0; + batchInstances.length = 0; }); - it("invokes distinct read scripts with byte decoding", async () => { - const client = fakeClient(Buffer.from([0, ...Buffer.from("plain")]), Buffer.from([1, 0, 0xff]), null); + it("uses GET and a non-atomic primary MGET batch that preserves caller WATCH state", async () => { + const client = fakeClient( + redisFrame("plain"), + [[redisFrame(Buffer.from([0, 0xff])), Buffer.from("0")]], + null, + ); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); await expect(adapter.read({ valueKey: "plain:value" })).resolves.toBe("plain"); @@ -76,21 +153,115 @@ describe("Valkey GLIDE adapter", () => { ).resolves.toEqual(Buffer.from([0, 0xff])); await expect(adapter.read({ valueKey: "missing:value" })).resolves.toBeNull(); - expect(client.invokeScript).toHaveBeenNthCalledWith( + expect(client.get).toHaveBeenNthCalledWith( 1, - expect.any(MockScript), - { keys: ["plain:value"], args: [], decoder: decoderBytes }, + "plain:value", + { decoder: decoderBytes }, ); - expect(client.invokeScript).toHaveBeenNthCalledWith( + expect(client.get).toHaveBeenNthCalledWith( 2, - expect.any(MockScript), + "missing:value", + { decoder: decoderBytes }, + ); + expect(batchInstances).toHaveLength(1); + expect(batchInstances[0]?.isAtomic).toBe(false); + expect(batchInstances[0]?.mget).toHaveBeenCalledWith([ + "tracked:{id}:value", + "tracked:{id}:watermark", + ]); + expect(client.exec).toHaveBeenCalledWith( + batchInstances[0], + true, + { decoder: decoderBytes }, + ); + expect(client.invokeScript).not.toHaveBeenCalled(); + expect(scriptInstances).toHaveLength(3); + }); + + it("routes tracked cluster MGET directly to the slot primary", async () => { + const client = fakeClusterClient([ + redisFrame("tracked-cluster"), + Buffer.from("0"), + ]); + const adapter = createValkeyGlideDialCacheClient(client, mockGlide); + + await expect( + adapter.read({ + valueKey: "cluster:{id}:value", + watermarkKey: "cluster:{id}:watermark", + }), + ).resolves.toBe("tracked-cluster"); + + expect(client.customCommand).toHaveBeenCalledWith( + ["MGET", "cluster:{id}:value", "cluster:{id}:watermark"], { - keys: ["tracked:{id}:value", "tracked:{id}:watermark"], - args: [], decoder: decoderBytes, + route: { type: "primarySlotKey", key: "cluster:{id}:value" }, }, ); - expect(scriptInstances).toHaveLength(5); + expect(client.exec).not.toHaveBeenCalled(); + expect(batchInstances).toHaveLength(0); + }); + + it("rejects forwarding wrappers instead of silently treating them as standalone", () => { + const directClient = fakeClient(); + const forwardingWrapper = { + exec: directClient.exec, + get: directClient.get, + invokeScript: directClient.invokeScript, + }; + + expect( + () => createValkeyGlideDialCacheClient(forwardingWrapper, mockGlide), + ).toThrow( + "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", () => { + const client = fakeClient(); + const otherGlide = { + ...mockGlide, + GlideClient: mockClientIdentity(new WeakSet()), + GlideClusterClient: mockClientIdentity(new WeakSet()), + }; + + expect( + () => createValkeyGlideDialCacheClient(client, otherGlide), + ).toThrow( + "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", () => { + const client = fakeClient(); + clusterClients.add(client); + + expect( + () => createValkeyGlideDialCacheClient(client, mockGlide), + ).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", () => { + const client = fakeClient(); + const glideWithoutBatch = { + ...mockGlide, + Batch: 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); }); it("preserves GLIDE invocation options when given a core read context", async () => { @@ -103,9 +274,9 @@ describe("Valkey GLIDE adapter", () => { { timeoutMs: 25, signal: controller.signal }, ); - expect(client.invokeScript).toHaveBeenCalledWith( - expect.any(MockScript), - { keys: ["plain:value"], args: [], decoder: decoderBytes }, + expect(client.get).toHaveBeenCalledWith( + "plain:value", + { decoder: decoderBytes }, ); adapter.dispose(); }); @@ -151,15 +322,27 @@ describe("Valkey GLIDE adapter", () => { ); }); - it("rejects malformed script replies", async () => { - const client = fakeClient("not-bytes", Buffer.alloc(0), Buffer.from([2, 1]), "not-an-integer", null); + it("rejects malformed native read and mutation script replies", async () => { + const client = fakeClient( + "not-bytes", + redisFrame("invalid", { encoding: 2 }), + "not-a-batch-reply", + [[redisFrame("missing-watermark")]], + "not-an-integer", + null, + ); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); await expect(adapter.read({ valueKey: "wrong-type" })).rejects.toBeInstanceOf(DialCacheRedisPayloadError); - await expect(adapter.read({ valueKey: "empty" })).rejects.toBeInstanceOf(DialCacheRedisPayloadError); await expect(adapter.read({ valueKey: "wrong-encoding" })).rejects.toBeInstanceOf( DialCacheRedisPayloadEncodingError, ); + await expect( + adapter.read({ valueKey: "bad:{id}:value", watermarkKey: "bad:{id}:watermark" }), + ).rejects.toBeInstanceOf(DialCacheRedisPayloadError); + await expect( + 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", @@ -217,18 +400,20 @@ describe("Valkey GLIDE adapter", () => { adapter.dispose(); adapter.dispose(); - expect(scriptInstances).toHaveLength(5); + 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("does not release scripts while an invocation is in flight", async () => { + it("does not release scripts while a native read is in flight", async () => { let resolveRead: ((value: Buffer) => void) | undefined; const client = fakeClient(); - client.invokeScript.mockImplementationOnce( + client.get.mockImplementationOnce( async () => await new Promise((resolve) => { resolveRead = resolve; }), @@ -241,28 +426,46 @@ describe("Valkey GLIDE adapter", () => { ); expect(scriptInstances.every((script) => script.release.mock.calls.length === 0)).toBe(true); - resolveRead?.(Buffer.from([0, ...Buffer.from("done")])); + resolveRead?.(redisFrame("done")); await expect(read).resolves.toBe("done"); adapter.dispose(); expect(scriptInstances.every((script) => script.release.mock.calls.length === 1)).toBe(true); }); - it("uses Script and Decoder from the supplied GLIDE module instance", async () => { + it("uses Batch, Script, 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(null); + const client = fakeClient( + [[redisFrame("tracked"), Buffer.from("0")]], + 1, + ); const adapter = createValkeyGlideDialCacheClient(client, mockGlide); - await adapter.read({ valueKey: "module-instance" }); + await adapter.read({ + 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();