Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<K, V>` into a
`CacheProvider<IK, IV>` by transforming keys and values. Available transformers:
`CacheProvider<IK, IV>` 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`.
Expand Down
36 changes: 30 additions & 6 deletions src/adapted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,36 @@ export class AdaptedCache<K, V, IK = K, IV = V> implements CacheProvider<K, V> {
}

public async get(key: K, loader: CacheLoader<K, V>): Promise<V> {
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<IV> => {
const loaderValue = await loader(key);
const transformedValue = await this.valueInputTransformer(loaderValue);

await this.valueOutputTransformer(transformedValue);

return transformedValue;
};

let loadedValue: Promise<IV> | undefined;

const transformedLoader: CacheLoader<IK, IV> = () => {
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<void> {
Expand Down
271 changes: 267 additions & 4 deletions test/adapted.test.ts
Original file line number Diff line number Diff line change
@@ -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<CacheProvider<string, string>> = {
Expand Down Expand Up @@ -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);
Comment thread
Fryuni marked this conversation as resolved.
});

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<void>(resolve => {
markLoaderStarted = resolve;
});
const firstLoader = jest.fn(
() => new Promise<string>(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<TimestampedCacheEntry<string>>();
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<void>(resolve => {
markLoaderStarted = resolve;
});
const loader = jest.fn(
() => new Promise<string>(resolve => {
resolveLoader = resolve;
markLoaderStarted();
}),
);

let markRecoveryStarted!: () => void;
const recoveryStarted = new Promise<void>(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<string>(),
AdaptedCache.jsonDeserializer<string>(),
);
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<string>();
const adaptedCache = AdaptedCache.transformValues(
new AutoSaveCache(inner),
AdaptedCache.jsonSerializer<string>(),
AdaptedCache.jsonDeserializer<string>(),
);
const cache = new SharedInFlightCache(adaptedCache);

await inner.set('key', 'malformed');

let resolveLoader!: (value: string) => void;
let markLoaderStarted!: () => void;
const loaderStarted = new Promise<void>(resolve => {
markLoaderStarted = resolve;
});
const loader = jest.fn()
.mockImplementationOnce(
() => new Promise<string>(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');

Expand All @@ -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');
});
Expand Down
Loading