From ffbae4d6dd18569d8be6d9a2ae390d8e9ea9e4cf Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Thu, 16 Jul 2026 20:50:17 +0000 Subject: [PATCH 1/5] Fix adapted cache deserialization recovery --- src/adapted.ts | 25 +++++-- test/adapted.test.ts | 171 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 187 insertions(+), 9 deletions(-) diff --git a/src/adapted.ts b/src/adapted.ts index 77ed4808..4c13e1b8 100644 --- a/src/adapted.ts +++ b/src/adapted.ts @@ -82,12 +82,25 @@ export class AdaptedCache implements CacheProvider { } public async get(key: K, loader: CacheLoader): Promise { - return this.cache - .get( - await this.keyTransformer(key), - () => loader(key).then(this.valueInputTransformer), - ) - .then(this.valueOutputTransformer); + const transformedKey = await this.keyTransformer(key); + + const transformedLoader: CacheLoader = async () => { + const loadedValue = await loader(key); + const transformedValue = await this.valueInputTransformer(loadedValue); + + await this.valueOutputTransformer(transformedValue); + + return transformedValue; + }; + const value = await this.cache.get(transformedKey, transformedLoader); + + try { + return await this.valueOutputTransformer(value); + } catch { + await this.cache.delete(transformedKey); + } + + return this.valueOutputTransformer(await this.cache.get(transformedKey, transformedLoader)); } public async set(key: K, value: V): Promise { diff --git a/test/adapted.test.ts b/test/adapted.test.ts index 429af6a6..dadfb453 100644 --- a/test/adapted.test.ts +++ b/test/adapted.test.ts @@ -1,5 +1,5 @@ import type {CacheProvider} from '../src'; -import {AdaptedCache} from '../src'; +import {AdaptedCache, SharedInFlightCache} from '../src'; describe('A cache adapter that can transform keys and values', () => { const mockCache: jest.MockedObject> = { @@ -74,11 +74,174 @@ describe('A cache adapter that can transform keys and values', () => { expect(loader).not.toHaveBeenCalled(); }); + it('should delete a malformed cached value and reload it through the cache', async () => { + mockCache.get.mockReset(); + mockCache.delete.mockReset(); + + const deserializationError = new Error('Failed to deserialize cached value'); + const keyTransformer = jest.fn().mockReturnValue('transformed-key'); + const inputTransformer = jest.fn((value: string) => value); + const outputTransformer = jest.fn((value: string) => { + if (value === 'malformed') { + throw deserializationError; + } + + return value; + }); + const loader = jest.fn().mockResolvedValue('fresh'); + const cache = new AdaptedCache({ + cache: mockCache, + keyTransformer: keyTransformer, + valueInputTransformer: inputTransformer, + valueOutputTransformer: outputTransformer, + }); + + mockCache.get + .mockResolvedValueOnce('malformed') + .mockImplementationOnce((_key, cacheLoader) => cacheLoader('transformed-key')); + + await expect(cache.get('key', loader)).resolves.toBe('fresh'); + + expect(keyTransformer).toHaveBeenCalledTimes(1); + expect(mockCache.get).toHaveBeenCalledTimes(2); + expect(mockCache.get).toHaveBeenNthCalledWith(1, 'transformed-key', expect.any(Function)); + expect(mockCache.get).toHaveBeenNthCalledWith(2, 'transformed-key', expect.any(Function)); + expect(mockCache.delete).toHaveBeenCalledTimes(1); + expect(mockCache.delete).toHaveBeenCalledWith('transformed-key'); + expect(loader).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledWith('key'); + expect(inputTransformer).toHaveBeenCalledWith('fresh'); + expect(outputTransformer).toHaveBeenNthCalledWith(1, 'malformed'); + expect(outputTransformer).toHaveBeenNthCalledWith(2, 'fresh'); + }); + + it('should propagate deserialization errors from freshly loaded values without retrying', async () => { + mockCache.get.mockReset(); + mockCache.delete.mockReset(); + + const deserializationError = new Error('Failed to deserialize loaded value'); + const keyTransformer = jest.fn().mockReturnValue('transformed-key'); + const inputTransformer = jest.fn((value: string) => value); + const outputTransformer = jest.fn(() => { + throw deserializationError; + }); + const loader = jest.fn().mockResolvedValue('malformed'); + const cache = new AdaptedCache({ + cache: mockCache, + keyTransformer: keyTransformer, + valueInputTransformer: inputTransformer, + valueOutputTransformer: outputTransformer, + }); + + mockCache.get.mockImplementation((_key, cacheLoader) => cacheLoader('transformed-key')); + + await expect(cache.get('key', loader)).rejects.toThrow(deserializationError); + + expect(mockCache.get).toHaveBeenCalledTimes(1); + expect(mockCache.get).toHaveBeenCalledWith('transformed-key', expect.any(Function)); + expect(mockCache.delete).not.toHaveBeenCalled(); + expect(loader).toHaveBeenCalledTimes(1); + expect(loader).toHaveBeenCalledWith('key'); + expect(inputTransformer).toHaveBeenCalledWith('malformed'); + expect(outputTransformer).toHaveBeenCalledWith('malformed'); + }); + + it('should propagate shared freshly loaded deserialization errors without retrying', async () => { + mockCache.get.mockReset(); + mockCache.delete.mockReset(); + + const deserializationError = new Error('Failed to deserialize shared loaded value'); + const inputTransformer = jest.fn((value: string) => value); + const outputTransformer = jest.fn(() => { + throw deserializationError; + }); + const sharedCache = new SharedInFlightCache(mockCache); + const cache = AdaptedCache.transformValues(sharedCache, inputTransformer, outputTransformer); + + let resolveLoader: (value: string) => void = () => { /* noop */ }; + const firstLoader = jest.fn( + () => new Promise(resolve => { + resolveLoader = resolve; + }), + ); + const secondLoader = jest.fn(); + + mockCache.get.mockImplementation((_key, cacheLoader) => cacheLoader('key')); + + const firstRequest = cache.get('key', firstLoader); + + await Promise.resolve(); + + const secondRequest = cache.get('key', secondLoader); + + resolveLoader('malformed'); + + const results = await Promise.allSettled([firstRequest, secondRequest]); + + expect(results).toStrictEqual([ + {status: 'rejected', reason: deserializationError}, + {status: 'rejected', reason: deserializationError}, + ]); + expect(mockCache.get).toHaveBeenCalledTimes(1); + expect(mockCache.delete).not.toHaveBeenCalled(); + expect(firstLoader).toHaveBeenCalledTimes(1); + expect(secondLoader).not.toHaveBeenCalled(); + expect(inputTransformer).toHaveBeenCalledTimes(1); + expect(outputTransformer).toHaveBeenCalledTimes(1); + }); + it('should recover a malformed cached value after starting background revalidation', async () => { + mockCache.get.mockReset(); + mockCache.delete.mockReset(); + + const deserializationError = new Error('Failed to deserialize cached value'); + const keyTransformer = jest.fn().mockReturnValue('transformed-key'); + const inputTransformer = jest.fn((value: string) => value); + const outputTransformer = jest.fn((value: string) => { + if (value === 'malformed') { + throw deserializationError; + } + + return value; + }); + const loader = jest.fn().mockResolvedValue('fresh'); + const cache = new AdaptedCache({ + cache: mockCache, + keyTransformer: keyTransformer, + valueInputTransformer: inputTransformer, + valueOutputTransformer: outputTransformer, + }); + + mockCache.get + .mockImplementationOnce(async (_key, cacheLoader) => { + await cacheLoader('transformed-key'); + + return 'malformed'; + }) + .mockImplementationOnce((_key, cacheLoader) => cacheLoader('transformed-key')); + + await expect(cache.get('key', loader)).resolves.toBe('fresh'); + + expect(keyTransformer).toHaveBeenCalledTimes(1); + expect(mockCache.get).toHaveBeenCalledTimes(2); + expect(mockCache.get).toHaveBeenNthCalledWith(1, 'transformed-key', expect.any(Function)); + expect(mockCache.get).toHaveBeenNthCalledWith(2, 'transformed-key', expect.any(Function)); + expect(mockCache.delete).toHaveBeenCalledTimes(1); + expect(mockCache.delete).toHaveBeenCalledWith('transformed-key'); + expect(loader).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenCalledWith('key'); + expect(inputTransformer).toHaveBeenCalledTimes(2); + expect(inputTransformer).toHaveBeenCalledWith('fresh'); + expect(outputTransformer).toHaveBeenNthCalledWith(1, 'fresh'); + expect(outputTransformer).toHaveBeenNthCalledWith(2, 'malformed'); + expect(outputTransformer).toHaveBeenNthCalledWith(3, 'fresh'); + expect(outputTransformer).toHaveBeenNthCalledWith(4, 'fresh'); + }); + it('should apply the value input transformer on loader value', async () => { mockCache.get.mockImplementation((key, loader) => loader(key)); const inputTransformer = jest.fn().mockReturnValueOnce('transformedInput'); - const outputTransformer = jest.fn().mockReturnValueOnce('transformedOutput'); + const outputTransformer = jest.fn().mockReturnValue('transformedOutput'); const loader = jest.fn().mockResolvedValue('loaderValue'); @@ -91,7 +254,9 @@ describe('A cache adapter that can transform keys and values', () => { const result = await cache.get('key', loader); expect(inputTransformer).toHaveBeenCalledWith('loaderValue'); - expect(outputTransformer).toHaveBeenCalledWith('transformedInput'); + expect(outputTransformer).toHaveBeenCalledTimes(2); + expect(outputTransformer).toHaveBeenNthCalledWith(1, 'transformedInput'); + expect(outputTransformer).toHaveBeenNthCalledWith(2, 'transformedInput'); expect(loader).toHaveBeenCalledWith('key'); expect(result).toBe('transformedOutput'); }); From c4adb01cb31835c372943f166ee3d593dd72e0fe Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Mon, 7 Sep 2026 14:51:36 -0300 Subject: [PATCH 2/5] Avoid double loading --- src/adapted.ts | 10 +++++++++- test/adapted.test.ts | 43 ++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/adapted.ts b/src/adapted.ts index 4c13e1b8..5cd72629 100644 --- a/src/adapted.ts +++ b/src/adapted.ts @@ -84,13 +84,21 @@ export class AdaptedCache implements CacheProvider { public async get(key: K, loader: CacheLoader): Promise { const transformedKey = await this.keyTransformer(key); - const transformedLoader: CacheLoader = async () => { + let loadedValue: Promise; + + const transformedLoader: CacheLoader = () => { + if (loadedValue === undefined) { + loadedValue = (async () => { const loadedValue = await loader(key); const transformedValue = await this.valueInputTransformer(loadedValue); await this.valueOutputTransformer(transformedValue); return transformedValue; + })(); + } + + return loadedValue; }; const value = await this.cache.get(transformedKey, transformedLoader); diff --git a/test/adapted.test.ts b/test/adapted.test.ts index dadfb453..8fcf8ed0 100644 --- a/test/adapted.test.ts +++ b/test/adapted.test.ts @@ -146,6 +146,44 @@ describe('A cache adapter that can transform keys and values', () => { expect(outputTransformer).toHaveBeenCalledWith('malformed'); }); + it('should reuse a loaded value when output transformation fails after a cache miss', async () => { + mockCache.get.mockImplementation((key, cacheLoader) => cacheLoader(key)); + + const error = new Error('Failed to transform loaded value'); + const outputTransformer = jest.fn(() => { + throw error; + }); + const loader = jest.fn().mockResolvedValueOnce('fresh') + const cache = AdaptedCache.transformValues(mockCache, (value: string) => value, outputTransformer); + + await expect(cache.get('key', loader)).rejects.toBe(error); + + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should reuse rejected transformations only within the current invocation', async () => { + mockCache.get.mockImplementation(async (key, cacheLoader) => { + try { + return await cacheLoader(key); + } catch { + return 'malformed'; + } + }); + + const error = new Error('Failed to transform loaded value'); + const outputTransformer = jest.fn(() => { + throw error; + }); + const loader = jest.fn().mockResolvedValue('fresh'); + const cache = AdaptedCache.transformValues(mockCache, (value: string) => value, outputTransformer); + + await expect(cache.get('key', loader)).rejects.toBe(error); + expect(loader).toHaveBeenCalledTimes(1); + + await expect(cache.get('key', loader)).rejects.toBe(error); + expect(loader).toHaveBeenCalledTimes(2); + }); + it('should propagate shared freshly loaded deserialization errors without retrying', async () => { mockCache.get.mockReset(); mockCache.delete.mockReset(); @@ -227,14 +265,13 @@ describe('A cache adapter that can transform keys and values', () => { expect(mockCache.get).toHaveBeenNthCalledWith(2, 'transformed-key', expect.any(Function)); expect(mockCache.delete).toHaveBeenCalledTimes(1); expect(mockCache.delete).toHaveBeenCalledWith('transformed-key'); - expect(loader).toHaveBeenCalledTimes(2); + expect(loader).toHaveBeenCalledTimes(1); expect(loader).toHaveBeenCalledWith('key'); - expect(inputTransformer).toHaveBeenCalledTimes(2); + expect(inputTransformer).toHaveBeenCalledTimes(1); expect(inputTransformer).toHaveBeenCalledWith('fresh'); expect(outputTransformer).toHaveBeenNthCalledWith(1, 'fresh'); expect(outputTransformer).toHaveBeenNthCalledWith(2, 'malformed'); expect(outputTransformer).toHaveBeenNthCalledWith(3, 'fresh'); - expect(outputTransformer).toHaveBeenNthCalledWith(4, 'fresh'); }); it('should apply the value input transformer on loader value', async () => { From 254cfac0c9223bf22b09f2a9249cf350904fbbcc Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Mon, 7 Sep 2026 14:53:38 -0300 Subject: [PATCH 3/5] Lint fix --- src/adapted.ts | 8 ++++---- test/adapted.test.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/adapted.ts b/src/adapted.ts index 5cd72629..4d8e9cab 100644 --- a/src/adapted.ts +++ b/src/adapted.ts @@ -89,12 +89,12 @@ export class AdaptedCache implements CacheProvider { const transformedLoader: CacheLoader = () => { if (loadedValue === undefined) { loadedValue = (async () => { - const loadedValue = await loader(key); - const transformedValue = await this.valueInputTransformer(loadedValue); + const loaderValue = await loader(key); + const transformedValue = await this.valueInputTransformer(loaderValue); - await this.valueOutputTransformer(transformedValue); + await this.valueOutputTransformer(transformedValue); - return transformedValue; + return transformedValue; })(); } diff --git a/test/adapted.test.ts b/test/adapted.test.ts index 8fcf8ed0..236b04ff 100644 --- a/test/adapted.test.ts +++ b/test/adapted.test.ts @@ -153,7 +153,7 @@ describe('A cache adapter that can transform keys and values', () => { const outputTransformer = jest.fn(() => { throw error; }); - const loader = jest.fn().mockResolvedValueOnce('fresh') + const loader = jest.fn().mockResolvedValueOnce('fresh'); const cache = AdaptedCache.transformValues(mockCache, (value: string) => value, outputTransformer); await expect(cache.get('key', loader)).rejects.toBe(error); From 51cf5c3f769d4788ff7686b3934c9352974dd7a3 Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Tue, 8 Sep 2026 11:03:14 -0300 Subject: [PATCH 4/5] Make initial undefined state explicit on the type --- src/adapted.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapted.ts b/src/adapted.ts index 4d8e9cab..61e71d2b 100644 --- a/src/adapted.ts +++ b/src/adapted.ts @@ -84,7 +84,7 @@ export class AdaptedCache implements CacheProvider { public async get(key: K, loader: CacheLoader): Promise { const transformedKey = await this.keyTransformer(key); - let loadedValue: Promise; + let loadedValue: Promise | undefined; const transformedLoader: CacheLoader = () => { if (loadedValue === undefined) { From f4935f4f1e38dd3d1238a76bfda13cbe772287f2 Mon Sep 17 00:00:00 2001 From: Luiz Ferraz Date: Thu, 10 Sep 2026 11:44:56 +0000 Subject: [PATCH 5/5] Address adapted cache recovery review feedback --- README.md | 7 +- src/adapted.ts | 19 ++-- test/adapted.test.ts | 201 ++++++++++++++++++++++++++++--------------- 3 files changed, 148 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 14e20b04..b571fc55 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,12 @@ This library ships with a few `CacheProvider` implementations, including: - [`PrefixedCache`](src/prefixed.ts): A cache wrapper that prefixes all keys with a string. - [`AdaptedCache`](src/adapted.ts): A cache wrapper that adapts a typed `CacheProvider` into a -`CacheProvider` by transforming keys and values. Available transformers: +`CacheProvider` by transforming keys and values. If a cached value cannot be transformed, +the adapter deletes it and retries once through the underlying cache. Loader reuse and recovery are +scoped to each execution; the adapter does not coordinate separate calls. To share the entire read +and recovery operation, wrap the adapter in a `SharedInFlightCache`. Freshly loaded values are +validated before the underlying cache can store them; validation failures propagate without starting +recovery. Available transformers: - `AdaptedCache.createHashSerializer`: Serializes any object into a hash. This is typically used to transform keys into strings. - `AdaptedCache.jsonSerializer`: Serializes any object to a JSON string. This is a typed wrapper around `JSON.stringify`. - `AdaptedCache.jsonDeserializer`: Deserializes a JSON string into a `JsonValue`. This is a typed wrapper around `JSON.parse`. diff --git a/src/adapted.ts b/src/adapted.ts index 61e71d2b..b3769095 100644 --- a/src/adapted.ts +++ b/src/adapted.ts @@ -84,22 +84,25 @@ export class AdaptedCache implements CacheProvider { public async get(key: K, loader: CacheLoader): Promise { const transformedKey = await this.keyTransformer(key); + const retrieveAndTransform = async (): Promise => { + const loaderValue = await loader(key); + const transformedValue = await this.valueInputTransformer(loaderValue); + + await this.valueOutputTransformer(transformedValue); + + return transformedValue; + }; + let loadedValue: Promise | undefined; const transformedLoader: CacheLoader = () => { if (loadedValue === undefined) { - loadedValue = (async () => { - const loaderValue = await loader(key); - const transformedValue = await this.valueInputTransformer(loaderValue); - - await this.valueOutputTransformer(transformedValue); - - return transformedValue; - })(); + loadedValue = retrieveAndTransform(); } return loadedValue; }; + const value = await this.cache.get(transformedKey, transformedLoader); try { diff --git a/test/adapted.test.ts b/test/adapted.test.ts index 236b04ff..09ae33f6 100644 --- a/test/adapted.test.ts +++ b/test/adapted.test.ts @@ -1,5 +1,7 @@ -import type {CacheProvider} from '../src'; -import {AdaptedCache, SharedInFlightCache} from '../src'; +import {Instant, TimeZone} from '@croct/time'; +import {FixedClock} from '@croct/time/clock/fixedClock'; +import type {CacheProvider, TimestampedCacheEntry} from '../src'; +import {AdaptedCache, AutoSaveCache, InMemoryCache, SharedInFlightCache, StaleWhileRevalidateCache} from '../src'; describe('A cache adapter that can transform keys and values', () => { const mockCache: jest.MockedObject> = { @@ -75,9 +77,6 @@ describe('A cache adapter that can transform keys and values', () => { }); it('should delete a malformed cached value and reload it through the cache', async () => { - mockCache.get.mockReset(); - mockCache.delete.mockReset(); - const deserializationError = new Error('Failed to deserialize cached value'); const keyTransformer = jest.fn().mockReturnValue('transformed-key'); const inputTransformer = jest.fn((value: string) => value); @@ -102,23 +101,10 @@ describe('A cache adapter that can transform keys and values', () => { await expect(cache.get('key', loader)).resolves.toBe('fresh'); - expect(keyTransformer).toHaveBeenCalledTimes(1); - expect(mockCache.get).toHaveBeenCalledTimes(2); - expect(mockCache.get).toHaveBeenNthCalledWith(1, 'transformed-key', expect.any(Function)); - expect(mockCache.get).toHaveBeenNthCalledWith(2, 'transformed-key', expect.any(Function)); - expect(mockCache.delete).toHaveBeenCalledTimes(1); expect(mockCache.delete).toHaveBeenCalledWith('transformed-key'); - expect(loader).toHaveBeenCalledTimes(1); - expect(loader).toHaveBeenCalledWith('key'); - expect(inputTransformer).toHaveBeenCalledWith('fresh'); - expect(outputTransformer).toHaveBeenNthCalledWith(1, 'malformed'); - expect(outputTransformer).toHaveBeenNthCalledWith(2, 'fresh'); }); it('should propagate deserialization errors from freshly loaded values without retrying', async () => { - mockCache.get.mockReset(); - mockCache.delete.mockReset(); - const deserializationError = new Error('Failed to deserialize loaded value'); const keyTransformer = jest.fn().mockReturnValue('transformed-key'); const inputTransformer = jest.fn((value: string) => value); @@ -137,27 +123,24 @@ describe('A cache adapter that can transform keys and values', () => { await expect(cache.get('key', loader)).rejects.toThrow(deserializationError); - expect(mockCache.get).toHaveBeenCalledTimes(1); - expect(mockCache.get).toHaveBeenCalledWith('transformed-key', expect.any(Function)); expect(mockCache.delete).not.toHaveBeenCalled(); expect(loader).toHaveBeenCalledTimes(1); - expect(loader).toHaveBeenCalledWith('key'); - expect(inputTransformer).toHaveBeenCalledWith('malformed'); - expect(outputTransformer).toHaveBeenCalledWith('malformed'); }); it('should reuse a loaded value when output transformation fails after a cache miss', async () => { mockCache.get.mockImplementation((key, cacheLoader) => cacheLoader(key)); const error = new Error('Failed to transform loaded value'); - const outputTransformer = jest.fn(() => { - throw error; - }); + const outputTransformer = jest.fn() + .mockReturnValueOnce('fresh') + .mockRejectedValueOnce(error) + .mockReturnValueOnce('fresh'); const loader = jest.fn().mockResolvedValueOnce('fresh'); const cache = AdaptedCache.transformValues(mockCache, (value: string) => value, outputTransformer); - await expect(cache.get('key', loader)).rejects.toBe(error); + await expect(cache.get('key', loader)).resolves.toBe('fresh'); + expect(mockCache.delete).toHaveBeenCalledWith('key'); expect(loader).toHaveBeenCalledTimes(1); }); @@ -185,9 +168,6 @@ describe('A cache adapter that can transform keys and values', () => { }); it('should propagate shared freshly loaded deserialization errors without retrying', async () => { - mockCache.get.mockReset(); - mockCache.delete.mockReset(); - const deserializationError = new Error('Failed to deserialize shared loaded value'); const inputTransformer = jest.fn((value: string) => value); const outputTransformer = jest.fn(() => { @@ -196,10 +176,15 @@ describe('A cache adapter that can transform keys and values', () => { const sharedCache = new SharedInFlightCache(mockCache); const cache = AdaptedCache.transformValues(sharedCache, inputTransformer, outputTransformer); - let resolveLoader: (value: string) => void = () => { /* noop */ }; + let resolveLoader!: (value: string) => void; + let markLoaderStarted!: () => void; + const loaderStarted = new Promise(resolve => { + markLoaderStarted = resolve; + }); const firstLoader = jest.fn( () => new Promise(resolve => { resolveLoader = resolve; + markLoaderStarted(); }), ); const secondLoader = jest.fn(); @@ -208,70 +193,146 @@ describe('A cache adapter that can transform keys and values', () => { const firstRequest = cache.get('key', firstLoader); - await Promise.resolve(); + await loaderStarted; const secondRequest = cache.get('key', secondLoader); resolveLoader('malformed'); - const results = await Promise.allSettled([firstRequest, secondRequest]); - - expect(results).toStrictEqual([ + await expect(Promise.allSettled([firstRequest, secondRequest])).resolves.toStrictEqual([ {status: 'rejected', reason: deserializationError}, {status: 'rejected', reason: deserializationError}, ]); - expect(mockCache.get).toHaveBeenCalledTimes(1); + expect(mockCache.delete).not.toHaveBeenCalled(); expect(firstLoader).toHaveBeenCalledTimes(1); expect(secondLoader).not.toHaveBeenCalled(); - expect(inputTransformer).toHaveBeenCalledTimes(1); - expect(outputTransformer).toHaveBeenCalledTimes(1); }); - it('should recover a malformed cached value after starting background revalidation', async () => { - mockCache.get.mockReset(); - mockCache.delete.mockReset(); - const deserializationError = new Error('Failed to deserialize cached value'); - const keyTransformer = jest.fn().mockReturnValue('transformed-key'); - const inputTransformer = jest.fn((value: string) => value); - const outputTransformer = jest.fn((value: string) => { + it('should recover a malformed cached value while background revalidation is pending', async () => { + const now = Instant.ofEpochMilli(12345); + const inner = new InMemoryCache>(); + const staleCache = new StaleWhileRevalidateCache({ + cacheProvider: inner, + freshPeriod: 10, + clock: FixedClock.of(now, TimeZone.UTC), + }); + + const outputTransformer = (value: string): string => { if (value === 'malformed') { - throw deserializationError; + throw new Error('Failed to deserialize cached value'); } return value; + }; + const cache = AdaptedCache.transformValues(staleCache, (value: string) => value, outputTransformer); + + await inner.set('key', { + value: 'malformed', + timestamp: now.plusSeconds(-11), }); - const loader = jest.fn().mockResolvedValue('fresh'); - const cache = new AdaptedCache({ - cache: mockCache, - keyTransformer: keyTransformer, - valueInputTransformer: inputTransformer, - valueOutputTransformer: outputTransformer, + + let resolveLoader!: (value: string) => void; + let markLoaderStarted!: () => void; + const loaderStarted = new Promise(resolve => { + markLoaderStarted = resolve; + }); + const loader = jest.fn( + () => new Promise(resolve => { + resolveLoader = resolve; + markLoaderStarted(); + }), + ); + + let markRecoveryStarted!: () => void; + const recoveryStarted = new Promise(resolve => { + markRecoveryStarted = resolve; }); + const deleteEntry = inner.delete.bind(inner); + jest.spyOn(inner, 'delete').mockImplementation(async key => { + await deleteEntry(key); + markRecoveryStarted(); + }); + + const request = cache.get('key', loader); + + await Promise.all([loaderStarted, recoveryStarted]); + resolveLoader('fresh'); + + await expect(request).resolves.toBe('fresh'); + await expect(cache.get('key', jest.fn())).resolves.toBe('fresh'); + + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should recover concurrent calls independently without a coordinating wrapper', async () => { mockCache.get - .mockImplementationOnce(async (_key, cacheLoader) => { - await cacheLoader('transformed-key'); + .mockResolvedValueOnce('malformed') + .mockResolvedValueOnce('malformed') + .mockImplementation((key, loader) => loader(key)); - return 'malformed'; - }) - .mockImplementationOnce((_key, cacheLoader) => cacheLoader('transformed-key')); + const cache = AdaptedCache.transformValues( + mockCache, + AdaptedCache.jsonSerializer(), + AdaptedCache.jsonDeserializer(), + ); + const firstLoader = jest.fn().mockResolvedValue('first'); + const secondLoader = jest.fn().mockResolvedValue('second'); - await expect(cache.get('key', loader)).resolves.toBe('fresh'); + const results = await Promise.all([ + cache.get('key', firstLoader), + cache.get('key', secondLoader), + ]); + + expect(results).toStrictEqual(['first', 'second']); + expect(firstLoader).toHaveBeenCalledTimes(1); + expect(secondLoader).toHaveBeenCalledTimes(1); + }); + + it('should share malformed entry recovery when wrapped in a shared in-flight cache', async () => { + const inner = new InMemoryCache(); + const adaptedCache = AdaptedCache.transformValues( + new AutoSaveCache(inner), + AdaptedCache.jsonSerializer(), + AdaptedCache.jsonDeserializer(), + ); + const cache = new SharedInFlightCache(adaptedCache); + + await inner.set('key', 'malformed'); + + let resolveLoader!: (value: string) => void; + let markLoaderStarted!: () => void; + const loaderStarted = new Promise(resolve => { + markLoaderStarted = resolve; + }); + const loader = jest.fn() + .mockImplementationOnce( + () => new Promise(resolve => { + resolveLoader = resolve; + markLoaderStarted(); + }), + ) + .mockResolvedValue('fresh'); + + const firstRequest = cache.get('key', loader); + const secondRequest = cache.get('key', loader); + + await loaderStarted; + resolveLoader('fresh'); + + await expect(firstRequest).resolves.toBe('fresh'); + + await expect(secondRequest).resolves.toBe('fresh'); + await expect(cache.get('key', jest.fn())).resolves.toBe('fresh'); - expect(keyTransformer).toHaveBeenCalledTimes(1); - expect(mockCache.get).toHaveBeenCalledTimes(2); - expect(mockCache.get).toHaveBeenNthCalledWith(1, 'transformed-key', expect.any(Function)); - expect(mockCache.get).toHaveBeenNthCalledWith(2, 'transformed-key', expect.any(Function)); - expect(mockCache.delete).toHaveBeenCalledTimes(1); - expect(mockCache.delete).toHaveBeenCalledWith('transformed-key'); expect(loader).toHaveBeenCalledTimes(1); - expect(loader).toHaveBeenCalledWith('key'); - expect(inputTransformer).toHaveBeenCalledTimes(1); - expect(inputTransformer).toHaveBeenCalledWith('fresh'); - expect(outputTransformer).toHaveBeenNthCalledWith(1, 'fresh'); - expect(outputTransformer).toHaveBeenNthCalledWith(2, 'malformed'); - expect(outputTransformer).toHaveBeenNthCalledWith(3, 'fresh'); + + await inner.set('key', 'malformed'); + + await expect(cache.get('key', loader)).resolves.toBe('fresh'); + + expect(loader).toHaveBeenCalledTimes(2); }); it('should apply the value input transformer on loader value', async () => {