Skip to content

The transactional outbox

2 min read · main part only; folded sections are optional

After this you can answer: How do a booking and its 'send the email' message never get out of step?

flowchart TB
B["bookTicket"] -->|"one transaction"| T[("Postgres:<br/>booking row + outbox row")]
R["relay loop<br/>(every api task)"] -->|"pending rows, 10 at a time"| T
R -->|"Publish"| S["SNS topic<br/>booking-events"]
R -->|"mark sent, or retry later"| T
  1. Write both rows together. The booking transaction inserts the booking AND an outbox row whose payload is the message. Either both commit, or neither does.
  2. The relay picks up work. Each api task runs one loop. It selects up to 10 pending rows with FOR UPDATE SKIP LOCKED, so two api tasks never take the same row.
  3. Publish, then mark. For each row it calls SNS once. Accepted: the row gets sent_at. Refused or timed out: the row gets a later available_at.
  4. Retry with growing waits. 1 s, 2 s, 4 s, … up to 60 s apart, without a limit. A committed booking is never given up on.
  5. Loop. A full batch of 10 means more may wait, so the loop goes again at once; otherwise it sleeps 1 s.

The outbox row is written in apps/api/src/queries/bookings.ts → createBookingTx. The relay is apps/api/src/services/outbox.ts → startOutboxRelay and the SQL lives in apps/api/src/queries/outbox.ts → deliverBatch. The message carries the request’s correlation id, so the worker’s log lines match the booking request.

There is one honest gap: SNS can accept a message and the api can crash before the “sent” mark commits. The row is then published again. That is why every consumer must be idempotent (see DynamoDB). The outbox gives at-least-once, never “lost”.

From → toWhat happensWhat allows it
API app → Transactional outboxthe booking and its outbox row are written in one transaction
  • code createBookingTx inserts the booking and the BookingConfirmedMessage row togethercreateBookingTx
Transactional outbox → RDS Postgresonly when dataEnabled = truethe outbox is a Postgres table
  • code queries/outbox.ts deliverBatch reads pending rows in a transactiondeliverBatch
Transactional outbox → Booking-events SNS topicthe relay publishes each pending row once to SNS
See the outbox on the map →

Nothing to click: the outbox is a Postgres table. Its relay lines are in CloudWatch → Log groups → /minitickets/dev/api.

SymptomCauseWhere to look
Bookings succeed, but no emails or tickets; outbox rows stay pendingBOOKING_EVENTS_TOPIC_ARN is unset, or the task role may not sns:Publishapi log: “outbox delivery failed”
The same message is published twiceSNS accepted it, but the “sent” mark did not commitConsumers’ idempotency records
Why not publish to SNS right after the commit?

A crash between the commit and the publish loses the message for good. Publishing before the commit is worse: a rolled-back booking would still get an email.

What happens if SNS is down for an hour?

Bookings keep working. Their outbox rows wait and are retried every 60 s at most. When SNS is back, the relay drains them, 10 per transaction.

What is the trade-off of a relay inside the api?

No extra service to run, but each api task does background work, and messages wait up to about a second. A separate relay, or change data capture from the database log, would scale further.

Why does the relay never log the message?

The payload holds the customer’s email address. The log line has only the message id, the booking id and the correlation id.

Advanced: Details: the SNS client and the lock

The SNS client has maxAttempts: 1, a 1 s connect timeout and a 2 s request timeout (apps/api/src/queues/jobs.ts → publishBookingConfirmed). The outbox owns retries, so a slow SNS can never hold the transaction and its row locks for long. When the api stops, it waits for the current batch to commit before closing the database pool.

Answer these without scrolling up. If one feels hard, reread that section.

  1. Which two rows are written in one transaction?
  2. Why does the relay use SKIP LOCKED?
  3. How long does the relay wait between retries, at most?
  4. In which case can the same message be published twice?
  5. What does a reader of the api log never see in an outbox line, and why?

Redraw it from memory: booking transaction → booking row + outbox row → relay (10 rows, SKIP LOCKED) → SNS → mark sent or retry later

Try this: on the map, select the transactional outbox and follow its two arrows: one into Postgres, one to the SNS topic

Read this book online: https://book.theversehub.in/