Skip to content
Open
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
65 changes: 65 additions & 0 deletions src/analytics/ttl-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { TtlCache } from './ttl-cache';

describe('TtlCache', () => {
it('should return cached value within TTL without calling loader again', async () => {
const cache = new TtlCache<number>(1000);
const loader = jest.fn().mockResolvedValue(42);

const first = await cache.getOrLoad(loader);
const second = await cache.getOrLoad(loader);

expect(first).toBe(42);
expect(second).toBe(42);
expect(loader).toHaveBeenCalledTimes(1);
});

it('should coalesce concurrent calls into a single loader invocation', async () => {
const cache = new TtlCache<string>(1000);
let resolveLoader!: (val: string) => void;
const loader = jest.fn(
() =>
new Promise<string>((resolve) => {
resolveLoader = resolve;
}),
);

const p1 = cache.getOrLoad(loader);
const p2 = cache.getOrLoad(loader);

resolveLoader('result');
const [res1, res2] = await Promise.all([p1, p2]);

expect(res1).toBe('result');
expect(res2).toBe('result');
expect(loader).toHaveBeenCalledTimes(1);
});

it('should discard in-flight result if invalidate() was called while loading (#463)', async () => {
const cache = new TtlCache<string>(5000);
let resolveStale!: (val: string) => void;

const staleLoader = jest.fn(
() =>
new Promise<string>((resolve) => {
resolveStale = resolve;
}),
);

// 1. Uruchamiamy powolny loader
const stalePromise = cache.getOrLoad(staleLoader);

// 2. W trakcie trwania zapytania następuje unieważnienie cache
cache.invalidate();

// 3. Stary loader kończy działanie
resolveStale('stale_data');
await stalePromise;

// 4. Kolejne odpytanie musi wymusić świeże pobranie zamiast serwować stale_data
const freshLoader = jest.fn().mockResolvedValue('fresh_data');
const freshResult = await cache.getOrLoad(freshLoader);

expect(freshResult).toBe('fresh_data');
expect(freshLoader).toHaveBeenCalledTimes(1);
});
});
15 changes: 13 additions & 2 deletions src/analytics/ttl-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
export class TtlCache<T> {
private entry: { data: T; expiresAt: number } | null = null;
private inflight: Promise<T> | null = null;
private generation = 0;

constructor(private readonly ttlMs: number) {}

invalidate(): void {
this.entry = null;
this.inflight = null;
this.generation++;
}

async getOrLoad(loader: () => Promise<T>): Promise<T> {
Expand All @@ -20,15 +23,23 @@ export class TtlCache<T> {
if (this.inflight) {
return this.inflight;
}

const currentGen = this.generation;

this.inflight = (async () => {
try {
const data = await loader();
this.entry = { data, expiresAt: Date.now() + this.ttlMs };
if (currentGen === this.generation) {
this.entry = { data, expiresAt: Date.now() + this.ttlMs };
}
return data;
} finally {
this.inflight = null;
if (currentGen === this.generation) {
this.inflight = null;
}
}
})();

return this.inflight;
}
}