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 |
senamakel
left a comment
There was a problem hiding this comment.
Automated technical review: not approved.
Unmet gates:
-
Merge conflict. The PR has merge conflicts (
mergeStateStatus: DIRTY,mergeable: CONFLICTING). It must be rebased onto the latestmainbefore it can be merged. -
CI failures. Three checks fail:
Markdown Link Check-- pre-existing issue (broken fragment anchors in translated docs, not related to code changes).Rust Core Coverage (cargo-llvm-cov)-- pre-existing compilation error in thetinyagentsdependency (variantAgentEvent::ToolCompletedhas no fieldstarted_at_ms``), not caused by this PR.PR CI Gate-- aggregate failure due to the above.
The CI must be green (including pre-existing issues resolved on main) before approval.
N+1 claim: The approach is correct. The new get_chunk_lifecycle_statuses_tx function correctly batches the prior-lifecycle read using WHERE id IN (...) windowed at MAX_FETCH_BATCH, mirroring the existing get_chunks_batch / get_chunk_embeddings_for_signature_batch patterns. The test coverage (batch matches single reads for present/missing ids, empty input issues no query) is thorough. The ingest pipeline change (collecting staged ids, calling the batch function, adjusting the prior.get from .cloned().flatten() to .cloned() since the batch returns HashMap<String, String> instead of HashMap<String, Option<String>>) is behavior-preserving.
Once rebased and CI is green, this PR is ready for approval.
|
Closing this as superseded rather than rebasing — thanks for the review, @senamakel, but the rebase isn't viable: the code this PR optimized no longer exists in the repo. Since this branch was opened,
A trial rebase confirms this: a modify/delete conflict on the (now-deleted) test file plus a ~190-line content conflict in The N+1 finding was validated at the time, but the in-repo target it applied to has been refactored away, so there's nothing left here to land. Closing as superseded. |
|
Superseded by the ingest refactor that moved chunk-store persistence into the tinycortex crate and removed the per-chunk lifecycle loop from ingest_pipeline.rs. Details in the comment above. |
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