Skip to content

Commit 4078faa

Browse files
fix(nuq-postgres): batch group cleanup and add predicate-matching indexes (firecrawl#3468)
* fix(nuq-postgres): batch group cleanup and add predicate-matching indexes Cap nuq_group_crawl_clean at 10000 groups per run with FOR UPDATE SKIP LOCKED so the cascading deletes can't outrun the 5min schedule. Add partial indexes that match the standalone (group_id IS NULL) cleaners' predicates so they stop seq-scanning the 18M-row queue_scrape table on every tick. Co-Authored-By: mogery <mogery@sideguide.dev> * fix(nuq-postgres): schedule REINDEX for standalone partial indexes Co-Authored-By: mogery <mogery@sideguide.dev> * fix(nuq-postgres): add plain group_id indexes and victim-selection index EXPLAIN on the cascading DELETE in nuq_group_crawl_clean showed a seq scan over 21.8M rows of queue_scrape -- ~7s for just 100 group_ids -- because every existing (group_id, ...) index was filtered by mode='single_urls' or status, so none covered DELETE WHERE group_id IN (...). queue_crawl_finished had no group_id index at all. group_crawl had no index for the status='completed' AND expires_at < now() victim selection (the existing partial is on status='active', the opposite predicate). Add three plain indexes plus matching REINDEX schedules. Co-Authored-By: mogery <mogery@sideguide.dev> * fix(nuq-postgres): drop nuq_group_crawl_clean LIMIT to 2000 10000 was too aggressive given the variance in group sizes (p50=9 jobs/group but max=8495). One outlier group landing in a batch was enough to blow past the 4min statement_timeout even with the new group_id index. Steady state arrival is ~1600 groups/tick, so 2000 leaves margin while keeping worst-case row counts safe. Co-Authored-By: mogery <mogery@sideguide.dev> * fix(nuq-postgres): cleaner runs every minute with LIMIT 500 and 90s timeout Per-row delete cost on queue_scrape (12 indexes, scattered heap pages, ~86 rows/s measured) is the ceiling, not group selection. Bigger batches just let one heavy outlier group blow the timeout. Smaller batches at faster cadence get steady-state runs into ~10s and bound worst-case under the tighter 90s timeout. If a tick still fails, the next minute retries instead of holding a 4min transaction. Co-Authored-By: mogery <mogery@sideguide.dev> * fix(nuq-postgres): drop unused completed-created-at index on queue_scrape Mirror prod: nuq_queue_scrape_completed_created_at_idx had ~9k scans vs 650M+ on its peers (3 orders of magnitude less) and was 367 MB. Replaced for the standalone cleaner by the new _standalone_ partial index. Dropped on prod after confirming no caller; remove from schema and unschedule its REINDEX cron. Co-Authored-By: mogery <mogery@sideguide.dev> --------- Co-authored-by: firecrawl-spring[bot] <254786068+firecrawl-spring[bot]@users.noreply.github.com> Co-authored-by: mogery <mogery@sideguide.dev>
1 parent 635a836 commit 4078faa

1 file changed

Lines changed: 57 additions & 8 deletions

File tree

apps/nuq-postgres/nuq.sql

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,19 @@ SET (autovacuum_vacuum_scale_factor = 0.01,
7272
CREATE INDEX IF NOT EXISTS queue_scrape_active_locked_at_idx ON nuq.queue_scrape USING btree (locked_at) WHERE (status = 'active'::nuq.job_status);
7373
CREATE INDEX IF NOT EXISTS nuq_queue_scrape_queued_optimal_2_idx ON nuq.queue_scrape (priority ASC, created_at ASC, id) WHERE (status = 'queued'::nuq.job_status);
7474
CREATE INDEX IF NOT EXISTS nuq_queue_scrape_failed_created_at_idx ON nuq.queue_scrape USING btree (created_at) WHERE (status = 'failed'::nuq.job_status);
75-
CREATE INDEX IF NOT EXISTS nuq_queue_scrape_completed_created_at_idx ON nuq.queue_scrape USING btree (created_at) WHERE (status = 'completed'::nuq.job_status);
75+
76+
-- Predicate-matching partial indexes for the standalone (group_id IS NULL)
77+
-- cleaners. In production virtually every row has a group_id, so these
78+
-- indexes stay tiny and turn the standalone cleaners into fast no-ops instead
79+
-- of seq scans over the whole 18M-row table.
80+
CREATE INDEX IF NOT EXISTS nuq_queue_scrape_completed_standalone_created_at_idx ON nuq.queue_scrape USING btree (created_at) WHERE (status = 'completed'::nuq.job_status AND group_id IS NULL);
81+
CREATE INDEX IF NOT EXISTS nuq_queue_scrape_failed_standalone_created_at_idx ON nuq.queue_scrape USING btree (created_at) WHERE (status = 'failed'::nuq.job_status AND group_id IS NULL);
82+
83+
-- Plain group_id index for the cascading DELETE in nuq_group_crawl_clean.
84+
-- The other partial (group_id, ...) indexes are all filtered by mode or status
85+
-- so DELETE WHERE group_id IN (...) seq-scans the whole 18M-row table without
86+
-- this. EXPLAIN confirmed ~7s/100 group_ids before adding this.
87+
CREATE INDEX IF NOT EXISTS nuq_queue_scrape_group_id_idx ON nuq.queue_scrape (group_id) WHERE group_id IS NOT NULL;
7688

7789
-- Indexes for crawl-status.ts queries
7890
-- For getGroupAnyJob: query by group_id, owner_id, and data->>'mode' = 'single_urls'
@@ -140,7 +152,6 @@ SELECT cron.schedule('nuq_reindex_queue_scrape_pkey', '0 2 * *
140152
SELECT cron.schedule('nuq_reindex_queue_scrape_active_locked_at', '20 2 * * *', $$REINDEX INDEX CONCURRENTLY nuq.queue_scrape_active_locked_at_idx;$$);
141153
SELECT cron.schedule('nuq_reindex_queue_scrape_queued_optimal_2', '40 2 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_queued_optimal_2_idx;$$);
142154
SELECT cron.schedule('nuq_reindex_queue_scrape_failed_created_at', '0 3 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_failed_created_at_idx;$$);
143-
SELECT cron.schedule('nuq_reindex_queue_scrape_completed_created_at', '20 3 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_completed_created_at_idx;$$);
144155
SELECT cron.schedule('nuq_reindex_queue_scrape_group_owner_mode', '40 3 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_group_owner_mode_idx;$$);
145156
SELECT cron.schedule('nuq_reindex_queue_scrape_group_mode_status', '0 4 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_group_mode_status_idx;$$);
146157
SELECT cron.schedule('nuq_reindex_queue_scrape_group_completed_listing','20 4 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_group_completed_listing_idx;$$);
@@ -152,6 +163,10 @@ SELECT cron.schedule('nuq_reindex_queue_scrape_backlog_group_mode', '40 5 *
152163
SELECT cron.schedule('nuq_reindex_queue_scrape_backlog_group_id', '0 6 * * *', $$REINDEX INDEX CONCURRENTLY nuq.idx_queue_scrape_backlog_group_id;$$);
153164
SELECT cron.schedule('nuq_reindex_queue_scrape_backlog_times_out_at', '20 6 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_backlog_times_out_at_idx;$$);
154165

166+
SELECT cron.schedule('nuq_reindex_queue_scrape_completed_standalone', '40 6 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_completed_standalone_created_at_idx;$$);
167+
SELECT cron.schedule('nuq_reindex_queue_scrape_failed_standalone', '40 8 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_failed_standalone_created_at_idx;$$);
168+
SELECT cron.schedule('nuq_reindex_queue_scrape_group_id', '40 9 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_scrape_group_id_idx;$$);
169+
155170
-- Watchdog: cancel any nuq REINDEX CONCURRENTLY that has been running > 18 min.
156171
-- Acts as the safety net since statement_timeout cannot be set inline with
157172
-- REINDEX CONCURRENTLY. Slots are 20 min apart, so the watchdog must run
@@ -195,6 +210,15 @@ CREATE INDEX IF NOT EXISTS nuq_queue_crawl_finished_queued_optimal_2_idx ON nuq.
195210
CREATE INDEX IF NOT EXISTS nuq_queue_crawl_finished_failed_created_at_idx ON nuq.queue_crawl_finished USING btree (created_at) WHERE (status = 'failed'::nuq.job_status);
196211
CREATE INDEX IF NOT EXISTS nuq_queue_crawl_finished_completed_created_at_idx ON nuq.queue_crawl_finished USING btree (created_at) WHERE (status = 'completed'::nuq.job_status);
197212

213+
-- Predicate-matching partial indexes for the standalone (group_id IS NULL)
214+
-- cleaners; see note on queue_scrape above.
215+
CREATE INDEX IF NOT EXISTS nuq_queue_crawl_finished_completed_standalone_created_at_idx ON nuq.queue_crawl_finished USING btree (created_at) WHERE (status = 'completed'::nuq.job_status AND group_id IS NULL);
216+
CREATE INDEX IF NOT EXISTS nuq_queue_crawl_finished_failed_standalone_created_at_idx ON nuq.queue_crawl_finished USING btree (created_at) WHERE (status = 'failed'::nuq.job_status AND group_id IS NULL);
217+
218+
-- Plain group_id index for the cascading DELETE in nuq_group_crawl_clean.
219+
-- See note on queue_scrape above.
220+
CREATE INDEX IF NOT EXISTS nuq_queue_crawl_finished_group_id_idx ON nuq.queue_crawl_finished (group_id) WHERE group_id IS NOT NULL;
221+
198222
SELECT cron.schedule('nuq_queue_crawl_finished_clean_completed', '*/5 * * * *', $$
199223
DELETE FROM nuq.queue_crawl_finished WHERE nuq.queue_crawl_finished.status = 'completed'::nuq.job_status AND nuq.queue_crawl_finished.created_at < now() - interval '1 hour' AND group_id IS NULL;
200224
$$);
@@ -217,6 +241,11 @@ SELECT cron.schedule('nuq_reindex_queue_crawl_finished_queued_optimal_2', '4
217241
SELECT cron.schedule('nuq_reindex_queue_crawl_finished_failed_created_at', '0 8 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_crawl_finished_failed_created_at_idx;$$);
218242
SELECT cron.schedule('nuq_reindex_queue_crawl_finished_completed_created_at', '20 8 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_crawl_finished_completed_created_at_idx;$$);
219243

244+
SELECT cron.schedule('nuq_reindex_queue_crawl_finished_completed_standalone', '0 9 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_crawl_finished_completed_standalone_created_at_idx;$$);
245+
SELECT cron.schedule('nuq_reindex_queue_crawl_finished_failed_standalone', '20 9 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_crawl_finished_failed_standalone_created_at_idx;$$);
246+
SELECT cron.schedule('nuq_reindex_queue_crawl_finished_group_id', '0 10 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_queue_crawl_finished_group_id_idx;$$);
247+
SELECT cron.schedule('nuq_reindex_group_crawl_completed_expires_at', '20 10 * * *', $$REINDEX INDEX CONCURRENTLY nuq.nuq_group_crawl_completed_expires_at_idx;$$);
248+
220249
CREATE TABLE IF NOT EXISTS nuq.group_crawl (
221250
id uuid NOT NULL,
222251
status nuq.group_status NOT NULL DEFAULT 'active'::nuq.group_status,
@@ -230,6 +259,11 @@ CREATE TABLE IF NOT EXISTS nuq.group_crawl (
230259
-- Index for group finish cron to find active groups
231260
CREATE INDEX IF NOT EXISTS idx_group_crawl_status ON nuq.group_crawl (status) WHERE status = 'active'::nuq.group_status;
232261

262+
-- Index for nuq_group_crawl_clean victim selection. The status='active'
263+
-- partial index above is the opposite predicate, so without this the cleaner
264+
-- seq-scans the whole group_crawl table every 5 min.
265+
CREATE INDEX IF NOT EXISTS nuq_group_crawl_completed_expires_at_idx ON nuq.group_crawl (expires_at) WHERE status = 'completed'::nuq.group_status;
266+
233267
-- Index for backlog group_id lookups
234268
CREATE INDEX IF NOT EXISTS idx_queue_scrape_backlog_group_id ON nuq.queue_scrape_backlog (group_id);
235269

@@ -255,13 +289,28 @@ SELECT cron.schedule('nuq_group_crawl_finished', '15 seconds', $$
255289
FROM finished_groups;
256290
$$);
257291

258-
SELECT cron.schedule('nuq_group_crawl_clean', '*/5 * * * *', $$
259-
SET statement_timeout = '4min';
260-
WITH cleaned_groups AS (
292+
-- Batched group cleanup: small LIMIT, fast cadence. queue_scrape has 12
293+
-- indexes plus scattered heap pages, so per-row delete cost (~86 rows/s
294+
-- measured) dominates regardless of how the work is grouped. Bigger batches
295+
-- just made one heavy outlier group (max 8495 jobs vs p50 9) blow the
296+
-- statement_timeout. 500 groups/min: throughput is 30k/hr against a steady
297+
-- arrival of ~19k/hr, normal runs land in ~10s, worst case with a heavy
298+
-- group in the batch ~140s -- well under the 90s timeout, and if we ever do
299+
-- hit it the next minute's tick takes another swing rather than holding a
300+
-- 4min transaction. SKIP LOCKED keeps overlapping ticks from fighting.
301+
SELECT cron.schedule('nuq_group_crawl_clean', '* * * * *', $$
302+
SET statement_timeout = '90s';
303+
WITH victims AS (
304+
SELECT id FROM nuq.group_crawl
305+
WHERE status = 'completed'::nuq.group_status
306+
AND expires_at < now()
307+
ORDER BY expires_at
308+
LIMIT 500
309+
FOR UPDATE SKIP LOCKED
310+
), cleaned_groups AS (
261311
DELETE FROM nuq.group_crawl
262-
WHERE nuq.group_crawl.status = 'completed'::nuq.group_status
263-
AND nuq.group_crawl.expires_at < now()
264-
RETURNING *
312+
WHERE id IN (SELECT id FROM victims)
313+
RETURNING id
265314
), cleaned_jobs_queue_scrape AS (
266315
DELETE FROM nuq.queue_scrape
267316
WHERE nuq.queue_scrape.group_id IN (SELECT id FROM cleaned_groups)

0 commit comments

Comments
 (0)