From b1377f864bd2b002729ac497d3284780c6e495c2 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 2 Nov 2026 23:34:19 +0000 Subject: [PATCH 1/3] test(cachekit): pin the write-behind copy's receipt-delay excess behind a suspended wrapper A write-behind copy is in L1 before its PUT is sent and is capped only when the backend's set resolves, so a wrapper still suspended after the 2xx keeps it serving past receipt plus Fresh-For, never past its TTL from the L1 fill. The protocol now names this the receipt-delay excess; the README and the SetResult doc say so. --- packages/cachekit/README.md | 4 +- .../src/backends/cachekitio-freshness.test.ts | 93 +++++++++++++++++++ packages/cachekit/src/backends/types.ts | 8 +- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index fc4c806f..349be986 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -599,7 +599,9 @@ 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. +until then, past that bound and even after a `0`, though never past `ttl` +from its L1 fill (the protocol's receipt-delay excess, API-108). A wrapper +should resolve as soon as the inner `set` does. 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. diff --git a/packages/cachekit/src/backends/cachekitio-freshness.test.ts b/packages/cachekit/src/backends/cachekitio-freshness.test.ts index c2f6140f..eac5e962 100644 --- a/packages/cachekit/src/backends/cachekitio-freshness.test.ts +++ b/packages/cachekit/src/backends/cachekitio-freshness.test.ts @@ -639,6 +639,99 @@ 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-108). Reads go through get(), which never waits on the + // pending write. + 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 + await vi.waitFor(() => expect(receivedAt).toBeDefined()); + return { cache, key: generateKey('wb', []), received: receivedAt! }; + } + + /** 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, received } = await suspendedWrite(3600); + + vi.advanceTimersByTime(1_500); + expect(Date.now() - received).toBeGreaterThan(1_000); // past API-103's bound + expect(await cache.get(key)).toBe('computed'); + expect(gets).toBe(1); // 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(); + }); + + it('serves a copy whose PUT said Fresh-For: 0 until the wrapper resolves', async () => { + putHeaders = { 'X-CacheKit-Fresh-For': '0' }; + const { cache, key } = await suspendedWrite(3600); + + vi.advanceTimersByTime(60_000); + expect(await cache.get(key)).toBe('computed'); + expect(gets).toBe(1); // API-103 keeps no copy; L1 still serves it + + await settle(); + expect(await cache.get(key)).toBe('computed'); + expect(gets).toBe(2); + + await cache.close(); + }); + + it('never serves the copy past its TTL from the L1 fill', async () => { + putHeaders = { 'X-CacheKit-Fresh-For': '0' }; + const { cache, key } = await suspendedWrite(2); + + vi.advanceTimersByTime(2_000); + 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 df33b2d5..44724b2a 100644 --- a/packages/cachekit/src/backends/types.ts +++ b/packages/cachekit/src/backends/types.ts @@ -264,9 +264,11 @@ 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 + * until then, even past the bound or after a `freshFor` of `0`, though never + * past the TTL from its L1 fill (the receipt-delay excess, protocol + * spec/saas-api.md API-108). 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). */ From d9f32474afae44d08c28cfb980f8e4554559d4bd Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 2 Nov 2026 23:43:44 +0000 Subject: [PATCH 2/3] test(cachekit): fold the Fresh-For 0 case into the TTL ceiling test; cite API-103 Review follow-up: the ceiling test now shows a hit just before fill plus ttl and a miss at it, with Fresh-For 0, so it fails if L1 was never filled. The protocol states the receipt-delay excess under API-103, not a new id. --- packages/cachekit/README.md | 5 ++-- .../src/backends/cachekitio-freshness.test.ts | 27 +++++++------------ packages/cachekit/src/backends/types.ts | 4 +-- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index 349be986..95be635a 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -599,8 +599,9 @@ 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, past that bound and even after a `0`, though never past `ttl` -from its L1 fill (the protocol's receipt-delay excess, API-108). A wrapper +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. A write whose error degradation absorbed (a timeout, say) got no response, and keeps its L1 copy for `ttl`. diff --git a/packages/cachekit/src/backends/cachekitio-freshness.test.ts b/packages/cachekit/src/backends/cachekitio-freshness.test.ts index eac5e962..3a28e60e 100644 --- a/packages/cachekit/src/backends/cachekitio-freshness.test.ts +++ b/packages/cachekit/src/backends/cachekitio-freshness.test.ts @@ -644,8 +644,9 @@ describe('CachekitIO writer L1 bound', () => { // 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-108). Reads go through get(), which never waits on the - // pending write. + // 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; @@ -678,8 +679,9 @@ describe('CachekitIO writer L1 bound', () => { 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', []), received: receivedAt! }; + return { cache, key: generateKey('wb', []), filled, received: receivedAt! }; } /** Resolves the wrapper and lets the write's cap run. */ @@ -704,26 +706,15 @@ describe('CachekitIO writer L1 bound', () => { await cache.close(); }); - it('serves a copy whose PUT said Fresh-For: 0 until the wrapper resolves', async () => { + it('serves a Fresh-For: 0 copy until its TTL from the L1 fill, never past it', async () => { putHeaders = { 'X-CacheKit-Fresh-For': '0' }; - const { cache, key } = await suspendedWrite(3600); + const { cache, key, filled } = await suspendedWrite(2); - vi.advanceTimersByTime(60_000); + 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 - await settle(); - expect(await cache.get(key)).toBe('computed'); - expect(gets).toBe(2); - - await cache.close(); - }); - - it('never serves the copy past its TTL from the L1 fill', async () => { - putHeaders = { 'X-CacheKit-Fresh-For': '0' }; - const { cache, key } = await suspendedWrite(2); - - vi.advanceTimersByTime(2_000); + vi.advanceTimersByTime(1); expect(await cache.get(key)).toBe('computed'); expect(gets).toBe(2); // expired at fill plus ttl, wrapper still suspended diff --git a/packages/cachekit/src/backends/types.ts b/packages/cachekit/src/backends/types.ts index 44724b2a..300ac52c 100644 --- a/packages/cachekit/src/backends/types.ts +++ b/packages/cachekit/src/backends/types.ts @@ -264,9 +264,9 @@ 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 or after a `freshFor` of `0`, though never + * until then, even past the bound or when `freshFor` was `0`, though never * past the TTL from its L1 fill (the receipt-delay excess, protocol - * spec/saas-api.md API-108). So a wrapper should resolve as soon as the inner + * spec/saas-api.md API-103). 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 From 0b255b16f8d8e0887cb825249d6545a7c2f3619e Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Mon, 2 Nov 2026 23:58:10 +0000 Subject: [PATCH 3/3] test(cachekit): name the suspended-copy ceiling test for what it pins Review follow-up: the Fresh-For 0 test pins the TTL-from-fill ceiling, since the header never reaches CacheImpl while the wrapper is suspended; drop an assertion that restated the clock advance; cite API-103 once in SetResult. --- .../src/backends/cachekitio-freshness.test.ts | 12 +++++++----- packages/cachekit/src/backends/types.ts | 12 ++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/cachekit/src/backends/cachekitio-freshness.test.ts b/packages/cachekit/src/backends/cachekitio-freshness.test.ts index 3a28e60e..cab64f6b 100644 --- a/packages/cachekit/src/backends/cachekitio-freshness.test.ts +++ b/packages/cachekit/src/backends/cachekitio-freshness.test.ts @@ -681,7 +681,7 @@ describe('CachekitIO writer L1 bound', () => { 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, received: receivedAt! }; + return { cache, key: generateKey('wb', []), filled }; } /** Resolves the wrapper and lets the write's cap run. */ @@ -692,12 +692,11 @@ describe('CachekitIO writer L1 bound', () => { it('serves the copy past receipt plus Fresh-For: 1 until the wrapper resolves', async () => { putHeaders = { 'X-CacheKit-Fresh-For': '1' }; - const { cache, key, received } = await suspendedWrite(3600); + const { cache, key } = await suspendedWrite(3600); vi.advanceTimersByTime(1_500); - expect(Date.now() - received).toBeGreaterThan(1_000); // past API-103's bound expect(await cache.get(key)).toBe('computed'); - expect(gets).toBe(1); // served from L1 + expect(gets).toBe(1); // past receipt plus 1 s, served from L1 await settle(); expect(await cache.get(key)).toBe('computed'); @@ -706,7 +705,10 @@ describe('CachekitIO writer L1 bound', () => { await cache.close(); }); - it('serves a Fresh-For: 0 copy until its TTL from the L1 fill, never past it', async () => { + // 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); diff --git a/packages/cachekit/src/backends/types.ts b/packages/cachekit/src/backends/types.ts index 300ac52c..aeff9d66 100644 --- a/packages/cachekit/src/backends/types.ts +++ b/packages/cachekit/src/backends/types.ts @@ -265,12 +265,12 @@ export interface GetWithTtlResult { * 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 or when `freshFor` was `0`, though never - * past the TTL from its L1 fill (the receipt-delay excess, protocol - * spec/saas-api.md API-103). 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). + * 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 { /**