mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
fix(cache): do not fan a failed getOrSet out to its joiners (#2260)
This commit is contained in:
1 parent
45f694310a
commit
afeaddea22
3 files changed
+116
-19
No files matched your search
+35
-4
@@ -94,18 +94,49 @@ describe('ICacheService.getOrSet', () => {
|
||||
expect(store.get('absent')).toBe('null');
|
||||
});
|
||||
|
||||
it('rejects every waiter and retries on the next call when the factory fails', async () => {
|
||||
it('rejects every waiter after a single coalesced retry when the factory keeps failing', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const failing = vi.fn(async () => {
|
||||
await delay(10);
|
||||
throw new Error('factory failed');
|
||||
});
|
||||
const settled = await Promise.allSettled([cache.getOrSet('key', failing), cache.getOrSet('key', failing)]);
|
||||
expect(settled.map((result) => result.status)).toEqual(['rejected', 'rejected']);
|
||||
expect(failing).toHaveBeenCalledTimes(1);
|
||||
const settled = await Promise.allSettled([
|
||||
cache.getOrSet('key', failing),
|
||||
cache.getOrSet('key', failing),
|
||||
cache.getOrSet('key', failing),
|
||||
cache.getOrSet('key', failing),
|
||||
]);
|
||||
expect(settled.map((result) => result.status)).toEqual(['rejected', 'rejected', 'rejected', 'rejected']);
|
||||
expect(failing).toHaveBeenCalledTimes(2);
|
||||
await expect(cache.exists('key')).resolves.toBe(false);
|
||||
const succeeding = vi.fn(async () => 11);
|
||||
await expect(cache.getOrSet('key', succeeding)).resolves.toBe(11);
|
||||
expect(succeeding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not fan a transient producer failure out to the callers that joined it', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
let calls = 0;
|
||||
const factory = vi.fn(async () => {
|
||||
calls += 1;
|
||||
const attempt = calls;
|
||||
await delay(10);
|
||||
if (attempt === 1) {
|
||||
throw new Error('transient failure');
|
||||
}
|
||||
return 11;
|
||||
});
|
||||
const settled = await Promise.allSettled([
|
||||
cache.getOrSet('key', factory),
|
||||
cache.getOrSet('key', factory),
|
||||
cache.getOrSet('key', factory),
|
||||
cache.getOrSet('key', factory),
|
||||
]);
|
||||
expect(settled.map((result) => result.status)).toEqual(['rejected', 'fulfilled', 'fulfilled', 'fulfilled']);
|
||||
expect(settled.filter((result) => result.status === 'fulfilled').map((result) => result.value)).toEqual([
|
||||
11, 11, 11,
|
||||
]);
|
||||
expect(factory).toHaveBeenCalledTimes(2);
|
||||
await expect(cache.get('key')).resolves.toBe(11);
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,18 @@
|
||||
import {InMemoryProvider} from '@pkgs/cache/src/providers/InMemoryProvider';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function deferred<T>(): {promise: Promise<T>; resolve: (value: T) => void} {
|
||||
function deferred<T>(): {promise: Promise<T>; resolve: (value: T) => void; reject: (error: Error) => void} {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return {promise, resolve};
|
||||
return {promise, resolve, reject};
|
||||
}
|
||||
|
||||
function flush(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe('cache invalidation during an in-flight produce', () => {
|
||||
@@ -31,6 +37,46 @@ describe('cache invalidation during an in-flight produce', () => {
|
||||
expect(await cache.get('session')).toBe('live-session');
|
||||
});
|
||||
|
||||
it('does not resurrect a value deleted while a retried produce was running', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
gates.push(gate);
|
||||
return await gate.promise;
|
||||
};
|
||||
const producer = cache.getOrSet('session', factory, 30);
|
||||
const joiner = cache.getOrSet('session', factory, 30);
|
||||
await flush();
|
||||
gates[0].reject(new Error('produce failed'));
|
||||
await expect(producer).rejects.toThrow('produce failed');
|
||||
await flush();
|
||||
expect(gates).toHaveLength(2);
|
||||
await cache.delete('session');
|
||||
gates[1].resolve('fresh-after-delete');
|
||||
await expect(joiner).resolves.toBe('fresh-after-delete');
|
||||
expect(await cache.get('session')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores the value a retried produce built when no invalidation happens', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const gates: Array<ReturnType<typeof deferred<string>>> = [];
|
||||
const factory = async () => {
|
||||
const gate = deferred<string>();
|
||||
gates.push(gate);
|
||||
return await gate.promise;
|
||||
};
|
||||
const producer = cache.getOrSet('session', factory, 30);
|
||||
const joiner = cache.getOrSet('session', factory, 30);
|
||||
await flush();
|
||||
gates[0].reject(new Error('produce failed'));
|
||||
await expect(producer).rejects.toThrow('produce failed');
|
||||
await flush();
|
||||
gates[1].resolve('retried-session');
|
||||
await expect(joiner).resolves.toBe('retried-session');
|
||||
expect(await cache.get('session')).toBe('retried-session');
|
||||
});
|
||||
|
||||
it('keeps a later produce cacheable after an earlier one was invalidated', async () => {
|
||||
const cache = new InMemoryProvider();
|
||||
const first = deferred<string>();
|
||||
|
||||
Reference in new issue
Block a user