fix(ingest): batch a commit larger than one bulk request instead of 500ing - #1286
Conversation
|
@claude please review at head Four things I'd like attacked specifically:
Not in scope: capping fact count at the route with a 422. The audit offered that as an alternative and I rejected it — preview has already shown the user those facts, so refusing to commit them is a worse answer than writing them in batches. |
|
Working my own question 4 rather than leaving it open, so you can falsify the reasoning instead of re-deriving it. Claim: on every exit,
Success path. Each batch's Failure path at
Checked against the test's numbers: 210 facts, batch 1 of 3 fails → The one thing I could not settle by reading is question 3, the |
Claude Code Review ✅ No issues found. The batching logic correctly slices Reviewed by |
Claude Code Review ✅ No issues found. The batching logic correctly preserves ordering, accumulates counts across successful batches, and the shared Reviewed by |
d9a25d1 to
1d03ad8
Compare
|
@erni-a ready for your review — H-07, the opaque 500 that lost every fact when a commit exceeded 100. State at head
Two things worth your eye: 1. The shared attempt id. Every batch reuses 2. One question neither review could settle, and I am not reading silence as agreement. The per-item Also flagging a deliberate rejection: the audit offered "cap fact count at the route with a 422" as an alternative. I did not take it — preview has already shown the user those facts, so refusing to commit them is a worse answer than writing them in batches. Say so if you'd rather have the cap. Not merging — that's Eldad's. |
…00ing ingest_commit packed every surviving fact into a single BulkMemoryCreate, whose items field carries max_length=BULK_MAX_ITEMS (100). Nothing upstream caps the fact count: IngestCommitRequest.facts has no max_length, and preview caps nothing either — it accepts documents up to 100k tokens, sections them at ~2k tokens, and asks the extractor for 5-20 facts per section, so a ~40k-token document routinely clears 100 facts. The model was constructed OUTSIDE the try below it, which catches only HTTPException in any case, and core-api registers no handler for a raw pydantic.ValidationError — RequestValidationError is FastAPI's request-body wrapper and a hand-built model does not raise it. So an over-100 commit reached the global exception handler as an opaque 500 with ZERO facts persisted, and the retry re-extracted the same set and failed identically. The user's only recourse was to feed in a smaller document. Reproduced before fixing, straight out of the pre-fix path: ingest_service.py:1070: pydantic_core._pydantic_core.ValidationError: 1 validation error for BulkMemoryCreate Survivors are now chunked into batches of BULK_MAX_ITEMS and committed sequentially. Every batch shares ONE bulk_attempt_id — run_id, unchanged — and that is the part worth reading twice. It is correct only because H-08 (#1283) made the per-item idempotency key content-derived, so batch boundaries no longer enter the key. The audit's suggested f"{run_id}:batch{n}" would have been actively harmful: the pre-loop dedup shrinks the survivor list between attempts, so a retry re-cuts the boundaries and the same fact lands in a different batch — computing a different key each time and losing the duplicate_attempt resolution that reusing run_id exists to provide. A test pins the shared id so a later change cannot quietly reintroduce per-batch keys. Sequential rather than gathered: each batch already fans out its own embed/enrich internally and takes a per-tenant storage slot, so concurrency here would multiply pressure on the same bulkhead, and it keeps the abort-on-failure semantics honest. A batch failing with HTTPException now stops the run and keeps the counts the earlier batches earned. It previously zeroed them and logged "0 facts persisted on this attempt" — true while there could only ever be one batch, and a false statement to an operator once there can be several, at exactly the moment they are deciding whether to clean up. The message now names how many landed, how many were not attempted, and which fact index the failing batch started at. Stopping rather than continuing is deliberate: these failures are overwhelmingly systemic, so the remaining batches would queue behind the same wall and turn one failure into N. The per-item "fact[N]" warning offsets item.index by the batch start. That format is what the P1.C-lite runbook and operator greps key on; without the offset every batch would restart at fact[0] and point an operator at the wrong fact. The frame of reference is unchanged — it counts within the survivor list, as it always has, not within the caller's original fact list. Tests: three, all confirmed to fail without the fix (the first two with the ValidationError above). The over-100 commit persists every fact — asserted by comparing the full written-content list against the input, not just the batch count, so a chunking bug that dropped or duplicated a fact at a boundary cannot pass. The attempt id is shared across batches. And the mid-run failure keeps the earlier count, stops rather than continuing, and does not log the now-false "0 facts persisted". The captured fixture gains two knobs for this: bulk_attempt_ids records the id each call received, and raise_http_on_batch injects a whole-call failure keyed on batch number — distinct from the existing write_raise_for, which produces per-item error results rather than an HTTPException from the call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Eldad Caura <eldad@caura.ai>
1d03ad8 to
63507d0
Compare
Closes audit finding H-07. Depends on the content-keyed idempotency from #1283 — see Why one attempt id below.
The defect
ingest_commitpacked every surviving fact into a singleBulkMemoryCreate, whoseitemsfield carriesmax_length=BULK_MAX_ITEMS(100). Nothing upstream caps the fact count:IngestCommitRequest.factshas nomax_length.The model was built outside the
trybelow it — which catches onlyHTTPExceptionin any case — and core-api registers no handler for a rawpydantic.ValidationError(RequestValidationErroris FastAPI's request-body wrapper, and a hand-built model doesn't raise it). So it reached the global handler as an opaque 500 with zero facts persisted, and the retry re-extracted the same set and failed identically. The user's only recourse was a smaller document.Reproduced before fixing
Straight out of the pre-fix path:
The fix
Survivors are chunked into batches of
BULK_MAX_ITEMSand committed sequentially.Why one attempt id, not one per batch
Every batch shares
run_id, unchanged — and this is the part worth reading twice.It is correct only because #1283 made the per-item idempotency key content-derived, so batch boundaries no longer enter the key at all.
The audit's suggested
f"{run_id}:batch{n}"would have been actively harmful: the pre-loop dedup shrinks the survivor list between attempts, so a retry re-cuts the boundaries and the same fact lands in a different batch — computing a different key each time and losing theduplicate_attemptresolution that reusingrun_idexists to provide. A test pins the shared id so a later change cannot quietly reintroduce per-batch keys.Sequential, not gathered
Each batch already fans out its own embed/enrich internally and takes a per-tenant storage slot, so concurrency here would multiply pressure on the same bulkhead — and it keeps the abort-on-failure semantics below honest.
A mid-run failure no longer lies about what landed
A batch failing with
HTTPExceptionnow stops the run and keeps the counts earlier batches earned.It previously zeroed them and logged "0 facts persisted on this attempt" — true while there could only ever be one batch, and a false statement to an operator once there can be several, at exactly the moment they are deciding whether to clean up. The message now names how many landed, how many were not attempted, and which fact index the failing batch started at.
Stopping rather than continuing is deliberate: these failures are overwhelmingly systemic (storage down, budget burned), so the remaining batches would queue behind the same wall and turn one failure into N.
The
fact[N]log formatThe per-item warning offsets
item.indexby the batch start. That format is what the P1.C-lite runbook and operator greps key on; without the offset every batch would restart atfact[0]and point an operator at the wrong fact. The frame of reference is unchanged — it counts within the survivor list, as it always has, not within the caller's original fact list.Tests
Three, all confirmed to fail without the fix (the first two with the
ValidationErrorabove):The
capturedfixture gains two knobs:bulk_attempt_idsrecords the id each call received, andraise_http_on_batchinjects a whole-call failure keyed on batch number — distinct from the existingwrite_raise_for, which produces per-item error results rather than anHTTPExceptionfrom the call.Verification
ruff checkandruff format --checkrun separately at CI's exact scopes — clean.mypyclean apart from 2 pre-existingtypes-python-dateutilstub errors in an untouched file.legacy_name_ratchet.py→ No new lines. ·do_not_touch_sentinel.py→ All 39 protected strings survive. ·tenant_scope_gate.py→ exit 0. All aftergit add.origin/main, rebased ontoc2ac9e87.🤖 Generated with Claude Code