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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ Node-redis forces tracked cluster commands to the slot primary. GLIDE uses an ex

Invalidation is the only remaining Lua operation. Both adapters dispatch it as `EVALSHA` by the script source's SHA1 and retry a rejected dispatch once by re-sending the source as `EVAL`. The script is idempotent: its watermark advances monotonically and its TTL only widens, so a duplicate execution after an ambiguous failure is harmless. Reply-domain violations are deterministic and are not retried. If the retry also fails, GLIDE attaches the original rejection as `cause` when possible; node-redis surfaces the retry rejection unmodified because disconnect failures may be shared across callers. A healed retry is indistinguishable from first-attempt success in DialCache metrics. Monitor server-side `INFO commandstats` for unexpected `EVAL` volume or rejected `EVALSHA` calls.

Command-restricted Redis ACLs must allow native `GET`, `MGET`, and `SET`, plus `EVALSHA` and `EVAL` for invalidation recovery. If script-invoked commands are checked separately, the invalidation script needs `GET`, `SET`, and `PTTL`. Redis `TIME`, `MULTI`, `EXEC`, `WATCH`, `UNLINK`, and `SCRIPT LOAD` are not used by DialCache. Conditional refill suppression reuses the existing tracked `MGET` result and adds no command or round trip. The integration matrix covers Redis 6.2 and Valkey 8.
Command-restricted Redis ACLs must allow native `GET`, `MGET`, and `SET`, plus `EVALSHA` and `EVAL` for invalidation recovery. If script-invoked commands are checked separately, the invalidation script needs `GET`, `SET`, and `PTTL`. Redis `TIME`, `MULTI`, `EXEC`, `WATCH`, `UNLINK`, and `SCRIPT LOAD` are not used by DialCache. The integration matrix covers Redis 6.2 and Valkey 8.

#### Stale on source error

Expand Down
4 changes: 4 additions & 0 deletions scripts/test-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ import {
isRedisReadMiss as isRedisProtocolReadMiss,
validateRedisScriptInvalidationReply,
validateRedisSetReply,
type CacheMissReason as ProtocolCacheMissReason,
type DecodedRedisFrame,
type RedisReadMiss as RedisProtocolReadMiss,
type RedisReadResult as RedisProtocolReadResult,
Expand Down Expand Up @@ -555,6 +556,8 @@ const missReasons: Readonly<Record<CacheMissReason, true>> = {
watermark_fenced: true,
unclassified: true,
};
const protocolMissReason: ProtocolCacheMissReason = missMetricLabels.reason;
const rootMissReasonFromProtocol: CacheMissReason = protocolMissReason;
// @ts-expect-error Miss reasons are a bounded public taxonomy.
const unboundedMissReason: CacheMissReason = "evicted";
// @ts-expect-error The reason is required only for the miss callback's labels.
Expand Down Expand Up @@ -773,6 +776,7 @@ void requestLocalCoalescingLabels;
void cacheMetricLabels;
void missMetricLabels;
void missReasons;
void rootMissReasonFromProtocol;
void unboundedMissReason;
void missingMissReason;
void cacheMetricLabelsWithMissReason;
Expand Down
1 change: 1 addition & 0 deletions src/internal/cache-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type CacheGetResult<T> =
readonly skipCacheWrite?: boolean;
};

/** Read disposition consumed by stale recovery; distinct from the metric's CacheMissReason. */
export type RedisCacheMissReason = "cache_miss" | "deserialization_error";

export type RedisCacheGetResult<T> =
Expand Down
8 changes: 2 additions & 6 deletions src/internal/redis-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CacheLayer } from "../config.js";
import { RedisReadTimeoutError } from "../errors.js";
import { invalidationPrefix, redisClusterHashTag, type DialCacheKey } from "../key.js";
import {
CACHE_MISS_REASONS,
isCacheMissReason,
labelsFor,
REMOTE_SHADOW_CACHE_LAYER,
type CacheMissReason,
Expand Down Expand Up @@ -537,7 +537,7 @@ export class RedisCache {
frame: DecodedRedisFrame,
metricLayer: MetricLayer,
): FrameAgeResult {
if (!Number.isSafeInteger(frame.createdAtMs) || frame.createdAtMs < 0) {
if (!isValidRedisTimestampMs(frame.createdAtMs)) {
return { status: "invalid" };
}

Expand Down Expand Up @@ -649,7 +649,3 @@ function payloadSize(payload: string | Buffer): number {
function elapsedSeconds(startMs: number): number {
return Math.max((performance.now() - startMs) / 1000, 0);
}

function isCacheMissReason(value: unknown): value is CacheMissReason {
return typeof value === "string" && (CACHE_MISS_REASONS as readonly string[]).includes(value);
}
5 changes: 5 additions & 0 deletions src/metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export interface CacheMetricLabels {
export const CACHE_MISS_REASONS = ["value_absent", "expired", "watermark_fenced", "unclassified"] as const;
export type CacheMissReason = (typeof CACHE_MISS_REASONS)[number];

/** Package-private guard for reasons supplied by custom Redis adapters. */
export function isCacheMissReason(value: unknown): value is CacheMissReason {
return typeof value === "string" && (CACHE_MISS_REASONS as readonly string[]).includes(value);
}

export interface MissMetricLabels extends CacheMetricLabels {
readonly reason: CacheMissReason;
}
Expand Down
1 change: 1 addition & 0 deletions src/redis-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* never decompress or otherwise rewrite payload bytes.
*/
export { ceilSupportedCacheTtlMs } from "./internal/duration.js";
export type { CacheMissReason } from "./metrics.js";
export { INVALIDATE_CACHE_SCRIPT } from "./internal/redis-scripts.js";
export {
decodeRedisReadResult,
Expand Down
10 changes: 10 additions & 0 deletions test/dialcache-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,18 @@ import {
type ShadowValidationMetricLabels,
type StaleRecoveryMetricLabels,
} from "../src/index.js";
import { isCacheMissReason } from "../src/metrics.js";
import { encodeFrame, FakeRedis } from "./fake-redis.js";

it("accepts only the bounded miss reasons, not inherited keys or non-string values", () => {
for (const reason of ["value_absent", "expired", "watermark_fenced", "unclassified"]) {
expect(isCacheMissReason(reason)).toBe(true);
}
for (const value of ["constructor", "toString", "__proto__", "includes", "invented", "", 0, null, undefined, {}]) {
expect(isCacheMissReason(value)).toBe(false);
}
});

class RecordingMetrics implements DialCacheMetricsAdapter {
readonly events: Array<{ readonly name: string; readonly labels: Record<string, unknown>; readonly value?: number }> = [];

Expand Down
10 changes: 10 additions & 0 deletions test/redis-payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DialCacheRedisPayloadEncodingError,
DialCacheRedisPayloadError,
} from "../src/redis-client.js";
import { isValidRedisTimestampMs } from "../src/internal/redis-payload.js";

function encodeFrame(
payload: string | Buffer,
Expand All @@ -27,6 +28,15 @@ function encodeFrame(
}

describe("Redis frame decoding", () => {
it("shares one nonnegative safe-integer timestamp domain without coercing values", () => {
for (const timestamp of [0, 1, Number.MAX_SAFE_INTEGER]) {
expect(isValidRedisTimestampMs(timestamp)).toBe(true);
}
for (const value of [-1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, "0", 1n, null, undefined, {}]) {
expect(isValidRedisTimestampMs(value)).toBe(false);
}
});

it("decodes UTF-8 and binary payloads without copying binary data", () => {
expect(decodeRedisReadResult(encodeFrame("cached"))).toEqual({
payload: "cached",
Expand Down
Loading