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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,18 +90,18 @@ const dialcache = new DialCache({
});
```

The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. Other clients can implement that semantic interface without changing DialCache; distinct untracked/tracked read and write Lua sources, the invalidation source, and wire constants are available from `dialcache/redis-protocol`, so a Valkey GLIDE adapter can be added without depending on node-redis. Custom adapters can use the root-exported `DialCacheRedisPayloadError` and `DialCacheRedisPayloadEncodingError` classes to preserve the standard metrics labels.
The core Redis boundary is the client-agnostic `DialCacheRedisClient` interface. It exchanges serialized values as `string | Buffer` and does not expose node-redis commands or wire encodings. Other clients can implement that semantic interface without changing DialCache; distinct untracked/tracked read and write Lua sources, the invalidation source, and wire constants are available from `dialcache/redis-protocol`, so a Valkey GLIDE adapter can use its own script lifecycle, cluster routing, and byte decoder without depending on node-redis. Custom adapters can use the root-exported `DialCacheRedisPayloadError` and `DialCacheRedisPayloadEncodingError` classes to preserve the standard metrics labels.

Redis values use a compact binary frame:

```text
byte 1 format version
bytes 2-9 Redis-created timestamp in milliseconds (uint64, big-endian)
byte 10 payload encoding (0 = UTF-8, 1 = base64)
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 cached function's serializer, or by `JsonSerializer` by default. Custom serializers can return either `string` or `Buffer`; Buffer payloads are base64 encoded and reconstructed before `serializer.load`.
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 cached function'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`.

## Targeted invalidation and watermarks

Expand Down
10 changes: 9 additions & 1 deletion scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { promisify } from "node:util";
const exec = promisify(execFile);
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const workspace = await mkdtemp(join(tmpdir(), "dialcache-package-"));
const consumer = `import { DialCache, DialCacheKeyConfig } from "dialcache";
const consumer = `import { DialCache, DialCacheKeyConfig, type DialCacheRedisClient } from "dialcache";
import { createNodeRedisDialCacheClient } from "dialcache/node-redis";
import { READ_CACHE_SCRIPT } from "dialcache/redis-protocol";

Expand All @@ -23,6 +23,14 @@ const load = cache.cached(async (id: string) => id, {
void load;
void createNodeRedisDialCacheClient;
void READ_CACHE_SCRIPT;

const customRedisClient: DialCacheRedisClient = {
read: async () => Buffer.from([0, 255]),
write: async ({ value }) => typeof value === "string" || Buffer.isBuffer(value),
invalidate: async () => undefined,
flushAll: async () => undefined,
};
void customRedisClient;
`;

try {
Expand Down
1 change: 0 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export type {
RedisCachePayload,
RedisClientFactory,
RedisInvalidationRequest,
RedisPayloadEncoding,
RedisReadRequest,
RedisWriteRequest,
} from "./redis-client.js";
Expand Down
11 changes: 3 additions & 8 deletions src/internal/redis-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CacheLayer, DEFAULT_WATERMARK_TTL_SEC, type CacheConfigProvider, type C
import { invalidationPrefix, redisClusterHashTag, type DialCacheKey } from "../key.js";
import type { DialCacheMetricsAdapter } from "../metrics.js";
import { errorName, labelsFor } from "../metrics.js";
import type { DialCacheRedisClient, RedisCachePayload, RedisClientFactory } from "../redis-client.js";
import type { DialCacheRedisClient, RedisClientFactory } from "../redis-client.js";
import { JsonSerializer, type Serializer } from "../serializer.js";
import type { CacheGetResult } from "./cache-result.js";
import { fetchKeyConfig, resolveLayerConfigResult, type ResolvedLayerConfig } from "./runtime-config.js";
Expand Down Expand Up @@ -87,7 +87,7 @@ export class RedisCache {

const start = performance.now();
try {
const value = (await this.serializerFor(key).load(decodePayload(payload))) as T;
const value = (await this.serializerFor(key).load(payload)) as T;
return { status: "hit", value };
} catch (error) {
this.recordMetric((metrics) => metrics.error({ ...labelsFor(key, CacheLayer.REMOTE), error: errorName(error), inFallback: false }));
Expand Down Expand Up @@ -120,8 +120,7 @@ export class RedisCache {
const request = {
valueKey: this.redisKey(key),
cacheTtlMs,
encoding: Buffer.isBuffer(serialized) ? "base64" : "utf8",
value: Buffer.isBuffer(serialized) ? serialized.toString("base64") : serialized,
value: serialized,
} as const;
return key.trackForInvalidation
? await client.write({
Expand Down Expand Up @@ -206,10 +205,6 @@ export class RedisCache {
}
}

function decodePayload(payload: RedisCachePayload): string | Buffer {
return payload.encoding === "base64" ? Buffer.from(payload.value, "base64") : payload.value;
}

function payloadSize(payload: string | Buffer): number {
return Buffer.isBuffer(payload) ? payload.byteLength : Buffer.byteLength(payload);
}
Expand Down
4 changes: 2 additions & 2 deletions src/internal/redis-scripts.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const REDIS_FRAME_VERSION = 1;
export const REDIS_ENCODING_UTF8 = 0;
export const REDIS_ENCODING_BASE64 = 1;
export const REDIS_ENCODING_BINARY = 1;

const WATERMARK_TTL_MARGIN_MS = 60_000;

Expand Down Expand Up @@ -39,7 +39,7 @@ local encoding = tonumber(ARGV[2])
if not cache_ttl_ms or cache_ttl_ms <= 0 then
return redis.error_reply("ERR invalid DialCache TTL")
end
if not encoding or (encoding ~= ${REDIS_ENCODING_UTF8} and encoding ~= ${REDIS_ENCODING_BASE64}) then
if not encoding or (encoding ~= ${REDIS_ENCODING_UTF8} and encoding ~= ${REDIS_ENCODING_BINARY}) then
return redis.error_reply("ERR invalid DialCache payload encoding")
end`;

Expand Down
56 changes: 35 additions & 21 deletions src/node-redis.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
import { defineScript } from "redis";
import { commandOptions, defineScript } from "redis";

import {
INVALIDATE_CACHE_SCRIPT,
READ_CACHE_SCRIPT,
READ_TRACKED_CACHE_SCRIPT,
REDIS_ENCODING_BASE64,
REDIS_ENCODING_BINARY,
REDIS_ENCODING_UTF8,
WRITE_CACHE_SCRIPT,
WRITE_TRACKED_CACHE_SCRIPT,
} from "./internal/redis-scripts.js";
import { DialCacheRedisPayloadEncodingError, DialCacheRedisPayloadError } from "./redis-client.js";
import type { DialCacheRedisClient, RedisCachePayload } from "./redis-client.js";

type BufferReplyOptions = ReturnType<typeof commandOptions<{ readonly returnBuffers: true }>>;
// 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 integerReply = (reply: number): number => reply;
type NodeRedisArgument = string | Buffer;

interface NodeRedisScript<Args extends Array<unknown>, Reply> {
readonly SCRIPT: string;
readonly SHA1: string;
readonly NUMBER_OF_KEYS: number;
readonly FIRST_KEY_INDEX: number;
readonly IS_READ_ONLY: boolean;
transformArguments(...args: Args): Array<string>;
transformArguments(...args: Args): Array<NodeRedisArgument>;
transformReply(reply: Reply): Reply;
}

Expand All @@ -37,7 +41,7 @@ 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],
[valueKey: string, cacheTtlMs: number, encoding: number, payload: string | Buffer],
number
>;
readonly dialcacheWriteTracked: NodeRedisScript<
Expand All @@ -46,7 +50,7 @@ export type DialCacheNodeRedisScripts = {
watermarkKey: string,
cacheTtlMs: number,
encoding: number,
payload: string,
payload: string | Buffer,
watermarkTtlFloorMs: number,
],
number
Expand Down Expand Up @@ -84,7 +88,12 @@ export const dialcacheRedisScripts: DialCacheNodeRedisScripts = {
NUMBER_OF_KEYS: 1,
FIRST_KEY_INDEX: 0,
IS_READ_ONLY: false,
transformArguments(valueKey: string, cacheTtlMs: number, encoding: number, payload: string): Array<string> {
transformArguments(
valueKey: string,
cacheTtlMs: number,
encoding: number,
payload: string | Buffer,
): Array<NodeRedisArgument> {
return [valueKey, String(cacheTtlMs), String(encoding), payload];
},
transformReply: integerReply,
Expand All @@ -99,9 +108,9 @@ export const dialcacheRedisScripts: DialCacheNodeRedisScripts = {
watermarkKey: string,
cacheTtlMs: number,
encoding: number,
payload: string,
payload: string | Buffer,
watermarkTtlFloorMs: number,
): Array<string> {
): Array<NodeRedisArgument> {
return [valueKey, watermarkKey, String(cacheTtlMs), String(encoding), payload, String(watermarkTtlFloorMs)];
},
transformReply: integerReply,
Expand All @@ -119,15 +128,19 @@ export const dialcacheRedisScripts: DialCacheNodeRedisScripts = {
};

interface NodeRedisScriptClient {
dialcacheRead(valueKey: string): Promise<string | null>;
dialcacheReadTracked(valueKey: string, watermarkKey: string): Promise<string | null>;
dialcacheWrite(valueKey: string, cacheTtlMs: number, encoding: number, payload: string): Promise<number>;
dialcacheRead(options: BufferReplyOptions, valueKey: string): Promise<Buffer | null>;
dialcacheReadTracked(
options: BufferReplyOptions,
valueKey: string,
watermarkKey: string,
): Promise<Buffer | null>;
dialcacheWrite(valueKey: string, cacheTtlMs: number, encoding: number, payload: string | Buffer): Promise<number>;
dialcacheWriteTracked(
valueKey: string,
watermarkKey: string,
cacheTtlMs: number,
encoding: number,
payload: string,
payload: string | Buffer,
watermarkTtlFloorMs: number,
): Promise<number>;
dialcacheInvalidate(watermarkKey: string, futureBufferMs: number, watermarkTtlFloorMs: number): Promise<number>;
Expand All @@ -143,13 +156,13 @@ export function createNodeRedisDialCacheClient(client: NodeRedisScriptClient): D
return {
async read({ valueKey, watermarkKey }) {
const raw = watermarkKey === undefined
? await client.dialcacheRead(valueKey)
: await client.dialcacheReadTracked(valueKey, watermarkKey);
? await client.dialcacheRead(bufferReplyOptions, valueKey)
: await client.dialcacheReadTracked(bufferReplyOptions, valueKey, watermarkKey);
return raw === null ? null : decodePayload(raw);
},
async write(request) {
const { valueKey, watermarkKey, cacheTtlMs, encoding, value } = request;
const encodingByte = encoding === "base64" ? REDIS_ENCODING_BASE64 : REDIS_ENCODING_UTF8;
const { valueKey, watermarkKey, cacheTtlMs, value } = request;
const encodingByte = Buffer.isBuffer(value) ? REDIS_ENCODING_BINARY : REDIS_ENCODING_UTF8;
const result = watermarkKey === undefined
? await client.dialcacheWrite(valueKey, cacheTtlMs, encodingByte, value)
: await client.dialcacheWriteTracked(
Expand Down Expand Up @@ -187,17 +200,18 @@ export function createNodeRedisDialCacheClient(client: NodeRedisScriptClient): D
};
}

function decodePayload(raw: string): RedisCachePayload {
function decodePayload(raw: Buffer): RedisCachePayload {
if (raw.length === 0) {
throw new DialCacheRedisPayloadError("Invalid DialCache Redis payload");
}

const encoding = raw.charCodeAt(0);
const encoding = raw[0];
const payload = raw.subarray(1);
if (encoding === REDIS_ENCODING_UTF8) {
return { encoding: "utf8", value: raw.slice(1) };
return payload.toString("utf8");
}
if (encoding === REDIS_ENCODING_BASE64) {
return { encoding: "base64", value: raw.slice(1) };
if (encoding === REDIS_ENCODING_BINARY) {
return payload;
}
throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding");
}
Expand Down
11 changes: 4 additions & 7 deletions src/redis-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,8 @@ export class DialCacheRedisPayloadEncodingError extends Error {
}
}

export type RedisPayloadEncoding = "utf8" | "base64";

export interface RedisCachePayload {
readonly encoding: RedisPayloadEncoding;
readonly value: string;
}
/** Serialized cache data, independent of any Redis client or wire framing. */
export type RedisCachePayload = string | Buffer;

interface RedisValueRequest {
readonly valueKey: string;
Expand All @@ -35,8 +31,9 @@ interface UntrackedRedisValueRequest extends RedisValueRequest {

export type RedisReadRequest = TrackedRedisValueRequest | UntrackedRedisValueRequest;

interface RedisWriteBase extends RedisValueRequest, RedisCachePayload {
interface RedisWriteBase extends RedisValueRequest {
readonly cacheTtlMs: number;
readonly value: RedisCachePayload;
}

interface TrackedRedisWriteRequest extends RedisWriteBase, TrackedRedisValueRequest {
Expand Down
2 changes: 1 addition & 1 deletion src/redis-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export {
INVALIDATE_CACHE_SCRIPT,
READ_CACHE_SCRIPT,
READ_TRACKED_CACHE_SCRIPT,
REDIS_ENCODING_BASE64,
REDIS_ENCODING_BINARY,
REDIS_ENCODING_UTF8,
REDIS_FRAME_VERSION,
WRITE_CACHE_SCRIPT,
Expand Down
17 changes: 16 additions & 1 deletion test/dialcache-redis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,26 @@ describe("DialCache Redis TTL layer", () => {
expect(value).toEqual({ id: "123", source: "fallback" });
expect(readerCalls).toBe(0);
expect(frame.encoding).toBe(1);
expect(Buffer.from(frame.payload, "base64").toString("utf8")).toBe("123|fallback");
expect(frame.payload).toEqual(Buffer.from("123|fallback"));
expect(serializer.dump).toHaveBeenCalledOnce();
expect(serializer.load).toHaveBeenCalledOnce();
});

it("isolates binary read payloads from the stored Redis frame", async () => {
const redis = new FakeRedis();
const valueKey = "binary-isolation:{item:123}:value";
const payload = Buffer.from([0, 1, 2, 0xff]);
await redis.write({ valueKey, cacheTtlMs: 60_000, value: payload });

const firstRead = await redis.read({ valueKey });
if (!Buffer.isBuffer(firstRead)) {
throw new Error("Expected a binary Redis payload");
}
firstRead[0] = 0xff;

expect(await redis.read({ valueKey })).toEqual(payload);
});

it("fails open when Redis serializer dump fails", async () => {
// Given Redis serialization fails after the fallback returns but local cache is still configured.
const redis = new FakeRedis();
Expand Down
29 changes: 17 additions & 12 deletions test/fake-redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ export class FakeRedis implements DialCacheRedisClient {
valueKey,
watermarkKey,
cacheTtlMs,
encoding,
value,
watermarkTtlFloorMs,
}: RedisWriteRequest): Promise<boolean> {
Expand All @@ -55,14 +54,14 @@ export class FakeRedis implements DialCacheRedisClient {
if (watermark >= Date.now()) {
return false;
}
this.storeFrame(valueKey, cacheTtlMs, encoding, value);
this.storeFrame(valueKey, cacheTtlMs, value);
const currentTtlMs = this.remainingTtlMs(watermarkKey);
const desiredTtlMs = Math.max(currentTtlMs, watermarkTtlFloorMs, cacheTtlMs + WATERMARK_TTL_MARGIN_MS);
this.storeWatermark(watermarkKey, watermark, desiredTtlMs);
return true;
}

this.storeFrame(valueKey, cacheTtlMs, encoding, value);
this.storeFrame(valueKey, cacheTtlMs, value);
return true;
}

Expand Down Expand Up @@ -155,22 +154,22 @@ export class FakeRedis implements DialCacheRedisClient {

const encoding = raw[ENCODING_OFFSET];
if (encoding === 0) {
return { encoding: "utf8", value: raw.subarray(PAYLOAD_OFFSET).toString("utf8") };
return raw.subarray(PAYLOAD_OFFSET).toString("utf8");
}
if (encoding === 1) {
return { encoding: "base64", value: raw.subarray(PAYLOAD_OFFSET).toString("utf8") };
return Buffer.from(raw.subarray(PAYLOAD_OFFSET));
}
throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding");
}

private storeFrame(key: string, ttlMs: number, encoding: "utf8" | "base64", payload: string): void {
private storeFrame(key: string, ttlMs: number, payload: RedisCachePayload): void {
const timestamp = Buffer.alloc(8);
timestamp.writeBigUInt64BE(BigInt(Date.now()));
this.values.set(key, {
value: Buffer.concat([
Buffer.from([FRAME_VERSION]),
timestamp,
Buffer.from([encoding === "base64" ? 1 : 0]),
Buffer.from([Buffer.isBuffer(payload) ? 1 : 0]),
Buffer.from(payload),
]),
expiresAtMs: Date.now() + ttlMs,
Expand Down Expand Up @@ -221,18 +220,24 @@ export class FakeRedis implements DialCacheRedisClient {
export function encodeFrame(value: unknown, createdAtMs = Date.now(), encoding = 0): Buffer {
const timestamp = Buffer.alloc(8);
timestamp.writeBigUInt64BE(BigInt(createdAtMs));
const payload = typeof value === "string" ? value : JSON.stringify(value);
return Buffer.concat([Buffer.from([FRAME_VERSION]), timestamp, Buffer.from([encoding]), Buffer.from(payload)]);
const payload = Buffer.isBuffer(value)
? value
: Buffer.from(typeof value === "string" ? value : JSON.stringify(value));
return Buffer.concat([Buffer.from([FRAME_VERSION]), timestamp, Buffer.from([encoding]), payload]);
}

export function decodeFrame(raw: Buffer): { readonly createdAtMs: number; readonly encoding: number; readonly payload: string } {
export function decodeFrame(
raw: Buffer,
): { readonly createdAtMs: number; readonly encoding: number; readonly payload: string | Buffer } {
if (raw.length < PAYLOAD_OFFSET || raw[0] !== FRAME_VERSION) {
throw new Error("Invalid DialCache frame");
}
const encoding = raw[ENCODING_OFFSET] ?? -1;
const payload = raw.subarray(PAYLOAD_OFFSET);
return {
createdAtMs: Number(readTimestamp(raw)),
encoding: raw[ENCODING_OFFSET] ?? -1,
payload: raw.subarray(PAYLOAD_OFFSET).toString("utf8"),
encoding,
payload: encoding === 0 ? payload.toString("utf8") : payload,
};
}

Expand Down
Loading
Loading