ADR-0001: Logical-decoding message delivery guarantees (transactional vs non-transactional)
Copy Markdown View SourceStatus: Accepted
Date: 2026-07-14
Deciders: replicant maintainer (feature A2; spec 2026-07-11-replicant-messages-multipub-design.md §7)
Context
Postgres pg_logical_emit_message/3 writes logical-decoding messages into WAL, surfaced by
pgoutput as 'M' frames. They come in two flavors:
- Transactional (
flags = 1): emitted inside a transaction, bracketed byBegin/Commit(orStreamCommitfor proto-v2 streamed txns). They have a commit boundary. - Non-transactional (
flags = 0): emitted standalone, written to WAL immediately with no Begin/Commit bracket and no commit LSN of a surrounding transaction.
replicant's flagship guarantee is effect-once on the sink-owned atomic path: a
handle_transaction/1 that persists data + checkpoint in one DB transaction, deduped by
commit_lsn. Messages needed a delivery contract that does not silently violate that guarantee
or silently lose data. A message can be consumed for two very different jobs — a transactional
outbox row that must be exactly-once with its data, or a heartbeat emitted outside any
transaction — so one uniform guarantee cannot serve both honestly.
Decision
Adopt a split guarantee keyed on the message's transactional? flag, opt-in via a top-level
messages: true config:
- Transactional messages ride
%Transaction{messages: [...]}and inherit the transaction path's effect-once dedup (the txn'scommit_lsn). They are delivered atomically with the transaction's row changes. (decoder/messages.ex,assembler.exv1 + streamed clauses;transaction.ex.) - Non-transactional messages route to optional
Replicant.Sink.handle_message/2and are at-least-once — NO dedup key; duplicates are possible on reconnect (documented in thehandle_message/2docstring — Critical Rule 3, guarantee honesty). (sink.ex,config.ex.) - Fail-closed opt-in:
messages: truerequires the sink to implementhandle_message/2(Sink.supports_messages?/1); otherwise the pipeline is rejected at START with:messages_unsupported— never silently dropping messages later. (config.exfetch_messages.)
Two durability seams prevent a non-transactional message from causing silent loss by acking the slot past undelivered/undurable data:
- §8.1 idle-ack seam — a non-txn message in flight bumps
last_commit_lsn, so the idle-ack keepalive path (idle?/1) refuses to advance the slot towal_enduntil the message is durably delivered ({:sink_committed, msg_lsn}advancescheckpoint_lsn). (connection.extrack_txn/2non-txn clause.) - §8.4 batch-boundary seam — when a lib-batch or sink-owned batch is OPEN, the message
signals
{:flush_before_message}: the AssemblerServer flushes + acks the batch first (durability-before-ack), then re-dispatches the message, which now finds no open batch and delivers. (assembler.exnon-txn Message clause;assembler_server.exdispatch/3.)
Options Considered
Option A: Both flavors at-least-once (uniform, simplest)
| Dimension | Assessment |
|---|---|
| Complexity | Low |
| Honesty | Poor — silently downgrades transactional outbox from the effect-once the txn path already provides |
Cons: A transactional outbox message emitted with its data would lose exactly-once semantics the surrounding transaction already guarantees. Rejected.
Option B: Both flavors effect-once (uniform, strongest-sounding)
Cons: Impossible for non-transactional messages — they carry no commit boundary and no dedup key, so there is nothing to dedup against. Claiming effect-once here would be a false guarantee. Rejected.
Option C: Split guarantee keyed on transactional? (adopted)
| Dimension | Assessment |
|---|---|
| Complexity | Medium (two delivery paths + two durability seams) |
| Honesty | High — each flavor gets the strongest guarantee its structure actually supports, truthfully documented |
Trade-off Analysis
The split trades one extra delivery path and two composition seams for honest guarantees.
The alternative "one guarantee" designs each require lying about one flavor. The durability
seams add real complexity precisely at the composition of a message with the deferral modes
(lib-batch, sink-owned batch, streaming) — this is the library's known
cross-mode-composition-blindspot class, so the seams are load-bearing and must be tested at
those compositions.
Consequences
- Easier: Transactional-outbox consumers get exactly-once delivery of the message with its data. Non-txn heartbeat consumers get simple standalone delivery.
- Harder: Non-transactional message consumers must be idempotent — duplicates are possible on reconnect (by design; there is no dedup key). Every durability-signal / batch path must preserve the §8.1 and §8.4 seams or a message can ack past undurable data (silent loss).
- Revisit if: a future need for exactly-once non-transactional messages arises — it would require a consumer-supplied dedup key, a product change requiring its own ADR/supersession.
Ordinal — the shared per-txn interleaving hint
Within a delivered %Transaction{}, changes and messages are each in commit order, and the
library never uses Message.ordinal for its own delivery ordering — it is a hint for consumers
who choose to interleave the two lists. Both the v1 and the proto-v2 streamed paths assign
ordinal from a single shared per-transaction counter incremented per change AND per
transactional message (stamped at accumulation/attach and preserved through replay/spill), so a
message emitted between two changes sorts strictly between them. An aborted (rolled-back) streamed
change and an interleaved message each occupy a slot, so surviving ordinals may have gaps —
sorting the changes ∪ messages union by ordinal still yields commit-emission order. (Earlier
the streamed message used the change-buffer length at attach, which could collide with a following
change's replay ordinal — resolved 2026-07-14.)
Telemetry (§10)
[:replicant, :message, :received] fires for BOTH message kinds — non-transactional at
handle_message/2 delivery (transactional: false) and each transactional message when its
transaction is durably delivered (transactional: true) — carrying only commit_lsn + byte_size
- the
transactionalboolean (neverprefix/content, Rule 1).
Verification (closeout 2026-07-14)
Both seams and both flavors are proven at runtime against live PG16 by
test/integration/messages_test.exs (transactional effect-once dup=0 across a crash; non-txn
at-least-once via handle_message/2; the §8.4 batch-boundary flush loss=0 in lib-batch and
sink-owned batch). Real captured 'M' bytes (both flavors) corroborate the decoder in
test/replicant/decoder/conformance_test.exs.