perf(memory): batch the prior-lifecycle read in ingest, dropping an N+1#4520
perf(memory): batch the prior-lifecycle read in ingest, dropping an N+1#4520mysma-9403 wants to merge 1 commit into
Conversation
Before the chunk upsert, the ingest pipeline reads each staged chunk's prior lifecycle status to decide which chunks are genuinely new (and need an extract_chunk job) versus already past extraction. That was an N+1: one `SELECT ... WHERE id = ?` per staged chunk, in a loop, inside the write transaction. A single document fans out into many chunks, so a large note/email/page issued dozens–hundreds of single-row queries — each a fresh prepare + B-tree lookup — before the upsert. Add get_chunk_lifecycle_statuses_tx: one `SELECT ... WHERE id IN (...)` windowed at MAX_FETCH_BATCH, mirroring the two batched readers already in this store (get_chunks_batch, get_chunk_embeddings_for_signature_batch). Call it once instead of looping. Remove the now-unused single-id get_chunk_lifecycle_status_tx (the non-tx single-id getter stays for read RPC). The main win is a shorter write-transaction critical section: the loop ran inside an IMMEDIATE transaction and SQLite serialises writers, so collapsing N statements into one shortens the write-lock hold time under concurrent ingest. Statement prepares + lookups also drop from O(chunks) to O(1) query. Behaviour-preserving: the map is keyed by chunk id, present rows map to their status, missing ids are absent — identical to the old `.get(id).flatten()` treating a miss as "no prior row". Reads the same pre-upsert snapshot inside the same transaction. Tests: lifecycle_statuses_batch_matches_single_reads (batch == looping the single-id read, incl. a missing id) and lifecycle_statuses_batch_empty_input_issues_no_query.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughReplaced the per-chunk ChangesBatched lifecycle status lookup
Estimated code review effort: 2 (Simple) | ~12 minutes Sequence Diagram(s)sequenceDiagram
participant IngestPipeline
participant ChunksStore
participant SQLite
IngestPipeline->>IngestPipeline: collect staged chunk IDs
IngestPipeline->>ChunksStore: get_chunk_lifecycle_statuses_tx(ids)
ChunksStore->>SQLite: SELECT id, lifecycle_status WHERE id IN (...)
SQLite-->>ChunksStore: rows
ChunksStore-->>IngestPipeline: HashMap of prior statuses
IngestPipeline->>IngestPipeline: decide CHUNK_STATUS_PENDING_EXTRACTION per chunk
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
What
The memory ingest pipeline reads every staged chunk's prior lifecycle status before the upsert, to decide which chunks are genuinely new (and need an
extract_chunkjob) versus already past extraction. That read was an N+1: oneSELECT … WHERE id = ?per staged chunk, in a loop, inside the write transaction.src/openhuman/memory/ingest_pipeline.rs:A single document fans out into many chunks, so a large note/email/Notion page issues dozens–hundreds of single-row queries — each a fresh
prepare+ B-tree lookup — before the batch even upserts.Change
Add
get_chunk_lifecycle_statuses_tx: oneSELECT id, lifecycle_status … WHERE id IN (…)(windowed atMAX_FETCH_BATCHto stay under SQLite's bound-parameter cap), and call it once:This is not a new pattern — it mirrors the two batched readers already in the same store (
get_chunks_batch,get_chunk_embeddings_for_signature_batch), reusing their windowing/placeholder shape. The now-unused single-idget_chunk_lifecycle_status_txis removed; the single-idget_chunk_lifecycle_status(used by read RPC) stays.Is it worth it? (honest take)
SQLite is in-process, so each avoided round-trip is cheap in absolute terms — this is not a dramatic speedup, and I won't oversell it. Two things make it worthwhile anyway:
IMMEDIATEwrite transaction, and SQLite serialises writers — every extra statement holds the write lock a little longer, blocking other ingest workers. Collapsing N statements into one shortens that hold time, which is the kind of win that actually shows up under concurrent ingest.Correctness
Behaviour-preserving: the map is keyed by chunk id, present rows map to their status, missing ids are absent — identical to the old
.get(id).flatten()treating a miss as "no prior row" (genuinely new). Ordering is irrelevant (map lookup). The batch reads inside the same transaction, so it sees the same pre-upsert snapshot the loop did.Tests
New unit tests in
store_tests.rs:lifecycle_statuses_batch_matches_single_reads— batch result equals looping the single-id read for present ids (explicitadmitted/sealedplus the insert default), and a missing id is absent.lifecycle_statuses_batch_empty_input_issues_no_query— empty input → empty map, no SQL.cargo test --lib memory_store::chunksgreen.Summary by CodeRabbit
Bug Fixes
Tests