From 90ca9a4d162d80d88728d87089fb25b38239ae64 Mon Sep 17 00:00:00 2001 From: Dilshan Madushanka Date: Sun, 15 Sep 2024 06:46:39 +0800 Subject: [PATCH] fix: Disable concurrent `sourceFn` calls between refetches (#11) --- .coderabbit.yaml | 15 ++++++ .github/workflows/run-tests.yml | 38 +++++++-------- README.md | 2 +- src/run-cache.test.ts | 86 +++++++++++++++++++++++++++------ src/run-cache.ts | 17 ++++++- 5 files changed, 122 insertions(+), 36 deletions(-) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..97f638c --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +early_access: false +reviews: + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false +chat: + auto_reply: true diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b58cc44..8193e4c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -2,28 +2,28 @@ name: Run Tests on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: test: runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - registry-url: 'https://registry.npmjs.org' - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Test - run: npm test \ No newline at end of file + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "18" + registry-url: "https://registry.npmjs.org" + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Test + run: npm test diff --git a/README.md b/README.md index 2a04be8..52d70bd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![npm](https://img.shields.io/npm/v/run-cache?colorA=000000&colorB=ff98a2)](https://www.npmjs.com/package/run-cache) -# RunCache +# Run~time~Cache RunCache is a dependency nil, light-weight in-memory caching library for JavaScript and TypeScript that allows you to cache `string` values with optional time-to-live (TTL) settings. It also supports caching values generated from asynchronous functions and provides methods to refetch them on demand. diff --git a/src/run-cache.test.ts b/src/run-cache.test.ts index 765a931..a460b4a 100644 --- a/src/run-cache.test.ts +++ b/src/run-cache.test.ts @@ -32,19 +32,22 @@ describe("RunCache", () => { }); it("should throw an error when the source function throws an error", async () => { - const sourceFn = async () => { + const sourceFn = jest.fn(async () => { throw Error("Unexpected Error"); - }; + }); await expect( RunCache.set({ key: "key1", sourceFn, }), ).rejects.toThrow("Source function failed for key: 'key1'"); + + expect(sourceFn).toHaveBeenCalledTimes(1); }); it("should throw an error when the autoRefetch: true while ttl is not provided", async () => { - const sourceFn = async () => "dynamicValue"; + const sourceFn = jest.fn(async () => "dynamicValue"); + await expect( RunCache.set({ key: "key2", @@ -52,19 +55,25 @@ describe("RunCache", () => { autoRefetch: true, }), ).rejects.toThrow("`autoRefetch` is not allowed without a `ttl`"); + + expect(sourceFn).toHaveBeenCalledTimes(0); }); it("should be able to set a value with source function successfully", async () => { - const sourceFn = async () => "dynamicValue"; + const sourceFn = jest.fn(async () => "dynamicValue"); + await RunCache.set({ key: "key2", sourceFn, }); expect(await RunCache.get("key2")).toBe(JSON.stringify("dynamicValue")); + + expect(sourceFn).toHaveBeenCalledTimes(1); }); it("should be able to set a value with source function, autoRefetch enabled successfully", async () => { - const sourceFn = async () => "dynamicValue"; + const sourceFn = jest.fn(async () => "dynamicValue"); + await RunCache.set({ key: "key2", sourceFn, @@ -72,6 +81,8 @@ describe("RunCache", () => { autoRefetch: true, }); expect(await RunCache.get("key2")).toBe(JSON.stringify("dynamicValue")); + + expect(sourceFn).toHaveBeenCalledTimes(1); }); it("should return true if the cache value set successfully", async () => { @@ -101,7 +112,7 @@ describe("RunCache", () => { }); it("should return the value successfully if the cache is not expired", async () => { - const sourceFn = async () => "value1"; + const sourceFn = jest.fn(async () => "value1"); await RunCache.set({ key: "key1", @@ -110,12 +121,14 @@ describe("RunCache", () => { }); expect(await RunCache.get("key1")).toBe(JSON.stringify("value1")); + + expect(sourceFn).toHaveBeenCalledTimes(1); }); it("should auto refetch and return the new value successfully", async () => { let dynamicValue = "initialValue"; - const sourceFn = async () => dynamicValue; + const sourceFn = jest.fn(async () => dynamicValue); await RunCache.set({ key: "key2", @@ -124,6 +137,8 @@ describe("RunCache", () => { ttl: 100, }); + expect(sourceFn).toHaveBeenCalledTimes(1); + expect(await RunCache.get("key2")).toBe(JSON.stringify("initialValue")); dynamicValue = "updatedValue"; @@ -132,6 +147,8 @@ describe("RunCache", () => { await sleep(150); expect(await RunCache.get("key2")).toBe(JSON.stringify("updatedValue")); + + expect(sourceFn).toHaveBeenCalledTimes(2); }); it("should return the value successfully", async () => { @@ -212,61 +229,100 @@ describe("RunCache", () => { it("should throw an error when the source function throws an error", async () => { let shouldThrowError = false; - const sourceFn = async () => { + const sourceFn = jest.fn(async () => { if (shouldThrowError) { throw Error("Unexpected Error"); } else { return "SomeValue"; } - }; + }); await RunCache.set({ key: "key3", sourceFn }); + expect(sourceFn).toHaveBeenCalledTimes(1); + // Make source function to fail shouldThrowError = true; expect(RunCache.refetch("key3")).rejects.toThrow( "Source function failed for key: 'key3'", ); + + expect(sourceFn).toHaveBeenCalledTimes(2); }); it("should not refetch if the key does not exist", async () => { await expect(RunCache.refetch("nonExistentKey")).resolves.toBeFalsy(); }); + it("should not call sourceFn more than once at a time", async () => { + const sourceFn = jest.fn(async () => { + await sleep(1000); + return "value"; + }); + + await RunCache.set({ key: "key1", value: "value1", sourceFn }); + + const [firstRefetch, secondRefetch, thirdRefetch] = await Promise.all([ + RunCache.refetch("key1"), + RunCache.refetch("key1"), + RunCache.refetch("key1"), + ]); + + expect(firstRefetch).toBeTruthy(); + expect(secondRefetch).toBeFalsy(); + expect(thirdRefetch).toBeFalsy(); + + expect(sourceFn).toHaveBeenCalledTimes(1); + }); + it("should refetch and update the value from the source function", async () => { let dynamicValue = "initialValue"; - const sourceFn = async () => dynamicValue; + const sourceFn = jest.fn(async () => dynamicValue); await RunCache.set({ key: "key1", sourceFn }); expect(await RunCache.get("key1")).toBe(JSON.stringify("initialValue")); + expect(sourceFn).toHaveBeenCalledTimes(1); + // Update what's being returned in the source function dynamicValue = "updatedValue"; await RunCache.refetch("key1"); + + expect(sourceFn).toHaveBeenCalledTimes(2); + expect(await RunCache.get("key1")).toBe(JSON.stringify("updatedValue")); }); it("should trigger onRefetch event on refetch", async () => { let dynamicValue = "initialValue"; - const sourceFn = async () => dynamicValue; + const sourceFn = jest.fn(async () => dynamicValue); - const funcToBeExecutedOnRefetch = async (cacheState: EventParam) => { - expect(cacheState.key).toBe("key2"); - expect(cacheState.value).toBe(JSON.stringify("updatedValue")); - }; + const funcToBeExecutedOnRefetch = jest.fn( + async (cacheState: EventParam) => { + expect(cacheState.key).toBe("key2"); + expect(cacheState.value).toBe(JSON.stringify("updatedValue")); + }, + ); await RunCache.set({ key: "key2", sourceFn, onRefetch: funcToBeExecutedOnRefetch, }); + + expect(sourceFn).toHaveBeenCalledTimes(1); + expect(funcToBeExecutedOnRefetch).toHaveBeenCalledTimes(0); + expect(await RunCache.get("key2")).toBe(JSON.stringify("initialValue")); // Update what's being returned in the source function dynamicValue = "updatedValue"; await RunCache.refetch("key2"); + + expect(sourceFn).toHaveBeenCalledTimes(2); + expect(funcToBeExecutedOnRefetch).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/run-cache.ts b/src/run-cache.ts index e367b2a..f083246 100644 --- a/src/run-cache.ts +++ b/src/run-cache.ts @@ -4,6 +4,7 @@ type CacheState = { updateAt: number; ttl?: number; autoRefetch?: boolean; + fetching?: boolean; sourceFn?: SourceFn; onRefetch?: EventFn; @@ -146,7 +147,13 @@ class RunCache { throw Error(`No source function found for key: '${key}'`); } + if (cached.fetching) { + return false; + } + try { + RunCache.cache.set(key, { fetching: true, ...cached }); + const value = await cached.sourceFn(); const refetchedCache = { @@ -167,10 +174,18 @@ class RunCache { }); } - RunCache.cache.set(key, refetchedCache); + RunCache.cache.set(key, { + fetching: undefined, + ...refetchedCache, + }); return true; } catch (e) { + RunCache.cache.set(key, { + fetching: undefined, + ...cached, + }); + throw Error(`Source function failed for key: '${key}'`); } }