From fc19c0df165e7a86d65e10c2f911bac72641e7b0 Mon Sep 17 00:00:00 2001 From: tobiasvdorp Date: Thu, 2 Jul 2026 15:23:03 +0200 Subject: [PATCH 1/2] fix: correct Redis cache TTL calculation Co-authored-by: Cursor --- .../cache-handler/src/handlers/redis.test.ts | 31 +++++++++++++++++++ packages/cache-handler/src/handlers/redis.ts | 7 ++--- .../src/helpers/lifespan.test.ts | 18 ++++++++++- .../cache-handler/src/helpers/lifespan.ts | 11 +++++++ 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/cache-handler/src/handlers/redis.test.ts b/packages/cache-handler/src/handlers/redis.test.ts index ef4e6bc..f37291b 100644 --- a/packages/cache-handler/src/handlers/redis.test.ts +++ b/packages/cache-handler/src/handlers/redis.test.ts @@ -102,6 +102,10 @@ class FakeRedis { listenerCount(event: string): number { return this.listeners.get(event)?.length ?? 0; } + + getStored(key: string): { value: string; ttl?: number } | undefined { + return this.store.get(key); + } } // Mock ioredis to return our FakeRedis @@ -416,6 +420,33 @@ describe("RedisCacheHandler", () => { }); }); + describe("TTL on set", () => { + test("should store entry with setex when defaultTTL is configured", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-02T03:04:05.000Z")); + + const handler = new RedisCacheHandler({ defaultTTL: 3600 }); + + const value: CacheValue = { + kind: "FETCH", + data: { + headers: { "content-type": "application/json" }, + body: '{"ttl": true}', + status: 200, + url: "https://example.com", + }, + revalidate: 3600, + }; + + await handler.set("ttl-key", value); + + const stored = fakeRedis.getStored("nextjs:cache:ttl-key"); + expect(stored).toBeDefined(); + expect(stored?.ttl).toBeGreaterThan(0); + expect(stored?.ttl).toBeLessThanOrEqual(3600); + }); + }); + describe("existing Redis client support", () => { // Use a separate FakeRedis instance for external-client tests // so we can distinguish it from the mock constructor's fakeRedis diff --git a/packages/cache-handler/src/handlers/redis.ts b/packages/cache-handler/src/handlers/redis.ts index b24e527..3f2ab37 100644 --- a/packages/cache-handler/src/handlers/redis.ts +++ b/packages/cache-handler/src/handlers/redis.ts @@ -1,5 +1,5 @@ import Redis, { type RedisOptions } from "ioredis"; -import { calculateLifespan, isExpired } from "../helpers/lifespan.js"; +import { calculateLifespan, isExpired, redisTtlSecondsFromLifespan } from "../helpers/lifespan.js"; import { jsonReplacer, jsonReviver } from "../helpers/serialization.js"; import type { CacheHandler, @@ -254,9 +254,8 @@ export class RedisCacheHandler implements CacheHandler { // Determine TTL for Redis let ttl: number | undefined; if (lifespan?.expireAt) { - // Use expire time as TTL - ttl = Math.ceil((lifespan.expireAt - Date.now()) / 1000); - if (ttl <= 0) { + ttl = redisTtlSecondsFromLifespan(lifespan); + if (!ttl || ttl <= 0) { this.log("SET", cacheKey, "SKIP (already expired)"); return; } diff --git a/packages/cache-handler/src/helpers/lifespan.test.ts b/packages/cache-handler/src/helpers/lifespan.test.ts index 23b7d3a..1cd0930 100644 --- a/packages/cache-handler/src/helpers/lifespan.test.ts +++ b/packages/cache-handler/src/helpers/lifespan.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { calculateLifespan, isExpired } from "./lifespan.js"; +import { calculateLifespan, isExpired, redisTtlSecondsFromLifespan } from "./lifespan.js"; const MOCK_TIME = new Date("2024-01-02T03:04:05.000Z"); @@ -71,4 +71,20 @@ describe("lifespan helpers", () => { vi.setSystemTime(new Date(MOCK_TIME.getTime() + 2_000)); expect(isExpired(lifespan)).toBe(true); }); + + test("redisTtlSecondsFromLifespan computes positive Redis TTL from lifespan", () => { + const lifespan = calculateLifespan(3600, 3600); + const ttl = redisTtlSecondsFromLifespan(lifespan); + + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(3600); + }); + + test("redisTtlSecondsFromLifespan documents the broken formula always skips writes", () => { + const lifespan = calculateLifespan(3600, 3600); + expect(lifespan).not.toBeNull(); + + const broken = Math.ceil((lifespan.expireAt - Date.now()) / 1000); + expect(broken).toBeLessThanOrEqual(0); + }); }); diff --git a/packages/cache-handler/src/helpers/lifespan.ts b/packages/cache-handler/src/helpers/lifespan.ts index 2561772..c1c333a 100644 --- a/packages/cache-handler/src/helpers/lifespan.ts +++ b/packages/cache-handler/src/helpers/lifespan.ts @@ -51,3 +51,14 @@ export function isExpired(lifespan: LifespanParameters | null): boolean { const nowSeconds = Math.floor(Date.now() / 1000); return nowSeconds >= lifespan.expireAt; } + +/** + * Compute remaining Redis EX TTL in seconds from lifespan parameters. + * expireAt is epoch seconds (see calculateLifespan). + */ +export function redisTtlSecondsFromLifespan( + lifespan: LifespanParameters | null, +): number | undefined { + if (!lifespan?.expireAt) return undefined; + return lifespan.expireAt - Math.floor(Date.now() / 1000); +} From 8a9e1538bdb7717825a95bcdd5f2ef78ce0c2225 Mon Sep 17 00:00:00 2001 From: tobiasvdorp Date: Thu, 2 Jul 2026 15:43:24 +0200 Subject: [PATCH 2/2] test: restore timers after Redis TTL test Co-authored-by: Cursor --- packages/cache-handler/src/handlers/redis.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cache-handler/src/handlers/redis.test.ts b/packages/cache-handler/src/handlers/redis.test.ts index f37291b..5112fb2 100644 --- a/packages/cache-handler/src/handlers/redis.test.ts +++ b/packages/cache-handler/src/handlers/redis.test.ts @@ -421,6 +421,10 @@ describe("RedisCacheHandler", () => { }); describe("TTL on set", () => { + afterEach(() => { + vi.useRealTimers(); + }); + test("should store entry with setex when defaultTTL is configured", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-01-02T03:04:05.000Z"));