Skip to content
Merged
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
11 changes: 7 additions & 4 deletions packages/cachekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions packages/cachekit/src/backends/cachekitio-freshness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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 });
Expand Down
13 changes: 7 additions & 6 deletions packages/cachekit/src/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
Loading