diff --git a/README.md b/README.md index d5a9247..1dd0075 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 76b4e50..026f77f 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -183,6 +183,7 @@ import { isRedisReadMiss as isRedisProtocolReadMiss, validateRedisScriptInvalidationReply, validateRedisSetReply, + type CacheMissReason as ProtocolCacheMissReason, type DecodedRedisFrame, type RedisReadMiss as RedisProtocolReadMiss, type RedisReadResult as RedisProtocolReadResult, @@ -555,6 +556,8 @@ const missReasons: Readonly> = { 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. @@ -773,6 +776,7 @@ void requestLocalCoalescingLabels; void cacheMetricLabels; void missMetricLabels; void missReasons; +void rootMissReasonFromProtocol; void unboundedMissReason; void missingMissReason; void cacheMetricLabelsWithMissReason; diff --git a/src/internal/cache-result.ts b/src/internal/cache-result.ts index 8b2d114..a353e50 100644 --- a/src/internal/cache-result.ts +++ b/src/internal/cache-result.ts @@ -11,6 +11,7 @@ export type CacheGetResult = 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 = diff --git a/src/internal/redis-cache.ts b/src/internal/redis-cache.ts index 08677fc..90935e3 100644 --- a/src/internal/redis-cache.ts +++ b/src/internal/redis-cache.ts @@ -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, @@ -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" }; } @@ -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); -} diff --git a/src/metrics.ts b/src/metrics.ts index 2ff19e7..daea992 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -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; } diff --git a/src/redis-protocol.ts b/src/redis-protocol.ts index 5311315..d63f2cc 100644 --- a/src/redis-protocol.ts +++ b/src/redis-protocol.ts @@ -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, diff --git a/test/dialcache-metrics.test.ts b/test/dialcache-metrics.test.ts index 214898e..ce1029a 100644 --- a/test/dialcache-metrics.test.ts +++ b/test/dialcache-metrics.test.ts @@ -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; readonly value?: number }> = []; diff --git a/test/redis-payload.test.ts b/test/redis-payload.test.ts index 5f94679..f7f1a5b 100644 --- a/test/redis-payload.test.ts +++ b/test/redis-payload.test.ts @@ -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, @@ -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",