Skip to content

Commit 1ed4d98

Browse files
committed
feat(cache): add in-memory LRU cache for GET /api/campaigns with 5-second TTL
Adds campaignCache.ts backed by lru-cache to avoid SQLite on every campaign-list request. Cache hit returns the pre-serialised JSON body directly, skipping the query entirely. All write paths (create, pledge, reconcile, claim, refund) call invalidateCampaignCache() so stale data is never served past the current request. Cache-Control: max-age=5 and X-Cache: HIT/MISS headers are set on every response. Max cache size is configurable via CAMPAIGN_CACHE_MAX_SIZE env var (default 100). Closes #216
1 parent 1beddc1 commit 1ed4d98

5 files changed

Lines changed: 136 additions & 1 deletion

File tree

backend/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"cors": "^2.8.5",
1111
"dotenv": "^17.3.1",
1212
"express": "^4.21.2",
13+
"lru-cache": "^11.5.1",
1314
"redis": "^4.6.13",
1415
"zod": "^4.3.6"
1516
},

backend/src/index.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ import {
5858
normalizeQueryValue,
5959
} from './validation/schemas';
6060
import { logError, logInfo } from './logger';
61+
import {
62+
buildCampaignCacheKey,
63+
getCampaignCacheEntry,
64+
invalidateCampaignCache,
65+
setCampaignCacheEntry,
66+
} from './services/campaignCache';
6167
export const app = express();
6268

6369
type CampaignListItem = CampaignRecord & { progress: CampaignProgress };
@@ -270,6 +276,22 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
270276

271277
const params = queryResult.data;
272278

279+
// Build a stable cache key from the sorted query string
280+
const qs = Object.keys(req.query as Record<string, unknown>)
281+
.sort()
282+
.map((k) => `${k}=${(req.query as Record<string, unknown>)[k]}`)
283+
.join('&');
284+
const cacheKey = buildCampaignCacheKey(qs);
285+
286+
const cached = getCampaignCacheEntry(cacheKey);
287+
if (cached) {
288+
res.setHeader('Cache-Control', 'max-age=5');
289+
res.setHeader('X-Cache', 'HIT');
290+
res.setHeader('Content-Type', 'application/json');
291+
res.send(cached);
292+
return;
293+
}
294+
273295
const listOptions: ListCampaignsOptions = {
274296
searchQuery: params.search || params.q,
275297
assetCodes: params.asset,
@@ -299,7 +321,7 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
299321
? 1
300322
: Math.max(1, Math.ceil(totalCount / limit));
301323

302-
res.json({
324+
const responseBody = JSON.stringify({
303325
data,
304326
pagination: {
305327
total: totalCount,
@@ -308,6 +330,13 @@ app.get('/api/campaigns', (req: Request, res: Response) => {
308330
totalPages,
309331
},
310332
});
333+
334+
setCampaignCacheEntry(cacheKey, responseBody);
335+
336+
res.setHeader('Cache-Control', 'max-age=5');
337+
res.setHeader('X-Cache', 'MISS');
338+
res.setHeader('Content-Type', 'application/json');
339+
res.send(responseBody);
311340
});
312341

313342
app.get('/api/campaigns/:id', (req: Request, res: Response) => {
@@ -382,6 +411,7 @@ app.post('/api/campaigns', (req: Request, res: Response) => {
382411
};
383412

384413
const campaign = createCampaign(campaignInput);
414+
invalidateCampaignCache();
385415
res.status(201).json({ data: { ...campaign, progress: calculateProgress(campaign) } });
386416
});
387417

@@ -400,6 +430,7 @@ app.post(
400430
}
401431

402432
const campaign = addPledge(parsedId.value, parsedBody.data);
433+
invalidateCampaignCache();
403434
res.status(201).json({ data: { ...campaign, progress: calculateProgress(campaign) } });
404435
},
405436
);
@@ -419,6 +450,7 @@ app.post(
419450
}
420451

421452
const campaign = reconcileOnChainPledge(parsedId.value, parsedBody.data);
453+
invalidateCampaignCache();
422454
res.status(201).json({
423455
data: {
424456
campaign: { ...campaign, progress: calculateProgress(campaign) },
@@ -447,6 +479,7 @@ app.post(
447479
transactionHash: parsedBody.data.transactionHash,
448480
confirmedAt: parsedBody.data.confirmedAt,
449481
});
482+
invalidateCampaignCache();
450483
res.json({ data: { ...campaign, progress: calculateProgress(campaign) } });
451484
},
452485
);
@@ -476,6 +509,7 @@ app.post(
476509
latestLedger: verified.latestLedger ?? parsedBody.data.soroban.latestLedger,
477510
source: 'soroban-contract',
478511
});
512+
invalidateCampaignCache();
479513

480514
res.json({
481515
data: {
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, it, expect, beforeEach } from 'vitest';
2+
import {
3+
buildCampaignCacheKey,
4+
getCampaignCacheEntry,
5+
setCampaignCacheEntry,
6+
invalidateCampaignCache,
7+
getCampaignCacheSize,
8+
} from './campaignCache';
9+
10+
describe('campaignCache', () => {
11+
beforeEach(() => {
12+
invalidateCampaignCache();
13+
});
14+
15+
it('returns undefined for a key that has not been set', () => {
16+
expect(getCampaignCacheEntry('campaigns:missing')).toBeUndefined();
17+
});
18+
19+
it('returns the stored body after a set', () => {
20+
const key = buildCampaignCacheKey('status=open');
21+
const body = JSON.stringify({ data: [], pagination: {} });
22+
setCampaignCacheEntry(key, body);
23+
expect(getCampaignCacheEntry(key)).toBe(body);
24+
});
25+
26+
it('buildCampaignCacheKey namespaces the key correctly', () => {
27+
expect(buildCampaignCacheKey('foo=bar')).toBe('campaigns:foo=bar');
28+
expect(buildCampaignCacheKey('')).toBe('campaigns:');
29+
});
30+
31+
it('stores separate entries for different query strings', () => {
32+
const k1 = buildCampaignCacheKey('status=open');
33+
const k2 = buildCampaignCacheKey('status=funded');
34+
setCampaignCacheEntry(k1, 'open-response');
35+
setCampaignCacheEntry(k2, 'funded-response');
36+
expect(getCampaignCacheEntry(k1)).toBe('open-response');
37+
expect(getCampaignCacheEntry(k2)).toBe('funded-response');
38+
});
39+
40+
it('invalidateCampaignCache clears all entries', () => {
41+
setCampaignCacheEntry(buildCampaignCacheKey('a=1'), 'body-a');
42+
setCampaignCacheEntry(buildCampaignCacheKey('b=2'), 'body-b');
43+
expect(getCampaignCacheSize()).toBe(2);
44+
45+
invalidateCampaignCache();
46+
47+
expect(getCampaignCacheSize()).toBe(0);
48+
expect(getCampaignCacheEntry(buildCampaignCacheKey('a=1'))).toBeUndefined();
49+
});
50+
51+
it('overwrites an existing entry for the same key', () => {
52+
const key = buildCampaignCacheKey('page=1');
53+
setCampaignCacheEntry(key, 'first');
54+
setCampaignCacheEntry(key, 'second');
55+
expect(getCampaignCacheEntry(key)).toBe('second');
56+
});
57+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { LRUCache } from 'lru-cache';
2+
3+
const CACHE_TTL_MS = 5_000;
4+
const CACHE_MAX_SIZE = Number(process.env.CAMPAIGN_CACHE_MAX_SIZE ?? 100);
5+
6+
interface CacheEntry {
7+
body: string;
8+
}
9+
10+
const cache = new LRUCache<string, CacheEntry>({
11+
max: CACHE_MAX_SIZE,
12+
ttl: CACHE_TTL_MS,
13+
});
14+
15+
export function buildCampaignCacheKey(queryString: string): string {
16+
return `campaigns:${queryString}`;
17+
}
18+
19+
export function getCampaignCacheEntry(key: string): string | undefined {
20+
return cache.get(key)?.body;
21+
}
22+
23+
export function setCampaignCacheEntry(key: string, body: string): void {
24+
cache.set(key, { body });
25+
}
26+
27+
export function invalidateCampaignCache(): void {
28+
cache.clear();
29+
}
30+
31+
export function getCampaignCacheSize(): number {
32+
return cache.size;
33+
}

0 commit comments

Comments
 (0)