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
14 changes: 8 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ src/
prometheus.ts # Optional Prometheus adapter
datadog.ts # Optional Datadog (DogStatsD) adapter
redis-client.ts # Client-independent semantic Redis interface and its public error classes
node-redis.ts # node-redis adapter and script registration
node-redis.ts # node-redis adapter and invalidation dispatch
valkey-glide.ts # Valkey GLIDE adapter (standalone and cluster)
redis-protocol.ts # Public frame codec and Lua protocol exports
serializer.ts # Serializer contract and JSON implementation
internal/ # Cache layers, runtime config, payload compression, and mutation Lua scripts
internal/ # Cache layers, runtime config, payload compression, and invalidation Lua script
test/ # Unit and Redis integration tests
```

Expand All @@ -36,10 +36,12 @@ test/ # Unit and Redis integration tests
- Cache plumbing fails open; explicit maintenance operations surface mutation failures.
- Tracked Redis values and invalidation watermarks share a Redis Cluster hash tag.
- Tracked reads run on primaries so replica lag cannot hide invalidation.
- A tracked write's placeholder frame (version byte 0) is unreadable on both
read paths until the stamp script promotes it, and the stamp promotes only
the placeholder carrying its own per-write nonce.
- A SET failure is the tracked write's outcome even when the stamp settled.
- Every Redis value write is one native `SET` of a complete version-1 frame
stamped from the writer process's clock; Redis value writes never create or
extend watermarks.
- A tracked read atomically reads the value and watermark from the primary and
serves the frame only when `createdAtMs` is strictly greater than the
watermark. A missing watermark is the zero baseline.
- Local entries are process-local and are not synchronously invalidated across instances.

## Conventions
Expand Down
120 changes: 68 additions & 52 deletions README.md

Large diffs are not rendered by default.

95 changes: 48 additions & 47 deletions scripts/benchmark-redis-write.mjs
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
// Maintainer benchmark for the Redis write path. Measures the local build's
// tracked and untracked writes against a live Redis and reports server-side
// command cost per write (INFO commandstats; the EVALSHA entry envelopes
// script-internal calls) alongside client-side latency percentiles. It
// asserts nothing and applies no timing thresholds: absolute numbers are
// machine-, engine-, and load-dependent, so compare runs only against the
// same environment.
// writes against a live Redis and reports server-side command cost per write
// (INFO commandstats) alongside client-side latency percentiles. It asserts
// the steady-state one-SET shape and that no write invokes Lua or Redis TIME,
// but applies no timing thresholds: absolute numbers are machine-, engine-,
// and load-dependent, so compare runs only against the same idle Redis.
//
// Requires a reachable Redis, e.g.: docker run --rm -p 6379:6379 redis:6.2
// Usage: pnpm benchmark:redis-write (REDIS_URL to override)
// DIALCACHE_BENCH_WRITE_SCALE scales iteration counts (default 1).
import assert from "node:assert/strict";

import { createClient } from "redis";

import { createNodeRedisDialCacheClient, dialcacheRedisScripts } from "../dist/node-redis.js";
import { createNodeRedisDialCacheClient } from "../dist/node-redis.js";

const REDIS_URL = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
const SCALE = Number(process.env.DIALCACHE_BENCH_WRITE_SCALE ?? "1");
Expand Down Expand Up @@ -42,7 +43,6 @@ async function commandStats(client) {

const client = createClient({
url: REDIS_URL,
scripts: dialcacheRedisScripts,
disableOfflineQueue: true,
socket: { connectTimeout: 2_000 },
});
Expand All @@ -56,57 +56,58 @@ try {
const adapter = createNodeRedisDialCacheClient(client);

const rows = [];
for (const mode of ["tracked", "untracked"]) {
for (const size of SIZES) {
const iterations = Math.max(1, Math.round(size.n * SCALE));
const payload = "x".repeat(size.bytes);
const valueKey = `benchmark:write:${mode}:${size.bytes}:value`;
const watermarkKey = `benchmark:write:${mode}:${size.bytes}:watermark`;
const request = mode === "tracked"
? { valueKey, watermarkKey, cacheTtlMs: 60_000, value: payload }
: { valueKey, cacheTtlMs: 60_000, value: payload };

for (let i = 0; i < WARMUP; i += 1) {
await adapter.write(request);
}
await client.sendCommand(["CONFIG", "RESETSTAT"]);
for (const size of SIZES) {
const iterations = Math.max(1, Math.round(size.n * SCALE));
const payload = "x".repeat(size.bytes);
const valueKey = `benchmark:write:native:${size.bytes}:value`;
const request = { valueKey, cacheTtlMs: 60_000, value: payload };

const latenciesUsec = [];
for (let i = 0; i < iterations; i += 1) {
const start = process.hrtime.bigint();
await adapter.write(request);
latenciesUsec.push(Number(process.hrtime.bigint() - start) / 1_000);
}
for (let i = 0; i < WARMUP; i += 1) {
await adapter.write(request);
}
await client.sendCommand(["CONFIG", "RESETSTAT"]);

// Sum only the commands the client dispatches top-level (SET, EVALSHA,
// and the EVAL recovery). Script-internal calls surface in commandstats
// too, but the EVALSHA entry already envelopes their execution time.
const stats = await commandStats(client);
const serverUsec = (stats.set?.usec ?? 0)
+ (stats.evalsha?.usec ?? 0)
+ (stats.eval?.usec ?? 0);
latenciesUsec.sort((a, b) => a - b);
rows.push({
mode,
size: size.name,
writes: iterations,
serverUsecPerWrite: serverUsec / iterations,
clientP50Usec: percentile(latenciesUsec, 50),
clientP95Usec: percentile(latenciesUsec, 95),
});
const latenciesUsec = [];
for (let i = 0; i < iterations; i += 1) {
const start = process.hrtime.bigint();
await adapter.write(request);
latenciesUsec.push(Number(process.hrtime.bigint() - start) / 1_000);
}

const stats = await commandStats(client);
const setCalls = stats.set?.calls ?? 0;
const scriptCalls = (stats.evalsha?.calls ?? 0) + (stats.eval?.calls ?? 0);
const timeCalls = stats.time?.calls ?? 0;
assert.equal(setCalls, iterations, "writes must issue one top-level SET each");
assert.equal(scriptCalls, 0, "writes must not dispatch Lua scripts");
assert.equal(timeCalls, 0, "writes must not invoke Redis TIME");
const serverUsec = stats.set?.usec ?? 0;
latenciesUsec.sort((a, b) => a - b);
rows.push({
size: size.name,
writes: iterations,
setCallsPerWrite: setCalls / iterations,
scriptCallsPerWrite: scriptCalls / iterations,
timeCallsPerWrite: timeCalls / iterations,
serverUsecPerWrite: serverUsec / iterations,
clientP50Usec: percentile(latenciesUsec, 50),
clientP95Usec: percentile(latenciesUsec, 95),
});
}
await client.quit();

console.log(`Redis write benchmark — ${REDIS_URL}`);
console.log("mode size writes server µs/write client p50 µs client p95 µs");
console.log("size writes SET/op script/op TIME/op server µs/write client p50 µs client p95 µs");
for (const row of rows) {
console.log(
row.mode.padEnd(10)
+ row.size.padEnd(10)
row.size.padEnd(10)
+ String(row.writes).padEnd(9)
+ row.setCallsPerWrite.toFixed(1).padEnd(9)
+ row.scriptCallsPerWrite.toFixed(1).padEnd(12)
+ row.timeCallsPerWrite.toFixed(1).padEnd(10)
+ row.serverUsecPerWrite.toFixed(1).padEnd(18)
+ row.clientP50Usec.toFixed(0).padEnd(16)
+ row.clientP95Usec.toFixed(0),
);
}
console.log("Command-shape assertions passed; elapsed times are informational and have no pass/fail threshold.");
22 changes: 11 additions & 11 deletions scripts/benchmark-request-local.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,9 @@ async function benchmarkRedisReadDeadlineCoalescing(fanout) {
redisReadCalls += 1;
started.resolve();
await gate.promise;
return JSON.stringify("shared");
},
async write() {
return true;
return { payload: JSON.stringify("shared"), createdAtMs: Date.now() };
},
async write() {},
async invalidate() {},
};
const dialcache = new DialCache({
Expand Down Expand Up @@ -318,15 +316,15 @@ async function benchmarkSequentialTrackedRedisHits(iterations, { scenario, useCa
let redisReadCalls = 0;
let redisWriteCalls = 0;
let redisInvalidationCalls = 0;
const frame = { payload: JSON.stringify("shared"), createdAtMs: Date.now() };
const redisClient = {
async read({ watermarkKey }) {
assert.equal(typeof watermarkKey, "string", "the benchmark must exercise tracked Redis reads");
redisReadCalls += 1;
return JSON.stringify("shared");
return frame;
},
async write() {
redisWriteCalls += 1;
return true;
},
async invalidate() {
redisInvalidationCalls += 1;
Expand Down Expand Up @@ -399,7 +397,6 @@ async function benchmarkDarkShadowDetachment() {
},
async write() {
redisWriteCalls += 1;
return true;
},
async invalidate() {
redisInvalidationCalls += 1;
Expand Down Expand Up @@ -451,7 +448,7 @@ async function benchmarkDarkShadowDetachment() {
assert.equal(redisReadCalls, 1, "the detached C0 read should have started");
assert.equal(fallbackCalls, 1, "the caller and shadow validation must share one SoT invocation");

readGate.resolve(JSON.stringify(cachedValue));
readGate.resolve({ payload: JSON.stringify(cachedValue), createdAtMs: Date.now() });
await nextTurn();
assert.equal(await outcomeGate.promise, "mismatch");
assert.equal(redisReadCalls, 2, "only a mismatch candidate should add confirmation C1");
Expand Down Expand Up @@ -481,12 +478,15 @@ async function benchmarkDarkShadowFillDetachment() {
redisReadCalls += 1;
return null;
},
async write({ watermarkKey }) {
assert.equal(typeof watermarkKey, "string", "dark shadow fills must remain tracked");
async write(request) {
assert.equal(
Object.hasOwn(request, "watermarkKey"),
false,
"dark shadow fills must use the unified native write request",
);
redisWriteCalls += 1;
writeStarted.resolve();
await writeGate.promise;
return true;
},
async invalidate() {},
};
Expand Down
Loading
Loading