Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,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@^2.4.2
pnpm add @valkey/valkey-glide
# Add a metrics client only when using its adapter:
pnpm add prom-client@^15.1.3
# or
Expand Down Expand Up @@ -308,19 +308,20 @@ async function shutdown(): Promise<void> {

`redis.client` is required when Redis is configured and accepts the semantic `DialCacheRedisClient` interface. 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.

Valkey GLIDE users pass an already-created standalone or cluster client to the GLIDE adapter:
Valkey GLIDE users pass an already-created standalone or cluster client and its
module namespace to the GLIDE adapter:

```ts
import { GlideClient } from "@valkey/valkey-glide";
import * as valkeyGlide from "@valkey/valkey-glide";
import { DialCache } from "dialcache";
import { createValkeyGlideDialCacheClient } from "dialcache/valkey-glide";

const glideClient = await GlideClient.createClient({
const glideClient = await valkeyGlide.GlideClient.createClient({
addresses: [{ host: "127.0.0.1", port: 6379 }],
requestTimeout: 2_000,
advancedConfiguration: { connectionTimeout: 2_000 },
});
const redisClient = createValkeyGlideDialCacheClient(glideClient);
const redisClient = createValkeyGlideDialCacheClient(glideClient, valkeyGlide);
const dialcache = new DialCache({
namespace: "users-api",
redis: { client: redisClient },
Expand All @@ -333,6 +334,11 @@ 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.

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 outstanding cached-function and `invalidateRemote()` promise, including calls still running fallbacks that may later write Redis. Then dispose adapter-owned resources and close the underlying connection. DialCache only borrows `redis.client`; it has no close or drain method and never disposes or closes caller resources.

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.
Expand All @@ -343,7 +349,7 @@ Node-redis computes each script's SHA, uses `EVALSHA`, and retries with `EVAL` a

DialCache fail-open behavior is rejection-driven: a Redis promise that never settles cannot fall through. Every `DialCacheRedisClient.read`, `write`, and `invalidate` promise must therefore resolve or reject within a finite application-defined budget covering connection establishment, reconnection, retries, offline queueing, dispatch, and response time. A connection timeout alone does not satisfy this contract. A pending read prevents fallback from starting, while a pending write withholds an already-computed fallback result and retains its process flight.

Valkey GLIDE users should configure [`requestTimeout`](https://github.com/valkey-io/valkey-glide/blob/v2.4.2/node/src/BaseClient.ts#L770-L776) and [`advancedConfiguration.connectionTimeout`](https://github.com/valkey-io/valkey-glide/blob/v2.4.2/node/src/BaseClient.ts#L957-L964), as shown above. GLIDE applies the request timeout while sending, waiting for a response, reconnecting, and retrying. This bounds client waiting; it is not server-side cancellation, so a write dispatched before timeout may still execute.
Valkey GLIDE users should configure [`requestTimeout`](https://glide.valkey.io/languages/nodejs/api/interfaces/BaseClient.BaseClientConfiguration.html) and [`advancedConfiguration.connectionTimeout`](https://glide.valkey.io/languages/nodejs/api/interfaces/BaseClient.AdvancedBaseClientConfiguration.html), as shown above. GLIDE applies the request timeout while sending, waiting for a response, reconnecting, and retrying. This bounds client waiting; it is not server-side cancellation, so a write dispatched before timeout may still execute.

In node-redis 4.7, `socket.connectTimeout`, `disableOfflineQueue`, and `commandsQueueMaxLength` bound connection or queue behavior but do not impose a response deadline on a dispatched command. A [per-command `AbortSignal`](https://redis.io/docs/latest/develop/clients/nodejs/produsage/) can remove work that is still waiting to be sent, but once dispatched it no longer controls the pending reply. Node-redis 4.7 has no built-in strict dispatched-response deadline, and the bundled adapter does not inject queue cancellation. Applications requiring finite node-redis settlement must supply and document a custom `DialCacheRedisClient` policy for queue removal, hung connections, and ambiguous writes. Do not put Redis writes or invalidations behind a bare `Promise.race`: rejecting the outer promise neither removes queued work nor proves that an already-dispatched command did not execute.

Expand Down
4 changes: 0 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,10 @@
"vitest": "^4.0.14"
},
"peerDependencies": {
"@valkey/valkey-glide": "^2.4.2",
"prom-client": "^15.1.3",
"redis": "~4.7.1"
},
"peerDependenciesMeta": {
"@valkey/valkey-glide": {
"optional": true
},
"prom-client": {
"optional": true
},
Expand Down
53 changes: 48 additions & 5 deletions scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,8 @@ void datadogMetrics;
void datadogClassAdapter;
void missingObservationType;
`;
const integrationConsumer = `import { DialCache } from "dialcache";
const integrationConsumer = `import * as valkeyGlide from "@valkey/valkey-glide";
import { DialCache } from "dialcache";
import StatsD from "hot-shots";
import {
DatadogDialCacheMetrics,
Expand All @@ -330,6 +331,7 @@ import {
import {
createValkeyGlideDialCacheClient,
type ValkeyGlideDialCacheClient,
type ValkeyGlideRuntime,
} from "dialcache/valkey-glide";
import { Registry, type OpenMetricsContentType } from "prom-client";

Expand All @@ -343,6 +345,13 @@ openMetricsRegistry.setContentType(Registry.OPENMETRICS_CONTENT_TYPE);
const openMetricsAdapter = new PrometheusDialCacheMetrics({ registry: openMetricsRegistry, prefix: "open_" });
const registryIsRequired: {} extends Pick<PrometheusMetricsOptions, "registry"> ? false : true = true;
const glideRedisClient: ValkeyGlideDialCacheClient | undefined = undefined;
const glideRuntime: ValkeyGlideRuntime<valkeyGlide.Script, valkeyGlide.Decoder> = valkeyGlide;
declare const standaloneGlideClient: valkeyGlide.GlideClient;
declare const clusterGlideClient: valkeyGlide.GlideClusterClient;
const standaloneGlideAdapter = createValkeyGlideDialCacheClient(standaloneGlideClient, glideRuntime);
const clusterGlideAdapter = createValkeyGlideDialCacheClient(clusterGlideClient, glideRuntime);
// @ts-expect-error The caller's GLIDE runtime is required for native Script ownership.
createValkeyGlideDialCacheClient(standaloneGlideClient);
const dogStatsD = new StatsD({ mock: true });
const compatibleDogStatsD: DatadogDogStatsDClient = dogStatsD;
const observationMetricType: DatadogObservationMetricType = "distribution";
Expand All @@ -365,7 +374,8 @@ void classAdapter;
void openMetricsAdapter;
void registryIsRequired;
void glideRedisClient;
void createValkeyGlideDialCacheClient;
void standaloneGlideAdapter;
void clusterGlideAdapter;
void datadogClassAdapter;
void datadogCache;
void observationMetricTypeIsRequired;
Expand Down Expand Up @@ -415,6 +425,7 @@ try {
"--eval",
`const root = await import("dialcache");
const nodeRedis = await import("dialcache/node-redis");
await import("dialcache/valkey-glide");
await import("dialcache/datadog");
await import("dialcache/redis-protocol");
const fallbackTimeoutError = new root.FallbackTimeoutError("PackageRuntime", 1000);
Expand Down Expand Up @@ -490,6 +501,7 @@ if (calls !== 1 || second !== first) {
"--eval",
`const root = require("dialcache");
const nodeRedis = require("dialcache/node-redis");
require("dialcache/valkey-glide");
require("dialcache/datadog");
require("dialcache/redis-protocol");
const fallbackTimeoutError = new root.FallbackTimeoutError("PackageRuntime", 1000);
Expand Down Expand Up @@ -582,7 +594,8 @@ void (async () => {
"redis@~4.7.1",
"typescript@5.9.3",
"prom-client@^15.1.3",
"@valkey/valkey-glide@^2.4.2",
"@valkey/valkey-glide@2.2.10",
"dialcache-test-glide@npm:@valkey/valkey-glide@2.4.2",
"hot-shots@^17.0.0",
],
{ cwd: workspace },
Expand All @@ -601,11 +614,26 @@ void (async () => {
"--eval",
`const root = await import("dialcache");
const glide = await import("dialcache/valkey-glide");
const appGlide = await import("@valkey/valkey-glide");
const otherGlide = await import("dialcache-test-glide");
await import("dialcache/datadog");
await import("dialcache/prometheus");
await import("dialcache/redis-protocol");
await import("dialcache/node-redis");
const adapter = glide.createValkeyGlideDialCacheClient({ invokeScript: async () => 2 });
if (appGlide.Script === otherGlide.Script) {
throw new Error("The package test requires two distinct GLIDE module instances");
}
const adapter = glide.createValkeyGlideDialCacheClient({
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");
}
if (options.decoder !== appGlide.Decoder.Bytes) {
throw new Error("The ESM adapter did not use the caller-supplied GLIDE byte decoder");
}
return 2;
},
}, appGlide);
try {
await adapter.write({ valueKey: "value", cacheTtlMs: 1_000, value: "payload" });
throw new Error("Expected an invalid GLIDE script reply to fail");
Expand All @@ -625,12 +653,27 @@ try {
"--eval",
`const root = require("dialcache");
const glide = require("dialcache/valkey-glide");
const appGlide = require("@valkey/valkey-glide");
const otherGlide = require("dialcache-test-glide");
require("dialcache/datadog");
require("dialcache/prometheus");
require("dialcache/redis-protocol");
require("dialcache/node-redis");
void (async () => {
const adapter = glide.createValkeyGlideDialCacheClient({ invokeScript: async () => 2 });
if (appGlide.Script === otherGlide.Script) {
throw new Error("The package test requires two distinct GLIDE module instances");
}
const adapter = glide.createValkeyGlideDialCacheClient({
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");
}
if (options.decoder !== appGlide.Decoder.Bytes) {
throw new Error("The CommonJS adapter did not use the caller-supplied GLIDE byte decoder");
}
return 2;
},
}, appGlide);
try {
await adapter.write({ valueKey: "value", cacheTtlMs: 1_000, value: "payload" });
throw new Error("Expected an invalid GLIDE script reply to fail");
Expand Down
83 changes: 51 additions & 32 deletions src/valkey-glide.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,3 @@
import {
Decoder,
Script,
type GlideClient,
type GlideClusterClient,
type GlideReturnType,
type GlideString,
} from "@valkey/valkey-glide";

import { decodeRedisPayload, redisPayloadEncoding } from "./internal/redis-payload.js";
import {
INVALIDATE_CACHE_SCRIPT,
Expand All @@ -21,14 +12,39 @@ import {
} from "./internal/redis-script-reply.js";
import { DialCacheRedisPayloadError, type DialCacheRedisClient } from "./redis-client.js";

type SupportedValkeyGlideClient = GlideClient | GlideClusterClient;
type ValkeyGlideString = string | Buffer;

export interface ValkeyGlideScriptHandle {
/** Release the native GLIDE script registration. */
release(): void;
}

export interface ValkeyGlideScriptingClient<TScript, TDecoder> {
invokeScript(
script: TScript,
options: {
keys: ValkeyGlideString[];
args: ValkeyGlideString[];
decoder: TDecoder;
},
): Promise<unknown>;
}

export interface ValkeyGlideRuntime<TScript extends ValkeyGlideScriptHandle, TDecoder> {
/** The Script constructor exported by the same GLIDE module instance as the client. */
readonly Script: new (source: string) => TScript;
/** The Decoder enum exported by the same GLIDE module instance as the client. */
readonly Decoder: {
readonly Bytes: TDecoder;
};
}

interface DialCacheGlideScripts {
readonly read: Script;
readonly readTracked: Script;
readonly write: Script;
readonly writeTracked: Script;
readonly invalidate: Script;
interface DialCacheGlideScripts<TScript> {
readonly read: TScript;
readonly readTracked: TScript;
readonly write: TScript;
readonly writeTracked: TScript;
readonly invalidate: TScript;
}

export interface ValkeyGlideDialCacheClient extends DialCacheRedisClient {
Expand All @@ -38,34 +54,37 @@ 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`; callers
* dispose the handles after draining work, then close GLIDE. A request timeout
* bounds client waiting but is not server-side command cancellation.
* 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.
*/
export function createValkeyGlideDialCacheClient(
client: SupportedValkeyGlideClient,
export function createValkeyGlideDialCacheClient<TScript extends ValkeyGlideScriptHandle, TDecoder>(
client: ValkeyGlideScriptingClient<TScript, TDecoder>,
glide: ValkeyGlideRuntime<TScript, TDecoder>,
): ValkeyGlideDialCacheClient {
const scripts: DialCacheGlideScripts = {
read: new Script(READ_CACHE_SCRIPT),
readTracked: new Script(READ_TRACKED_CACHE_SCRIPT),
write: new Script(WRITE_CACHE_SCRIPT),
writeTracked: new Script(WRITE_TRACKED_CACHE_SCRIPT),
invalidate: new Script(INVALIDATE_CACHE_SCRIPT),
const scripts: DialCacheGlideScripts<TScript> = {
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;

const invoke = async (
script: Script,
keys: GlideString[],
args: GlideString[] = [],
): Promise<GlideReturnType> => {
script: TScript,
keys: ValkeyGlideString[],
args: ValkeyGlideString[] = [],
): Promise<unknown> => {
if (disposed) {
throw new Error("Valkey GLIDE DialCache client is disposed");
}
activeInvocations += 1;
try {
return await client.invokeScript(script, { keys, args, decoder: Decoder.Bytes });
return await client.invokeScript(script, { keys, args, decoder: glide.Decoder.Bytes });
} finally {
activeInvocations -= 1;
}
Expand Down
Loading
Loading