From fcfbe7b7fa3fd8ff03eccee66a70df4a493fb5ef Mon Sep 17 00:00:00 2001 From: quacumque <43530070+0x009922@users.noreply.github.com> Date: Wed, 24 Jul 2024 10:37:28 +0900 Subject: [PATCH] feat: new cache strategy - load data on init - always return latest successful data - update data in background on each request - all without data races! --- src/main.ts | 37 ++++++++--------------------- src/util.spec.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++++ src/util.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 28 deletions(-) create mode 100644 src/util.spec.ts create mode 100644 src/util.ts diff --git a/src/main.ts b/src/main.ts index aa202d8..79d3205 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,9 @@ import Api from "./api.ts"; -import { getMatrix, Matrix } from "./aggregate.ts"; +import { getMatrix } from "./aggregate.ts"; import * as web from "./web.ts"; import { get as getConfig } from "./config.ts"; import { log, match, P } from "../deps.ts"; +import { useFreshData } from "./util.ts"; const CONFIG = { apiToken: getConfig("ALLURE_API_TOKEN"), @@ -40,36 +41,16 @@ const api = new Api({ baseUrl: CONFIG.allureBaseUrl, }); -// 24 hours -const CACHE_TTL = 60_000 * 60 * 24; - -class State { - #data: null | { matrix: Matrix; lastUpdated: Date } = null; - #promise: null | Promise = null; - - getMatrix(): Promise { - if (this.#promise) return this.#promise; - const now = Date.now(); - if (!this.#data || this.#data.lastUpdated.getTime() + CACHE_TTL < now) { - log.debug("No data/cache is stale, reloading matrix"); - this.#promise = getMatrix(api).then((matrix) => { - this.#data = { matrix, lastUpdated: new Date() }; - return matrix; - }).finally(() => { - this.#promise = null; - }); - return this.#promise; - } - log.debug("Using cached data"); - return Promise.resolve(this.#data.matrix); - } -} - -const state = new State(); +const { get: getMatrixWithCache } = useFreshData(async () => { + log.info("Getting matrix"); + const data = await getMatrix(api); + log.info("Matrix is ready"); + return data; +}); await web.run({ port: CONFIG.port, provider: { - getMatrix: () => state.getMatrix(), + getMatrix: getMatrixWithCache, }, }); diff --git a/src/util.spec.ts b/src/util.spec.ts new file mode 100644 index 0000000..87a91db --- /dev/null +++ b/src/util.spec.ts @@ -0,0 +1,60 @@ +// Doesn't work with Deno! +// In order to run tests, install vitest globally using some node.js based package manager and then run `vitest` + +import { describe, expect, test, vi } from "vitest"; +import { useFreshData } from "./util.ts"; + +describe("useFreshData", () => { + test("fetches data before first get", async () => { + let called = false; + + useFreshData(() => { + called = true; + return Promise.resolve(null); + }); + + await expect.poll(() => called).toBe(true); + }); + + test("initial call fails, then succeeds", async () => { + const data = vi.fn().mockRejectedValueOnce("foo").mockResolvedValueOnce( + "ok", + ); + + const { get } = useFreshData(data); + + expect(() => get()).rejects.toThrow("foo"); + await expect.poll(() => get()).toBe("ok"); + }); + + test("initially ok, then fails, then ok", async () => { + const data = vi.fn().mockResolvedValueOnce(1).mockRejectedValueOnce("foo") + .mockResolvedValueOnce(2); + + const { get } = useFreshData(data); + + expect(await get()).toBe(1); + // waitUntil disallows errors + vi.waitUntil(async () => { + const value = await get(); + return value === 2; + }); + }); + + test("updates data 5 times in a row", async () => { + const { get } = useFreshData( + vi.fn() + .mockResolvedValueOnce(1) + .mockResolvedValueOnce(2) + .mockResolvedValueOnce(3) + .mockResolvedValueOnce(4) + .mockResolvedValueOnce(5), + ); + + expect(await get()).toBe(1); + expect(await get()).toBe(1); + expect(await get()).toBe(2); + expect(await get()).toBe(3); + expect(await get()).toBe(4); + }); +}); diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..77ecdc9 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,59 @@ +export function useFreshData( + fn: () => Promise, +): { get: () => Promise } { + let state: { data: null | { some: T }; promise: null | Promise } = { + data: null, + promise: null, + }; + + function createPromise(): Promise { + const promise = fn().then((data) => { + if (state.promise === promise) { + state.data = { some: data }; + state.promise = null; + } + return data; + }).catch((err) => { + if (state.promise === promise) { + state.promise = null; + } + throw err; + }); + return promise; + } + + async function get(): Promise { + if (state.data && state.promise) { + return (state.data.some); + } + if (state.data) { + const promise = createPromise(); + promise.catch((err) => { + console.error(err); + }); + state.promise = promise; + return (state.data.some); + } + if (state.promise) { + const data = await state.promise; + state = { data: { some: data }, promise: null }; + return data; + } + const promise = createPromise(); + state = { data: null, promise }; + try { + const data = await promise; + state = { data: { some: data }, promise: null }; + return data; + } catch (err) { + state.promise = null; + throw err; + } + } + + get().catch((err) => { + console.error(err); + }); + + return { get }; +}