Skip to content

Commit 32c48bb

Browse files
ralyodioclaude
andcommitted
The homepage's cache has no writer, so give it one
Following on from 054efbe, which was correct and not sufficient. Widening INDEX_MAX_STALE_MS stopped readers blocking on an entry that existed; it did nothing about there being no way to create one. `remember` fills itself from readers: past the TTL a reader starts a background `refresh()`. That only works when the computation fits in the timeout the page is willing to wait, and this one never does -- `countFeedsByKind` is a `group by` over 476,000 rows with no covering index, so every refresh is abandoned at 20s and the entry is never renewed. It could only ever age out. Which is what happened in production within minutes of 054efbe deploying. Redis no longer had the key, so the homepage fell to the branch below the widened ceiling: waited its 20 seconds for a computation that was always going to fail, then rendered the empty-directory branch. A directory of 476,000 feeds reporting that it has none -- worse than the slow page it replaced, and my fault for changing when the entry is served without checking what writes it. So the writer moves to the poller, where `warmStatsCache` already does exactly this for `categoryStats` on a connection with a 150-second deadline. Same shape: its own patient connection, never throws, logs `directory-warmed` / `directory-warm-skipped`. It runs after the category warm rather than beside it so the two whole-table scans queue instead of competing, and it is not gated on the category warm succeeding -- the keys fail independently. The web side is untouched. `remember` finds a warm entry and serves it, and its own doomed refreshes become harmless rather than load-bearing. Verified: suite green (11 packages, fail 0), `pnpm build` compiles, and `warmDirectoryCache` resolves through the package entry point. The key name and the `{rows, total, byKind}` shape are a contract with apps/web/src/lib/directory.js that no test covers -- a drift there is a silently empty homepage -- so it is checked against the live poller log and the page after deploy rather than only in CI. Still does not restore the crawler: the write path stays wedged until the Turso quota is raised or resets on 1 Sep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 054efbe commit 32c48bb

3 files changed

Lines changed: 88 additions & 1 deletion

File tree

‎apps/poller/src/index.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
alerts,
88
takeWriteTally,
99
warmStatsCache,
10+
warmDirectoryCache,
1011
} from '@rssamplifier/db';
1112
import {
1213
crawlDue,
@@ -872,6 +873,21 @@ async function statsTick() {
872873
warming = true;
873874
try {
874875
const result = await warmStatsCache({ log });
876+
877+
// The homepage's counts, warmed on the same tick and for the same reason.
878+
// Its `remember` entry can only ever be written from here: a reader's own
879+
// background refresh is capped at the 20 seconds the page will wait, and
880+
// `countFeedsByKind` needs far longer, so without this the entry ages out
881+
// and the directory renders itself as empty.
882+
//
883+
// Deliberately after the category warm rather than beside it: both are
884+
// whole-table reads, and running them in sequence keeps one long scan on
885+
// this connection at a time instead of two competing for the same instance.
886+
// Not gated on `result.ok` either — the two keys fail independently, and a
887+
// category breakdown that timed out says nothing about whether the counts
888+
// will.
889+
await warmDirectoryCache({ log });
890+
875891
if (!result.ok) return;
876892
} finally {
877893
warming = false;

‎packages/db/index.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export { connect, newId, nowIso } from './src/client.js';
22
export { createWriteWorker, WRITE_QUEUE, takeWriteTally } from './src/writeQueue.js';
33
export { remember, redisClient, primeCache, resetCacheState } from './src/cache.js';
4-
export { warmStatsCache } from './src/statsWarmer.js';
4+
export { warmStatsCache, warmDirectoryCache } from './src/statsWarmer.js';
55
export { migrate } from './src/migrate.js';
66
export * as q from './src/queries.js';
77
export * as accounts from './src/accounts.js';

‎packages/db/src/statsWarmer.js‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ const WARM_TIMEOUT_MS = 150_000;
4848
/** How much growth history the breakdown carries; matches the web's GROWTH_DAYS. */
4949
const GROWTH_DAYS = 30;
5050

51+
/** How many blogs the directory index lists; matches the web's `INDEX_LIMIT`. */
52+
const INDEX_LIMIT = 60;
53+
5154
/**
5255
* Compute the category breakdown on a patient connection and cache it.
5356
*
@@ -88,3 +91,71 @@ export async function warmStatsCache(opts = {}) {
8891
try { db?.close(); } catch { /* closing a broken client is not a failure */ }
8992
}
9093
}
94+
95+
/**
96+
* The same treatment for the homepage's three reads.
97+
*
98+
* ## Why this has to exist, and not just a wider staleness window
99+
*
100+
* `remember` fills its own cache from readers: past the TTL a reader triggers
101+
* `refresh()`, which recomputes behind them. That works only if the computation
102+
* can finish inside the timeout the *page* is willing to wait — and for this key
103+
* it cannot. `countFeedsByKind` is a `group by category` over 476,000 rows with
104+
* no covering index, so every refresh is abandoned at 20 seconds and the entry
105+
* is never renewed.
106+
*
107+
* Which leaves the entry ageing out with nothing able to replace it. Widening
108+
* `INDEX_MAX_STALE_MS` bought time and not a fix: once Redis dropped the key the
109+
* homepage had nothing to serve, waited its 20 seconds for a computation that
110+
* was always going to fail, and rendered the empty-directory branch — a
111+
* directory of 476,000 feeds reporting itself as having none. That is worse than
112+
* the slow page it replaced, and it is what production did on 2026-08-25 within
113+
* minutes of the staleness change deploying.
114+
*
115+
* So the writer moves here, where a 150-second deadline is allowed and the read
116+
* actually completes. The web side keeps `remember` exactly as it is: it finds a
117+
* warm entry and serves it, and its own doomed refreshes become harmless.
118+
*
119+
* Priming on the same tick as the category breakdown is deliberate — both are
120+
* whole-table reads and running them together keeps the number of full scans
121+
* this database sees to one burst an hour rather than two.
122+
*
123+
* @param {{ log?: (event: string, fields?: object) => void, client?: any }} [opts]
124+
* @returns {Promise<{ ok: boolean, ms: number, cached: boolean, error?: string }>}
125+
*/
126+
export async function warmDirectoryCache(opts = {}) {
127+
const started = Date.now();
128+
const log = opts.log ?? (() => {});
129+
130+
/** @type {import('@libsql/client').Client|null} */
131+
let db = null;
132+
try {
133+
db = connect({ timeoutMs: WARM_TIMEOUT_MS, queue: false });
134+
135+
// Shape and key must match `apps/web/src/lib/directory.js` exactly: it
136+
// destructures all three, and a mismatch here is an empty homepage that no
137+
// error anywhere would explain.
138+
const [rows, total, byKind] = await Promise.all([
139+
q.listFeeds(db, { limit: INDEX_LIMIT }),
140+
q.countFeeds(db),
141+
q.countFeedsByKind(db),
142+
]);
143+
const cached = await primeCache('directoryIndex', { rows, total, byKind }, {
144+
client: opts.client,
145+
});
146+
147+
const ms = Date.now() - started;
148+
log('directory-warmed', { ms, total, rows: rows?.length ?? 0, cached });
149+
return { ok: true, ms, cached };
150+
} catch (error) {
151+
const ms = Date.now() - started;
152+
// `…-skipped` rather than `…-error`, for the reason above: the page has a
153+
// cached answer and the operational-error panel treats a trailing "error"
154+
// as an alarm.
155+
const reason = error instanceof Error ? error.message : String(error);
156+
log('directory-warm-skipped', { ms, reason });
157+
return { ok: false, ms, cached: false, error: reason };
158+
} finally {
159+
try { db?.close(); } catch { /* closing a broken client is not a failure */ }
160+
}
161+
}

0 commit comments

Comments
 (0)