Skip to content

Commit

Permalink
feat(waitUntil): support background tasks as Promise (#103)
Browse files Browse the repository at this point in the history
  • Loading branch information
richardscarrott authored May 8, 2024
1 parent 0a66e49 commit 3edb5dd
Show file tree
Hide file tree
Showing 4 changed files with 79 additions and 38 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,15 @@ interface CachifiedOptions<Value> {
* Default: `0`
*/
staleRefreshTimeout?: number;
/**
* Promises passed to `waitUntil` represent background tasks which must be
* completed before the server can shutdown. e.g. swr cache revalidation
*
* Useful for serverless environments such as Cloudflare Workers.
*
* Default: `undefined`
*/
waitUntil?: (promise: Promise<unknown>) => void;
}
```

Expand Down
18 changes: 15 additions & 3 deletions src/cachified.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ describe('cachified', () => {
const reporter = createReporter();

cache.set('weather', createCacheEntry('☁️'));
const waitUntil = jest.fn();
const value = await cachified(
{
cache,
Expand All @@ -405,12 +406,17 @@ describe('cachified', () => {
getFreshValue() {
throw new Error('Never');
},
waitUntil,
},
reporter,
);

expect(value).toBe('☀️');
await delay(1);
expect(cache.get('weather')?.value).toBe('☁️');
expect(waitUntil).toHaveBeenCalledTimes(1);
expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise));
// Wait for promise (migration is done in background)
await waitUntil.mock.calls[0][0];
expect(cache.get('weather')?.value).toBe('☀️');
expect(report(reporter.mock.calls)).toMatchInlineSnapshot(`
"1. init
Expand Down Expand Up @@ -1016,6 +1022,7 @@ describe('cachified', () => {
const reporter = createReporter();
let i = 0;
const getFreshValue = jest.fn(() => `value-${i++}`);
const waitUntil = jest.fn();
const getValue = () =>
cachified(
{
Expand All @@ -1024,20 +1031,25 @@ describe('cachified', () => {
ttl: 5,
staleWhileRevalidate: 10,
getFreshValue,
waitUntil,
},
reporter,
);

expect(await getValue()).toBe('value-0');
currentTime = 6;
// receive cached response since call exceeds ttl but is in stale while revalidate range
expect(cache.get('test')?.value).toBe('value-0');
expect(await getValue()).toBe('value-0');
// wait for next tick (revalidation is done in background)
await delay(0);
// wait for promise (revalidation is done in background)
expect(waitUntil).toHaveBeenCalledTimes(1);
expect(waitUntil).toHaveBeenCalledWith(expect.any(Promise));
await waitUntil.mock.calls[0][0];
// We don't care about the latter calls
const calls = [...reporter.mock.calls];

// next call gets the revalidated response
expect(cache.get('test')?.value).toBe('value-1');
expect(await getValue()).toBe('value-1');

const getFreshValueCalls = getFreshValue.mock.calls as any as Parameters<
Expand Down
10 changes: 10 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,15 @@ export interface CachifiedOptions<Value> {
* @deprecated pass reporter as second argument to cachified
*/
reporter?: never;
/**
* Promises passed to `waitUntil` represent background tasks which must be
* completed before the server can shutdown. e.g. swr cache revalidation
*
* Useful for serverless environments such as Cloudflare Workers.
*
* Default: `undefined`
*/
waitUntil?: (promise: Promise<unknown>) => void;
}

/* When using a schema validator, a strongly typed getFreshValue is not required
Expand Down Expand Up @@ -229,6 +238,7 @@ export function createContext<Value>(
forceFresh: false,
...options,
metadata: createCacheMetaData({ ttl, swr: staleWhileRevalidate }),
waitUntil: options.waitUntil ?? (() => {}),
};

const report =
Expand Down
80 changes: 45 additions & 35 deletions src/getCachedValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,26 @@ export async function getCachedValue<Value>(

if (staleRefresh) {
// refresh cache in background so future requests are faster
setTimeout(() => {
report({ name: 'refreshValueStart' });
void cachified({
...context,
getFreshValue({ metadata }) {
return context.getFreshValue({ metadata, background: true });
},
forceFresh: true,
fallbackToCache: false,
})
.then((value) => {
report({ name: 'refreshValueSuccess', value });
context.waitUntil(
Promise.resolve().then(async () => {
await sleep(staleRefreshTimeout);
report({ name: 'refreshValueStart' });
await cachified({
...context,
getFreshValue({ metadata }) {
return context.getFreshValue({ metadata, background: true });
},
forceFresh: true,
fallbackToCache: false,
})
.catch((error) => {
report({ name: 'refreshValueError', error });
});
}, staleRefreshTimeout);
.then((value) => {
report({ name: 'refreshValueSuccess', value });
})
.catch((error) => {
report({ name: 'refreshValueError', error });
});
}),
);
}

if (!refresh || staleRefresh) {
Expand All @@ -86,27 +89,30 @@ export async function getCachedValue<Value>(
}

if (valueCheck.migrated) {
setTimeout(async () => {
try {
const cached = await context.cache.get(context.key);
context.waitUntil(
Promise.resolve().then(async () => {
try {
await sleep(0); // align with original setTimeout behavior (allowing other microtasks/tasks to run)
const cached = await context.cache.get(context.key);

// Unless cached value was changed in the meantime or is about to
// change
if (
cached &&
cached.metadata.createdTime === metadata.createdTime &&
!hasPendingValue()
) {
// update with migrated value
await context.cache.set(context.key, {
...cached,
value: valueCheck.value,
});
// Unless cached value was changed in the meantime or is about to
// change
if (
cached &&
cached.metadata.createdTime === metadata.createdTime &&
!hasPendingValue()
) {
// update with migrated value
await context.cache.set(context.key, {
...cached,
value: valueCheck.value,
});
}
} catch (err) {
/* ¯\_(ツ)_/¯ */
}
} catch (err) {
/* ¯\_(ツ)_/¯ */
}
}, 0);
}),
);
}

return valueCheck.value;
Expand All @@ -131,3 +137,7 @@ export async function getCachedValue<Value>(

return CACHE_EMPTY;
}

function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

0 comments on commit 3edb5dd

Please sign in to comment.