diff --git a/.secrets.baseline b/.secrets.baseline index b53783ee..ebddd94a 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -157,14 +157,14 @@ "filename": "packages/cachekit/src/backends/cachekitio.test.ts", "hashed_secret": "9242986caf890b01f4265ee595e88ee087af4b90", "is_verified": false, - "line_number": 23 + "line_number": 24 }, { "type": "Secret Keyword", "filename": "packages/cachekit/src/backends/cachekitio.test.ts", "hashed_secret": "a62f2225bf70bfaccbc7f1ef2a397836717377de", "is_verified": false, - "line_number": 49 + "line_number": 50 } ], "packages/cachekit/src/intents.test.ts": [ @@ -1332,5 +1332,5 @@ } ] }, - "generated_at": "2026-10-09T09:07:33Z" + "generated_at": "2026-11-01T05:53:25Z" } diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index fca63e4c..c2f08f8f 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -516,14 +516,18 @@ const getReport = cache.wrap(async (id: string) => buildReport(id), { like any write. Past the window the server answers 404 and the call takes the ordinary miss path. - A stale value never enters L1, and the L1 copy of a written value still - expires at `ttl`: the window is served by the server only. + expires at `ttl`, or sooner when the server reports a shorter lifetime + for the write: the window is served by the server only. - On Cloudflare Workers the revalidation needs a context bound with `withExecutionContext(ctx)`; without one, a stale read is treated as a miss and recomputed in the request. - `wrap()` throws `ConfigurationError` when `staleTtl` is set on an encrypted cache (a secure cache's TTL is often a revocation boundary), on a backend - other than CachekitIO, above 2,591,999 seconds, or when `ttl + staleTtl` - exceeds 2,592,000 (30 days). Fractions round up; `0` turns it off. + other than CachekitIO, above 7,775,999 seconds, or when `ttl + staleTtl` + exceeds 7,776,000 (90 days, the protocol's TTL range). Fractions round up; + `0` turns it off. A window that does not fit the tenant's plan maximum TTL + is not an error: the server shortens it (see the CachekitIO TTL note + below). > [!WARNING] > The stale window applies to every reader of the key, not only to this @@ -566,12 +570,28 @@ Four backends implement the same `Backend` interface (raw bytes in/out) and plug | Backend | Import | Runtime | Notes | | ----------------- | ----------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Redis | `redis` from `@cachekit-io/cachekit` | Node | ioredis; TTL inspection + distributed locking | -| CachekitIO (SaaS) | `cachekitio` from `@cachekit-io/cachekit` | Node, Workers | fetch-based; TTL + locking variants; accepts finite TTLs > 0 up to 2,592,000 s (30 days), fractional values round up; invalid values throw `ConfigurationError` per the protocol spec | +| CachekitIO (SaaS) | `cachekitio` from `@cachekit-io/cachekit` | Node, Workers | fetch-based; TTL + locking variants; accepts finite TTLs > 0 up to 7,776,000 s (90 days), fractional values round up; invalid values throw `ConfigurationError` per the protocol spec | | Memcached | `memcached` from `@cachekit-io/cachekit/backends/memcached` | Node | memjs (binary protocol, multi-server); requires the optional `memjs` peer dependency | | File | `file` from `@cachekit-io/cachekit/backends/file` | Node | local disk, on-disk format shared with cachekit-py | The Memcached and File backends are **Node-runtime only** and live behind subpath exports, so the root entry never loads `memjs` or the File backend. The root entry is itself **Node-only**: it loads `node:crypto` and the NAPI core, whose loader requires `node:fs`. ioredis loads when the app creates a Redis backend, not when it imports the package, so an app that never uses Redis never loads it; commands issued before the load finishes wait for it. Bundlers follow that load, so a bundle that uses Redis carries ioredis. ioredis is CommonJS and requires Node builtins, so an esbuild ESM bundle for Node needs `require` defined (the usual `createRequire` banner). Edge code uses the `workerd` export condition or the `@cachekit-io/cachekit/workers` subpath. Bun resolves the root to the Node entry and runs it, NAPI core included (checked on Bun 1.3.8); the `/workers` subpath does not load on Bun. No entry targets Deno, Deno Deploy or Vercel/Next edge. +**CachekitIO TTL and the plan maximum.** The TTL range is 1 to 7,776,000 +seconds (90 days); the SDK rejects anything outside it before any request. +Each plan also has a maximum TTL: Free 86,400 s (24 hours), Startup +2,592,000 s (30 days), Professional 7,776,000 s (90 days), Enterprise none. +The SDK does not know your plan, so it sends a TTL inside the range as is, +and the server stores the write with at most the plan maximum (the fresh +window first, then whatever stale window is left). That clamp never rejects +a write. The server reports the lifetime it applied in `X-CacheKit-Fresh-For` +on the write's response, and the writing process's L1 copy expires after +`min(ttl, X-CacheKit-Fresh-For)` seconds, counted from that response; a +malformed or `0` value keeps no L1 copy. A write whose error degradation +absorbed (a timeout, say) got no response, and keeps its L1 copy for `ttl`. +`refreshTTL` (`PATCH /ttl`) never changes how long an L1 copy is served. +This SDK always sends a TTL (`defaultTtl`, one hour unless set), so it never +stores a CachekitIO entry without an expiry. + **L1 freshness across processes.** When a plain `get()` hits L2, the value is re-populated into this process's L1. On backends that surface the entry's remaining TTL in the same read (`Backend.getWithTtl` — Redis via a pipelined diff --git a/packages/cachekit/src/backends/cachekitio-freshness.test.ts b/packages/cachekit/src/backends/cachekitio-freshness.test.ts index 8eec818b..b1b95b1c 100644 --- a/packages/cachekit/src/backends/cachekitio-freshness.test.ts +++ b/packages/cachekit/src/backends/cachekitio-freshness.test.ts @@ -6,9 +6,11 @@ import { cachekitioWithLocking, cachekitioWithTTL, } from './cachekitio-factory.js'; -import type { Backend, CachekitIOBackendConfig } from './types.js'; +import type { Backend, CachekitIOBackendConfig, SetResult } from './types.js'; import { createCache } from '../cache.js'; import { CacheImpl } from '../cache-core.js'; +import { generateKey } from '../serialization/key-generator.js'; +import type { L1Cache } from '../l1/lru-cache.js'; // The L1 backfill honours X-CacheKit-Freshness and X-CacheKit-Fresh-For // (protocol spec/saas-api.md § Remaining Freshness, § Reading a stale entry). @@ -40,6 +42,13 @@ describe('freshnessFromHeaders', () => { expect(parse({ 'X-CacheKit-Fresh-For': '2592000' }).freshFor).toBe(2_592_000); }); + // The header's cap is its own grammar bound, not the 90-day TTL range. + it('keeps the 2,592,000 cap below the TTL range', () => { + expect(parse({ 'X-CacheKit-Fresh-For': '2592000' }).freshFor).toBe(2_592_000); + expect(parse({ 'X-CacheKit-Fresh-For': '2592001' }).freshFor).toBe(0); + expect(parse({ 'X-CacheKit-Fresh-For': '7776000' }).freshFor).toBe(0); + }); + it('maps every malformed Fresh-For to 0', () => { for (const raw of [ '', @@ -74,7 +83,8 @@ describe('CachekitIO L1 backfill bound', () => { 'fetch', vi.fn(async (url: string, init: RequestInit) => { if (init.method === 'PUT') { - store.set(url, init.body as Uint8Array); + if (!(init.body instanceof Uint8Array)) throw new TypeError('PUT body must be bytes'); + store.set(url, init.body); return new Response(null, { status: 200 }); } gets++; @@ -246,3 +256,344 @@ describe('CachekitIO L1 backfill bound', () => { await reader.close(); }); }); + +// The writer's own copy (protocol spec/saas-api.md § Remaining Freshness, +// API-103): the X-CacheKit-Fresh-For on the PUT 2xx caps the L1 copy of the +// value written, counted from receipt; absent leaves the TTL; malformed or 0 +// keeps no copy. And a PATCH /ttl response never touches an L1 bound (API-104). +describe('CachekitIO writer L1 bound', () => { + let store: Map; + /** Headers on every PUT 2xx. */ + let putHeaders: Record; + let gets: number; + let patches: number; + + beforeEach(() => { + store = new Map(); + putHeaders = {}; + gets = 0; + patches = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: RequestInit) => { + if (init.method === 'PUT') { + if (!(init.body instanceof Uint8Array)) throw new TypeError('PUT body must be bytes'); + store.set(url, init.body); + return new Response(null, { status: 200, headers: putHeaders }); + } + if (init.method === 'PATCH') { + patches++; + // A hostile or buggy hop's header on the PATCH: it must change nothing. + return new Response(null, { + status: 200, + headers: { 'X-CacheKit-Fresh-For': '2592000' }, + }); + } + gets++; + const body = store.get(url); + return body ? new Response(body, { status: 200 }) : new Response(null, { status: 404 }); + }) + ); + vi.useFakeTimers({ toFake: ['Date'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + // Every factory wraps CachekitIOCore differently; each must return set's result. + const factories = { cachekitio, cachekitioWithLocking, cachekitioWithTTL, cachekitioFull }; + + for (const [name, factory] of Object.entries(factories)) { + it(`caps the L1 copy at a Fresh-For shorter than the TTL (${name})`, async () => { + const cache = createCache({ backend: factory(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '2' }; + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + vi.advanceTimersByTime(1_900); + expect(await cache.get('ns:k')).toBe('v1'); + expect(gets).toBe(0); // inside the bound: L1 hit + + vi.advanceTimersByTime(100); + expect(await cache.get('ns:k')).toBe('v1'); + expect(gets).toBe(1); // at the bound: the L1 copy is gone, back to L2 + + await cache.close(); + }); + } + + it('keeps the TTL when the PUT carries no Fresh-For', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + vi.advanceTimersByTime(3_599_000); + expect(await cache.get('ns:k')).toBe('v1'); + expect(gets).toBe(0); + + vi.advanceTimersByTime(1_000); + await cache.get('ns:k'); + expect(gets).toBe(1); + + await cache.close(); + }); + + it('never lets a Fresh-For longer than the TTL extend the copy', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '2592000' }; + await cache.set('ns:k', 'v1', { ttl: 60 }); + + vi.advanceTimersByTime(59_999); + await cache.get('ns:k'); + expect(gets).toBe(0); // the copy was kept, for the TTL + + vi.advanceTimersByTime(1); + await cache.get('ns:k'); + expect(gets).toBe(1); + + await cache.close(); + }); + + it.each(['0', 'abc', '-1', '2592001', '12345678', ''])( + 'keeps no L1 copy when the PUT Fresh-For is %j', + async (raw) => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + await cache.set('ns:k', 'v0', { ttl: 3600 }); // an older copy the write must not leave behind + putHeaders = { 'X-CacheKit-Fresh-For': raw }; + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + expect(await cache.get('ns:k')).toBe('v1'); + expect(gets).toBe(1); + + await cache.close(); + } + ); + + it('counts the bound from receipt of the 2xx, not from dispatch', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '2' }; + // The PUT takes 1.5 s on the faked clock between dispatch and receipt. + const fetchStub = vi.mocked(fetch); + const inner = fetchStub.getMockImplementation()!; + fetchStub.mockImplementationOnce(async (url, init) => { + vi.advanceTimersByTime(1_500); + return inner(url, init); + }); + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + vi.advanceTimersByTime(1_900); + await cache.get('ns:k'); + expect(gets).toBe(0); // 1.9 s after receipt (3.4 s after dispatch): inside the bound + + vi.advanceTimersByTime(100); + await cache.get('ns:k'); + expect(gets).toBe(1); + + await cache.close(); + }); + + it('holds the bound on the stale-window write (X-CacheKit-Stale-TTL never lengthens it)', async () => { + const cache = createCache({ backend: cachekitioFull(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '1' }; + const fn = vi.fn(async () => 'computed'); + const wrapped = cache.wrap(fn, { namespace: 'sg', ttl: 3600, staleTtl: 600 }); + + expect(await wrapped()).toBe('computed'); // cold miss: GET 404, then the PUT + expect(gets).toBe(1); + expect(await wrapped()).toBe('computed'); + expect(gets).toBe(1); // L1 hit + + vi.advanceTimersByTime(1_000); + expect(await wrapped()).toBe('computed'); + expect(gets).toBe(2); // past Fresh-For, though inside both TTL and stale window + expect(fn).toHaveBeenCalledTimes(1); + + await cache.close(); + }); + + it('caps a write-behind copy once its PUT lands', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '1' }; + const wrapped = cache.wrap(async () => 'computed', { + namespace: 'wb', + ttl: 3600, + writeBehind: true, + }); + + expect(await wrapped()).toBe('computed'); // L1 filled before the PUT is sent + await vi.waitFor(() => expect(store.size).toBe(1)); + await new Promise((resolve) => setTimeout(resolve, 0)); // let the write settle + expect(gets).toBe(1); + + vi.advanceTimersByTime(1_000); + expect(await wrapped()).toBe('computed'); + expect(gets).toBe(2); // the copy was capped at the PUT's Fresh-For + + await cache.close(); + }); + + it('measures SWR staleness of a capped write-behind copy against the cap, not the TTL', async () => { + const cache = createCache({ + backend: cachekitio(config), + defaultTtl: 3600, + l1: { swrEnabled: true }, + }); + putHeaders = { 'X-CacheKit-Fresh-For': '60' }; + const fn = vi.fn(async () => 'computed'); + const wrapped = cache.wrap(fn, { namespace: 'wb', ttl: 3600, writeBehind: true }); + + expect(await wrapped()).toBe('computed'); + await vi.waitFor(() => expect(store.size).toBe(1)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 60 s left of a 60 s bound is fresh; read against the 3600 s TTL it + // would look stale and refresh the origin on every call. + const l1 = (cache as unknown as { l1: L1Cache }).l1; + const entry = l1.entry(generateKey('wb', [])) as unknown as { originalTtl: number }; + expect(entry.originalTtl).toBeLessThanOrEqual(60_000); + expect(await wrapped()).toBe('computed'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fn).toHaveBeenCalledTimes(1); + + await cache.close(); + }); + + it('drops a write-behind copy when its PUT Fresh-For is 0', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '0' }; + const wrapped = cache.wrap(async () => 'computed', { + namespace: 'wb', + ttl: 3600, + writeBehind: true, + }); + + expect(await wrapped()).toBe('computed'); + await vi.waitFor(() => expect(store.size).toBe(1)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(await wrapped()).toBe('computed'); + expect(gets).toBe(2); + + await cache.close(); + }); + + describe('SWR refresh (L1 written only through completeRefresh)', () => { + // swrThresholdRatio 2 makes every live L1 entry stale, so the second call refreshes. + const swr = () => + createCache({ + backend: cachekitio(config), + defaultTtl: 3600, + l1: { swrEnabled: true, swrThresholdRatio: 2 }, + }); + + it('caps the refreshed copy at the refresh PUT Fresh-For', async () => { + const cache = swr(); + let gen = 0; + const wrapped = cache.wrap(async () => ++gen, { namespace: 'swr', ttl: 3600 }); + const key = generateKey('swr', []); + + expect(await wrapped()).toBe(1); + putHeaders = { 'X-CacheKit-Fresh-For': '1' }; + expect(await wrapped()).toBe(1); // stale hit: refresh scheduled + await vi.waitFor(async () => expect(await cache.get(key)).toBe(2)); + expect(gets).toBe(1); // the cold miss only: gen 2 came from L1 + + vi.advanceTimersByTime(1_000); + expect(await cache.get(key)).toBe(2); + expect(gets).toBe(2); + + await cache.close(); + }); + + it('keeps neither copy when the refresh PUT Fresh-For is 0', async () => { + const cache = swr(); + let gen = 0; + const wrapped = cache.wrap(async () => ++gen, { namespace: 'swr', ttl: 3600 }); + const key = generateKey('swr', []); + + expect(await wrapped()).toBe(1); + putHeaders = { 'X-CacheKit-Fresh-For': '0' }; + expect(await wrapped()).toBe(1); + await vi.waitFor(() => expect(gen).toBe(2)); + await vi.waitFor(async () => { + const before = gets; + expect(await cache.get(key)).toBe(2); // read from L2: L1 holds neither generation + expect(gets).toBe(before + 1); + }); + + await cache.close(); + }); + }); + + it('a PATCH /ttl response never creates, refreshes or extends an L1 bound (API-104)', async () => { + const backend = cachekitioFull(config); + const cache = createCache({ backend, defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '2' }; + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + vi.advanceTimersByTime(1_000); + expect(await backend.refreshTTL('ns:k', 7_776_000)).toBe(true); + expect(patches).toBe(1); + + vi.advanceTimersByTime(1_000); + await cache.get('ns:k'); + expect(gets).toBe(1); // still expired at the PUT's bound + + // Nor does a PATCH of a key L1 does not hold create a copy. + expect(await backend.refreshTTL('ns:other', 3600)).toBe(true); + expect(await cache.get('ns:other')).toBeNull(); + expect(gets).toBe(2); + + await cache.close(); + }); +}); + +describe('a custom backend reporting a bound', () => { + /** In-memory backend whose set resolves to `result`. */ + class ReportingBackend implements Backend { + store = new Map(); + gets = 0; + constructor(private readonly result: unknown) {} + async get(key: string) { + this.gets++; + return this.store.get(key) ?? null; + } + async set(key: string, value: Uint8Array) { + this.store.set(key, value); + return this.result as SetResult; + } + async delete(key: string) { + return this.store.delete(key); + } + async exists(key: string) { + return this.store.has(key); + } + async close() {} + } + + it.each([NaN, -1, '60'])( + 'keeps no L1 copy for a freshFor of %j (fails closed)', + async (freshFor) => { + const backend = new ReportingBackend({ freshFor }); + const cache = createCache({ backend, defaultTtl: 3600 }); + await cache.set('ns:k', 'v1'); + + expect(await cache.get('ns:k')).toBe('v1'); + expect(backend.gets).toBe(1); + + await cache.close(); + } + ); + + it('keeps the TTL for a backend that resolves to nothing', async () => { + const backend = new ReportingBackend(undefined); + const cache = createCache({ backend, defaultTtl: 3600 }); + await cache.set('ns:k', 'v1'); + + expect(await cache.get('ns:k')).toBe('v1'); + expect(backend.gets).toBe(0); + + await cache.close(); + }); +}); diff --git a/packages/cachekit/src/backends/cachekitio-ttl.test.ts b/packages/cachekit/src/backends/cachekitio-ttl.test.ts index 2729d39d..f6f5d406 100644 --- a/packages/cachekit/src/backends/cachekitio-ttl.test.ts +++ b/packages/cachekit/src/backends/cachekitio-ttl.test.ts @@ -59,11 +59,20 @@ describe('TTLCachekitIO', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); - it('refreshTTL rejects TTL above 2,592,000 (30 days)', async () => { - await expect(ttlBackend.refreshTTL('key', 2_592_001)).rejects.toThrow(ConfigurationError); + it('refreshTTL rejects TTL above 7,776,000 (90 days)', async () => { + await expect(ttlBackend.refreshTTL('key', 7_776_001)).rejects.toThrow(ConfigurationError); expect(fetchSpy).not.toHaveBeenCalled(); }); + // A TTL over a plan's maximum but inside the TTL range is sent as is: the + // SDK does not know the plan, and the store clamps it. + it.each([1, 2_592_001, 7_776_000])('refreshTTL sends %i as is in the PATCH body', async (ttl) => { + fetchSpy.mockResolvedValueOnce(mockResponse(200, { success: true })); + await ttlBackend.refreshTTL('key', ttl); + const [, opts] = fetchSpy.mock.calls[0]; + expect(JSON.parse(new TextDecoder().decode(opts.body))).toEqual({ ttl }); + }); + it('refreshTTL ceils fractional TTL to whole seconds in the PATCH body', async () => { fetchSpy.mockResolvedValueOnce(mockResponse(200, { success: true })); await ttlBackend.refreshTTL('key', 0.5); diff --git a/packages/cachekit/src/backends/cachekitio.test.ts b/packages/cachekit/src/backends/cachekitio.test.ts index 119d9c47..221d99d1 100644 --- a/packages/cachekit/src/backends/cachekitio.test.ts +++ b/packages/cachekit/src/backends/cachekitio.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { cachekitio } from './cachekitio-factory.js'; +import { validateStaleTtl, validateTtl } from './cachekitio.js'; import type { Backend, CachekitIOBackendConfig } from './types.js'; import { BackendError, ConfigurationError, TimeoutError } from '../errors.js'; @@ -353,7 +354,7 @@ describe('CachekitIO Backend', () => { describe('set edge cases', () => { // Protocol TTL Validation Rules (protocol/spec/saas-api.md): TTL=0, - // negative, and > 30 days MUST be rejected at the SDK boundary. + // negative, and > 90 days (the TTL range) MUST be rejected at the SDK boundary. it('rejects TTL of 0', async () => { await expect(backend.set('key', new Uint8Array([1]), 0)).rejects.toThrow(ConfigurationError); expect(fetchSpy).not.toHaveBeenCalled(); @@ -364,18 +365,20 @@ describe('CachekitIO Backend', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); - it('rejects TTL above 2,592,000 (30 days)', async () => { - await expect(backend.set('key', new Uint8Array([1]), 2_592_001)).rejects.toThrow( + it('rejects TTL above 7,776,000 (90 days)', async () => { + await expect(backend.set('key', new Uint8Array([1]), 7_776_001)).rejects.toThrow( ConfigurationError ); expect(fetchSpy).not.toHaveBeenCalled(); }); - it('accepts TTL at the 30-day ceiling', async () => { + // 2,592,001 is over the Startup plan maximum and the old 30-day ceiling: + // the SDK sends it as is, because it does not know the plan. + it.each([1, 2_592_001, 7_776_000])('sends TTL %i as is', async (ttl) => { fetchSpy.mockResolvedValueOnce(mockResponse(200)); - await backend.set('key', new Uint8Array([1]), 2_592_000); + await backend.set('key', new Uint8Array([1]), ttl); const [, opts] = fetchSpy.mock.calls[0] as [string, RequestInit]; - expect((opts.headers as Record)['X-CacheKit-TTL']).toBe('2592000'); + expect((opts.headers as Record)['X-CacheKit-TTL']).toBe(String(ttl)); }); it('ceils sub-second TTL to 1, never truncates to 0', async () => { @@ -397,9 +400,10 @@ describe('CachekitIO Backend', () => { it('rejects invalid defaultTtl at construction', () => { expect(() => cachekitio({ ...validConfig, defaultTtl: 0 })).toThrow(ConfigurationError); - expect(() => cachekitio({ ...validConfig, defaultTtl: 2_592_001 })).toThrow( + expect(() => cachekitio({ ...validConfig, defaultTtl: 7_776_001 })).toThrow( ConfigurationError ); + expect(() => cachekitio({ ...validConfig, defaultTtl: 7_776_000 })).not.toThrow(); }); it('closed state rejects set', async () => { @@ -517,3 +521,29 @@ describe('CachekitIO Backend', () => { }); }); }); + +// The 90-day TTL range (protocol spec/saas-api.md, TTL Validation Rules and +// stale-window Validation), each bound and one over. The X-CacheKit-Fresh-For +// cap stays 2,592,000: see cachekitio-freshness.test.ts. +describe('TTL range bounds', () => { + it('accepts a TTL of 1..7,776,000 and rejects one over', () => { + expect(validateTtl(1)).toBe(1); + expect(validateTtl(7_776_000)).toBe(7_776_000); + expect(() => validateTtl(7_776_001)).toThrow(ConfigurationError); + expect(() => validateTtl(7_776_000.5)).toThrow(ConfigurationError); + }); + + it('accepts a staleTtl up to 7,775,999 and rejects one over, before the sum', () => { + expect(validateStaleTtl(7_775_999, 1)).toBe(7_775_999); + expect(() => validateStaleTtl(7_776_000, 0)).toThrow(/at most 7775999/); + }); + + it('accepts ttl + staleTtl up to 7,776,000 and rejects one over', () => { + expect(validateStaleTtl(1, 7_775_999)).toBe(1); + expect(validateStaleTtl(7_775_990, 10)).toBe(7_775_990); + expect(() => validateStaleTtl(7_775_991, 10)).toThrow( + /ttl \+ staleTtl must be at most 7776000/ + ); + expect(() => validateStaleTtl(1, 7_776_000)).toThrow(ConfigurationError); + }); +}); diff --git a/packages/cachekit/src/backends/cachekitio.ts b/packages/cachekit/src/backends/cachekitio.ts index 77f67676..eed1daf6 100644 --- a/packages/cachekit/src/backends/cachekitio.ts +++ b/packages/cachekit/src/backends/cachekitio.ts @@ -1,4 +1,4 @@ -import { Backend, CachekitIOBackendConfig, GetWithTtlResult } from './types.js'; +import { Backend, CachekitIOBackendConfig, GetWithTtlResult, SetResult } from './types.js'; import { BackendError, ConfigurationError, TimeoutError } from '../errors.js'; import { DEFAULT_TTL_SECONDS } from '../constants.js'; import { getSessionHeaders } from './session.js'; @@ -89,26 +89,39 @@ function parseFreshFor(raw: string): number { if (digit < 0 || digit > 9) return 0; seconds = seconds * 10 + digit; } - return seconds > MAX_TTL_SECONDS ? 0 : seconds; + return seconds > MAX_FRESH_FOR_SECONDS ? 0 : seconds; } const DEFAULT_API_URL = 'https://api.cachekit.io'; const DEFAULT_TIMEOUT_MS = 5_000; -/** Protocol TTL ceiling: 30 days in seconds (protocol/spec/saas-api.md, TTL Validation Rules). */ -const MAX_TTL_SECONDS = 2_592_000; +/** + * The `X-CacheKit-Fresh-For` cap: 30 days in seconds (protocol spec/saas-api.md + * § Remaining Freshness, API-32). A grammar bound on the header, deliberately + * NOT the TTL range below: the header never exceeds it, so a larger value is a + * buggy tier and reads as `0`. Never merge the two constants. + */ +const MAX_FRESH_FOR_SECONDS = 2_592_000; + +/** + * The TTL range ceiling: 90 days in seconds (protocol spec/saas-api.md, TTL + * Validation Rules). It bounds a stated TTL, not an entry's lifetime: the + * store clamps a TTL above the tenant's plan maximum, and the SDK sends it + * as is because it does not know the plan. + */ +const MAX_TTL_SECONDS = 7_776_000; /** * Validate a TTL per the protocol's normative TTL Validation Rules * (protocol/spec/saas-api.md): zero, negative, non-finite, and values over - * 30 days are rejected; sub-second/fractional durations are ceiled to whole + * 90 days are rejected; sub-second/fractional durations are ceiled to whole * seconds (never truncated to 0). Exported for the TTL decorator's * refreshTTL, which sends the same value in the PATCH body. */ export function validateTtl(ttl: number): number { if (!Number.isFinite(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) { throw new ConfigurationError( - `TTL must be greater than 0 and at most ${MAX_TTL_SECONDS} seconds (30 days), got ${ttl}` + `TTL must be greater than 0 and at most ${MAX_TTL_SECONDS} seconds (90 days), got ${ttl}` ); } return Math.ceil(ttl); @@ -117,9 +130,9 @@ export function validateTtl(ttl: number): number { /** * Validate a stale-grace window per the protocol's Stale-While-Revalidate * validation rules (protocol/spec/saas-api.md): negative, non-finite and - * values over 2,591,999 are rejected before any arithmetic; fractions are - * ceiled (API-54); the window shares the 30-day cap with `wireTtl`, the - * already-validated TTL. Returns the whole seconds to send; `0` sends nothing. + * values over 7,775,999 are rejected before any arithmetic; fractions are + * ceiled (API-54); the window shares the 90-day TTL range with `wireTtl`, + * the already-validated TTL. Returns the whole seconds to send; `0` sends nothing. */ export function validateStaleTtl(staleTtl: number, wireTtl: number): number { if (!Number.isFinite(staleTtl) || staleTtl < 0 || staleTtl > MAX_TTL_SECONDS - 1) { @@ -130,7 +143,7 @@ export function validateStaleTtl(staleTtl: number, wireTtl: number): number { const wire = Math.ceil(staleTtl); if (wireTtl + wire > MAX_TTL_SECONDS) { throw new ConfigurationError( - `ttl + staleTtl must be at most ${MAX_TTL_SECONDS} seconds (30 days), got ${wireTtl} + ${wire}` + `ttl + staleTtl must be at most ${MAX_TTL_SECONDS} seconds (90 days), got ${wireTtl} + ${wire}` ); } return wire; @@ -238,25 +251,28 @@ export class CachekitIOCore implements Backend { encodeKey(key); } - async set(key: string, value: Uint8Array, ttl?: number): Promise { + /** Resolves to the store's `X-CacheKit-Fresh-For` off the `PUT` 2xx, when + * it sent one: the bound a plan maximum TTL applied (see SetResult). */ + async set(key: string, value: Uint8Array, ttl?: number): Promise { this.ensureNotClosed(); const effectiveTtl = validateTtl(ttl ?? this.defaultTtl); - await this.put(key, value, { 'X-CacheKit-TTL': String(effectiveTtl) }); + return this.put(key, value, { 'X-CacheKit-TTL': String(effectiveTtl) }); } /** Backend.setWithStaleTtl capability: the same PUT, also carrying * `X-CacheKit-Stale-TTL`. Validated before any I/O; a `0` window sends no - * header, which the server reads the same way. */ + * header, which the server reads the same way. Resolves like `set`: the + * stale window never lengthens the reported bound. */ async setWithStaleTtl( key: string, value: Uint8Array, ttl: number, staleTtl: number - ): Promise { + ): Promise { this.ensureNotClosed(); const effectiveTtl = validateTtl(ttl); const wireStaleTtl = validateStaleTtl(staleTtl, effectiveTtl); - await this.put(key, value, { + return this.put(key, value, { 'X-CacheKit-TTL': String(effectiveTtl), ...(wireStaleTtl > 0 && { 'X-CacheKit-Stale-TTL': String(wireStaleTtl) }), }); @@ -341,13 +357,16 @@ export class CachekitIOCore implements Backend { // ── Internal ────────────────────────────────────────────── - /** PUT the value with `headers`. Validate before calling: the catch - * below would wrap a ConfigurationError as a BackendError. */ + /** PUT the value with `headers`, and return the 2xx's `X-CacheKit-Fresh-For` + * read with the GET grammar (malformed = `0`), or `{}` when it is absent + * (protocol spec/saas-api.md § Remaining Freshness, API-103). Validate + * before calling: the catch below would wrap a ConfigurationError as a + * BackendError. */ private async put( key: string, value: Uint8Array, headers: Record - ): Promise { + ): Promise { const url = this.cacheUrl(key); try { @@ -359,6 +378,8 @@ export class CachekitIOCore implements Backend { if (!response.ok) { throw await this.httpError('set', response); } + const freshFor = response.headers.get('X-CacheKit-Fresh-For'); + return freshFor === null ? {} : { freshFor: parseFreshFor(freshFor) }; } catch (error) { if (error instanceof BackendError || error instanceof TimeoutError) throw error; throw this.wrapError('set', error); diff --git a/packages/cachekit/src/backends/types.ts b/packages/cachekit/src/backends/types.ts index 79d3801f..f2842446 100644 --- a/packages/cachekit/src/backends/types.ts +++ b/packages/cachekit/src/backends/types.ts @@ -56,12 +56,21 @@ export interface Backend { /** * Store a value with optional TTL. * + * A backend whose store can apply a shorter lifetime than the one it was + * given (CachekitIO: a plan maximum TTL clamps the write) resolves to a + * {@link SetResult} reporting that bound, and CacheImpl caps the L1 copy of + * the value it wrote with it. Every other backend resolves to nothing. + * + * Delegating wrappers MUST return the inner backend's result, or they hide + * the bound from CacheImpl. + * * @param key - Cache key * @param value - Raw bytes to store * @param ttl - Time-to-live in seconds (optional, backend default if omitted) + * @returns Nothing, or the bound the store applied (see {@link SetResult}) * @throws {BackendError} if the operation fails */ - set(key: string, value: Uint8Array, ttl?: number): Promise; + set(key: string, value: Uint8Array, ttl?: number): Promise; /** * Optional capability: `set` plus a stale-grace window, the time the store @@ -71,13 +80,20 @@ export interface Backend { * CacheImpl refuses `WrapOptions.staleTtl` on a backend without both, because * a reader that cannot see the stale label caches stale bytes as fresh. * - * Delegating wrappers MUST forward the inner backend's implementation. + * Delegating wrappers MUST forward the inner backend's implementation and + * return its result, as for {@link set}. * * @param ttl - Time-to-live in seconds (required: the window counts from it) * @param staleTtl - Stale-grace window in seconds, >= 0; `0` sends no window + * @returns Nothing, or the bound the store applied (see {@link SetResult}) * @throws {BackendError} if the operation fails */ - setWithStaleTtl?(key: string, value: Uint8Array, ttl: number, staleTtl: number): Promise; + setWithStaleTtl?( + key: string, + value: Uint8Array, + ttl: number, + staleTtl: number + ): Promise; /** * Optional capability: synchronously reject a TTL this backend cannot @@ -89,7 +105,7 @@ export interface Backend { * failures. Same rationale as the interop-rejection path in CacheImpl. * * Implement only where the backend has hard TTL bounds (CachekitIO's - * protocol rules: reject 0/negative/> 30 days). Backends that map + * protocol rules: reject 0/negative/> 90 days). Backends that map * out-of-range TTLs to a defined behavior (e.g. `ttl <= 0` = no expiry * on Redis/File/Workers KV) omit it. * @@ -235,6 +251,24 @@ export interface GetWithTtlResult { freshFor?: number; } +/** + * Result of a {@link Backend.set} (or `setWithStaleTtl`) whose store reported + * the bound it applied to the write. + */ +export interface SetResult { + /** + * The stored entry's remaining freshness in whole seconds, as the store + * reported it on the write's success response (CachekitIO + * `X-CacheKit-Fresh-For` on the `PUT` 2xx, read with the same grammar as + * on a `GET`). CacheImpl stops serving its L1 copy of the value once this + * many seconds have passed since it received the response, whatever the + * TTL; `0` (a malformed header included) means L1 keeps no copy (protocol + * spec/saas-api.md § Remaining Freshness, API-103). Omitted = the store + * applied the TTL it was given, and the L1 copy keeps its usual lifetime. + */ + freshFor?: number; +} + /** * Configuration for Redis backend. */ @@ -360,9 +394,12 @@ export interface CachekitIOBackendConfig { /** * Default TTL in seconds for set operations without explicit TTL. * Protocol bounds apply (saas-api.md TTL Validation Rules): must be - * greater than 0 and at most 2,592,000 (30 days) — out-of-range values + * greater than 0 and at most 7,776,000 (90 days) — out-of-range values * throw ConfigurationError at construction. Unlike File/Memcached, - * `0` does NOT mean "never expire" here; the protocol rejects it. + * `0` does NOT mean "never expire" here; the protocol rejects it. The + * server clamps a TTL above the tenant's plan maximum TTL (Free 24 hours, + * Startup 30 days, Professional 90 days) rather than rejecting it, and + * the write's L1 copy follows the clamped lifetime. */ defaultTtl?: number; /** Per-attempt request timeout in milliseconds (default: 5000, the protocol's CACHEKIT_TIMEOUT default) */ diff --git a/packages/cachekit/src/cache-core.ts b/packages/cachekit/src/cache-core.ts index 5c6d72ca..28ef6004 100644 --- a/packages/cachekit/src/cache-core.ts +++ b/packages/cachekit/src/cache-core.ts @@ -8,7 +8,7 @@ import type { EncryptionConfig, InvalidationConfig, } from './types/cache.js'; -import type { Backend, L1Metrics, LockableBackend } from './backends/types.js'; +import type { Backend, L1Metrics, LockableBackend, SetResult } from './backends/types.js'; import type { L1LatencyReport } from './backends/telemetry.js'; import type { InvalidationEvent } from './l1/types.js'; import type { MetricsCollector, MetricsConfig } from './metrics/prometheus.js'; @@ -18,6 +18,7 @@ import { L1Cache, type L1Entry } from './l1/lru-cache.js'; import { ReliabilityExecutor } from './reliability/executor.js'; import { BackgroundRefreshManager, + boundedL1TtlMs, type WaitUntil, type L1Write, } from './cache/background-refresh.js'; @@ -168,6 +169,22 @@ const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout const WASM_RUNTIME_ERROR = (globalThis as { WebAssembly?: { RuntimeError?: ErrorConstructor } }) .WebAssembly?.RuntimeError; +/** + * When (Date.now() ms) the store's bound on a write ends, read off what + * Backend.set resolved to at receipt of its success response: `freshFor` + * seconds from now, or undefined when the backend reported none. A value + * that is not a non-negative number (a custom backend's bug) fails closed, + * as `0`: NaN would otherwise slip past every bound check and keep the L1 + * copy forever. + */ +function freshUntil(result: SetResult | void): number | undefined { + if (!result) return undefined; + const { freshFor } = result; + if (freshFor === undefined) return undefined; + const now = Date.now(); + return typeof freshFor === 'number' && freshFor >= 0 ? now + freshFor * 1000 : now; +} + /** * A failure inside unpack that is not a verdict on the bytes: a wasm trap * (an allocation abort on a Workers isolate surfaces as @@ -1327,7 +1344,7 @@ export class CacheImpl implements SecureCache { throw new ConfigurationError(`Invalid TTL: ${ttl}. Must be a non-negative finite number.`); } - // A backend with hard TTL bounds (CachekitIO: reject 0 / > 30 days per + // A backend with hard TTL bounds (CachekitIO: reject 0 / > 90 days per // protocol) or a key it cannot address (CachekitIO's reserved path // segments) rejects here, synchronously — for the same reason as the // interop rejection below: inside `run`, degradation would swallow the @@ -1412,11 +1429,15 @@ export class CacheImpl implements SecureCache { }; // The L2 write, under retry, the breaker and degradation; the stale - // window rides it on a stale-grace wrap. - const writeL2 = (): Promise => - staleTtl > 0 - ? this.backend.setWithStaleTtl!(key, data, ttl, staleTtl) - : this.backend.set(key, data, ttl); + // window rides it on a stale-grace wrap. It resolves to when the store's + // bound on the write ends (L1Write.freshUntil), counted from receipt of + // the success response, or undefined when the store reported none. + const writeL2 = async (): Promise => + freshUntil( + staleTtl > 0 + ? await this.backend.setWithStaleTtl!(key, data, ttl, staleTtl) + : await this.backend.set(key, data, ttl) + ); // A stale-grace revalidation never fills L1 (its l1Mode is skip), and may // be dropped: the stale entry still serves. @@ -1435,13 +1456,25 @@ export class CacheImpl implements SecureCache { // keeps the value, as it keeps a degraded write's below. A write sent // back to the caller fills L1 after its L2 write, as with the option off. // A skipL1 call fills L1 in neither case, so until its write lands this - // process misses the key. + // process misses the key. When the write lands with a bound from the + // store, the L1 copy is capped at it then (API-103), if L1 still holds + // that copy; L1 is filled before the write can settle, so `held` is set. const fillsL1 = l1Mode === 'fill' && this.l1 !== null; + let held: L1Entry | undefined; + const writeBehind = async (): Promise => { + const until = await writeL2(); + if (held === undefined || until === undefined || !this.l1) return; + this.l1.capExpiryAt(held, until); + this.publishL1Stats(); + }; const wroteBehind = deferred?.kind === 'write-behind' && - this.tryWriteBehind(key, data.length, writeL2, deferred.waitUntil, fillsL1); + this.tryWriteBehind(key, data.length, writeBehind, deferred.waitUntil, fillsL1); if (wroteBehind) { - if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace); + if (l1Mode === 'fill') { + this.fillL1(key, l1Write, ttl, namespace); + held = this.l1?.entry(key); + } return l1Write; } @@ -1470,29 +1503,47 @@ export class CacheImpl implements SecureCache { // L1 but is overwritten in L2 by the refresh's value until the entry next // expires or refreshes (a conditional L2 write would need CAS the Backend // contract doesn't have). + // A write that got a success response carrying the store's bound caps + // the L1 copy at it (API-103), here and on the refresh path through the + // returned L1Write. A write degradation absorbed got no response: its copy + // keeps the TTL, counted from the L1 write (API-36). + let until: number | undefined; if (this.keyTails.has(key)) { await this.inKeyOrder(key, async () => { - await this.run('set', key, undefined, writeL2); - if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace); + until = await this.run('set', key, undefined, writeL2); + if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace, until); }); - return l1Write; - } - // An idle key: what inKeyOrder does for it, inline. Every plain set() - // takes this path, and inKeyOrder's closure and promise chain were most - // of what per-key ordering added to it, so the two lines above repeat. - this.enterInFlight(key); - try { - await this.run('set', key, undefined, writeL2); - if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace); - } finally { - this.leaveInFlight(key); + } else { + // An idle key: what inKeyOrder does for it, inline. Every plain set() + // takes this path, and inKeyOrder's closure and promise chain were most + // of what per-key ordering added to it, so the two lines above repeat. + this.enterInFlight(key); + try { + until = await this.run('set', key, undefined, writeL2); + if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace, until); + } finally { + this.leaveInFlight(key); + } } - return l1Write; + return until === undefined ? l1Write : { ...l1Write, freshUntil: until }; } - private fillL1(key: string, l1Write: L1Write, ttl: number, namespace: string): void { + /** + * Put the value a write produced in L1 for `ttl` seconds, capped at the + * store's bound `until` (L1Write.freshUntil). Past the bound L1 keeps no + * copy, and drops the key's older one, which the write superseded. + */ + private fillL1( + key: string, + l1Write: L1Write, + ttl: number, + namespace: string, + until?: number + ): void { if (!this.l1) return; - this.l1.set(key, l1Write.l1, ttl * 1000, namespace, l1Write.serializedSize, l1Write.count); + const ttlMs = boundedL1TtlMs(ttl, until); + if (ttlMs === null) this.l1.delete(key); + else this.l1.set(key, l1Write.l1, ttlMs, namespace, l1Write.serializedSize, l1Write.count); this.publishL1Stats(); } @@ -1518,7 +1569,7 @@ export class CacheImpl implements SecureCache { private tryWriteBehind( key: string, bytes: number, - writeL2: () => Promise, + writeL2: () => Promise, waitUntil: WaitUntil | undefined, heldInL1: boolean ): boolean { @@ -1647,7 +1698,7 @@ export class CacheImpl implements SecureCache { private queueRevalidationWrite( key: string, bytes: number, - writeL2: () => Promise + writeL2: () => Promise ): Promise | null { if ( this.closed || @@ -1657,7 +1708,9 @@ export class CacheImpl implements SecureCache { this.revalidationDropped(key); return null; } - const send = (): Promise => this.run('set', key, undefined, writeL2); + const send = async (): Promise => { + await this.run('set', key, undefined, writeL2); + }; // On Workers no tail exists here (checked above), so inKeyOrder counts it in flight. if (this.swrRequiresWaitUntil) return this.inKeyOrder(key, send); return this.startPendingWrite(key, bytes, () => { diff --git a/packages/cachekit/src/cache.stale-grace.test.ts b/packages/cachekit/src/cache.stale-grace.test.ts index 152a9bf4..9c1b6260 100644 --- a/packages/cachekit/src/cache.stale-grace.test.ts +++ b/packages/cachekit/src/cache.stale-grace.test.ts @@ -462,18 +462,19 @@ describe('server stale-grace (WrapOptions.staleTtl)', () => { ['negative', -1], ['NaN', NaN], ['Infinity', Infinity], - ['over 2,591,999', 2_591_999.5], + ['over 7,775,999', 7_775_999.5], + ['7,776,000, one over the standalone bound', 7_776_000], ['a string', '60' as unknown as number], - ['over the 30-day cap with the TTL', 2_591_991], + ['over the 90-day TTL range with the TTL', 7_775_991], ])('rejects a staleTtl that is %s', (_name, staleTtl) => { const cache = process(new StaleGraceBackend()); expect(() => cache.wrap(async () => 'v', { ...WRAP, staleTtl })).toThrow(ConfigurationError); }); - it('accepts the largest window the 30-day cap leaves', async () => { + it('accepts the largest window the 90-day TTL range leaves', async () => { const backend = new StaleGraceBackend(); - await process(backend).wrap(async () => 'v', { ...WRAP, staleTtl: 2_591_990 })(); - expect(backend.writes).toEqual([{ ttl: 10, staleTtl: 2_591_990 }]); + await process(backend).wrap(async () => 'v', { ...WRAP, staleTtl: 7_775_990 })(); + expect(backend.writes).toEqual([{ ttl: 10, staleTtl: 7_775_990 }]); }); it('ceils a fractional window (API-54) and treats 0 as off', async () => { @@ -616,8 +617,10 @@ describe('CachekitIO stale window on the wire', () => { for (const [ttl, staleTtl] of [ [10, -1], [10, NaN], - [10, 2_591_999.5], - [10, 2_591_991], + [10, 7_775_999.5], + [1, 7_776_000], + [10, 7_775_991], + [7_776_000, 1], [0, 60], ]) { await expect(backend.setWithStaleTtl!('k', value, ttl!, staleTtl!)).rejects.toThrow( diff --git a/packages/cachekit/src/cache/background-refresh.test.ts b/packages/cachekit/src/cache/background-refresh.test.ts index 13587b6b..2a1fc33e 100644 --- a/packages/cachekit/src/cache/background-refresh.test.ts +++ b/packages/cachekit/src/cache/background-refresh.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; -import { BackgroundRefreshManager, type PersistCallback } from './background-refresh.js'; +import { + BackgroundRefreshManager, + boundedL1TtlMs, + type PersistCallback, +} from './background-refresh.js'; import { L1Cache } from '../l1/lru-cache.js'; // Pin the SWR jitter draw so tests are deterministic. 0.5 lands the @@ -329,3 +333,23 @@ describe('BackgroundRefreshManager', () => { }); }); }); + +describe('boundedL1TtlMs', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps the TTL without a bound, and 0 (no expiry) for ttl <= 0', () => { + expect(boundedL1TtlMs(60)).toBe(60_000); + expect(boundedL1TtlMs(0)).toBe(0); + expect(boundedL1TtlMs(-1)).toBe(0); + }); + + it('takes the shorter of TTL and bound, the bound alone for no expiry, null once past', () => { + vi.useFakeTimers({ now: 1_000 }); + expect(boundedL1TtlMs(60, 1_000 + 5_000)).toBe(5_000); + expect(boundedL1TtlMs(2, 1_000 + 5_000)).toBe(2_000); + expect(boundedL1TtlMs(0, 1_000 + 5_000)).toBe(5_000); + expect(boundedL1TtlMs(60, 1_000)).toBeNull(); + }); +}); diff --git a/packages/cachekit/src/cache/background-refresh.ts b/packages/cachekit/src/cache/background-refresh.ts index 30acd54c..210ec140 100644 --- a/packages/cachekit/src/cache/background-refresh.ts +++ b/packages/cachekit/src/cache/background-refresh.ts @@ -33,6 +33,29 @@ export interface L1Write { readonly serializedSize?: number; /** Heap objects and values in the value, counted as it was serialized (see L1Cache.set). */ readonly count?: ObjectCount; + /** + * When (Date.now() ms) the store's bound on the write ends: the receipt of + * its success response plus the `freshFor` it reported (Backend.set's + * SetResult). Unset when the store reported none, or the write never got a + * success response. See boundedL1TtlMs. + */ + readonly freshUntil?: number; +} + +/** + * The L1 lifetime, in ms, for the copy a cache keeps of a value it wrote with + * `ttlSeconds`, the store having bounded it until `freshUntil` (see + * L1Write.freshUntil): the shorter of the two, counted from now. `0` is L1's + * no-expiry encoding (a `ttlSeconds` of 0 or less, unbounded). Null when the + * bound has passed, so L1 must keep no copy (protocol spec/saas-api.md + * § Remaining Freshness, API-103). + */ +export function boundedL1TtlMs(ttlSeconds: number, freshUntil?: number): number | null { + const ttlMs = ttlSeconds > 0 ? ttlSeconds * 1000 : 0; + if (freshUntil === undefined) return ttlMs; + const remaining = freshUntil - Date.now(); + if (remaining <= 0) return null; + return ttlMs > 0 ? Math.min(ttlMs, remaining) : remaining; } /** @@ -121,15 +144,20 @@ export class BackgroundRefreshManager { // per minute. l1Cache.deferRefresh(key, versionToken); } else { - l1Cache.completeRefresh( - key, - persisted.l1, - options.ttl * 1000, - versionToken, - options.namespace, - persisted.serializedSize, - persisted.count - ); + // The store's bound on the write caps the copy; past it, the + // refreshed value is not kept and the stale entry goes too. + const ttlMs = boundedL1TtlMs(options.ttl, persisted.freshUntil); + if (ttlMs === null) l1Cache.discardRefresh(key, versionToken); + else + l1Cache.completeRefresh( + key, + persisted.l1, + ttlMs, + versionToken, + options.namespace, + persisted.serializedSize, + persisted.count + ); } } } catch (error) { diff --git a/packages/cachekit/src/exports-common.ts b/packages/cachekit/src/exports-common.ts index 35b141f5..cb01717e 100644 --- a/packages/cachekit/src/exports-common.ts +++ b/packages/cachekit/src/exports-common.ts @@ -23,6 +23,7 @@ export type { export type { Backend, GetWithTtlResult, + SetResult, CachekitIOBackendConfig, LockableBackend, TTLBackend, diff --git a/packages/cachekit/src/l1/lru-cache.test.ts b/packages/cachekit/src/l1/lru-cache.test.ts index d60d20a3..a7bac233 100644 --- a/packages/cachekit/src/l1/lru-cache.test.ts +++ b/packages/cachekit/src/l1/lru-cache.test.ts @@ -1338,3 +1338,58 @@ describe('L1Cache', () => { }); }); }); + +describe('L1Cache capExpiryAt and discardRefresh', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('bound caps the entry and shortens its SWR lifetime, never extends it', () => { + vi.useFakeTimers({ now: 0 }); + const c = new L1Cache(); + c.set('a', 'v', 10_000, 'ns'); + const entry = c.entry('a')!; + + c.capExpiryAt(entry, 20_000); // later than expiry: no change + vi.advanceTimersByTime(9_999); + expect(c.get('a')).toBe('v'); + + c.set('b', 'v', 10_000, 'ns'); + c.capExpiryAt(c.entry('b')!, Date.now() + 2_000); + vi.advanceTimersByTime(1_999); + expect(c.get('b')).toBe('v'); + vi.advanceTimersByTime(1); + expect(c.get('b')).toBeNull(); + }); + + it('bound past its deadline drops the entry; a superseded entry is left alone', () => { + vi.useFakeTimers({ now: 0 }); + const c = new L1Cache(); + c.set('a', 'old', 10_000, 'ns'); + const old = c.entry('a')!; + c.set('a', 'new', 10_000, 'ns'); + + c.capExpiryAt(old, 0); // not the entry L1 holds now + expect(c.get('a')).toBe('new'); + + c.capExpiryAt(c.entry('a')!, 0); + expect(c.get('a')).toBeNull(); + }); + + it('discardRefresh drops the entry only while its version matches', () => { + vi.useFakeTimers({ now: 0 }); + const c = new L1Cache({ swrEnabled: true, swrThresholdRatio: 2 }); + c.set('a', 'v', 10_000, 'ns'); + const { versionToken } = c.getWithSwr('a'); + + c.set('a', 'newer', 10_000, 'ns'); + c.discardRefresh('a', versionToken); + expect(c.get('a')).toBe('newer'); + + c.discardRefresh('a', c.getWithSwr('a').versionToken); + expect(c.get('a')).toBeNull(); + + c.discardRefresh('missing', 0); // no version: reads as 0, nothing to drop + expect(c.get('missing')).toBeNull(); + }); +}); diff --git a/packages/cachekit/src/l1/lru-cache.ts b/packages/cachekit/src/l1/lru-cache.ts index 1e5871ed..085f74de 100644 --- a/packages/cachekit/src/l1/lru-cache.ts +++ b/packages/cachekit/src/l1/lru-cache.ts @@ -294,6 +294,41 @@ export class L1Cache { return true; } + /** + * Cap an entry `entry` returned at `until` (a Date.now() time): it stops + * being served then, and is never extended. If `until` has passed, the + * entry is deleted now. + * The SWR threshold follows the shorter lifetime, so a capped entry is not + * read as stale for the whole of it. Nothing changes unless L1 still holds + * this very entry: a later write or invalidation of the key is untouched. + * Package-internal. + * @internal + */ + capExpiryAt(entry: L1Entry, until: number): void { + const node = this.cache.get(entry.key); + if (node === undefined || node !== entry) return; + const remaining = until - Date.now(); + if (remaining <= 0) { + this.delete(node.key); + return; + } + if (until >= node.expiresAt) return; + node.expiresAt = until; + node.originalTtl = remaining; + } + + /** + * End a SWR refresh whose value L1 may not keep (its store bounded it to + * nothing), dropping the stale entry it would have replaced if the version + * still matches. A newer write or invalidation of the key is kept. + * Package-internal. + * @internal + */ + discardRefresh(key: string, versionToken: number): void { + this.refreshingKeys.delete(key); + if ((this.entryVersion.get(key) ?? 0) === versionToken) this.delete(key); + } + /** * Complete a SWR refresh, updating the cache if version matches (a value * charged above an eighth of maxMemory drops the entry instead; see `set`). diff --git a/packages/cachekit/src/types/cache.ts b/packages/cachekit/src/types/cache.ts index d17233bb..782c3555 100644 --- a/packages/cachekit/src/types/cache.ts +++ b/packages/cachekit/src/types/cache.ts @@ -96,8 +96,10 @@ export interface WrapOptionsBase { * * Checked at wrap time (ConfigurationError): requires a backend that reads * and writes the stale window (CachekitIO); refused on encrypted caches; at - * most 2,591,999 seconds, and `ttl + staleTtl` at most 2,592,000 (30 days); - * fractions round up; `0` turns it off. + * most 7,775,999 seconds, and `ttl + staleTtl` at most 7,776,000 (90 days, + * the protocol's TTL range); fractions round up; `0` turns it off. The + * server shortens a window the tenant's plan maximum TTL leaves no room + * for, and never rejects it for that. * * On Workers the revalidation needs a bound `waitUntil` * (`withExecutionContext`); without one a stale read is a miss, recomputed