From 0aff09c33700413862d111686b335999394790c0 Mon Sep 17 00:00:00 2001 From: Mateus Boergeson Date: Sat, 29 Aug 2026 16:29:36 +0200 Subject: [PATCH 1/2] fix(ttl-cache): share one fetch between concurrent calls --- backend/src/utils/ttl-cache.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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; }; } From cfe8c5a52f4a1b0cb08f854d7af8f5cc81defad5 Mon Sep 17 00:00:00 2001 From: Mateus Boergeson Date: Sat, 29 Aug 2026 16:29:36 +0200 Subject: [PATCH 2/2] test(ttl-cache): cover failed and concurrent fetches --- backend/__tests__/utils/ttl-cache.spec.ts | 60 +++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 backend/__tests__/utils/ttl-cache.spec.ts 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); + }); +});