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 apps/legacy-cache-test/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions docs/redis.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ const redis = new Cluster([
export default createRedisDataCacheHandler({ redis: createIoredisAdapter(redis) });
```

**Cluster-mode is safe by design.** Redis Cluster and ElastiCache (cluster-mode
enabled) shard keys across hash slots and reject any command spanning more than
one slot with a `CROSSSLOT` error. This handler only ever issues **single-key
commands** — no multi-key `del`/`mget`/transactions — so cluster mode works
without hash tags or extra configuration. That invariant is enforced by a test
(`redis.test.ts` → "only ever issues single-key commands"), so it can't
regress silently.

### Key Expiration

Keys are automatically expired based on cache lifetime settings. Monitor Redis memory usage.
Expand Down Expand Up @@ -248,6 +256,12 @@ export default createCacheHandler({
});
```

This covers **cluster-mode-disabled** ElastiCache (a single primary with
replicas), which behaves like single-node Redis from the client. For
**cluster-mode-enabled** ElastiCache (sharded), use the ioredis `Cluster` client
shown in [Redis Cluster](#redis-cluster-horizontal-scaling) above — the handler
is slot-safe either way.

## Valkey

Valkey is a Redis-compatible open source fork. Switch the `type` to `"valkey"` (or set `CACHE_BACKEND=valkey`) and point at your cluster URL:
Expand Down
94 changes: 93 additions & 1 deletion packages/cache-handler/src/data-cache/redis.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { createRedisDataCacheHandler } from "./redis.js";
import { type RedisClient, createRedisDataCacheHandler } from "./redis.js";
import type { DataCacheEntry } from "./types.js";

class FakeRedis {
Expand Down Expand Up @@ -339,3 +339,95 @@ describe("RedisDataCacheHandler", () => {
expect(await handler.getExpiration(["T"])).toBe(BASE_TIME.getTime());
});
});

describe("RedisDataCacheHandler cluster safety", () => {
afterEach(() => {
vi.useRealTimers();
});

// Redis Cluster and ElastiCache (cluster-mode enabled) shard keys across hash
// slots and reject any command that spans more than one slot with a CROSSSLOT
// error. The handler stays cluster-compatible only by issuing exclusively
// single-key commands — no `del(k1, k2)`, `mget`, multi-key transactions, etc.
// This records every command across the full handler surface (including both
// tag-driven and TTL-driven deletion paths) and asserts none targets more than
// one key, so a future multi-key change can't silently break cluster users.
test("only ever issues single-key commands", async () => {
vi.useFakeTimers();
vi.setSystemTime(BASE_TIME);

const commands: Array<{ method: string; keys: string[] }> = [];
const backing = new FakeRedis();

// Delegate behavior to FakeRedis; record the key(s) each command receives.
const redis: RedisClient = {
get: (key) => {
commands.push({ method: "get", keys: [key] });
return backing.get(key);
},
set: (key, value, ...args) => {
commands.push({ method: "set", keys: [key] });
return backing.set(key, value, ...args);
},
del: (...keys) => {
commands.push({ method: "del", keys });
return backing.del(...keys);
},
exists: (...keys) => {
commands.push({ method: "exists", keys });
return backing.exists(...keys);
},
ttl: (key) => {
commands.push({ method: "ttl", keys: [key] });
return backing.ttl(key);
},
hGet: (key, field) => {
commands.push({ method: "hGet", keys: [key] });
return backing.hGet(key, field);
},
hSet: (key, field, value) => {
commands.push({ method: "hSet", keys: [key] });
return backing.hSet(key, field, value);
},
hGetAll: (key) => {
commands.push({ method: "hGetAll", keys: [key] });
return backing.hGetAll(key);
},
};

const handler = createRedisDataCacheHandler({ redis });

// Exercise the full surface, with multiple tags so multi-key ops would show.
await handler.set(
"k1",
Promise.resolve(
createEntry("v1", { tags: ["t1", "t2"], timestamp: BASE_TIME.getTime(), revalidate: 600 }),
),
);
await handler.get("k1", []); // read + per-tag hGetAll checks
await handler.updateTags(["t1", "t2"], { expire: 100 }); // per-tag hSet
await handler.getExpiration(["t1", "t2"]); // per-tag hGet

// Tag-driven deletion path: revalidate a tag with immediate hard expiry, then
// read the now-invalid entry so the handler issues del(key).
vi.setSystemTime(new Date(BASE_TIME.getTime() + 1000));
await handler.updateTags(["t1"]);
await handler.get("k1", []);

// TTL-driven deletion path: an entry past its revalidate window is deleted on get.
await handler.set(
"k2",
Promise.resolve(
createEntry("v2", { timestamp: BASE_TIME.getTime(), expire: 600, revalidate: 1 }),
),
);
vi.setSystemTime(new Date(BASE_TIME.getTime() + 5000));
await handler.get("k2", []);

// Both deletion paths must have run (otherwise the assertion is vacuous)...
expect(commands.some((c) => c.method === "del")).toBe(true);
Comment on lines +427 to +428

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While asserting that del was called is a good sanity check, we can make this test significantly more robust and precise by asserting the exact keys that were deleted (nextjs:data-cache:k1 and nextjs:data-cache:k2). This ensures that the correct keys are targeted for deletion and prevents false positives where del might be called with unexpected keys.

    // Both deletion paths must have run (otherwise the assertion is vacuous)...
    const delCommands = commands.filter((c) => c.method === "del");
    expect(delCommands).toEqual([
      { method: "del", keys: ["nextjs:data-cache:k1"] },
      { method: "del", keys: ["nextjs:data-cache:k2"] }
    ]);

// ...and every command issued must target exactly one key.
const multiKey = commands.filter((c) => c.keys.length !== 1);
expect(multiKey).toEqual([]);
});
});
Loading