Skip to content

Commit c3dfaf3

Browse files
authored
fix(retain): queue retain.completed webhook on boundary and zero-fact batches (#2861)
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.
1 parent 234f5a0 commit c3dfaf3

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,14 @@ async def _streaming_retain_batch(
13131313
retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags)
13141314
# Track whether document tracking has been done (by the first batch)
13151315
doc_tracking_done = [False]
1316+
# Track whether the transactional-outbox callback has already fired inside a
1317+
# batch write TXN. The in-TXN fire only runs on a final facts-bearing batch
1318+
# (is_last=True); two success paths never reach it — a committed-chunk count
1319+
# that lands exactly on a chunk_batch_size boundary (the sentinel drains an
1320+
# empty batch), and a final batch that extracts zero facts (it returns before
1321+
# the insert). A post-loop fallback fires the callback in those cases, so this
1322+
# flag exists to guarantee the callback fires exactly once.
1323+
outbox_fired = [False]
13161324

13171325
# ---------------------------------------------------------------------------
13181326
# Producer-consumer pipeline: LLM extraction runs concurrently with DB writes
@@ -1729,6 +1737,12 @@ async def _run_mini_batch_db_work() -> None:
17291737

17301738
logger.info(f"[streaming] Phase 2 (write txn): {time.time() - p2_start:.3f}s")
17311739

1740+
# The write TXN above committed the transactional-outbox row in the
1741+
# same transaction as this batch's facts. Record it so the post-loop
1742+
# fallback doesn't queue a duplicate delivery.
1743+
if is_last and outbox_callback is not None:
1744+
outbox_fired[0] = True
1745+
17321746
# Best-effort: flush entity_cooccurrences and other deferred stats.
17331747
try:
17341748
await entity_resolver.flush_pending_stats()
@@ -1862,6 +1876,21 @@ async def _run_mini_batch_db_work() -> None:
18621876
combined_content = ""
18631877
log_buffer.append(f"[streaming] Document {effective_doc_id} tracked (no facts extracted)")
18641878

1879+
# Transactional-outbox fallback. The in-TXN fire only runs on a final
1880+
# facts-bearing batch (is_last=True). When the committed-chunk count lands
1881+
# exactly on a chunk_batch_size boundary the sentinel drains an empty batch
1882+
# and never marks one last; when the final batch extracts zero facts it
1883+
# returns before the insert; and when every chunk is skipped as already
1884+
# committed no batch runs at all. In each of those the retain still
1885+
# succeeded, so the retain.completed delivery must be queued — exactly once,
1886+
# in its own transaction (there is no batch TXN left to attach it to). Skip
1887+
# it on a concurrent takeover: an aborted request must not emit completion.
1888+
if outbox_callback is not None and not outbox_fired[0] and not pipeline_aborted[0]:
1889+
async with acquire_with_retry(pool) as conn:
1890+
async with conn.transaction():
1891+
await outbox_callback(conn)
1892+
outbox_fired[0] = True
1893+
18651894
# Mark facts as committed in operation metadata (crash recovery checkpoint)
18661895
if operation_id and all_unit_ids:
18671896
try:

hindsight-api-slim/tests/test_webhooks.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,140 @@ async def test_async_batch_retain_queues_one_webhook_per_document(
979979
)
980980
await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)
981981

982+
@staticmethod
983+
async def _count_retain_deliveries(pool, bank_id: str) -> int:
984+
async with pool.acquire() as conn:
985+
return await conn.fetchval(
986+
"""
987+
SELECT count(*)
988+
FROM async_operations
989+
WHERE operation_type = 'webhook_delivery'
990+
AND bank_id = $1
991+
AND task_payload->>'event_type' = 'retain.completed'
992+
""",
993+
bank_id,
994+
)
995+
996+
@pytest.mark.asyncio
997+
async def test_retain_fires_outbox_on_chunk_batch_boundary(self, memory: MemoryEngine, request_context):
998+
"""A committed-chunk count that lands exactly on a ``retain_chunk_batch_size``
999+
boundary must still queue retain.completed exactly once.
1000+
1001+
Regression: the streaming consumer flushes full batches with
1002+
``is_last=False`` and only marks the *leftover* partial batch as last.
1003+
When the chunk count is an exact multiple of the batch size the sentinel
1004+
drains an empty batch, so the in-TXN outbox fire never runs and the
1005+
delivery was silently dropped. Batch size 1 makes every retain hit this
1006+
boundary, so it reproduces the drop deterministically.
1007+
"""
1008+
bank_id = f"wh-boundary-{uuid.uuid4().hex[:8]}"
1009+
webhook_id = uuid.uuid4()
1010+
original_manager = memory._webhook_manager
1011+
resolver_config = memory._config_resolver._global_config
1012+
original_batch_size = resolver_config.retain_chunk_batch_size
1013+
try:
1014+
memory._webhook_manager = WebhookManager(backend=memory._backend, global_webhooks=[])
1015+
resolver_config.retain_chunk_batch_size = 1
1016+
1017+
await _ensure_bank(memory._pool, bank_id)
1018+
async with memory._pool.acquire() as conn:
1019+
await conn.execute(
1020+
"""
1021+
INSERT INTO webhooks (id, bank_id, url, secret, event_types, enabled, created_at, updated_at)
1022+
VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW())
1023+
""",
1024+
webhook_id,
1025+
bank_id,
1026+
"https://example.com/retain-hook",
1027+
["retain.completed"],
1028+
)
1029+
1030+
contents = [{"content": "Alice works at Google", "document_id": "doc-boundary"}]
1031+
callback = memory._build_retain_outbox_callback(
1032+
bank_id=bank_id, contents=contents, operation_id="op-boundary"
1033+
)
1034+
assert callback is not None
1035+
await memory.retain_batch_async(
1036+
bank_id=bank_id,
1037+
contents=contents,
1038+
request_context=request_context,
1039+
outbox_callback=callback,
1040+
)
1041+
1042+
deliveries = await self._count_retain_deliveries(memory._pool, bank_id)
1043+
assert deliveries == 1, f"expected exactly one retain.completed delivery, got {deliveries}"
1044+
finally:
1045+
memory._webhook_manager = original_manager
1046+
resolver_config.retain_chunk_batch_size = original_batch_size
1047+
async with memory._pool.acquire() as conn:
1048+
await conn.execute(
1049+
"DELETE FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1",
1050+
bank_id,
1051+
)
1052+
await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)
1053+
await memory.delete_bank(bank_id, request_context=request_context)
1054+
1055+
@pytest.mark.asyncio
1056+
async def test_retain_fires_outbox_when_final_batch_extracts_zero_facts(
1057+
self, memory: MemoryEngine, request_context, monkeypatch
1058+
):
1059+
"""A retain whose final batch extracts zero facts must still queue
1060+
retain.completed exactly once.
1061+
1062+
Regression: ``_process_db_batch`` returns before the fact-insert call
1063+
site (which carries the outbox callback) when a batch has no facts, so a
1064+
document that extracts nothing — common for boilerplate content — never
1065+
queued its delivery.
1066+
"""
1067+
from hindsight_api.engine.response_models import TokenUsage
1068+
from hindsight_api.engine.retain import fact_extraction
1069+
1070+
bank_id = f"wh-zerofact-{uuid.uuid4().hex[:8]}"
1071+
webhook_id = uuid.uuid4()
1072+
original_manager = memory._webhook_manager
1073+
1074+
async def _extract_no_facts(*args, **kwargs):
1075+
return [], [], TokenUsage()
1076+
1077+
try:
1078+
memory._webhook_manager = WebhookManager(backend=memory._backend, global_webhooks=[])
1079+
monkeypatch.setattr(fact_extraction, "extract_facts_from_contents", _extract_no_facts)
1080+
1081+
await _ensure_bank(memory._pool, bank_id)
1082+
async with memory._pool.acquire() as conn:
1083+
await conn.execute(
1084+
"""
1085+
INSERT INTO webhooks (id, bank_id, url, secret, event_types, enabled, created_at, updated_at)
1086+
VALUES ($1, $2, $3, NULL, $4, true, NOW(), NOW())
1087+
""",
1088+
webhook_id,
1089+
bank_id,
1090+
"https://example.com/retain-hook",
1091+
["retain.completed"],
1092+
)
1093+
1094+
contents = [{"content": "nothing extractable here", "document_id": "doc-zero"}]
1095+
callback = memory._build_retain_outbox_callback(bank_id=bank_id, contents=contents, operation_id="op-zero")
1096+
assert callback is not None
1097+
await memory.retain_batch_async(
1098+
bank_id=bank_id,
1099+
contents=contents,
1100+
request_context=request_context,
1101+
outbox_callback=callback,
1102+
)
1103+
1104+
deliveries = await self._count_retain_deliveries(memory._pool, bank_id)
1105+
assert deliveries == 1, f"expected exactly one retain.completed delivery, got {deliveries}"
1106+
finally:
1107+
memory._webhook_manager = original_manager
1108+
async with memory._pool.acquire() as conn:
1109+
await conn.execute(
1110+
"DELETE FROM async_operations WHERE operation_type = 'webhook_delivery' AND bank_id = $1",
1111+
bank_id,
1112+
)
1113+
await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id)
1114+
await memory.delete_bank(bank_id, request_context=request_context)
1115+
9821116

9831117
# ---------------------------------------------------------------------------
9841118
# Schema-isolation tests

0 commit comments

Comments
 (0)