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
81 changes: 81 additions & 0 deletions packages/cache-handler/src/data-cache/redis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,30 @@ describe("RedisDataCacheHandler", () => {
expect(stale.revalidate).toBe(-1);
});

test("hard-expired tag wins over a merely-stale tag in the same entry", async () => {
vi.useFakeTimers();
vi.setSystemTime(BASE_TIME);

const redis = new FakeRedis();
const handler = createRedisDataCacheHandler({ redis });

const entry = createEntry("v1", {
tags: ["stale-tag", "expired-tag"],
timestamp: BASE_TIME.getTime(),
expire: 600,
revalidate: 300,
});
await handler.set("mixed-key", Promise.resolve(entry));

vi.setSystemTime(new Date(BASE_TIME.getTime() + 10_000));
await handler.updateTags(["stale-tag"], {}); // soft-stale only, no hard deadline
await handler.updateTags(["expired-tag"]); // no durations -> immediate hard expiry

const result = await handler.get("mixed-key", []);
expect(result).toBeUndefined();
expect(redis.delCalls).toContainEqual(["nextjs:data-cache:mixed-key"]);
});

test("getExpiration returns the latest revalidation event timestamp", async () => {
vi.useFakeTimers();
vi.setSystemTime(BASE_TIME);
Expand Down Expand Up @@ -437,3 +461,60 @@ describe("RedisDataCacheHandler cluster safety", () => {
expect(multiKey).toEqual([]);
});
});

describe("RedisDataCacheHandler tag revalidation checks", () => {
test("dispatches per-tag staleness checks concurrently, not sequentially", async () => {
const backing = new FakeRedis();
const tagKeys = ["nextjs:tags:t1", "nextjs:tags:t2", "nextjs:tags:t3"];
const started: string[] = [];
const releases: Array<() => void> = [];

// Block hGetAll for a tag key until explicitly released, so we can
// observe how many are in flight at once.
const redis: RedisClient = {
get: (key) => backing.get(key),
set: (key, value, ...args) => backing.set(key, value, ...args),
del: (...keys) => backing.del(...keys),
exists: (...keys) => backing.exists(...keys),
ttl: (key) => backing.ttl(key),
hGet: (key, field) => backing.hGet(key, field),
hSet: (key, field, value) => backing.hSet(key, field, value),
hGetAll: async (key) => {
if (!tagKeys.includes(key)) {
return backing.hGetAll(key);
}
started.push(key);
await new Promise<void>((resolve) => releases.push(resolve));
return backing.hGetAll(key);
},
};

const handler = createRedisDataCacheHandler({ redis });

await handler.set(
"multi-tag-key",
Promise.resolve(createEntry("v1", { tags: ["t1", "t2", "t3"] })),
);

const getPromise = handler.get("multi-tag-key", []);

// Flush microtasks so every dispatched tag lookup has had a chance to
// start, without letting any of the blocked ones resolve.
await new Promise((resolve) => setTimeout(resolve, 0));

// A sequential loop would only ever have the first tag lookup in
// flight here, since it can't dispatch the next until this one
// resolves. All three being in flight proves concurrent dispatch.
expect(started.slice().sort()).toEqual(tagKeys.slice().sort());

for (const release of releases) {
release();
}
const result = await getPromise;

if (!result) {
throw new Error("expected cache entry");
}
expect(await readStream(result.value)).toBe("v1");
});
});
31 changes: 24 additions & 7 deletions packages/cache-handler/src/data-cache/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,22 +224,39 @@ export function createRedisDataCacheHandler(
// re-cacheable after `revalidateTag(tag, "max")`). `expired`, when set,
// is the hard-deletion deadline; before it, the entry is served stale.
let revalidate = entry.revalidate;
for (const tag of entry.tags) {
const tagData = await redis.hGetAll(getTagKey(tag));
const tagResults = await Promise.all(
entry.tags.map((tag) => redis.hGetAll(getTagKey(tag))),
);

let hasStaleTag = false;
let expiredTag: string | undefined;

for (let i = 0; i < entry.tags.length; i++) {
const tag = entry.tags[i];
const tagData = tagResults[i];
const revalidatedAt = tagData.stale ? Number.parseInt(tagData.stale, 10) : 0;
const expireAt = tagData.expired ? Number.parseInt(tagData.expired, 10) : 0;

if (revalidatedAt && revalidatedAt > entry.timestamp) {
if (expireAt && Date.now() >= expireAt) {
log?.("get", cacheKey, "had expired tag", tag);
await redis.del(key);
return undefined;
expiredTag = tag;
break;
}
log?.("get", cacheKey, "had stale tag", tag);
revalidate = -1;
hasStaleTag = true;
}
}

if (expiredTag) {
log?.("get", cacheKey, "had expired tag", expiredTag);
await redis.del(key);
return undefined;
}

if (hasStaleTag) {
log?.("get", cacheKey, "had stale tag");
revalidate = -1;
}

// Tee the stream
const [returnStream, newSaved] = entry.value.tee();
entry.value = newSaved;
Expand Down
54 changes: 54 additions & 0 deletions packages/cache-handler/src/handlers/redis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,4 +513,58 @@ describe("RedisCacheHandler", () => {
expect(fakeRedis.quitCalled).toBe(true);
});
});

describe("tag revalidation checks", () => {
test("dispatches per-tag staleness checks concurrently, not sequentially", async () => {
const handler = new RedisCacheHandler();

const value: CacheValue = {
kind: "FETCH",
data: {
headers: { "content-type": "application/json" },
body: '{"test":true}',
status: 200,
url: "https://example.com",
},
revalidate: 60,
};

await handler.set("multi-tag-key", value, { revalidate: false, tags: ["t1", "t2", "t3"] });

const tagKeys = ["nextjs:tags:t1", "nextjs:tags:t2", "nextjs:tags:t3"];
const started: string[] = [];
const releases: Array<() => void> = [];
const originalGet = fakeRedis.get.bind(fakeRedis);

// Block any GET for a tag key until explicitly released, so we can
// observe how many are in flight at once.
fakeRedis.get = async (key: string) => {
if (!tagKeys.includes(key)) {
return originalGet(key);
}
started.push(key);
await new Promise<void>((resolve) => releases.push(resolve));
return originalGet(key);
};

const getPromise = handler.get("multi-tag-key");

// Flush microtasks so every dispatched tag lookup has had a chance to
// start, without letting any of the blocked ones resolve.
await new Promise((resolve) => setTimeout(resolve, 0));

// A sequential loop would only ever have the first tag lookup in
// flight here, since it can't dispatch the next until this one
// resolves. All three being in flight proves concurrent dispatch.
expect(started.slice().sort()).toEqual(tagKeys.slice().sort());

for (const release of releases) {
release();
}
const result = await getPromise;

expect(result).not.toBeNull();
expect(result?.value).toEqual(value);
});
});
});
19 changes: 10 additions & 9 deletions packages/cache-handler/src/handlers/redis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,17 +192,18 @@ export class RedisCacheHandler implements CacheHandler {

// Check if any tag (explicit or implicit) has been revalidated
const allTags = [...entry.tags, ...(meta?.implicitTags ?? [])];
const revalidatedAts = await Promise.all(
allTags.map((tag) => this.redis.get(this.getTagKey(tag))),
);

for (const tag of allTags) {
const tagKey = this.getTagKey(tag);
const revalidatedAt = await this.redis.get(tagKey);
const staleTagIndex = revalidatedAts.findIndex(
(revalidatedAt) => revalidatedAt && Number.parseInt(revalidatedAt) > entry.lastModified,
);

// If tag was revalidated after entry was last modified, entry is stale
if (revalidatedAt && Number.parseInt(revalidatedAt) > entry.lastModified) {
this.log("GET", cacheKey, "STALE (tag revalidated)", tag);
await this.delete(key);
return null;
}
if (staleTagIndex !== -1) {
this.log("GET", cacheKey, "STALE (tag revalidated)", allTags[staleTagIndex]);
await this.delete(key);
return null;
}

// Invalidate old APP_PAGE entries where segmentData was stored as a
Expand Down