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 77ed4808..b3769095 100644 --- a/src/adapted.ts +++ b/src/adapted.ts @@ -82,12 +82,36 @@ 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 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 = retrieveAndTransform(); + } + + return loadedValue; + }; + + 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..09ae33f6 100644 --- a/test/adapted.test.ts +++ b/test/adapted.test.ts @@ -1,5 +1,7 @@ -import type {CacheProvider} from '../src'; -import {AdaptedCache} 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> = { @@ -74,11 +76,270 @@ 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 () => { + 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(mockCache.delete).toHaveBeenCalledWith('transformed-key'); + }); + + it('should propagate deserialization errors from freshly loaded values without retrying', async () => { + 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.delete).not.toHaveBeenCalled(); + expect(loader).toHaveBeenCalledTimes(1); + }); + + 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() + .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)).resolves.toBe('fresh'); + + expect(mockCache.delete).toHaveBeenCalledWith('key'); + 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 () => { + 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; + let markLoaderStarted!: () => void; + const loaderStarted = new Promise(resolve => { + markLoaderStarted = resolve; + }); + const firstLoader = jest.fn( + () => new Promise(resolve => { + resolveLoader = resolve; + markLoaderStarted(); + }), + ); + const secondLoader = jest.fn(); + + mockCache.get.mockImplementation((_key, cacheLoader) => cacheLoader('key')); + + const firstRequest = cache.get('key', firstLoader); + + await loaderStarted; + + const secondRequest = cache.get('key', secondLoader); + + resolveLoader('malformed'); + + await expect(Promise.allSettled([firstRequest, secondRequest])).resolves.toStrictEqual([ + {status: 'rejected', reason: deserializationError}, + {status: 'rejected', reason: deserializationError}, + ]); + + expect(mockCache.delete).not.toHaveBeenCalled(); + expect(firstLoader).toHaveBeenCalledTimes(1); + expect(secondLoader).not.toHaveBeenCalled(); + }); + + 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 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), + }); + + 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 + .mockResolvedValueOnce('malformed') + .mockResolvedValueOnce('malformed') + .mockImplementation((key, loader) => loader(key)); + + const cache = AdaptedCache.transformValues( + mockCache, + AdaptedCache.jsonSerializer(), + AdaptedCache.jsonDeserializer(), + ); + const firstLoader = jest.fn().mockResolvedValue('first'); + const secondLoader = jest.fn().mockResolvedValue('second'); + + 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(loader).toHaveBeenCalledTimes(1); + + 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 () => { 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 +352,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'); });