diff --git a/backend/__tests__/utils/ttl-cache.spec.ts b/backend/__tests__/utils/ttl-cache.spec.ts new file mode 100644 index 000000000000..28f355bdca91 --- /dev/null +++ b/backend/__tests__/utils/ttl-cache.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { cacheWithTTL } from "../../src/utils/ttl-cache"; + +describe("cacheWithTTL", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("refetches after the ttl", async () => { + const fn = vi.fn(async () => Date.now()); + const cache = cacheWithTTL(1000, fn); + + expect(await cache()).toBe(10_000); + vi.advanceTimersByTime(500); + expect(await cache()).toBe(10_000); + vi.advanceTimersByTime(600); + expect(await cache()).toBe(11_100); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it("keeps the stale value until the ttl when the fetch fails", async () => { + const fn = vi + .fn<() => Promise>() + .mockResolvedValueOnce("first") + .mockRejectedValueOnce(new Error("down")) + .mockResolvedValueOnce("third"); + const cache = cacheWithTTL(1000, fn); + + expect(await cache()).toBe("first"); + vi.advanceTimersByTime(1100); + await expect(cache()).rejects.toThrow("down"); + expect(await cache()).toBe("first"); + expect(fn).toHaveBeenCalledTimes(2); + vi.advanceTimersByTime(1100); + expect(await cache()).toBe("third"); + }); + + it("shares one fetch between concurrent calls", async () => { + let resolve!: (value: string) => void; + const fn = vi.fn( + async () => + new Promise((res) => { + resolve = res; + }), + ); + const cache = cacheWithTTL(1000, fn); + + const a = cache(); + const b = cache(); + resolve("value"); + + expect(await a).toBe("value"); + expect(await b).toBe("value"); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/utils/ttl-cache.ts b/backend/src/utils/ttl-cache.ts index f7538be577b8..03cdeb3ab560 100644 --- a/backend/src/utils/ttl-cache.ts +++ b/backend/src/utils/ttl-cache.ts @@ -16,12 +16,21 @@ export function cacheWithTTL( ): () => Promise { let lastFetchTime = 0; let cache: T | undefined; + let pending: Promise | undefined; return async () => { - if (lastFetchTime < Date.now() - ttlMs) { + if (pending === undefined && lastFetchTime < Date.now() - ttlMs) { lastFetchTime = Date.now(); - cache = await fn(); + pending = fn() + .then((data) => { + cache = data; + return data; + }) + .finally(() => { + pending = undefined; + }); } + await pending; return cache; }; }