fix(retain): queue retain.completed webhook on boundary and zero-fact batches#2861
Conversation
… batches The transactional-outbox callback that queues the retain.completed webhook delivery only fired inside the final facts-bearing batch's write transaction (is_last=True). Two successful retain paths never reached it, silently dropping the delivery with no error and no retry: - Exact chunk-batch boundary: full batches flush with is_last=False and only the leftover partial batch is marked last. When the committed-chunk count is an exact multiple of retain_chunk_batch_size, the queue sentinel drains an empty batch, so is_last=True is never passed. - Zero-fact final batch: _process_db_batch returns before the fact-insert call site (which carries the callback) when a batch extracts no facts — common for boilerplate content. There is no backstop: the delivery row is only inserted by this callback, and the worker poller re-delivers existing rows, so a never-inserted row is lost. Fix: track whether the callback fired in-TXN and, on any successful non-aborted retain that didn't fire it, queue the delivery exactly once in a dedicated transaction after the consumer loop. Aborted (concurrent-takeover) retains are skipped so they don't emit a completion event. Regression tests assert exactly one retain.completed delivery for both the boundary (retain_chunk_batch_size=1) and zero-fact cases; both fail with 0 deliveries on main.
koriyoshi2041
left a comment
There was a problem hiding this comment.
The two regressions are real, but the fallback appears to reintroduce the same silent-loss class across a smaller crash window. On the boundary path, the final facts batch commits first; the delivery row is inserted only afterward in a separate transaction. A process exit between those commits leaves the retain stored with no retain.completed row to retry, which is exactly what the transactional-outbox coupling was meant to prevent.
Could the boundary case instead identify the final queued batch before its write transaction commits (for example with sentinel/lookahead semantics), and could the zero-fact early-return path invoke the callback inside the transaction that records the successful document/no-fact outcome? A crash-point regression or fault-injection test between the data commit and fallback would make the atomicity requirement explicit. The current tests prove eventual insertion on an uninterrupted run, but not the outbox invariant under interruption.
Summary
The transactional-outbox callback that queues the
retain.completedwebhook delivery only fires inside the final facts-bearing batch's write transaction (is_last=True). Two successful retain paths never reach that fire, so the webhook delivery is silently dropped — no error, no log, no retry — even though the memory data is stored correctly.This is a lost notification, not lost data: facts, chunks, entities and the document are all written. But a caller relying on
retain.completedto learn "my ingest finished" (advance a pipeline, mark a job done, trigger downstream indexing) never hears back.The two drop paths (in
_streaming_retain_batch)The streaming consumer drains extracted chunks in batches of
retain_chunk_batch_size(default 100). Full batches flush withis_last=False; only the leftover partial batch drained by the queue sentinel is markedis_last=True, and the callback is threaded into the write TXN at exactly one place (outbox_callback=outbox_callback if is_last else None).Exact chunk-batch boundary. When the committed-chunk count is an exact multiple of
retain_chunk_batch_size, there is no leftover — the last full batch already flushed asis_last=False, and the sentinel drains an empty batch (guarded byif batch and not pipeline_aborted[0]), sois_last=Trueis never passed. A document that extracts into exactly 100/200/300… chunks loses its webhook; 199 or 201 fire fine.Zero-fact final batch.
_process_db_batchreturns early when a batch extracts no facts, before reaching the fact-insert call site that carries the callback. Any retain whose final (or only) batch yields no facts — common for boilerplate content — never queues its delivery, regardless ofis_last.There is no backstop: the delivery row is only ever inserted by this callback, and the worker poller re-delivers rows that exist — a never-inserted row is gone.
Fix
Track whether the callback fired in-TXN (
outbox_fired). On any successful, non-aborted retain that didn't fire it, queue the delivery exactly once in a dedicated transaction after the consumer loop — the same place the existing no-batch document-tracking fallback already runs. This covers both drop paths (and the all-chunks-skipped case) without having to reason about whyis_lastdidn't fire, which is what made a chunk-counting approach fragile. Concurrent-takeover (pipeline_aborted) retains are skipped so an aborted request never emits a completion event.Verification
Two regression tests, both fail with
0deliveries onmainand pass with the fix, asserting exactly oneretain.completeddelivery through the real retain pipeline + a realWebhookManager:test_retain_fires_outbox_on_chunk_batch_boundary—retain_chunk_batch_size=1makes every retain land on a boundary, reproducing the drop deterministically.test_retain_fires_outbox_when_final_batch_extracts_zero_facts— extraction stubbed to return no facts.Full
tests/test_webhooks.py,test_delta_retain.py,test_large_document_replacement.py,test_async_batch_retain.pygreen (92 passed).ruff+ty checkclean.Scope
Deliberately minimal — this fixes only the confirmed outbox drop. A separate, lower-priority cost issue (an oversized
replacewhose slice-1 prefix changes re-extracts trailing history one time via theremoved_indicesdeletion path) is not in scope here; it produces correct state and is a cost optimization, tracked separately.Supersedes #2728, which diagnosed a different mechanism and whose
is_lastfix counted againsttotal_chunksrather than queued chunks (so it didn't fire on the recovery/skip path).