Skip to content

perf(sqlite): skip the per-entry FTS table scan on bulk ingest (4.1x) - #949

Merged
smunini merged 1 commit into
mainfrom
perf/fts-delete-scan
Sep 5, 2026
Merged

perf(sqlite): skip the per-entry FTS table scan on bulk ingest (4.1x)#949
smunini merged 1 commit into
mainfrom
perf/fts-delete-scan

Conversation

@angela-helios

Copy link
Copy Markdown
Contributor

The bug

delete_search_index runs before every indexing pass — including each entry of a bulk import. Its second statement removes the resource's row from resource_fts, an FTS5 virtual table, where a WHERE on plain columns cannot use an index:

EXPLAIN QUERY PLAN DELETE FROM resource_fts WHERE tenant_id=? AND resource_type=? AND resource_id=?
→ SCAN resource_fts VIRTUAL TABLE

On a fresh bulk load the resource never existed, the DELETE removes nothing — and the scan grows with every inserted row, making ingest quadratic in the FTS table size. This is the cause of the throughput decay observed on SQLite imports, and it was the bulk of the "84% unattributed" per-entry cost in #947: instrumented on the real 31 GB manifest, the delete consumed 311 of 366 seconds at 25,000 resources (12.4 ms/entry, ~72% of wall time).

The fix

A resource with no search_index rows was never FTS-indexed (every indexed resource carries at least its _id row), so the FTS delete now runs only when the main delete actually removed something. Three lines, no behavior change: any resource that could have an FTS row still gets it deleted.

Measured

Identical 7-minute window, same real manifest, single worker, fresh database:

before after
Throughput 56/s (decaying) 232/s (stable)
FTS-delete cost (cumulative) 311 s @ 25k resources 5 s @ 99k resources

With the scan gone the per-entry budget is fully attributed: batch commit ≈ 1.9 ms and index-row INSERTs ≈ 1.7 ms lead, everything else is sub-0.3 ms. Updates of existing resources still pay one FTS scan; the structural fix (external-content FTS5) is tracked in #947.

Full sqlite persistence suite green (344).

delete_search_index runs before every indexing pass, including each entry
of a bulk import. Its second statement deletes the resource's row from
resource_fts — an FTS5 virtual table, where a WHERE on plain columns
cannot use an index and scans the entire table. On a fresh bulk load the
resource never existed, the DELETE removes nothing, and the scan grows
with every inserted row: instrumented on the real 31 GB manifest, it
consumed 311 of 366 seconds at 25,000 resources (12.4 ms per entry, ~72%
of wall time) and made ingest throughput decay as the table grew.

A resource with no search_index rows was never FTS-indexed — every
indexed resource carries at least its _id row — so the FTS delete now
runs only when the main delete removed something. Same window on the
same manifest: 56/s before, 232/s after (4.1x), with the delete cost
falling from 311 s to 5 s and throughput no longer decaying.

Updates of existing resources still pay one FTS scan; replacing that
with an external-content FTS5 table is tracked in #947.
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@smunini
smunini merged commit 2366a66 into main Sep 5, 2026
20 checks passed
@smunini
smunini deleted the perf/fts-delete-scan branch September 5, 2026 13:20
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.

2 participants