Skip to content

LLD — Async messaging

Two asynchronous flows, both on Amazon SQS standard queues with dead-letter queues:

Flow Producer Queue Consumer
Document ingestion S3 ObjectCreated notifications ingest-queue → ingest-dlq ingest-worker
Domain events outbox-relay reading ops.outbox domain-events → domain-events-dlq events-worker

Kafka is not used: there is no high-throughput streaming need, and MSK is not available on the project's plan.

1. Transactional outbox

A business write and the event announcing it must not diverge. Writing to SQS inside a database transaction cannot be atomic, so the event is written to ops.outbox in the same transaction and published afterwards.

sequenceDiagram
    autonumber
    participant G as mcp-gateway
    participant C as core-api
    participant P as PostgreSQL
    participant R as outbox-relay
    participant Q as SQS domain-events
    participant W as events-worker

    G->>C: POST /v1/disputes (idempotency key)
    C->>P: BEGIN
    C->>P: INSERT ops.disputes
    C->>P: INSERT ops.outbox (DisputeRaised)
    C->>P: COMMIT
    C-->>G: 201 Created

    loop every 10 s (EventBridge Scheduler) and on demand
        R->>P: SELECT ... FROM ops.outbox WHERE published_at IS NULL ORDER BY created_at LIMIT 50 FOR UPDATE SKIP LOCKED
        R->>Q: SendMessageBatch (event_id as message attribute)
        Q-->>R: Successful / Failed entries
        R->>P: UPDATE ops.outbox SET published_at = now() WHERE event_id IN (successful)
        R->>P: COMMIT
    end

    Q->>W: ReceiveMessage (batch)
    W->>P: INSERT ops.processed_events (event_id) ON CONFLICT DO NOTHING
    alt first delivery
        W->>P: Apply effect (start TAT timer, enqueue review), COMMIT
    else duplicate delivery
        W->>W: Skip, ack
    end

Notice: the relay may publish an event twice (crash after send, before marking), so the consumer, not the queue, guarantees the effect happens once.

Property How it is achieved
No lost events Event row commits with the business row
No phantom events Rolled-back transactions leave no outbox row
Parallel relays are safe FOR UPDATE SKIP LOCKED
Ordering Best-effort by created_at; consumers do not depend on order (each event carries the full state they need)
Exactly-once effect At-least-once delivery + ops.processed_events dedupe table keyed by event_id (Proposed (to validate in P2): table added to v0.2 schema)

2. Event envelope

{
  "event_id": "evt_01JD3K5Z8Q3W6X2N4P7R9T1V0B",
  "event_type": "DisputeRaised",
  "event_version": 1,
  "aggregate_type": "dispute",
  "aggregate_id": "dsp_01JD3K5Z7M2B8C4D6F8H0J2K4L",
  "occurred_at": "2026-11-12T09:14:03.512Z",
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "payload": { "txn_id": "txn_...", "reason_code": "credit_not_received", "tat_due_at": "..." }
}
Event Emitted by Consumer effect
DisputeRaised raise_dispute Compute TAT, schedule reminder, auto-reverse eligible failed_but_debited UPI disputes
TransactionFlagged flag_transaction_for_review Create review queue entry, escalate to AML when risk score ≥ threshold
DocumentIngested ingest-worker Refresh KB statistics for get_service_health

trace_id carries the originating request's trace so an asynchronous effect appears in the same trace.

3. Queue configuration

Setting ingest-queue domain-events Reason
Visibility timeout 360 s 60 s AWS guidance: at least 6 × the consumer Lambda timeout
Consumer Lambda timeout 60 s 10 s
Batch size 5 10
ReportBatchItemFailures on on Only failed messages return to the queue
maxReceiveCount 3 5 Then moved to the DLQ
DLQ retention 14 days 14 days Time to investigate and redrive
Encryption SSE-SQS SSE-SQS
Long polling 20 s 20 s Fewer empty receives

4. Poison messages

flowchart LR
    q[["queue"]] --> c["consumer"]
    c -->|"success"| ack["delete message"]
    c -->|"transient error"| retry["return to queue<br/>(visibility timeout)"]
    retry --> q
    c -->|"permanent error<br/>(schema invalid, unknown type)"| dlqnow["log + let it fail<br/>until maxReceiveCount"]
    q -->|"receive count exceeded"| dlq[["DLQ"]]
    dlq --> alarm["CloudWatch alarm<br/>messages visible > 0"]
    alarm --> fix["fix cause"]
    fix --> redrive["SQS redrive to source queue"]
    redrive --> q

Notice: the DLQ is a holding area, not a graveyard; every DLQ message triggers an alarm and is redriven once the cause is fixed.

Error class Example Handling
Transient OpenAI 429, Postgres connection reset Raise; message retried after visibility timeout
Permanent Malformed envelope, unknown event_type, document without header block Log with event_id, raise; reaches DLQ after maxReceiveCount
Duplicate Same event_id delivered twice Dedupe table; ack without effect

5. Testing

Test Tooling
Outbox row written iff business row written (including rollback paths) testcontainers Postgres
Relay publishes, marks, and tolerates send failures LocalStack SQS
Consumer idempotency under duplicate delivery LocalStack SQS, same message sent twice
Poison message reaches DLQ after N receives LocalStack with short visibility timeout
Concurrent relays do not double-publish within one poll Two relay tasks, SKIP LOCKED assertions