diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index 113dab3c..57011c6b 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -599,10 +599,13 @@ carries the instant the response arrived (`SetResult.receivedAt`), so work the wrapper does afterwards shortens the L1 copy instead of restarting it. The SDK learns that instant only when the wrapper resolves, so a write-behind copy, which is in L1 before the request is sent, keeps serving -until then. A wrapper should resolve as soon as the inner `set` does. One -that resolves to nothing drops `X-CacheKit-Fresh-For`, and the L1 copy keeps -the full `ttl` from its L1 write. A write whose error degradation absorbed (a -timeout, say) got no response, and keeps its L1 copy for `ttl`. +until then, past that bound and even when the response's +`X-CacheKit-Fresh-For` was `0`, though never past `ttl` from its L1 fill (the +protocol's receipt-delay excess, API-103). A wrapper should resolve as soon +as the inner `set` does. One that resolves to nothing drops +`X-CacheKit-Fresh-For`, and the L1 copy keeps the full `ttl` from its L1 +write. 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 CacheKit Cloud entry without an expiry. diff --git a/packages/cachekit/src/backends/cachekitio-freshness.test.ts b/packages/cachekit/src/backends/cachekitio-freshness.test.ts index c2f6140f..cab64f6b 100644 --- a/packages/cachekit/src/backends/cachekitio-freshness.test.ts +++ b/packages/cachekit/src/backends/cachekitio-freshness.test.ts @@ -639,6 +639,92 @@ describe('CachekitIO writer L1 bound', () => { }); }); + // A write-behind copy is in L1 before its PUT is sent, and CacheImpl caps it + // only when the backend's set resolves. A wrapper still suspended after the + // PUT 2xx withholds receipt, so the copy serves past receipt plus Fresh-For + // until the wrapper resolves, but never past its own TTL from the L1 fill: + // the receipt-delay excess (protocol spec/saas-api.md § Remaining + // Freshness, API-103). Reads go through get(), which never waits on the + // pending write. A Fresh-For: 0 copy's drop once the wrapper resolves is + // the write-behind Fresh-For: 0 test above. + describe('behind a wrapper still suspended after the PUT 2xx', () => { + /** Date.now() as the inner set resolved: receipt of the PUT 2xx. */ + let receivedAt: number | undefined; + let release: () => void; + + const suspending = (inner: Backend): Backend => ({ + get: (key) => inner.get(key), + getWithTtl: (key) => inner.getWithTtl!(key), + async set(key, value, ttl) { + const result = await inner.set(key, value, ttl); + receivedAt = Date.now(); + await new Promise((resolve) => (release = resolve)); + return result; + }, + delete: (key) => inner.delete(key), + exists: (key) => inner.exists(key), + close: () => inner.close(), + }); + + beforeEach(() => { + receivedAt = undefined; + }); + + /** A write-behind miss that fills L1, its wrapper suspended past receipt. */ + async function suspendedWrite(ttl: number) { + const cache = createCache({ backend: suspending(cachekitio(config)), defaultTtl: 3600 }); + const wrapped = cache.wrap(async () => 'computed', { + namespace: 'wb', + ttl, + writeBehind: true, + }); + expect(await wrapped()).toBe('computed'); // cold miss: one GET, L1 filled + const filled = Date.now(); + await vi.waitFor(() => expect(receivedAt).toBeDefined()); + return { cache, key: generateKey('wb', []), filled }; + } + + /** Resolves the wrapper and lets the write's cap run. */ + async function settle() { + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + it('serves the copy past receipt plus Fresh-For: 1 until the wrapper resolves', async () => { + putHeaders = { 'X-CacheKit-Fresh-For': '1' }; + const { cache, key } = await suspendedWrite(3600); + + vi.advanceTimersByTime(1_500); + expect(await cache.get(key)).toBe('computed'); + expect(gets).toBe(1); // past receipt plus 1 s, served from L1 + + await settle(); + expect(await cache.get(key)).toBe('computed'); + expect(gets).toBe(2); // capped at receipt plus 1 s as the wrapper resolves + + await cache.close(); + }); + + // The header never reaches CacheImpl while the wrapper is suspended, so + // this pins the ceiling: the TTL from the L1 fill. '0' is the worst case + // the README names, a PUT that API-103 says keeps no copy. + it('never serves a suspended copy past its TTL from the L1 fill', async () => { + putHeaders = { 'X-CacheKit-Fresh-For': '0' }; + const { cache, key, filled } = await suspendedWrite(2); + + vi.advanceTimersByTime(filled + 1_999 - Date.now()); + expect(await cache.get(key)).toBe('computed'); + expect(gets).toBe(1); // API-103 keeps no copy; L1 still serves it + + vi.advanceTimersByTime(1); + expect(await cache.get(key)).toBe('computed'); + expect(gets).toBe(2); // expired at fill plus ttl, wrapper still suspended + + await settle(); + 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 }); diff --git a/packages/cachekit/src/backends/types.ts b/packages/cachekit/src/backends/types.ts index 8d73521e..d3594471 100644 --- a/packages/cachekit/src/backends/types.ts +++ b/packages/cachekit/src/backends/types.ts @@ -264,12 +264,13 @@ export interface GetWithTtlResult { * response, and any work the wrapper does after it shortens the L1 copy. * CacheImpl learns the bound only when the wrapper resolves, though: a * write-behind copy, filled in L1 before the request is sent, keeps serving - * until then, even past the bound, so a wrapper should resolve as soon as the - * inner set does and do any further work after that. A wrapper that rebuilds - * the result from `freshFor` alone restarts the bound when set resolves. One - * that resolves to nothing hides the bound: the copy keeps the full TTL from - * its L1 write, and a `freshFor` is lost (protocol spec/saas-api.md - * § Remaining Freshness, API-103). + * until then, even past the bound or when `freshFor` was `0`, though never + * past the TTL from its L1 fill (the receipt-delay excess). So a wrapper + * should resolve as soon as the inner set does and do any further work after + * that. A wrapper that rebuilds the result from `freshFor` alone restarts the + * bound when set resolves. One that resolves to nothing hides the bound: the + * copy keeps the full TTL from its L1 write, and a `freshFor` is lost + * (protocol spec/saas-api.md § Remaining Freshness, API-103). */ export interface SetResult { /**