Skip to content
Open
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
16 changes: 12 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ DialCache is a TypeScript caching library with explicit request-scoped enablemen

```text
src/
index.ts # Public root entry point (barrel)
dialcache.ts # Main DialCache API and cached-function wrapper
errors.ts # Public core error classes (DialCacheError hierarchy)
config.ts # Public configuration and rollout types
context.ts # AsyncLocalStorage-based enabled context
key.ts # Structured cache keys and Redis hash tags
metrics.ts # Backend-neutral metrics adapter contract
prometheus.ts # Optional Prometheus adapter
redis-client.ts # Client-independent semantic Redis interface
node-redis.ts # node-redis adapter and script registration
redis-protocol.ts # Public Lua protocol exports
datadog.ts # Optional Datadog (DogStatsD) adapter
redis-client.ts # Client-independent semantic Redis interface and its public error classes
node-redis.ts # node-redis adapter and script registration
valkey-glide.ts # Valkey GLIDE adapter (standalone and cluster)
redis-protocol.ts # Public frame codec and Lua protocol exports
serializer.ts # Serializer contract and JSON implementation
internal/ # Cache layers, runtime config, payload compression, and Lua scripts
internal/ # Cache layers, runtime config, payload compression, and mutation Lua scripts
test/ # Unit and Redis integration tests
```

Expand All @@ -32,6 +36,10 @@ test/ # Unit and Redis integration tests
- Cache plumbing fails open; explicit maintenance operations surface mutation failures.
- Tracked Redis values and invalidation watermarks share a Redis Cluster hash tag.
- Tracked reads run on primaries so replica lag cannot hide invalidation.
- A tracked write's placeholder frame (version byte 0) is unreadable on both
read paths until the stamp script promotes it, and the stamp promotes only
the placeholder carrying its own per-write nonce.
- A SET failure is the tracked write's outcome even when the stamp settled.
- Local entries are process-local and are not synchronously invalidated across instances.

## Conventions
Expand Down
46 changes: 25 additions & 21 deletions README.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion release.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ export default {
// A version PR records the selected version without selecting a new
// release itself. Earlier commits still determine the release type.
{ type: "release", release: false },
{ breaking: true, release: "major" },
// Pre-1.0 policy — see README "Releasing", which owns this table.
// Restore "major" here when cutting 1.0.0.
{ breaking: true, release: "minor" },
{ type: "feat", release: "minor" },
{ type: "fix", release: "patch" },
{ type: "perf", release: "patch" },
Expand Down
389 changes: 362 additions & 27 deletions scripts/test-package.mjs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export type { DialCacheKeyInit } from "./key.js";
export {
DialCacheRedisPayloadEncodingError,
DialCacheRedisPayloadError,
DialCacheRedisPlaceholderLostError,
DialCacheRedisProtocolError,
} from "./redis-client.js";
export type { CompressionConfig } from "./internal/compression.js";
Expand Down
17 changes: 17 additions & 0 deletions src/internal/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ export function cacheTtlSecToMs(ttlSec: number): number {
return ttlSec * 1_000;
}

/**
* Validate and ceil an adapter-level write TTL to the protocol's acceptance
* domain: fractional milliseconds round up, and the result must be a
* positive integer no greater than 365 days. Native SET PX requires an
* integer, and the stamp script re-checks the same domain server-side as
* defense in depth for adapters that skip this guard.
*/
export function ceilSupportedCacheTtlMs(cacheTtlMs: number): number {
const ceiled = typeof cacheTtlMs === "number" ? Math.ceil(cacheTtlMs) : Number.NaN;
if (!Number.isFinite(ceiled) || ceiled <= 0 || ceiled > MAX_SUPPORTED_DURATION_MS) {
throw new RangeError(
`DialCache Redis write cacheTtlMs must be a positive duration no greater than ${MAX_SUPPORTED_DURATION_MS} milliseconds`,
);
}
return ceiled;
}

export function assertSupportedFutureBufferMs(futureBufferMs: unknown): asserts futureBufferMs is number {
if (
typeof futureBufferMs !== "number"
Expand Down
77 changes: 69 additions & 8 deletions src/internal/redis-payload.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { randomBytes } from "node:crypto";

import {
DialCacheRedisPayloadEncodingError,
DialCacheRedisPayloadError,
type RedisCachePayload,
} from "../redis-client.js";
import {
REDIS_ENCODING_BINARY,
REDIS_ENCODING_UTF8,
REDIS_FRAME_VERSION,
} from "./redis-scripts.js";

const REDIS_FRAME_HEADER_BYTES = 9;
export const REDIS_FRAME_VERSION = 1;
const REDIS_ENCODING_UTF8 = 0;
const REDIS_ENCODING_BINARY = 1;
/** Version byte of a tracked-write placeholder; no read path serves it. */
export const REDIS_FRAME_PLACEHOLDER_VERSION = 0;
const REDIS_FRAME_TIMESTAMP_OFFSET = 1;
export const REDIS_FRAME_TIMESTAMP_BYTES = 8;

export const REDIS_FRAME_HEADER_BYTES = REDIS_FRAME_TIMESTAMP_OFFSET + REDIS_FRAME_TIMESTAMP_BYTES;
const REDIS_FRAME_MIN_BYTES = REDIS_FRAME_HEADER_BYTES + 1;

function validateRedisBulkStringReply(raw: unknown): Buffer | null {
Expand Down Expand Up @@ -40,7 +45,7 @@ function parseRedisWatermark(raw: Buffer | null): number | null {
return Number.isFinite(watermark) ? watermark : null;
}

export function redisPayloadEncoding(value: RedisCachePayload): number {
function redisPayloadEncoding(value: RedisCachePayload): number {
return Buffer.isBuffer(value) ? REDIS_ENCODING_BINARY : REDIS_ENCODING_UTF8;
}

Expand All @@ -56,6 +61,62 @@ function decodeRedisPayload(raw: Buffer): RedisCachePayload {
throw new DialCacheRedisPayloadEncodingError("Invalid DialCache Redis payload encoding");
}

function encodeFrameBytes(payload: RedisCachePayload, version: number, stampBytes: Buffer): Buffer {
const payloadBytes = Buffer.isBuffer(payload) ? payload.length : Buffer.byteLength(payload, "utf8");
const frame = Buffer.allocUnsafe(REDIS_FRAME_MIN_BYTES + payloadBytes);
frame[0] = version;
stampBytes.copy(frame, REDIS_FRAME_TIMESTAMP_OFFSET);
frame[REDIS_FRAME_HEADER_BYTES] = redisPayloadEncoding(payload);
if (Buffer.isBuffer(payload)) {
payload.copy(frame, REDIS_FRAME_MIN_BYTES);
} else {
frame.write(payload, REDIS_FRAME_MIN_BYTES, "utf8");
}
return frame;
}

/**
* Encode a serializer payload into a servable DialCache Redis frame.
*
* Untracked writes stamp an informational client-clock `createdAtMs`;
* untracked reads never consult it. Tracked writes must not use this
* directly — they pair `encodeTrackedRedisPlaceholder` with
* `WRITE_TRACKED_STAMP_SCRIPT` instead.
*/
export function encodeRedisFrame(payload: RedisCachePayload, createdAtMs: number): Buffer {
if (!Number.isSafeInteger(createdAtMs) || createdAtMs < 0) {
throw new RangeError("DialCache frame createdAtMs must be a nonnegative safe integer");
}
const timestamp = Buffer.allocUnsafe(REDIS_FRAME_TIMESTAMP_BYTES);
timestamp.writeBigUInt64BE(BigInt(createdAtMs));
return encodeFrameBytes(payload, REDIS_FRAME_VERSION, timestamp);
}

export interface TrackedRedisPlaceholder {
/** Version-0 frame that no read path serves until the stamp promotes it. */
readonly frame: Buffer;
/** Per-write identity passed to `WRITE_TRACKED_STAMP_SCRIPT` as its nonce argument. */
readonly nonce: Buffer;
}

/**
* Encode the placeholder frame a tracked write pairs with
* `WRITE_TRACKED_STAMP_SCRIPT`.
*
* The frame carries the placeholder version byte, so both read paths treat it
* as a miss, and a fresh random nonce where a stamped frame carries its
* timestamp. The stamp promotes the frame — patching version and server-time
* timestamp — only when the stored header matches this exact nonce, so it can
* never publish a placeholder left behind by a different write. Mint one
* placeholder per logical write: client-level retries must reuse the same
* frame and nonce so a retried SET re-establishes the placeholder its stamp
* expects.
*/
export function encodeTrackedRedisPlaceholder(payload: RedisCachePayload): TrackedRedisPlaceholder {
const nonce = randomBytes(REDIS_FRAME_TIMESTAMP_BYTES);
return { frame: encodeFrameBytes(payload, REDIS_FRAME_PLACEHOLDER_VERSION, nonce), nonce };
}

/**
* Decode an untracked DialCache frame returned as a Redis bulk string.
* Missing, short, and unsupported-version frames are cache misses. Invalid
Expand Down Expand Up @@ -87,7 +148,7 @@ export function decodeTrackedRedisFrame(
if (watermark === null) {
return null;
}
const createdAtMs = Number(frame.readBigUInt64BE(1));
const createdAtMs = Number(frame.readBigUInt64BE(REDIS_FRAME_TIMESTAMP_OFFSET));
return createdAtMs <= watermark
? null
: decodeRedisPayload(frame.subarray(REDIS_FRAME_HEADER_BYTES));
Expand Down
37 changes: 33 additions & 4 deletions src/internal/redis-script-reply.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,41 @@
import { DialCacheRedisProtocolError } from "../redis-client.js";
import {
DialCacheRedisPlaceholderLostError,
DialCacheRedisProtocolError,
} from "../redis-client.js";

export function validateRedisScriptWriteReply(reply: unknown): 0 | 1 {
if (reply !== 0 && reply !== 1) {
throw new DialCacheRedisProtocolError("Invalid DialCache Redis write reply; expected integer 0 or 1");
export function validateRedisSetReply(reply: unknown): void {
const text = typeof reply === "string"
? reply
: Buffer.isBuffer(reply)
? reply.toString("utf8")
: null;
if (text !== "OK") {
throw new DialCacheRedisProtocolError("Invalid DialCache Redis SET reply; expected OK");
}
}

export function validateRedisScriptWriteReply(reply: unknown): 0 | 1 | 2 {
if (reply !== 0 && reply !== 1 && reply !== 2) {
throw new DialCacheRedisProtocolError("Invalid DialCache Redis write reply; expected integer 0, 1, or 2");
}
return reply;
}

/**
* Map a validated stamp reply onto the write() boolean contract: 0 (fenced)
* is false, 1 (stamped) is true, and 2 — the paired placeholder was gone —
* fails the write so split pairs surface through the normal fail-open path.
*/
export function resolveTrackedRedisWriteReply(reply: unknown): boolean {
const stamp = validateRedisScriptWriteReply(reply);
if (stamp === 2) {
throw new DialCacheRedisPlaceholderLostError(
"DialCache tracked write lost its placeholder before the stamp; the SET was rejected, overwritten, or expired",
);
}
return stamp === 1;
}

export function validateRedisScriptInvalidationReply(reply: unknown): 1 {
if (reply !== 1) {
throw new DialCacheRedisProtocolError("Invalid DialCache Redis invalidate reply; expected integer 1");
Expand Down
54 changes: 26 additions & 28 deletions src/internal/redis-scripts.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { MAX_SUPPORTED_DURATION_MS } from "./duration.js";

export const REDIS_FRAME_VERSION = 1;
export const REDIS_ENCODING_UTF8 = 0;
export const REDIS_ENCODING_BINARY = 1;
import {
REDIS_FRAME_HEADER_BYTES,
REDIS_FRAME_PLACEHOLDER_VERSION,
REDIS_FRAME_TIMESTAMP_BYTES,
REDIS_FRAME_VERSION,
} from "./redis-payload.js";

const WATERMARK_TTL_MARGIN_MS = 60_000;

Expand All @@ -25,36 +27,21 @@ const CEIL_FINITE_NUMBER_LUA = String.raw`local function ceil_finite_number(raw)
return math.ceil(value)
end`;

const VALIDATE_WRITE_ARGUMENTS_LUA = String.raw`local cache_ttl_ms = ceil_finite_number(ARGV[1])
local encoding = tonumber(ARGV[2])
const VALIDATE_STAMP_ARGUMENTS_LUA = String.raw`local cache_ttl_ms = ceil_finite_number(ARGV[1])
if not cache_ttl_ms or cache_ttl_ms <= 0 or cache_ttl_ms > ${MAX_SUPPORTED_DURATION_MS} then
return redis.error_reply("ERR invalid DialCache TTL")
end
if not encoding or (encoding ~= ${REDIS_ENCODING_UTF8} and encoding ~= ${REDIS_ENCODING_BINARY}) then
return redis.error_reply("ERR invalid DialCache payload encoding")
if string.len(ARGV[2]) ~= ${REDIS_FRAME_TIMESTAMP_BYTES} then
return redis.error_reply("ERR invalid DialCache stamp nonce")
end`;

const REDIS_TIME_LUA = String.raw`local redis_time = redis.call("TIME")
local now_ms = tonumber(redis_time[1]) * 1000 + math.floor(tonumber(redis_time[2]) / 1000)`;

const WRITE_FRAME_LUA = String.raw`local frame = string.char(${REDIS_FRAME_VERSION})
.. struct.pack(">I8", now_ms)
.. string.char(encoding)
.. ARGV[3]
redis.call("SET", KEYS[1], frame, "PX", cache_ttl_ms)`;

export const WRITE_CACHE_SCRIPT = [
CEIL_FINITE_NUMBER_LUA,
VALIDATE_WRITE_ARGUMENTS_LUA,
REDIS_TIME_LUA,
WRITE_FRAME_LUA,
"return 1",
].join("\n\n");

export const WRITE_TRACKED_CACHE_SCRIPT = [
export const WRITE_TRACKED_STAMP_SCRIPT = [
PARSE_WATERMARK_LUA,
CEIL_FINITE_NUMBER_LUA,
VALIDATE_WRITE_ARGUMENTS_LUA,
VALIDATE_STAMP_ARGUMENTS_LUA,
REDIS_TIME_LUA,
String.raw`local raw_watermark = redis.call("GET", KEYS[2])
local watermark = 0
Expand All @@ -66,12 +53,23 @@ if raw_watermark then
end

if watermark >= now_ms then
-- A fenced fallback write can remove the stale frame that led to it. Reads that
-- fail before reaching this script cannot benefit from this partial mitigation.
-- A fenced fallback write removes the placeholder it paired with, along with any
-- stale frame that led to it. The UNLINK stays unconditional: any frame present
-- here is already fenced, and removing a foreign in-flight placeholder only
-- forces that writer's honest reply-2 failure. Reads that fail before reaching
-- this script cannot benefit from this partial mitigation.
redis.call("UNLINK", KEYS[1])
return 0
end`,
WRITE_FRAME_LUA,
String.raw`local stamped = 1
if redis.call("GETRANGE", KEYS[1], 0, ${REDIS_FRAME_HEADER_BYTES - 1}) == string.char(${REDIS_FRAME_PLACEHOLDER_VERSION}) .. ARGV[2] then
redis.call("SETRANGE", KEYS[1], 0, string.char(${REDIS_FRAME_VERSION}) .. struct.pack(">I8", now_ms))
else
-- The placeholder this stamp paired with is gone: its SET was rejected,
-- overwritten, or expired. Promoting any other frame could publish a value
-- this write does not own, so leave the key untouched and report 2.
stamped = 2
end`,
String.raw`local desired_ttl_ms = cache_ttl_ms + ${WATERMARK_TTL_MARGIN_MS}
if not raw_watermark then
redis.call("SET", KEYS[2], "0", "PX", desired_ttl_ms)
Expand All @@ -83,7 +81,7 @@ else
redis.call("PEXPIRE", KEYS[2], desired_ttl_ms)
end
end`,
"return 1",
"return stamped",
].join("\n\n");

export const INVALIDATE_CACHE_SCRIPT = [
Expand Down
Loading
Loading