blob: 0d0e2aff00a7093ecd4e1a289d0dcc33f55a7423 [file]
---
title: Server-to-server
---
Replicas talk to each other with the same 256-byte [framing](/docs/binary-protocol/framing) the client protocol uses, on a **dedicated TCP port**, with their own set of `command` discriminants. None of these frames ever appears on a client connection, and a client cannot reach this plane: it is a separate listener, gated by a handshake.
This page documents the replica plane as of binary protocol 0.11.0. It is internal protocol: only Iggy servers speak it, and it can change between server releases without a client-facing version bump.
## Transport
- Every node in `[[cluster.nodes]]` exposes a `tcp_replica` port next to the client ports: `ports = { tcp = 8090, quic = 8080, http = 3000, websocket = 8092, tcp_replica = 9090 }`. The node's `ip` is the roster address for the replica plane (clients fall back to it when `advertised_address` is unset).
- The replica plane is **TCP only**, by design: the prepare hash chain, cross-shard fd delegation, and view-change timing all assume an ordered byte stream.
- **Directional dialing:** a replica dials only peers with a strictly greater `replica_id` and accepts inbound connections only from strictly lower ids. Exactly one connection exists per pair, with no tiebreaker races.
- Frames are the standard `[256-byte header][optional body]` with `size` at offset 48. The maximum frame is **64 MiB** by default (`message_bus.max_message_size`). Headers are `#[repr(C)]`, decoded zero-copy, and require 16-byte alignment.
- Nothing inside a frame marks it as replica traffic. The separation is structural: client listeners parse every inbound frame as a `RequestHeader` (command `5` only), the replica listener requires the first frame to be `ReplicaHello`, and the client-bound commands `Reply` (8) and `Eviction` (13) are rejected if they arrive on the replica plane.
## Command discriminants
The full `command` byte registry, client values included. Values above 29 are rejected.
| Value | Command | Direction | Purpose |
|-------|---------|-----------|---------|
| 0 | `Reserved` | - | Invalid sentinel |
| 1-4 | `Ping`, `Pong`, `PingClient`, `PongClient` | - | Reserved; no production traffic today |
| 5 | `Request` | client to server | Client command ([framing](/docs/binary-protocol/framing)) |
| 6 | `Prepare` | primary to backup, backup to next backup | Replicate one operation |
| 7 | `PrepareOk` | backup to primary | Acknowledge a prepare |
| 8 | `Reply` | server to client | Client reply; rejected on the replica plane |
| 9 | `Commit` | primary to all | Commit-point broadcast, doubles as heartbeat |
| 10 | `StartViewChange` | any to all | View-change proposal |
| 11 | `DoViewChange` | any to all | View-change vote with log suffix |
| 12 | `StartView` | new primary to all | Install the new view |
| 13 | `Eviction` | server to client | Client eviction; rejected on the replica plane |
| 14 | `ReplicaHello` | dialer to acceptor | Handshake step 1 |
| 15 | `ReplicaChallenge` | acceptor to dialer | Handshake step 2 |
| 16 | `ReplicaFinish` | dialer to acceptor | Handshake step 3 |
| 17 | `RequestStartView` | recovering replica to all | Ask the current primary for a targeted `StartView` |
| 18 | `RequestPrepares` | lagging replica to peer | Journal repair: request an op range |
| 19 | `RepairPrepare` | serving peer to requester | One journaled prepare, verbatim |
| 20 | `RepairDone` | serving peer to requester | Repair stream terminator |
| 21 | `RangeEvicted` | serving peer to requester | Range no longer retained |
| 22 | `RequestStateTransfer` | requester to primary | Bulk catch-up: ask for the target |
| 23 | `StateTransferTarget` | primary to requester | Offer + state manifest |
| 24 | `RequestStateChunk` | requester to primary | Fetch one artifact chunk |
| 25 | `StateChunk` | primary to requester | Artifact bytes |
| 26 | `ForwardRegister` | backup to primary | Forward a verified login |
| 27 | `ForwardRegisterResult` | primary to backup | Login outcome |
| 28 | `ForwardLogout` | backup to primary | Forward a logout |
| 29 | `ForwardLogoutResult` | primary to backup | Logout outcome |
There is no dedicated replica heartbeat: liveness is the `Commit` broadcast, sent on `cluster.commit_broadcast_interval`.
## Frame integrity
The `checksum` and `checksum_body` fields that client frames leave zero are live on the replica plane:
- **Frame seal** (`checksum`, bytes 0..16): XxHash3-64 over header bytes 16..256, widened to u128. Sealed on every replica message **except** `Prepare`, `RepairPrepare`, and the three handshake frames (whose integrity comes from the keyed MAC). Verified once, at typed decode, before any field validation; a mismatch drops the frame.
- **Prepare identity** (`Prepare` / `RepairPrepare` only): those two spend `checksum` on an identity hash instead - XxHash3-64 over the whole 256-byte header computed with `checksum = 0` and `view = 0`, so a retransmit that re-stamps `view` keeps the same identity. `PrepareOk` echoes it in `prepare_checksum`, and each prepare's `parent` field carries the previous prepare's identity, forming a hash chain.
- **Body seal** (`checksum_body`, bytes 16..32): three regimes. Metadata-plane prepares seal their body with XxHash3-64. Partition-plane prepares leave it zero: the message batch inside already carries `batch_checksum`, verified at network ingress. `DoViewChange` and `StartView` seal their suffix bodies. Every other message leaves it zero (state-transfer bodies are verified per artifact, not per frame).
- The seals are **unkeyed integrity checks, not authentication**. Peer authentication comes from the handshake below; without `cluster.auth` and TLS enabled, the replica port trusts any peer that can reach it. Do not expose `tcp_replica` beyond the cluster network.
`Prepare`, `RepairPrepare`, and the session-forwarding headers validate their reserved regions as zero; the other replica headers leave reserved bytes covered by the frame seal only.
## Handshake
Commands 14-16, exchanged before any consensus traffic. These are raw `GenericHeader` frames (`size = 256`) using the per-command area at bytes 128..256:
| Offset | Size | Content |
|--------|------|---------|
| 128 | 32 | Nonce (dialer's in `Hello`, acceptor's in `Challenge`) |
| 160 | 32 | BLAKE3 keyed MAC (acceptor's in `Challenge`, dialer's in `Finish`) |
| 192 | 1 | `Challenge` only: handshake status |
Statuses: `0` Ok, `1` UnknownCommand, `2` ClusterMismatch, `3` DirectionalRule; values `4` and `5` are reserved (never sent), and a dialer treats any unknown status byte as a rejection. The MAC key derives from `cluster.auth.shared_secret` (32+ bytes; `previous_shared_secret` gives a rotation window), and with cluster TLS enabled (TLS 1.3 only, ALPN `iggy-replica`) the TLS exporter is folded into every MAC as channel binding. The handshake authenticates **cluster membership**, not per-replica identity: one shared secret for the whole cluster.
The `cluster` header field (bytes 32..48) is the first 16 bytes of `blake3(cluster_name)` as a little-endian u128 on every replica frame, checked during the handshake, so nodes from a differently named cluster cannot connect even with auth disabled.
## Replication
### Prepare (6)
Per-command fields (bytes 128..256):
| Offset | Size | Field | Description |
|--------|------|-------|-------------|
| 128 | 16 | `client` | Originating client id |
| 144 | 16 | `parent` | Previous prepare's identity checksum (hash chain) |
| 160 | 16 | `request_checksum` | Verbatim from the admitted request |
| 176 | 8 | `op` | Log position |
| 184 | 8 | `commit` | Primary's commit point when the prepare was built |
| 192 | 8 | `timestamp` | Primary-assigned monotonic timestamp |
| 200 | 8 | `request` | Client request number |
| 208 | 1 | `operation` | Operation discriminant (+7 padding) |
| 216 | 8 | `group` | Consensus group (see [planes](#consensus-planes)) |
| 224 | 4 | `user_id` | Authenticated user |
| 228 | 28 | reserved | Zero |
For metadata operations the body is the admitted command's payload **verbatim**. For `SendMessages` it is the [message batch](/docs/binary-protocol/messages) as stamped by the primary at journal append (`base_offset`, `base_timestamp`, and the recomputed `batch_checksum` are final); each backup re-derives the expected stamp from its own position and refuses a mismatch. Replication is **chain-form**: the primary sends to `(replica + 1) % replica_count`, each backup forwards to its own successor, and the chain stops when the next hop is the primary. `RepairPrepare` (19) is byte-identical to a journaled prepare with only the command byte rewritten.
### PrepareOk (7)
Header-only. `parent` (echo) at 128, `prepare_checksum` (echo of the prepare's identity) at 144, then `op` 160, `commit` 168, `timestamp` 176, `request` 184, `operation` 192, `group` 200. Two fields are the acker's own, not echoes: header `view` is the acking replica's view, and `commit` is its own commit point. Sent to the view's primary.
### Commit (9)
Header-only broadcast; validation rejects `size != 256`. `timestamp_monotonic` at 144 is the liveness token (a receiver resets its heartbeat timer only on a strictly newer value), `commit` at 152 is the commit point, `group` at 168. Bytes 128..144 and 160..168 are declared (`commit_checksum`, `checkpoint_op`) but currently unused.
## View changes
- **StartViewChange (10):** header-only, just `group` at 128. Broadcast when a replica suspects the primary.
- **DoViewChange (11):** `op` (highest op) 128, `commit` 136, `group` 144, `log_view` 152 (view when the sender was last in Normal status), `nack_bitset` 224 (bit i = "I never prepared suffix entry i"; truncation authority), `present_bitset` 240 (bit i = "I can serve entry i's body"). Body = the log suffix as up to **128 packed 256-byte `PrepareHeader`s**, headers only. Broadcast.
- **StartView (12):** `op` 128, `commit` 136 (max over the DVC quorum), `group` 144, `incarnation` 240 (echo of a `RequestStartView` incarnation, zero when unsolicited). Body = the view's suffix as packed `PrepareHeader`s, highest op first; empty is legal. Unicast when answering a probe, broadcast otherwise.
- **RequestStartView (17):** header-only recovery probe with `group` 128 and `incarnation` 240. A restarted replica broadcasts it instead of trusting stale local state; only the current view's primary answers, with a targeted `StartView`.
## Journal repair
A replica missing committed prepares (rejoin window or interior hole) pulls them from a Normal-status peer (the metadata plane also serves repair during a view change, so a forming primary cannot deadlock):
- **RequestPrepares (18):** `nonce` 128, `from_op` 144, `to_op` 152, `group` 160. `from_op = 0` or `from_op > to_op` is rejected.
- **RepairPrepare (19):** the journaled prepares, streamed in op order.
- **RepairDone (20)** / **RangeEvicted (21):** one shared layout (`nonce` 128, `op` 144, `group` 152) under two command bytes. `RepairDone.op` = last op served; `RangeEvicted.op` = oldest op the peer still retains, the honest answer when the front of the range is gone.
Pacing is bounded by `cluster.repair_chunk_max`, which must stay below `message_bus.peer_queue_capacity`: per-peer send queues drop overruns silently.
## State transfer
Bulk catch-up for a replica too far behind for journal repair. **Pull-based and lockstep** (at most one chunk in flight per artifact), because per-peer queues drop overruns:
1. **RequestStateTransfer (22):** `nonce` 128, `group` 144.
2. **StateTransferTarget (23):** `nonce` 128, `commit_op` 144, `group` 152, `available` 160, `unavailable_transient` 161, `commit_max` 168. `available = 0` is a header-only refusal. Body = the **state manifest**.
3. **RequestStateChunk (24):** `nonce` 128, `offset` 144, `group` 152, `len` 160, `artifact` 164. `len` describes the reply and is clamped by the server.
4. **StateChunk (25):** `nonce` 128, `offset` 144, `group` 152, `artifact` 160. Body = raw artifact bytes. Chunks carry no per-chunk integrity; each artifact is verified whole, by length and XxHash3-64, against its manifest entry.
The manifest (little-endian):
```text
[magic: "ISM1"][count: u32][entry_len: u8]
count x { kind: u8, frontier: u64, len: u64, checksum: u64 } # entry_len = 25
[trailer: XxHash3-64 over everything above]
```
At most 65536 entries. Entries longer than 25 bytes decode with the tail skipped (forward compatibility); shorter entries are rejected.
Artifact kinds:
| Kind | Artifact | Plane | Content |
|------|----------|-------|---------|
| 0 | `METADATA_SNAPSHOT` | metadata | `snapshot.bin` verbatim (MessagePack, snapshot format version 3); `frontier` = sequence number |
| 1 | `CLIENT_TABLE` | metadata | Client table encoding (magic `ICT2`); `frontier` = mutation frontier |
| 2 | `SEGMENT_LOG` | partition | One retained segment's `.log` verbatim; `frontier` = segment base offset |
| 3 | `CONSUMER_OFFSETS` | partition | Consumer + group offset tables and applied purge generation; `frontier` = offer's `commit_op` |
## Session forwarding
A client may dial a backup; authentication happens there, and only the verified identity travels to the primary (credentials never cross the replica plane):
- **ForwardRegister (26):** `client` 128, `nonce` 144, `user_id` 160.
- **ForwardRegisterResult (27):** `nonce` 128, `client` 144, `epoch` 160, `watermark` 168, outcome byte at **255**: `0` Ok, `1` NotPrimary, `2` NotCaughtUp, `3` PipelineFull, `4` InProgress, `5` Canceled, `6` ClientIdOwnedByAnotherUser. `epoch` and `watermark` must be zero on any non-Ok outcome.
- **ForwardLogout (28):** `client` 128, `nonce` 144, `session` 160, `request` 168.
- **ForwardLogoutResult (29):** `nonce` 128, `client` 144, `commit` 160, outcome at 255: `0` Ok, `1` NotPrimary, `2` PipelineFull, `3` InProgress, `4` Canceled.
## Consensus planes
Two consensus planes share this one message set; there are no plane-specific commands. Routing is by the `group: u64` field carried on every consensus header except the handshake and session-forwarding frames (those are implicitly metadata-plane):
- `group = 1 << 63` is the **metadata plane** (streams, topics, users, consumer groups; durable on-disk journal).
- Any other value is a packed stream/topic/partition key: a **partition plane** group (message batches, consumer offsets; in-memory journal).
Same wire, a few divergent semantics: partition-plane prepares leave `checksum_body` zero and rely on the batch's own `batch_checksum`, their identity checksum covers the header alone, and `StateTransferTarget.unavailable_transient` / `commit_max` are read only by the partition arm.