Skip to content

Commit c01e468

Browse files
ralyodioclaude
andcommitted
A homepage must not 500 because a count is slow
The site was answering `/` with a 500 every few minutes. Measured 2026-08-23: 30.1s then a 500, thirteen seconds into a `stats-warm` window; 5.5s on the retry; 0.24s once the window passed. Three things were true at once and this changes all three. **The warmer was running at a 42% duty cycle.** `statsTick` runs `categoryStats` -- nine conditional aggregates over 476,726 feeds, which no index covers -- on a connection given a deliberate 150s deadline, every `STATS_WARM_SECONDS` and that defaults to 300. Over 274 minutes: 56 runs, 86-150s each, 42.4% of the wall clock, and 20 of the 56 abandoned at the ceiling having paid the whole scan for nothing. `STATS_WARM_SECONDS=3600` is now set on the poller service. The app already serves this blob up to a day stale, so hourly is honest. **A read is not free, whatever the comment says.** Both `statsTick` and `statsWarmer.js` justify the job with "it is a read, and reads do not queue behind the single writer everything else contends for". That is true of `serializeWrites` and the Redis queue, which are in-process, and false at the database: one Turso instance, one SQLite file. Probed from outside across a confirmed 02:25:41-02:26:39 window, reads held at 79ms while an *empty* write transaction took 55,699ms -- one call spanning the entire warm and returning as it ended. That is where the crawler's `write-retried` -> `write-retried` -> `write-failed` chain comes from, and it is ~90s wide because the client deadline is 30s and it tries three times. **The web service was starting the same scan the poller had just given up on.** `CATEGORY_TTL_MS` was five minutes, so past that `remember` fires a background refresh, and on this key the refresh is the 86-150s scan against a 20s timeout it has never once met. Every time the warmer was abandoned the entry went stale and a reader picked the scan back up. Now an hour, matched to the warmer. **And the homepage had no guard at all.** It called `listFeeds`, `countFeeds` and `countFeedsByKind` directly, uncached, with no deadline and no fallback. `crawlstats.js` already documents what two of those cost at this size: a bare `count(*)` of `feeds` is 6.9s, and `select category, count(*) ... group by category` -- which is `countFeedsByKind` verbatim -- exceeds the 30s client deadline. When it does, nothing catches it and Next answers the site's most visited URL with a 500. They now go through `remember` in one key, so a stalled database costs a slightly old count instead of an error page, and a burst of readers costs one scan rather than one each. The interval change removes most of the occasions. The homepage change is the half that matters when there is a next one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e6e9acd commit c01e468

3 files changed

Lines changed: 142 additions & 11 deletions

File tree

‎apps/web/src/app/page.jsx‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { q } from '@rssamplifier/db';
2-
3-
import { db, siteUrl } from '../lib/db.js';
1+
import { siteUrl } from '../lib/db.js';
2+
import { directoryIndex } from '../lib/directory.js';
43
import { feedAlternates } from '../lib/subscribe.js';
54
import { AD_TEXT, adPlan } from '../lib/ads.js';
65
import Ad from './Ad.jsx';
@@ -46,12 +45,10 @@ export const metadata = {
4645
* Directory index: newest blogs first, with the submit box up top.
4746
*/
4847
export default async function Home() {
49-
const client = db();
50-
const [rows, total, byKind] = await Promise.all([
51-
q.listFeeds(client, { limit: 60 }),
52-
q.countFeeds(client),
53-
q.countFeedsByKind(client),
54-
]);
48+
// Cached in Redis and served stale on failure. Two of these three reads are
49+
// whole-table work at half a million feeds, and uncached they answered this
50+
// URL with a 500 whenever the database was busy — see ../lib/directory.js.
51+
const { rows, total, byKind } = await directoryIndex();
5552

5653
// The index is a long scan, so ads go *between* rows rather than around the
5754
// list. First one is deep enough that the fold is all directory, and there

‎apps/web/src/lib/crawlstats.js‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,29 @@ import { db } from './db.js';
4242
/** How long an hourly rollup read is trusted. */
4343
const HISTORY_TTL_MS = 60 * 1000;
4444

45-
/** How long a category breakdown is trusted. */
46-
const CATEGORY_TTL_MS = 5 * 60 * 1000;
45+
/**
46+
* How long a category breakdown is trusted.
47+
*
48+
* Matched to the poller's `STATS_WARM_SECONDS`, and that pairing is the whole
49+
* point rather than a coincidence. Past this age `remember` fires a background
50+
* `refresh()`, and on this key the refresh is a `categoryStats` full scan that
51+
* takes 86–150 seconds against half a million feeds — it cannot finish inside
52+
* `CHART_TIMEOUT_MS` and never once has. So a reader arriving after the TTL
53+
* lapsed was starting a doomed 20-second scan on the one instance the warmer
54+
* was already scanning, and throwing the result away. (`remember` dedupes to
55+
* one in-flight refresh per key, so this was one wasted scan at a time, not
56+
* one per reader — still one too many.)
57+
*
58+
* Five minutes made that certain: the warmer is abandoned at its own 150s
59+
* ceiling roughly a third of the time, and each time it was, the entry went
60+
* stale and the web service picked up the same scan the poller had just given
61+
* up on. An hour means the warmer refreshes it before a reader ever asks.
62+
*
63+
* Safe because the value is served stale for a day anyway
64+
* (`CHART_MAX_STALE_MS`): the honest ceiling on how old this may get was never
65+
* the TTL.
66+
*/
67+
const CATEGORY_TTL_MS = 60 * 60 * 1000;
4768

4869
/**
4970
* How stale a breakdown may get before a reader waits for a fresh one.

‎apps/web/src/lib/directory.js‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { q, remember } from '@rssamplifier/db';
2+
3+
import { db } from './db.js';
4+
5+
/**
6+
* The three reads behind the directory index, cached in Redis.
7+
*
8+
* ## Why this exists
9+
*
10+
* The homepage used to call `listFeeds`, `countFeeds` and `countFeedsByKind`
11+
* directly in a `Promise.all`, uncached, with no deadline and no fallback. Two
12+
* of the three are whole-table work at 476,000 feeds, and this module's sibling
13+
* `crawlstats.js` already documents what they cost: a bare `count(*)` of
14+
* `feeds` is 6.9 s, and `select category, count(*) … group by category` — which
15+
* is `countFeedsByKind`, verbatim — exceeds the client's 30 s deadline. When it
16+
* does, the read throws, nothing catches it, and Next answers the site's most
17+
* visited URL with a 500.
18+
*
19+
* That is not hypothetical and it is not only a bad-day failure. Measured
20+
* 2026-08-23: `GET /` returned 500 after 30.1 s, thirteen seconds into a
21+
* `stats-warm` window, then 5.5 s, then 0.24 s once the window passed. The
22+
* warmer's own scan is what pushes these over the line, so the interval change
23+
* that goes with this commit removes most of the occasions — but a page whose
24+
* only behaviour when the database is slow is to 500 will find the next one.
25+
*
26+
* ## Why a cache and not a faster query
27+
*
28+
* Same trade `crawlstats.js` settled: the columns are `category` and `status`,
29+
* `status` is rewritten on every crawl, and an index covering it would be paid
30+
* for on the write path, which is the binding constraint on this database.
31+
* Buying a fast homepage with a slower crawler is the wrong way round.
32+
*
33+
* ## Why one key and not three
34+
*
35+
* They are rendered together and they are read together, so one Redis round
36+
* trip beats three, and the three numbers can never disagree about which
37+
* moment they describe — a total that does not match the sum of the per-kind
38+
* counts is the kind of thing a reader notices and nobody can reproduce.
39+
*
40+
* ## What the reader sees when it fails
41+
*
42+
* `remember` serves the last good answer for up to a day rather than throwing,
43+
* so a stalled database costs a slightly old count instead of an error page.
44+
* With nothing cached at all it returns the zero shape below, and the homepage
45+
* already renders that: it has an explicit empty-directory branch, and the
46+
* shell around it — search, the submit box, the category index — is worth
47+
* serving on its own. An empty index for one request beats a 500 for a crawler
48+
* that will remember it.
49+
*/
50+
51+
/**
52+
* How long the index is trusted before a refresh is started behind the reader.
53+
*
54+
* A minute, matching `HISTORY_TTL_MS` next door. "Recently added" is a list
55+
* that changes when the crawler admits a feed, not something a visitor is
56+
* watching tick over, and a burst of readers should cost one scan rather than
57+
* one each.
58+
*/
59+
const INDEX_TTL_MS = 60 * 1000;
60+
61+
/**
62+
* How stale it may get before a reader waits for a fresh one.
63+
*
64+
* A day, for the reason `CHART_MAX_STALE_MS` is a day: when the underlying read
65+
* is failing the alternative is not a fresher page, it is no page.
66+
*/
67+
const INDEX_MAX_STALE_MS = 24 * 60 * 60 * 1000;
68+
69+
/**
70+
* Shorter than the client's own 30 s deadline, so a read that is going to hang
71+
* gives the page back before the browser gives up on it.
72+
*/
73+
const INDEX_TIMEOUT_MS = 20 * 1000;
74+
75+
/** How many blogs the index lists. */
76+
export const INDEX_LIMIT = 60;
77+
78+
/** What a reader gets when nothing has ever been cached and the read fails. */
79+
const EMPTY = { rows: [], total: 0, byKind: {} };
80+
81+
/**
82+
* Newest blogs, the directory total, and the per-category counts.
83+
*
84+
* @returns {Promise<{ rows: object[], total: number, byKind: Record<string, number> }>}
85+
*/
86+
export async function directoryIndex() {
87+
const value = await remember(
88+
'directoryIndex',
89+
{
90+
ttlMs: INDEX_TTL_MS,
91+
maxStaleMs: INDEX_MAX_STALE_MS,
92+
timeoutMs: INDEX_TIMEOUT_MS,
93+
fallback: null,
94+
},
95+
async () => {
96+
const client = db();
97+
const [rows, total, byKind] = await Promise.all([
98+
q.listFeeds(client, { limit: INDEX_LIMIT }),
99+
q.countFeeds(client),
100+
q.countFeedsByKind(client),
101+
]);
102+
return { rows, total, byKind };
103+
},
104+
);
105+
106+
// A cached entry predating a shape change, or the fallback, must not be able
107+
// to take the page down a second way — the caller destructures all three.
108+
return {
109+
rows: Array.isArray(value?.rows) ? value.rows : EMPTY.rows,
110+
total: Number(value?.total ?? 0),
111+
byKind: value?.byKind && typeof value.byKind === 'object' ? value.byKind : EMPTY.byKind,
112+
};
113+
}

0 commit comments

Comments
 (0)