Transactions in MoonMQ
Status: read_committed isolation + Last Stable Offset shipped (2026-07-18), completing the exactly-once story on top of atomic multi-partition transactions (2026-07-10) and the session-scoped idempotent producer (2026-06-21).
What now ships:
- Durable producers (
src/storage/producer_state.lua): a producer opened with a stableproducer_name(Kafka'stransactional_id) gets a PID + epoch that survive reconnects and broker restarts. Each new session bumps the epoch, fencing the old one (ERR_PRODUCER_FENCED). Idempotent-produce sequence memos are persisted, so a retry replays the original ack across a restart. - Atomic multi-partition transactions (
src/broker/txn_coordinator.lua):BEGIN_TXN/ transactionalPRODUCE_IDEMPOTENT/TXN_OFFSET_COMMIT/END_TXN. On commit the coordinator writes COMMIT control records to every participant partition and applies buffered offset commits; on abort it writes ABORT markers and drops the offsets. State lives in the internal__transaction_statetopic and is re-driven idempotently on crash recovery (ONGOING → abort, PREPARE_COMMIT → roll forward). read_committedisolation + Last Stable Offset (2026-07-18). How it fits together:- Transactional data records carry the producer session in the record header
(
ATTR_TXNbit + pid/epoch — seesrc/record/message.lua), so a reader can attribute any record to its transaction. - The produce path enrols a partition with the coordinator BEFORE the
transaction's first append there, capturing the partition's LEO as the
txn's
first_offset— the lower bound of everything it writes there. - LSO = min
first_offsetover unresolved transactions touching the partition (Coordinator:lso); aread_committedconsumer never reads at or past it, so every record it does read belongs to a resolved txn. - On abort, the coordinator durably records the range
[first_offset, abort_marker)per participant in the abort index (src/broker/abort_index.lua,<data_dir>/txn-aborts.json); below the LSO, records matching an aborted(pid, epoch, range)are filtered out. Entries are pruned once retention deletes their whole range. - On the wire,
FETCH/SUBSCRIBEtake an optional trailing isolation byte (0 = read_uncommitted — the default, and what old clients send; 1 = read_committed). The Lua client:Client.new{ isolation = "read_committed" }. - Boundary: transactional produce to a partition owned by a peer broker is
refused (
Producer:produce) — markers could not be written on the owner. - Boundary: abort-index ranges are offset-based, so they assume offsets are
stable — true on the segmented backend (the default for user topics; its
retention deletes whole segments without renumbering). Don't run
transactions against a
commitlog-backend topic withcleanup_policy=compact: compaction renumbers surviving records, which would break the aborted-range filter.
- Transactional data records carry the producer session in the record header
(
This document records:
- What ships today.
- What is intentionally NOT in scope today.
- The original design surface, retained for context.
1. Today: idempotent and transactional producers
A client asks for a producer id with INIT_PRODUCER_ID. Without a
producer_name (Client.new{ idempotent = true }) the id is ephemeral:
it lives for one connection, and the guarantees and non-guarantees below
apply. With a producer_name it is durable: PID, epoch and sequence memos
survive reconnects and restarts (__producer_state), a new session fences the
old one, and the producer may run transactions (see the client example at the
end of this section).
Guarantees
Within the lifetime of a single TCP connection that called
INIT_PRODUCER_ID (and, for a named producer, across connections), the
broker guarantees:
- A
PRODUCE_IDEMPOTENTretry with the same(PID, topic, seq)returns the original(partition, offset)instead of appending a duplicate. - An out-of-order
seq(gap or regression) is rejected withERR_OUT_OF_ORDER_SEQUENCEso the client can detect lost messages.
Non-guarantees (unnamed producers)
- No durability across the broker process. Ephemeral PIDs and sequence
memos live in broker memory. A restart wipes them; clients must call
INIT_PRODUCER_IDagain on reconnect. - No cross-connection idempotence. Two unnamed connections get two
distinct PIDs; the broker can't dedup retries that move across
connections. Give the producer a
producer_namefor that. - No transaction boundary. Without
BEGIN_TXN/END_TXN, multiplePRODUCE_IDEMPOTENTcalls do NOT form an atomic group; a crash mid-batch leaves a partial write. Transactions require a named producer.
Wire format
Opcodes (additive on PROTOCOL_VERSION 1):
| Op | Direction | Payload |
|---|---|---|
OP_INIT_PRODUCER_ID 0x0E |
C → S | str producer_name (empty, or no payload, = ephemeral) |
OP_PRODUCER_ID 0x88 |
S → C | u64 pid | u16 epoch |
OP_PRODUCE_IDEMPOTENT 0x0F |
C → S | u64 pid | u32 seq | u16 epoch | u8 codec | str topic | str key | str value [| headers] |
OP_BEGIN_TXN 0x13 |
C → S | empty |
OP_END_TXN 0x14 |
C → S | u8 commit (1 = commit, 0 = abort) |
OP_TXN_OFFSET_COMMIT 0x15 |
C → S | str group | u32 n | n × (str topic | u32 partition | u64 offset) |
Acks reuse OP_PRODUCE_ACK (0x82) with the original (partition, offset).
The optional trailing headers block is described in headers.md.
Errors use OP_ERROR with codes:
| Code | Name | Meaning |
|---|---|---|
| 10 | ERR_NO_PRODUCER_ID |
PRODUCE_IDEMPOTENT sent before INIT_PRODUCER_ID. |
| 11 | ERR_OUT_OF_ORDER_SEQUENCE |
seq not equal to last_seq or last_seq + 1. |
| 50 | ERR_PRODUCER_FENCED |
A newer session of the same producer_name exists. |
| 51 | ERR_INVALID_TXN_STATE |
Transaction call out of order (e.g. END_TXN with none open). |
| 52 | ERR_TRANSACTION_TIMED_OUT |
The transaction was aborted for running too long. |
| 53 | ERR_OFFSETS_OUT_OF_RANGE_FOR_TXN |
A transactional offset commit was out of range. |
Why per-(PID, topic) and not per-(PID, partition)?
Real Kafka tracks sequences per (PID, topic, partition). We track per
(PID, topic) because:
- TCP guarantees per-connection in-order delivery, so a single monotonic counter across partitions is internally consistent.
- The client doesn't have to know the broker's partition count or hashing function to pick the right seq slot.
- It costs: idempotent producers can't batch across partitions in parallel (we don't anyway).
Consumer groups and offset commits have since landed (see §3); moving to the full Kafka per-partition layout remains possible but has not been needed.
Client API
local Client = require("src.client")
-- Open a connection AND ask the broker for a producer ID.
local c = Client.new{
host = "127.0.0.1",
port = 9092,
username = "admin",
password = "admin",
idempotent = true,
}
-- produce() auto-uses the idempotent path when pid is set.
local ack = c:produce("orders", "key-1", "value-1")
-- ack.seq is the sequence number the broker dedup-keyed on.
-- For testing: send a specific seq (e.g. simulate a retry).
local replay = c:produce_at_seq("orders", "key-1", "value-1", 0)
-- replay.partition == ack.partition, replay.offset == ack.offsetA named producer runs transactions — here a consume-transform-produce step that commits its output and its input offset together:
local p = assert(Client.new{ host = "127.0.0.1", port = 9092,
username = "admin", password = "admin",
producer_name = "billing-writer" })
assert(p:begin_transaction())
assert(p:produce("invoices", "order-42", "…"))
assert(p:send_offsets_to_transaction("billing", {
{ topic = "orders", partition = 2, offset = 43 },
}))
assert(p:commit_transaction()) -- or p:abort_transaction()2. Out of scope (today)
Still deferred: nothing from the original design.
Now implemented (were previously out of scope):
- Transactional produce to a partition owned by a peer broker (2026-07-19).
The coordinator enrols the OWNER before the forwarded append
(
POST /cluster/txn/enrollfloors the owner's LSO at its pre-append LEO), forwards the record, and on END_TXN writes the COMMIT/ABORT marker on the owner and resolves there (POST /cluster/txn/resolve— on abort this also records the aborted range in the owner's abort index, since the owner's consumers do the read_committed filtering). Caveats: the owner's LSO floor for a REMOTE transaction is in-memory — if the owner restarts while the peer's transaction is unresolved, read_committed readers there may see the transaction's records until the markers arrive; and reassigning a partition away mid-transaction is unsupported (ownership is re-checked at marker time, but offsets captured at enrol time are not migrated). - PID expiry / garbage-collecting idle producer state (2026-07-19): durable
producers idle for
Server.ProducerExpirySeconds(default 1 day, 0 = off) have their identity + memos tombstoned in__producer_state; compaction drops the tombstones. Producers with an unresolved transaction or a live connection are never expired, and a persisted allocator watermark guarantees an expired PID is never re-issued. - COMMIT / ABORT control records in the log (v2 record format, control bit).
- Cross-partition atomic commit via the transaction coordinator.
- Transactional offset-commit (
TXN_OFFSET_COMMIT, applied on commit). __transaction_statecoordinator topic + crash recovery.- PID generation epochs and producer fencing across reconnects.
read_committedconsumer isolation level (2026-07-18).- Last Stable Offset (LSO) tracking on partitions (2026-07-18).
- Filtering aborted records out at read time via the durable abort index (2026-07-18).
3. Original design: full Kafka-style transactions
Everything below has since landed (including read_committed + LSO as of
2026-07-18); the section is retained as the design rationale.
- Consumer subsystem.
commit_offset/load_offsetare now durable (src/broker/consumer.lua→OffsetManager). __consumer_offsetstopic. Implemented (src/storage/offset_manager.lua);__producer_stateand__transaction_statefollow the same internal-topic pattern.
Components
-
Transaction coordinator. A new broker subsystem that owns
__transaction_state. Producers routeINIT_PRODUCER_IDto the coordinator, which tracks(transactional_id → (PID, epoch, state))durably. As shipped: the server assigns PIDs inline from__producer_state, andsrc/broker/txn_coordinator.luaowns the transaction state. -
Producer epochs. PIDs become 2-field:
(pid: u64, epoch: u16). On reconnect with a knowntransactional_id, the coordinator bumps epoch and fences older sessions (rejects writes with stale epoch viaERR_PRODUCER_FENCED). -
Control records. Every transactional log append carries a marker bit. The coordinator writes
COMMIT/ABORTcontrol records to every affected partition at txn boundary. The records are written through the same log path so consumers see them. -
Read-committed isolation. Consumers in
read_committedmode stop at the Last Stable Offset (LSO) — the smallest offset above which any partition has uncommitted transactional records. Aborted records below LSO are filtered out at read time using the ABORT markers. -
Transactional offset commit.
OFFSET_COMMIT_REQUESTcarries the PID + epoch + sequence; the offset commit becomes part of the txn and only takes effect when the COMMIT control record lands.
Opcodes (proposed)
As shipped: INIT_TRANSACTIONAL_ID became INIT_PRODUCER_ID with a
producer_name; ADD_PARTITIONS_TO_TXN became implicit enrolment on the
first transactional produce to a partition; WRITE_TXN_MARKERS became the
inter-broker /cluster/txn/enroll and /cluster/txn/resolve endpoints.
BEGIN_TXN, END_TXN and TXN_OFFSET_COMMIT shipped as proposed.
| Op | Notes |
|---|---|
INIT_TRANSACTIONAL_ID |
Negotiates PID + epoch for txn-id |
BEGIN_TXN |
Starts a transaction |
ADD_PARTITIONS_TO_TXN |
Coordinator-tracked participation |
END_TXN |
COMMIT / ABORT |
TXN_OFFSET_COMMIT |
Transactional offset commit |
WRITE_TXN_MARKERS (internal) |
Coordinator → partition leaders |
Error codes (proposed)
| Code | Name |
|---|---|
| 50 | ERR_PRODUCER_FENCED |
| 51 | ERR_INVALID_TXN_STATE |
| 52 | ERR_TRANSACTION_TIMED_OUT |
| 53 | ERR_OFFSETS_OUT_OF_RANGE_FOR_TXN |
Storage shape
__transaction_statepartition-of-N stores(transactional_id, pid, epoch, state, participating_partitions[], last_update_ms, timeout_ms).- Per-partition log adds a 1-bit transactional flag in the record
header (claim one of the high bits of
key_size, or add a v2 record format with a leading control byte).
Migration
Plan:
- Land consumer groups + durable offset commit (blocking).
- Add
__consumer_offsetsand__transaction_stateas internal topics (broker-managed, not user-creatable). - Add v2 record format with the control flag.
- Wire coordinator + new opcodes.
- Extend the consumer to honour
read_committed+ LSO. - Once all above land, the in-memory PID table from §1 becomes a
short-circuit: producers with
transactional_id == nilkeep the session-scoped behaviour; producers with a txn-id go through the coordinator.
References
- Kafka KIP-98: Exactly Once Delivery and Transactional Messaging
- Kafka KIP-129: Streams EOS
- Confluent's
Transactions in Apache Kafkablog post (2017)