Skip to content

Commit 934e79d

Browse files
ralyodioclaude
andauthored
/crawlstats: stop paying thirty seconds for a planner's guess (#143)
Three findings, measured against production at 444,009 feeds. **This database has never been ANALYZEd.** There is no `sqlite_stat1`, so SQLite falls back to its built-in guess that an equality test is more selective than a range test. Two of `jobBacklogs`'s five reads ask for a date and filter on a status, and the guess picks the status index for both: submitted 17,722ms -> 654ms (seeks 330k `pending` rows to find 5,954) enriched 16,067ms -> 119ms (seeks 109k `active` rows; the partial index is keyed by the very column being counted) Both are now `indexed by`. Identical results, 27x and 135x. Not a hint SQLite may ignore -- naming a missing index fails at prepare time -- so a test asserts both indexes exist as well as asserting the plans. **The API route bypassed every cache the page uses.** `/api/crawlstats` called `q.jobBacklogs` directly while `/crawlstats` has always called the cached reader, which is why the endpoint took 53 seconds to serve numbers the page rendered in 3.7. It now uses the same reader. The liveness numbers stay uncached -- `crawlStats` and `logActivity` are still read fresh -- so the endpoint still cannot report a dead crawler as alive, which is the one thing a status endpoint must never do. Together: jobBacklogs 27.9s -> 3.2s cold and 263ms warm, and the JSON endpoint drops from 53s to roughly the page's own cost. Left alone deliberately. `categoryStats`'s totals query wants `status`, `last_success_at` and `item_count` for every non-dead row and cannot finish inside the 30s deadline; the covering index that would fix it is over three columns rewritten on every crawl, and writes are the binding constraint. It is served stale-while-revalidate and the stale value is what the page shows. `failingFeeds` (2.4s) sorts every error row by `error_count`, which is written on every successful crawl too, so an index there has the same problem. Both want a rollup rather than an index. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2087c34 commit 934e79d

3 files changed

Lines changed: 136 additions & 5 deletions

File tree

‎apps/web/src/app/api/crawlstats/route.js‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { q, discovery, alerts } from '@rssamplifier/db';
22

33
import { db } from '../../../lib/db.js';
4-
import { categoryStats, indexingHistory } from '../../../lib/crawlstats.js';
4+
import { categoryStats, indexingHistory, jobBacklogs } from '../../../lib/crawlstats.js';
55
import { toLine } from '../../../lib/crawlLog.js';
66
import { jobRows } from '../../../lib/jobs.js';
77

@@ -43,7 +43,17 @@ export async function GET() {
4343
discovery.countQueuedKeywords(client),
4444
indexingHistory(),
4545
categoryStats(),
46-
q.jobBacklogs(client),
46+
// The cached reader, which is what the page has always used. This route
47+
// called `q.jobBacklogs` directly and so paid the uncached count on every
48+
// request -- 27.9 seconds of a 53-second response, while /crawlstats
49+
// rendered the same numbers in 3.7. Nothing about a status endpoint wants
50+
// that: the backlog it reports is hundreds of thousands of feeds draining
51+
// at a few hundred an hour, so a sixty-second-old answer is the same answer.
52+
//
53+
// The liveness numbers stay uncached, which is the distinction that matters
54+
// -- `crawlStats` and `logActivity` below are still read fresh, so this
55+
// endpoint can still never claim a dead crawler is alive.
56+
jobBacklogs(),
4757
q.logActivity(client, 1),
4858
// See the page: this only tells a sender with nobody to serve from one that
4959
// has stopped, which the log alone cannot say.
@@ -52,7 +62,11 @@ export async function GET() {
5262
]);
5363

5464
const jobs = jobRows({
55-
backlogs,
65+
// Null when the read failed and nothing was cached. `jobBacklogs` returns
66+
// null rather than zeroes on purpose -- "0 waiting" reads as "all caught
67+
// up", which would be a lie -- and an empty object leaves each backlog
68+
// undefined, which serialises as unknown rather than as done.
69+
backlogs: backlogs ?? {},
5670
activity,
5771
fetchedLastHour: stats.fetchedLastHour,
5872
keywordQueue: keywordsQueued,

‎packages/db/src/queries.js‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1398,8 +1398,17 @@ export async function jobBacklogs(db) {
13981398

13991399
// A short range read off feeds_created_idx: an hour of submissions is a
14001400
// handful of rows however large the directory gets.
1401+
//
1402+
// `indexed by` because the planner does not agree, and gets it badly wrong.
1403+
// **This database has never been ANALYZEd** -- there is no `sqlite_stat1` --
1404+
// so SQLite falls back to its built-in guess that an equality test beats a
1405+
// range test, picks `feeds_status_success_idx (status=?)`, and visits every
1406+
// `pending` row to check its `created_at`. `pending` is 330k of 444k rows.
1407+
// Measured: 17,722ms this way, 654ms forced onto the range, and the same
1408+
// 5,954 rows come back either way.
14011409
db.execute({
1402-
sql: `select count(*) as n from feeds where created_at >= ? and status = 'pending'`,
1410+
sql: `select count(*) as n from feeds indexed by feeds_created_idx
1411+
where created_at >= ? and status = 'pending'`,
14031412
args: [hourAgo],
14041413
}),
14051414

@@ -1417,10 +1426,16 @@ export async function jobBacklogs(db) {
14171426
// for the reason this whole function exists: 3,275 of 369,056 feeds carry a
14181427
// stamp, so this touches a few thousand index entries, while asking for the
14191428
// complement would visit every row. The backlog is arithmetic afterwards.
1429+
//
1430+
// `indexed by` for the same reason as `submitted` above, and it costs even
1431+
// more here: unforced the planner seeks `status='active'` (109k rows) on
1432+
// `feeds_status_success_idx` and reads `authors_checked_at` off each one,
1433+
// when the partial index *is* keyed by exactly the column being counted and
1434+
// filtered. Measured: 16,067ms unforced, 119ms forced -- 135x, same answer.
14201435
db.execute({
14211436
sql: `select count(*) as n,
14221437
sum(case when authors_checked_at >= ? then 1 else 0 end) as hour
1423-
from feeds
1438+
from feeds indexed by feeds_authors_due_idx
14241439
where status = 'active' and authors_checked_at is not null`,
14251440
args: [hourAgo],
14261441
}),
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import assert from 'node:assert/strict';
2+
import { test, before, after } from 'node:test';
3+
import { mkdtemp, rm } from 'node:fs/promises';
4+
import { tmpdir } from 'node:os';
5+
import { join } from 'node:path';
6+
7+
import { connect, newId, nowIso } from '../src/client.js';
8+
import { migrate } from '../src/migrate.js';
9+
import * as q from '../src/queries.js';
10+
11+
/**
12+
* The index choices behind `jobBacklogs`, pinned.
13+
*
14+
* These are `indexed by` in the query because this database has **never been
15+
* ANALYZEd** — there is no `sqlite_stat1` — so SQLite falls back to its built-in
16+
* guess that an equality beats a range, and picks the status index for two
17+
* queries that want a date. Measured against production: 17.7s vs 654ms, and
18+
* 16.1s vs 119ms, for identical results.
19+
*
20+
* A plan regression is invisible from the outside — same rows, same numbers,
21+
* thirty times the wall clock — so the plan itself is the thing worth asserting.
22+
* Locally the tables are tiny and both plans are instant; only the shape can be
23+
* checked here, which is precisely why it needs a test rather than a benchmark.
24+
*/
25+
26+
let dir;
27+
let db;
28+
29+
before(async () => {
30+
dir = await mkdtemp(join(tmpdir(), 'rssamp-plans-'));
31+
db = connect({ url: `file:${join(dir, 'test.db')}` });
32+
await migrate(db);
33+
});
34+
35+
after(async () => {
36+
await rm(dir, { recursive: true, force: true });
37+
});
38+
39+
/**
40+
* @param {string} sql
41+
* @param {unknown[]} args
42+
* @returns {Promise<string>}
43+
*/
44+
async function plan(sql, args = []) {
45+
const { rows } = await db.execute({ sql: `explain query plan ${sql}`, args });
46+
return rows.map((r) => String(r.detail)).join(' | ');
47+
}
48+
49+
test('the submissions count is read off the created_at index, not the status one', async () => {
50+
const detail = await plan(
51+
`select count(*) as n from feeds indexed by feeds_created_idx
52+
where created_at >= ? and status = 'pending'`,
53+
[nowIso(-3_600_000)],
54+
);
55+
56+
assert.match(detail, /feeds_created_idx/);
57+
assert.doesNotMatch(detail, /feeds_status_success_idx/);
58+
});
59+
60+
test('the enrichment count is read off its own partial index', async () => {
61+
const detail = await plan(
62+
`select count(*) as n,
63+
sum(case when authors_checked_at >= ? then 1 else 0 end) as hour
64+
from feeds indexed by feeds_authors_due_idx
65+
where status = 'active' and authors_checked_at is not null`,
66+
[nowIso(-3_600_000)],
67+
);
68+
69+
assert.match(detail, /feeds_authors_due_idx/);
70+
assert.doesNotMatch(detail, /feeds_status_success_idx/);
71+
});
72+
73+
test('both forced indexes exist, so the hint cannot become an error', async () => {
74+
// `indexed by` is not a hint SQLite may ignore -- naming an index that does
75+
// not exist is a hard failure at prepare time. A migration that renamed or
76+
// dropped either of these would take the whole jobs board down, so the
77+
// coupling is asserted rather than left to be discovered in production.
78+
const { rows } = await db.execute(
79+
`select name from sqlite_master where type = 'index'
80+
and name in ('feeds_created_idx', 'feeds_authors_due_idx')`,
81+
);
82+
83+
assert.equal(rows.length, 2, 'both indexes must exist for jobBacklogs to prepare');
84+
});
85+
86+
test('jobBacklogs still answers with the forced indexes in place', async () => {
87+
// The plans above say which index; this says the numbers survived the change.
88+
const now = nowIso();
89+
await db.execute({
90+
sql: `insert into feeds (id, slug, title, feed_url, status, next_fetch_at, created_at, updated_at)
91+
values (?, 'a', 'A', 'https://a.example/feed', 'pending', ?, ?, ?)`,
92+
args: [newId(), now, now, now],
93+
});
94+
95+
const backlogs = await q.jobBacklogs(db);
96+
97+
assert.equal(typeof backlogs.submittedLastHour, 'number');
98+
assert.equal(backlogs.submittedLastHour, 1);
99+
assert.equal(typeof backlogs.pendingFirstCrawl, 'number');
100+
assert.equal(typeof backlogs.authorsDone, 'number');
101+
assert.equal(typeof backlogs.authorsLastHour, 'number');
102+
});

0 commit comments

Comments
 (0)