/crawlstats: stop paying thirty seconds for a planner's guess - #143
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The headline
This database has never been
ANALYZEd. There is nosqlite_stat1table, 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:submittedpendingrows to find 5,954enrichedactiverows; the partial index is keyed by the very column being countedIdentical results either way — 27x and 135x purely from index choice. Both now use
indexed by.The other half
/api/crawlstatsbypassed every cache the page uses. It calledq.jobBacklogsdirectly, while/crawlstatshas 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 —
crawlStatsandlogActivityare 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/crawlstatspage: was 8.1s cold / 3.7s warmTests
indexed byis 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
categoryStatstotals wantsstatus,last_success_atanditem_countfor 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 byerror_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
ANALYZEonce 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.