From a1265f4c45705ec7df6ce6f6846d111e088fbaa4 Mon Sep 17 00:00:00 2001 From: Hazelnoot Date: Wed, 12 Nov 2025 00:44:38 -0500 Subject: [PATCH] implement refreshMaybe --- packages/backend/src/misc/QuantumKVCache.ts | 20 ++ .../test/unit/misc/QuantumKVCacheTests.ts | 174 ++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/packages/backend/src/misc/QuantumKVCache.ts b/packages/backend/src/misc/QuantumKVCache.ts index e3cf2645e0..1f539fc217 100644 --- a/packages/backend/src/misc/QuantumKVCache.ts +++ b/packages/backend/src/misc/QuantumKVCache.ts @@ -550,6 +550,26 @@ export class QuantumKVCache = Value> implements I return value; } + /** + * Refreshes the value of a key from the fetcher, returning undefined if not found. + * Whether a result is found or not, it then erases any stale caches across the cluster. + * Fires an onChanged event after the cache has been updated in all processes. + */ + @bindThis + public async refreshMaybe(key: string): Promise { + this.throwIfDisposed(); + + const value = await this.doFetchMaybe(key); + + if (value != null) { + await this.set(key, value); + } else { + await this.delete(key); + } + + return value; + } + /** * Refreshes multiple values from the cache, and erases any stale caches across the cluster. * Fires an onChanged event after the cache has been updated in all processes. diff --git a/packages/backend/test/unit/misc/QuantumKVCacheTests.ts b/packages/backend/test/unit/misc/QuantumKVCacheTests.ts index e7864d6f4e..3695489c96 100644 --- a/packages/backend/test/unit/misc/QuantumKVCacheTests.ts +++ b/packages/backend/test/unit/misc/QuantumKVCacheTests.ts @@ -919,6 +919,180 @@ describe(QuantumKVCache, () => { }); }); + describe('refreshMaybe', () => { + it('should return value when found by fetcher', async () => { + const cache = makeCache({ + optionalFetcher: () => 'bar', + }); + + const result = await cache.refreshMaybe('foo'); + + expect(result).toBe('bar'); + }); + + it('should persist value when found by fetcher', async () => { + const cache = makeCache({ + optionalFetcher: () => 'bar', + }); + + await cache.refreshMaybe('foo'); + const result = cache.get('foo'); + + expect(result).toBe('bar'); + }); + + it('should call onChanged when found by fetcher', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + optionalFetcher: () => 'bar', + onChanged: fakeOnChanged, + }); + + await cache.refreshMaybe('foo'); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], expect.objectContaining({ cache })); + }); + + it('should return undefined when fetcher returns undefined', async () => { + const cache = makeCache({ + optionalFetcher: () => undefined, + }); + + const result = await cache.refreshMaybe('foo'); + + expect(result).toBe(undefined); + }); + + it('should call onChanged when fetcher returns undefined', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + optionalFetcher: () => undefined, + onChanged: fakeOnChanged, + }); + + await cache.refreshMaybe('foo'); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], expect.objectContaining({ cache })); + }); + + it('should return undefined when fetcher returns null', async () => { + const cache = makeCache({ + optionalFetcher: () => null, + }); + + const result = await cache.refreshMaybe('foo'); + + expect(result).toBe(undefined); + }); + + it('should call onChanged when fetcher returns null', async () => { + const fakeOnChanged = jest.fn(() => Promise.resolve()); + const cache = makeCache({ + optionalFetcher: () => null, + onChanged: fakeOnChanged, + }); + + await cache.refreshMaybe('foo'); + + expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], expect.objectContaining({ cache })); + }); + + it('should throw FetchFailedError when fetcher throws error', async () => { + const cache = makeCache({ + optionalFetcher: () => { throw new Error('test error'); }, + }); + + await assert.throwsAsync(FetchFailedError, async () => { + return await cache.refreshMaybe('foo'); + }); + }); + + it('should fall back on fetcher when optionalFetcher is not defined', async () => { + const cache = makeCache({ + fetcher: () => 'bar', + }); + + const result = await cache.refreshMaybe('foo'); + + expect(result).toBe('bar'); + }); + + it('should replace the value if it exists', async () => { + const cache = makeCache({ + optionalFetcher: key => `value#${key}`, + }); + + await cache.set('foo', 'bar'); + const result = await cache.refreshMaybe('foo'); + + expect(result).toBe('value#foo'); + }); + + it('should emit event when found', async () => { + const cache = makeCache({ + name: 'fake', + optionalFetcher: key => `value#${key}`, + }); + + await cache.refreshMaybe('foo'); + + expect(mockInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); + }); + + it('should emit event when not found', async () => { + const cache = makeCache({ + name: 'fake', + optionalFetcher: () => undefined, + }); + + await cache.refreshMaybe('foo'); + + expect(mockInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]); + }); + + it('should respect optionalFetcherConcurrency', async () => { + await testConcurrency( + { + optionalFetcher: key => `value#${key}`, + optionalFetcherConcurrency: 2, + }, + (cache, key) => cache.refreshMaybe(key), + ['value#foo', 'value#bar', 'value#baz'], + ); + }); + + it('should respect maxConcurrency', async () => { + await testConcurrency( + { + fetcher: key => `value#${key}`, + maxConcurrency: 2, + }, + (cache, key) => cache.refreshMaybe(key), + ['value#foo', 'value#bar', 'value#baz'], + ); + }); + + it('should de-duplicate calls', async () => { + // Arrange + const testComplete = Promise.withResolvers(); + const mockFetcher = jest.fn(async (key: string) => { + await testComplete.promise; + return `value#${key}`; + }); + const cache = makeCache({ optionalFetcher: mockFetcher }); + + // Act + const fetch1 = cache.refreshMaybe('foo'); + const fetch2 = cache.refreshMaybe('foo'); + + // Assert + testComplete.resolve(); + await expect(fetch1).resolves.toBe('value#foo'); + await expect(fetch2).resolves.toBe('value#foo'); + expect(mockFetcher).toHaveBeenCalledTimes(1); + }); + }); + describe('refreshMany', () => { it('should do nothing for empty input', async () => { const fakeOnChanged = jest.fn(() => Promise.resolve());