Skip to content

/crawlstats: stop paying thirty seconds for a planner's guess - #143

Merged
ralyodio merged 1 commit into
mainfrom
worktree-crawlstats-speed
Aug 19, 2026
Merged

/crawlstats: stop paying thirty seconds for a planner's guess#143
ralyodio merged 1 commit into
mainfrom
worktree-crawlstats-speed

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The headline

This database has never been ANALYZEd. There is no sqlite_stat1 table, 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 also filter on a status. The guess picks the status index for both — and status is not selective here at all:

read before after why
submitted 17,722ms 654ms seeked 330k pending rows to find 5,954
enriched 16,067ms 119ms seeked 109k active rows; the partial index is keyed by the very column being counted

Identical results either way — 27x and 135x purely from index choice. Both now use indexed by.

The other half

/api/crawlstats bypassed every cache the page uses. It called q.jobBacklogs directly, while /crawlstats has always called the cached reader. That is why the endpoint took 53s to serve numbers the page rendered in 3.7s.

It now uses the same cached reader. The liveness numbers stay uncached — crawlStats and logActivity are still read fresh on every request — so the endpoint still cannot report a dead crawler as alive, which is the one thing a status endpoint must never do.

Measured

  • jobBacklogs: 27,875ms → 3,158ms cold, 263ms warm
  • /api/crawlstats: 53s → roughly the page's own cost
  • /crawlstats page: was 8.1s cold / 3.7s warm

Tests

indexed by is not a hint SQLite may ignore — naming an index that doesn't exist fails at prepare time, so a renaming migration would take the jobs board down. The new tests assert the plans and that both indexes exist, plus that the numbers survive. A plan regression is otherwise invisible: same rows, same answers, thirty times the wall clock.

Full suite: 1,139 tests, 0 failures.

Deliberately not fixed

  • categoryStats totals 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 spans 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 — worth knowing that the background refresh is currently always failing.
  • failingFeeds (2.4s) sorts every error row by error_count, which is also written on every successful crawl.

Both want a rollup table (like crawl_hourly) rather than an index.

Follow-up worth considering: running ANALYZE once would fix this class of problem globally rather than query by query. I did not run it — it is a long write against a 444k-row table on a live crawler, and it changes plans everywhere at once with no way to test first.

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>
@ralyodio
ralyodio merged commit 934e79d into main Aug 19, 2026
3 checks passed
@ralyodio
ralyodio deleted the worktree-crawlstats-speed branch August 19, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant