stored → extraction, and
indexed → registration. Worked example from staging (trace
6a5212fe9879e479f0d9e206acd2103c): document reached stored at 09:55:22;
extraction began at 09:56:32 — 70 seconds of queue-and-relay wait for about one second of
extraction. The lawyer watching the upload spinner has no idea the work finished a minute ago.
The worker that wrote the outbox row already holds an SQS client. After its transaction commits, it sends its own row — about ten milliseconds later, in the same invocation. The 60-second relay is left exactly as it is, and quietly changes job: from the primary courier to the sweeper that only ever finds rows whose immediate send died mid-flight.
today: COMMIT ──(wait ≤60s)──▶ relay ──▶ SQS p50 stage latency ≈ 30 s
fixed: COMMIT ──(~10 ms)──▶ same worker sends ──▶ SQS p50 ≈ 0
└── relay tick unchanged, sweeps the rare crash case
| File | Change |
|---|---|
src/shared/document_ingest_outbox.py |
The existing drain body (_relay_pending_outbox_events) gains an optional
document_id narrowing, and a new dispatch_document_outbox_now() runs that
same drain for ONE document, in a second transaction opened strictly after the caller's commit.
Best-effort: it never raises. |
src/workers/document_malware_results/handler.py |
After the stored transition commits, dispatches the leg it wrote —
document.ingest.requested, or document.render.requested for Office docs. |
src/workers/document_extraction/handler.py |
After the indexed transition commits, dispatches
document.registration.requested (Blake's hand-off). |
.github/scripts/assert-sqs-publish-capability.sh + both deploy workflows |
The new capability gate, plus the queue-URL env each worker now needs. The malware Lambda also moves off the shared execution role onto the documents pipeline-worker role. |
src/workers/document_ingest_relay/handler.py |
Unchanged. That is the point. |
The dangerous-sounding case is: the worker sends the message, then dies before stamping
dispatched_at. The relay later finds the row still marked pending and sends it again. Two
messages, one document — a duplicate extraction?
No. The outbox row's idempotency_key is passed to SQS as the FIFO
MessageDeduplicationId (DocumentIngestQueueClient.enqueue_batch), and that key
is deterministic in (event_type, firm_id, document_id). SQS drops the second copy inside its
deduplication window. The safety was already in the substrate; immediate dispatch just relies on it.
stored. The INSERT stays where it was; only
the delivery moved earlier.
A failed immediate send is logged and swallowed. The invocation still returns success,
because its real work — the status transition — is already committed and durable. The row
keeps dispatched_at IS NULL, its last_dispatch_error is stamped, and the
unchanged 60-second relay sweeps it on the next tick. The worst case after this change is precisely the
behaviour before it.
That is also the trap. A missing IAM grant would be invisible: every send fails silently, the sweeper covers, the pipeline still works — and the latency win simply never lands, with nothing red anywhere. So the grant gets a check that goes red at DEPLOY time, not at runtime:
$ .github/scripts/assert-sqs-publish-capability.sh \
pramaan-documents-pipeline-worker-staging \
pramaan-documents-ingestion-staging pramaan-documents-render-staging
arn:aws:sqs:ap-south-1:...:pramaan-documents-ingestion-staging implicitDeny
arn:aws:sqs:ap-south-1:...:pramaan-documents-render-staging implicitDeny
FAIL: pramaan-documents-pipeline-worker-staging cannot sqs:SendMessage to every queue
it publishes to. # exit 1
That is the observed-red run against staging as it exists today. The grant itself is a pramaan-infra Terraform resource (pramaan-infra#130), which must apply before this deploys; the gate script ships a planted-negative unit test so the check itself is proven to fail.
pramaan-lambda-execution-role — the role app-api, admin-api, and db-bridge also use.
Granting that role SendMessage would have handed the public API the power to inject
ingestion messages. Instead the Lambda moves onto the documents pipeline-worker role, which is where
Terraform already grants it its scan-gate permissions — closing a live-vs-Terraform drift on the way
past.
No new span, no raw tracer call. The immediate drain opens the SAME registry-approved batch span the relay
opens (DOCUMENT_INGEST_RELAY_BATCH and siblings), links from the trace context stored on the
outbox row, and the send itself is the same boto3 SQS call that sits on the ADOT auto-instrumentation
floor (pramaan-docs ADR-124 T1). Instrumentation-exempt by that tiering.
| Property | Test |
|---|---|
The committed row is sent immediately and stamped dispatched_at |
test_immediate_dispatch_sends_the_committed_row_and_stamps_dispatched_at |
| No double-processing: the relay finds nothing left to resend | test_immediate_dispatch_leaves_the_relay_nothing_to_resend |
| A forced SQS failure is swallowed and the sweeper still delivers | test_immediate_dispatch_failure_is_swallowed_and_left_for_the_sweeper |
| The send is strictly AFTER commit, in both producers | test_malware_worker_dispatches_the_stored_legs_after_commit,
test_extraction_worker_dispatches_the_registration_leg_after_commit |
| The capability gate is red without the grant | test_capability_gate_is_red_when_the_grant_is_absent (planted negative) |
The acceptance bar is wall-clock, so it can only be read after the deploy: upload one document and confirm
stored → extraction start < 5 s and indexed → registration start
< 5 s, from the status-event timestamps and the trace. Until pramaan-infra#130 applies, the deploy gate
holds this change out of staging entirely — which is the intended order.