fix: move _old_item_ids reads into the off-loop dupe-gate hop (#4441) - #5660
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of The change moves the expensive full-source reads into the existing off-loop gate hop, threads the resolved ids back via a tuple so both the in-transaction delete and Design-Verdict: PASS Blocking reads folded into the already-off-loop gate hop is the root-cause fix, correctly scoped, with the slip window explicitly deferred to #4457. [DESIGN-REVIEWED] 07241cf |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe refactor is behavior-preserving: No findings. [OPUS-REVIEWED] 07241cf Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of The change checks out against the contract: I read the intent file, the patch, and the surrounding First-Principles-Verdict: PASS Four on-loop full-source reads collapse into one off-loop helper — a declared mechanism-level fix, net-subtractive (4 query copies → 1), with recurrence pinned by an AST ratchet. What this change shipsIntent: stop a re-ingest of a changed document from freezing the agent's event loop for seconds on a large library (#4441) — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 07241cf |
Four synchronous full-source reads (SELECT id FROM items WHERE source_id = ?) resolving _old_item_ids ran directly on the asyncio event loop inside ingest_file (replace-all and existing-local-file paths) and ingest_text (hash-matched-existing and replace-all paths). On a large library each materializes ~20k rows, over a second of blocking SQLite per call - the same cost profile retired for the created-ids reads, but these compute the pre-existing group to DELETE, so the created-ids callback substitution cannot cover them. Fold the resolution into the existing off-loop duplicate-gate hop: a new _resolve_old_item_ids helper carries the query, and each gate closure resolves the group on the worker thread (thread-local sqlite connection) just ahead of the gate's own BEGIN IMMEDIATE, returning the resolved ids alongside the gate verdict. The read keeps its logical position relative to the gate/finalize hops - closing the read-to-delete slip window is tracked separately by #4457 and is out of scope here. The occurrence ratchet in test_knowledge_ingest_created_ids.py is repointed (ingest_file and ingest_text now pin zero query literals, the helper pins exactly one), and four new tests spy on the resolver to prove each original read site now runs off the loop while the resolved group still drives the replace. Closes #4441
dc88cfe to
07241cf
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean (CodeQL+Semgrep success 0-ann, no alerts), security checklist all-NO, AI reviewers green. Category: fix with clear root cause — the full-source _old_item_ids read (one row per item, seconds of blocking SQLite on a large library) ran on the event loop in the replace-all ingest paths; moved into the existing off-loop run_to_completion duplicate-gate hop (#4441) via _resolve_old_item_ids, with a ratchet test pinning it off-loop. Same query/ids, no behaviour change — concurrency offload only, no security surface.
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: fix -- moves the full-source _old_item_ids SELECT off the event loop into the off-loop duplicate-gate hop; clear root cause, behavior-preserving, test + coverage files only besides the one source file.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: moves the full-source _old_item_ids read (one row per item, seconds of blocking SQLite on a large library) off the event loop into the existing off-loop duplicate-gate hop, closing #4441 with no change to gate ordering.
Summary
Fixes #4441: four synchronous full-source reads (
SELECT id FROM items WHERE source_id = ?) resolving_old_item_idsran directly on the asyncio event loop inside asyncingest_file(replace-all and existing-local-file paths) andingest_text(changed-existing and replace-all paths). On a large library each materializes ~20k rows — over a second of blocking SQLite per call. This is the same cost profile PR #4443 retired for the created-ids reads, but these reads compute the pre-existing group to DELETE, so the created-ids callback substitution cannot cover them; #4443 explicitly scoped them out.What changed
src/kiro_crew/knowledge/ingestion.py— new_resolve_old_item_idshelper carries the query; each ingest path's duplicate-gate hop (run_to_completion(_gate)) now resolves the group on the worker thread (thread-local sqlite connection) just ahead of the gate's ownBEGIN IMMEDIATE, returning(dupe_job, ids)so the resolved group still reaches both the gate's in-txn delete and_finalize'sdelete_items_batch. The read keeps its logical position relative to the gate/finalize hops — closing the read-to-delete slip window is a separate shape change tracked by knowledge: transactional validation for rollback/ownership writes vs concurrent dedup and cascade (from #4443 round-5 review) #4457 and deliberately out of scope here.test/test_knowledge_ingest_created_ids.py— the occurrence ratchet is repointed:ingest_fileandingest_textnow pin zero query literals,_resolve_old_item_idspins exactly one. NewTestOldGroupReadRunsOffLoopadds one test per original read site: each spies on the resolver, asserts it ran off the loop thread, and pins that the resolved group still drives the replace (stale items deleted).test/test_knowledge_delete_off_loop.py— new AST ratchettest_resolve_old_item_ids_is_never_called_on_the_event_loop: any future call site added straight to a coroutine body fails the suite instead of reintroducing the stall.Verification
str-narrowing as out-of-scope surface (both call sites are truthy-guarded; noted for follow-up).No UI changes (backend-only), so no screenshots.
Closes #4441