mirror of
https://github.com/immich-app/services.git
synced 2026-09-30 13:23:10 +08:00
feat: stale-while-revalidate for /version memory cache (#23)
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
import { env, fetchMock, SELF } from 'cloudflare:test';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MemoryCache } from './memory-cache.js';
|
||||
import { versionCache } from './version-service.js';
|
||||
import { revalidationState, versionCache } from './version-service.js';
|
||||
import { compareSemVer, isGreaterThan, parseSemVer } from './version.js';
|
||||
import { verifyWebhookSignature } from './webhook.js';
|
||||
|
||||
@@ -86,10 +86,22 @@ describe('MemoryCache', () => {
|
||||
expect(cache.get()).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves a value', () => {
|
||||
it('stores and retrieves a fresh value', () => {
|
||||
const cache = new MemoryCache<string>(1000);
|
||||
cache.set('hello');
|
||||
expect(cache.get()).toBe('hello');
|
||||
const result = cache.get();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.value).toBe('hello');
|
||||
expect(result!.stale).toBe(false);
|
||||
});
|
||||
|
||||
it('returns stale after TTL expires', () => {
|
||||
const cache = new MemoryCache<string>(0); // 0ms TTL = immediately stale
|
||||
cache.set('hello');
|
||||
const result = cache.get();
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.value).toBe('hello');
|
||||
expect(result!.stale).toBe(true);
|
||||
});
|
||||
|
||||
it('invalidates the cache', () => {
|
||||
@@ -195,6 +207,7 @@ describe('Version Worker', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
versionCache.invalidate();
|
||||
revalidationState.inFlight = false;
|
||||
await env.VERSION_DB.exec('DELETE FROM releases');
|
||||
await seedReleases();
|
||||
});
|
||||
@@ -228,6 +241,23 @@ describe('Version Worker', () => {
|
||||
expect(body.published_at).toBe('2025-03-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('awaits D1 on cold start rather than deferring', async () => {
|
||||
// Cache is empty (invalidated in beforeEach), D1 has data
|
||||
// The first request must return real data, not 404 or empty
|
||||
const response = await SELF.fetch('https://example.com/version');
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as any;
|
||||
expect(body.version).toBe('v1.120.0');
|
||||
|
||||
// Now delete D1 data — second request should come from cache, proving
|
||||
// the first request populated the cache synchronously
|
||||
await env.VERSION_DB.exec('DELETE FROM releases');
|
||||
const second = await SELF.fetch('https://example.com/version');
|
||||
expect(second.status).toBe(200);
|
||||
const secondBody = (await second.json()) as any;
|
||||
expect(secondBody.version).toBe('v1.120.0');
|
||||
});
|
||||
|
||||
it('does not include Cache-Control header (memory cached, not CDN)', async () => {
|
||||
const response = await SELF.fetch('https://example.com/version');
|
||||
expect(response.headers.get('Cache-Control')).toBeNull();
|
||||
@@ -256,6 +286,76 @@ describe('Version Worker', () => {
|
||||
const response = await SELF.fetch('https://example.com/version');
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*');
|
||||
});
|
||||
|
||||
it('serves stale data while revalidating in the background', async () => {
|
||||
// Prime the cache
|
||||
const first = await SELF.fetch('https://example.com/version');
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
// Expire the cache by invalidating and setting with 0 TTL
|
||||
versionCache.invalidate();
|
||||
versionCache.set({ version: 'v1.120.0', published_at: '2025-03-01T00:00:00Z' });
|
||||
// Manually expire it
|
||||
Object.assign(versionCache, { expiresAt: 0 });
|
||||
|
||||
// Update D1 with a new version
|
||||
const semver = parseSemVer('v1.130.0')!;
|
||||
await env.VERSION_DB.prepare(
|
||||
`INSERT OR REPLACE INTO releases (id, tag_name, name, url, body, created_at, published_at, major, minor, patch)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
4,
|
||||
'v1.130.0',
|
||||
'v1.130.0',
|
||||
'',
|
||||
'',
|
||||
'2025-04-01T00:00:00Z',
|
||||
'2025-04-01T00:00:00Z',
|
||||
semver.major,
|
||||
semver.minor,
|
||||
semver.patch,
|
||||
)
|
||||
.run();
|
||||
|
||||
// This request should get stale v1.120.0 while triggering background refresh
|
||||
const stale = await SELF.fetch('https://example.com/version');
|
||||
expect(stale.status).toBe(200);
|
||||
const staleBody = (await stale.json()) as any;
|
||||
expect(staleBody.version).toBe('v1.120.0');
|
||||
|
||||
// Wait for background refresh to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Next request should get the updated version from refreshed cache
|
||||
const fresh = await SELF.fetch('https://example.com/version');
|
||||
expect(fresh.status).toBe(200);
|
||||
const freshBody = (await fresh.json()) as any;
|
||||
expect(freshBody.version).toBe('v1.130.0');
|
||||
});
|
||||
|
||||
it('deduplicates concurrent revalidation requests', async () => {
|
||||
// Set up stale cache
|
||||
versionCache.set({ version: 'v1.120.0', published_at: '2025-03-01T00:00:00Z' });
|
||||
Object.assign(versionCache, { expiresAt: 0 });
|
||||
|
||||
expect(revalidationState.inFlight).toBe(false);
|
||||
|
||||
// Fire two concurrent requests while stale
|
||||
const [r1, r2] = await Promise.all([
|
||||
SELF.fetch('https://example.com/version'),
|
||||
SELF.fetch('https://example.com/version'),
|
||||
]);
|
||||
|
||||
expect(r1.status).toBe(200);
|
||||
expect(r2.status).toBe(200);
|
||||
|
||||
// Both should return stale data immediately
|
||||
const b1 = (await r1.json()) as any;
|
||||
const b2 = (await r2.json()) as any;
|
||||
expect(b1.version).toBe('v1.120.0');
|
||||
expect(b2.version).toBe('v1.120.0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /changelog', () => {
|
||||
@@ -540,6 +640,7 @@ describe('Cron sync', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
versionCache.invalidate();
|
||||
revalidationState.inFlight = false;
|
||||
await env.VERSION_DB.exec('DELETE FROM releases');
|
||||
fetchMock.activate();
|
||||
fetchMock.disableNetConnect();
|
||||
|
||||
@@ -70,7 +70,7 @@ export default {
|
||||
},
|
||||
},
|
||||
async (): Promise<Response> => {
|
||||
const latest = await versionService.getLatestVersion();
|
||||
const latest = await versionService.getLatestVersion(deferredRepository);
|
||||
if (!latest) {
|
||||
return errorResponse('No releases found', 404);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
export interface CacheResult<T> {
|
||||
value: T;
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
export class MemoryCache<T> {
|
||||
private data: T | null = null;
|
||||
private expiresAt = 0;
|
||||
|
||||
constructor(private ttlMs: number) {}
|
||||
|
||||
get(): T | null {
|
||||
if (this.data !== null && Date.now() < this.expiresAt) {
|
||||
return this.data;
|
||||
get(): CacheResult<T> | null {
|
||||
if (this.data === null) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
|
||||
return {
|
||||
value: this.data,
|
||||
stale: Date.now() >= this.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
set(value: T): void {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DeferredRepository } from './deferred.js';
|
||||
import type { IGitHubRepository } from './github-repository.js';
|
||||
import { MemoryCache } from './memory-cache.js';
|
||||
import { type IMetricsRepository, Metric } from './metrics.js';
|
||||
@@ -7,8 +8,9 @@ import { parseSemVer } from './version.js';
|
||||
|
||||
const VERSION_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Module-level cache - persists across requests within the same isolate
|
||||
// Module-level state - persists across requests within the same isolate
|
||||
export const versionCache = new MemoryCache<VersionResponse>(VERSION_CACHE_TTL_MS);
|
||||
export const revalidationState = { inFlight: false };
|
||||
|
||||
export class VersionService {
|
||||
constructor(
|
||||
@@ -16,15 +18,34 @@ export class VersionService {
|
||||
private metrics: IMetricsRepository,
|
||||
) {}
|
||||
|
||||
async getLatestVersion(): Promise<VersionResponse | null> {
|
||||
async getLatestVersion(deferred: DeferredRepository): Promise<VersionResponse | null> {
|
||||
const cached = versionCache.get();
|
||||
if (cached) {
|
||||
|
||||
if (cached && !cached.stale) {
|
||||
this.metrics.push(Metric.create('memory_cache_hit').intField('count', 1));
|
||||
return cached;
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
if (cached?.stale) {
|
||||
this.metrics.push(Metric.create('memory_cache_stale').intField('count', 1));
|
||||
if (!revalidationState.inFlight) {
|
||||
revalidationState.inFlight = true;
|
||||
deferred.defer(async () => {
|
||||
try {
|
||||
await this.refreshVersionCache();
|
||||
} finally {
|
||||
revalidationState.inFlight = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
this.metrics.push(Metric.create('memory_cache_miss').intField('count', 1));
|
||||
return this.refreshVersionCache();
|
||||
}
|
||||
|
||||
private async refreshVersionCache(): Promise<VersionResponse | null> {
|
||||
const latest = await this.metrics.monitorAsyncFunction({ name: 'd1_get_latest' }, () =>
|
||||
this.releaseRepository.getLatest(),
|
||||
)();
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user