Repository navigation
crawler: search index in Postgres for many parallel writers (0.48.0) - #192
Merged
Merged
Conversation
SQLite's single writer capped the local search mirror at ~63 pages/s; a
million pages an hour needs ~280/s. CRAWL_INDEX_DATABASE_URL now selects a
PgIndexStore (src/pg-index-store.ts) that any number of publish processes
write at once; unset, the SQLite IndexStore is kept (tests, laptops).
- SearchIndex interface; IndexStore implements it (sync), PgIndexStore
(async); every caller awaits. /api/v1/search, /api/v1/corpus,
/api/v1/corpus/stats and /stats keep their shapes; /stats reads cached
figures with a 1 s cap.
- Postgres: pages with a stored weighted tsvector ('simple') + GIN
(fastupdate), page_policy, page_cards, policy_site_checks, index_meta,
in the role's default schema. One INSERT ... ON CONFLICT per batch over
unnest() of array literals; the page counter moves in the same statement.
- Search ranks a bounded candidate set with ts_rank under a statement
timeout; the product path keeps its newest-N style cap (8,000 matches).
- Corpus cursors stop at the newest id at least 60 s old, since concurrent
writers commit out of id order.
- Trim keeps the live pages (count x bytes a page) inside the budget, since
Postgres reuses freed space rather than shrinking files.
- pit-crawler migrate-index --from-sqlite <path>: streams pages, verdicts,
cards and site checks into Postgres with ids and read times kept,
resumable from a cursor kept in Postgres.
- The page archive names each process's file pages-<hour>-<host>-<pid>.jsonl.gz
so several publishers never share one; ship-archive.sh's glob still matches.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reatcrush)
Each flagged line is Bun's sql tagged template, which binds every ${} as a
parameter; nothing is concatenated.
Co-Authored-By: Claude Opus 5.5 <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.
SQLite's one writer caps pitbot's local search mirror at ~63 pages/s; 1M pages/hour needs ~280/s. With
CRAWL_INDEX_DATABASE_URLset, the mirror now lives in Postgres (services/crawler/src/pg-index-store.ts) and any number of publish processes write it at once. Unset, the existing SQLiteIndexStoreis used unchanged (tests, laptops).Schema (role's default schema, nothing names
public)pages(id bigint identity pk, url text unique, site, kind, title, description, keywords, body, language, fetched_at text, added_at timestamptz default clock_timestamp(), fts tsvector generated stored)fts= title A, keywords B, description + site (+ site split on dots) C, body D, config'simple'pages_ftsGIN (fastupdate = on),pages_fetched_at (fetched_at, id)page_policy(id pk, site, kind, language, fetched_at, train smallint, reason, source)+(train, id),(site, id),(train, site)page_cards(url pk, image, price),policy_site_checks(site pk, checked_at bigint),index_meta(k pk, n bigint)(pages_live, cursors, trim figures)pages/page_policy, set once.How it maps from SQLite
INSERT ... ON CONFLICT (url) DO UPDATEper batch overunnest()ofpgArray()literals (no JS arrays bound). The policy upsert, cards, andpages_live += inserted (xmax = 0)run in the same statement. Rows are sorted by url so concurrent batches lock in one order, and deadlocks are retried. NUL is stripped, and URLs over 2 kB are skipped so they can't fail a batch.websearch_to_tsquery('simple')with each token quoted (soor/-stay words). It ranks the first 3,000 GIN matches withts_rank({0.1,0.4,0.6,1.0})(bm25 used to score every match). AND first, OR fallback. Each query runs under a ~1.1 s statement timeout, and a timeout returns [].score= -ts_rank, so lower is still better.enable_seqscan/indexscan off,work_mem 32MBlocally. Otherwise a word in most pages made the planner seq-scan and decompress every tsvector (a 2 s timeout at 70k pages, cold). Now it uses a GIN bitmap.a b or c d), filters, andtotal_matchescap.count()/sizeBytes()are cached and refreshed in the background, andreport()caps them at 1 s.Behaves differently
added_at). Concurrent writers commit out of id order, and a cursor must not pass an uncommitted id. Writes have a 30 s statement timeout.trim()returnsliveBytes. Batches run under a try-advisory lock withFOR UPDATE SKIP LOCKED, never below keepMin.remove_diacritics 2), andnode.js/1.3.14are single tokens.migrate-index
pit-crawler migrate-index --from-sqlite <path> [--batch n] [--restart]opens SQLite read-only and pages through keyset batches of pages ⟕ page_policy. Each batch lands in one statement together with its cursor (index_meta.migrate_cursor), so it is resumable. Ids and fetched_at are kept. It first moves the id sequence past SQLite's max id. A URL already in PG (written by the crawl) wins. A page whose id was taken by an earlier PG write gets a fresh id. It then copies cards and site checks, andresync()s the counter.Archive
Each process writes
pages-<hour>-<host>-<pid>.jsonl.gz(.partial). A process seals its own past hours at once and another process's once that hour is two behind. Legacy untagged partials still seal. Thepages-*.jsonl.gzglob still matches.Tests
CRAWL_TEST_PGagainstpostgres:17-alpine(container stopped after): 121 pass, 10 skip (Redis/Obscura ones). The 10 new PG tests use their own role + schema each. They cover add/upsert/search/kind/cards/odd input/corpus+lag/policies/mapLegacy/stats, 4 concurrent stores counting 300 exactly, trim oldest-first + keepMin, and migrate-index incl. resume, restart, id collision and the sequence moving past.tscclean, 643 pass, 22 skip.Not deployed; nothing on the live stack changed.
🤖 Generated with Claude Code