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
98 changes: 98 additions & 0 deletions app/src/lib/launchpad/memo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { MEMO_MAX_KEYS, clearMemo, memo, memoSizeForTests } from "./memo.ts";

function clock(start = 1_000_000) {
let t = start;
return { now: () => t, advance: (ms: number) => void (t += ms) };
}

test("fresh hits share one value; a rejection is not cached", async () => {
clearMemo();
const c = clock();
let calls = 0;
const fn = async () => `v${++calls}`;
assert.equal(await memo("a", 2_000, fn, c.now), "v1");
assert.equal(await memo("a", 2_000, fn, c.now), "v1", "second call inside the TTL never reruns fn");
assert.equal(calls, 1);
c.advance(2_001);
assert.equal(await memo("a", 2_000, fn, c.now), "v2", "past the entry's own TTL it refetches");
assert.equal(calls, 2);

clearMemo();
let fails = 0;
const boom = async (): Promise<string> => { fails++; throw new Error("down"); };
await assert.rejects(memo("b", 2_000, boom, c.now));
await assert.rejects(memo("b", 2_000, boom, c.now), "failures delete the key: the next call retries");
assert.equal(fails, 2);
});

test("concurrent callers share the in-flight promise", async () => {
clearMemo();
const c = clock();
let calls = 0;
let release!: (v: string) => void;
const slow = async () => { calls++; return new Promise<string>((res) => { release = res; }); };
const p1 = memo("k", 2_000, slow, c.now);
const p2 = memo("k", 2_000, slow, c.now);
release("done");
assert.deepEqual(await Promise.all([p1, p2]), ["done", "done"]);
assert.equal(calls, 1);
});

test("the cache is bounded: bursts of fresh keys evict oldest first", async () => {
clearMemo();
const c = clock();
for (let i = 0; i < MEMO_MAX_KEYS + 50; i++) {
await memo(`key-${i}`, 60_000, async () => i, c.now);
}
assert.equal(memoSizeForTests(), MEMO_MAX_KEYS, "never grows past the cap, even when every entry is fresh");
let reruns = 0;
await memo("key-0", 60_000, async () => { reruns++; return -1; }, c.now);
assert.equal(reruns, 1, "the oldest key was evicted, so it refetches");
await memo(`key-${MEMO_MAX_KEYS + 49}`, 60_000, async () => { reruns++; return -1; }, c.now);
assert.equal(reruns, 1, "the newest key survived and still hits");
});

test("a hot key survives a burst of cold keys; expired entries go first", async () => {
clearMemo();
const c = clock();
await memo("hot", 60_000, async () => "hot", c.now);
for (let i = 0; i < MEMO_MAX_KEYS - 1; i++) {
await memo(`cold-${i}`, 60_000, async () => i, c.now);
}
assert.equal(memoSizeForTests(), MEMO_MAX_KEYS);
let hotReruns = 0;
await memo("hot", 60_000, async () => { hotReruns++; return "hot2"; }, c.now);
assert.equal(hotReruns, 0, "touching hot refreshes its recency");
await memo("newcomer", 60_000, async () => "new", c.now);
assert.equal(memoSizeForTests(), MEMO_MAX_KEYS);
await memo("hot", 60_000, async () => { hotReruns++; return "hot2"; }, c.now);
assert.equal(hotReruns, 0, "hot survives the overflow; an untouched cold key was evicted instead");

clearMemo();
for (let i = 0; i < MEMO_MAX_KEYS; i++) {
await memo(`short-${i}`, 1_000, async () => i, c.now);
}
c.advance(5_000);
await memo("fresh", 60_000, async () => "fresh", c.now);
assert.equal(memoSizeForTests(), MEMO_MAX_KEYS);
let freshReruns = 0;
await memo("fresh", 60_000, async () => { freshReruns++; return "x"; }, c.now);
assert.equal(freshReruns, 0, "fresh entry kept");
});

test("each entry expires by its own TTL, not the caller's", async () => {
clearMemo();
const c = clock();
let calls = 0;
await memo("own-ttl", 10_000, async () => ++calls, c.now);
c.advance(5_000);
// A caller passing a shorter TTL must not cut a longer-lived entry short,
// and a longer TTL must not extend a short-lived one past its own expiry.
await memo("own-ttl", 1_000, async () => ++calls, c.now);
assert.equal(calls, 1, "entry judged by its own 10s TTL, still fresh at 5s");
c.advance(6_000);
await memo("own-ttl", 60_000, async () => ++calls, c.now);
assert.equal(calls, 2, "11s > own 10s TTL: refetch despite the caller's 60s TTL");
});
50 changes: 43 additions & 7 deletions app/src/lib/launchpad/memo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,54 @@ import "server-only";
* Tiny in-process memo for hot read endpoints. With N browsers polling every
* 5s, a 2s TTL turns N queries into ~1 per key without anyone noticing.
* In-flight requests for the same key share one promise (no thundering herd).
*
* The cache is bounded: at most MEMO_MAX_KEYS entries. Expiry uses each
* entry's own TTL; when everything is still fresh, the oldest entries go
* first (a fresh hit refreshes recency, so hot keys survive bursts of cold
* ones). Without the bound, request-controlled keys (per-bucket candle pages,
* per-limit list keys) grow the map without limit inside one TTL window.
*/
const store = new Map<string, { at: number; value: Promise<unknown> }>();
export const MEMO_MAX_KEYS = 200;

export function memo<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
const now = Date.now();
const store = new Map<string, { at: number; ttlMs: number; value: Promise<unknown> }>();

/** Test-only: drop every entry so cases start isolated. */
export function clearMemo(): void {
store.clear();
}

/** Test-only: current entry count. */
export function memoSizeForTests(): number {
return store.size;
}

export function memo<T>(key: string, ttlMs: number, fn: () => Promise<T>, now: () => number = Date.now): Promise<T> {
const t = now();
const hit = store.get(key);
if (hit && now - hit.at < ttlMs) return hit.value as Promise<T>;
const value = fn().catch((err) => {
if (hit && t - hit.at < hit.ttlMs) {
// Refresh recency: a hot key must not be evicted by a burst of cold keys.
store.delete(key);
store.set(key, hit);
return hit.value as Promise<T>;
}
const value = fn().catch((err) => {
if (store.get(key)?.value === value) store.delete(key);
throw err;
});
store.set(key, { at: now, value });
if (store.size > 200) for (const [k, v] of store) if (now - v.at > ttlMs) store.delete(k);
if (hit) store.delete(key);
store.set(key, { at: t, ttlMs, value });
if (store.size > MEMO_MAX_KEYS) {
// Expired entries first (each judged by its own TTL)…
for (const [k, v] of store) {
if (store.size <= MEMO_MAX_KEYS) break;
if (t - v.at >= v.ttlMs) store.delete(k);
}
// …then oldest first. Map preserves insertion order and the key just
// written is newest, so this never evicts the caller's own entry.
for (const k of store.keys()) {
if (store.size <= MEMO_MAX_KEYS) break;
store.delete(k);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can evict an in-flight entry while its rejection handler at lines 37-38 still owns an unconditional store.delete(key). If the key is reloaded before the old request fails, that old failure deletes the fresh replacement and causes unnecessary backend work. Please make rejection cleanup conditional on the cached promise/entry still being the one that failed. Add an eviction -> successful replacement -> old rejection test; the issue reproduces without advancing time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Vasanthdev2004 Thanks — fixed to conditional delete if (store.get(key)?.value === value) store.delete(key) so the old rejection no longer deletes the fresh replacement. Verified with the constant-clock reproduction you described (pending → 200 inserts → reload → old reject → next lookup hits fresh).

}
}
return value;
}
Loading