Skip to content

Latest commit

 

History

History
498 lines (384 loc) · 27.1 KB

wire.md

File metadata and controls

498 lines (384 loc) · 27.1 KB

CRAFT wire protocol

Status: implemented. This document specifies the on-wire encoding the craft_client transport (craft_wire + net/) is built against -- the concrete encoding of the client-facing verbs and the transport requirements enumerated in client-internals.md ("What the transport must provide"). The reference model (craft_reference) can also drive the same client in-process over MemTransport.

Transport decision

The first real binding is TCP over io_uring: kernel TCP for reliability and congestion control, io_uring for submission (socket ops are SQEs on the ublk queue's own ring, so a reply is a CQE the queue already reaps -- requirement 7). Homa is the back-pocket transport if the quorum-ack tail under incast is ever measured as the bottleneck and an out-of-tree kernel module is acceptable; RDMA (RoCEv2 / iWARP) is the option for a site willing to spend on RDMA NICs and a lossless fabric. The message encoding below is transport-agnostic so those bindings do not force a re-encode.

Two layers

  • The CRAFT message (this document, most of it) is transport-agnostic: a fixed message header, a per-operation header, and an opaque bulk payload. Every transport carries these bytes unchanged.
  • The transport framing (the "TCP binding" section) owns what is transport-specific: stream delimiting, the connection-init handshake, and integrity digests. Digests live here, not in the message: over TCP they are CRC32C (TCP's own checksum is too weak for storage); over RDMA the NIC's ICRC already covers the wire, so no application digest is added.

Conventions

  • All multi-byte integers are little-endian and naturally aligned within their header. Headers are packed (no implicit padding); every reserved byte MUST be written 0 and ignored on read.
  • A field marked i64 is signed (an _lsn or dLSN, where -1 means "none/unknown"); u64/u32/u16/u8 are unsigned. uuid[16] is a 16-byte RFC-4122 identifier in network byte order (as boost::uuids::uuid serializes it).
  • Offsets in the per-operation tables are relative to the start of that operation header (which itself begins immediately after the 8-byte message header).
  • Little-endian is deliberate: every field is fixed-offset, so a receiver overlays the header struct on the buffer and reads in place -- no parse, no copy. The bulk payload is never interpreted by the wire layer.

The message header

Every request and every response begins with this 8-byte header.

      7      6      5      4      3      2      1      0
    +------+------+------+------+------+------+------+------+
  0 |                       op  (u8)                      |   see the op table; request and response
    +------+------+------+------+------+------+------+------+   are distinct op codes
  1 |                     status  (u8)                    |   response ops only; 0 = OK (see below)
    +------+------+------+------+------+------+------+------+
2-3 |                  request_id  (u16)                  |   per-connection correlator; response echoes it
    +------+------+------+------+------+------+------+------+
4-7 |                    body_len  (u32)                  |   bytes of payload after the operation header
    +------+------+------+------+------+------+------+------+

There is no op_hdr_len and no per-message version. The operation-header size is fixed per (op, version), and both are known -- op is in the header and the wire version is a connection-lifetime constant negotiated once at connection-init -- so op alone implies the operation-header layout. Only the variable payload length (body_len) has to be carried. The 8-byte header keeps the operation header 8-aligned, so it can be overlaid in place (its first fields are _lsn / token u64s). A receiver frames a message as 8 + opHdrSize(op) + body_len (+ digests); a message whose op it does not recognize (a version bug, or corruption the digest already flags) is unframeable and resets the connection, which is the correct response anyway.

op carries the operation and the direction: request and response are distinct codes (there is no response flag bit), so a server only ever receives request ops and a client only ever receives _RSP ops -- the value alone is a direction check. The op set is far under 64, so a single byte is ample.

op value op value
LOGIN 1 LOGIN_RSP 2
HELO 3 HELO_RSP 4
WRITE 5 WRITE_RSP 6
READ 7 READ_RSP 8
KEEPALIVE 9 KEEPALIVE_RSP 10
LOGOUT 11 LOGOUT_RSP 12
RESOLVE 13 RESOLVE_RSP 14
CREATE_VOLUME 15 CREATE_VOLUME_RSP 16
GET_RS_COMMIT_LSN 17 GET_RS_COMMIT_LSN_RSP 18
FETCH_DATA 19 FETCH_DATA_RSP 20
AUTH_START 21 AUTH_CHALLENGE 22
AUTH_PROOF 23 AUTH_RESULT 24

1-14 are the client plane. 15-20 are the peer plane (replica to replica, plus bootstrap volume creation), specified by wire.hpp and not described further here. 21-24 are the connection-phase AUTH exchange (see Authentication).

request_id correlates a response to its request. It is a per-connection correlator allocated per request -- not the ublk tag (a split read issues several requests against one tag) -- and is not reused on a connection until its reply arrives or the connection resets, so a reply the client abandoned on a timeout can never be miscorrelated with a later request (NVMe/TCP's "CID not reused until the command completes"; see transport.md). A u16 is ample: the in-flight window on one connection is one queue's depth times its split factor, and connections do not share an id space, so neither nr_hw_queues nor the replica count multiplies it. A request op sets status to 0; a response op (the _RSP codes) carries the result in status.

Session and connection model

The session is connection-bound. A client holds one connection per (queue, replica); each is bound once, at open, and every IO on it reuses that binding. There is no per-IO or per-op handshake -- that would be a latency killer -- so the only constants carried per IO are the ones that actually change (the commit watermarks).

  • AUTH (ops 21-24) runs first on every connection and admits it for one volume. It answers "may this connection exist"; it carries no session state. See Authentication.
  • LOGIN establishes a new session on the leader (the full GetRSCommitLSN -> SyncRSCommitLSN -> InternalLogin orchestration) and binds the connection it arrives on. It returns the session term, the starting dLSN, and the volume geometry.
  • HELO binds an additional connection (another queue, or a follower) to the already-established session. The replica knows the session term from the replicated InternalLogin, so this is an attach, not a re-login.
  • Fencing is by the connection's bound term, not a per-IO wire field: the replica compares the socket's bound term to its current session term on each IO. A takeover bumps the session term; the old connection's bound term then trails it and its IOs fail STALE_TERM. This is why the per-IO header does not carry term.

The common request header

WRITE, READ, KEEPALIVE, LOGOUT, and RESOLVE requests begin their operation header with this 16-byte block -- CRAFT's commit, piggybacked on the IO the client is already sending (there is no standalone commit verb). LOGIN, HELO, and the AUTH ops omit it (pre-session).

offset size field notes
0 8 commit_lsn (i64) advance the contiguous frontier toward this, in dLSN order; -1 = do not advance
8 8 all_committed_lsn (i64) client-computed set-wide min commit_lsn; floors journal reclaim; -1 = unknown

Operations

LOGIN (request op = 1, response op = 2)

Establish a session; leader-only orchestration. A follower does not fail -- it returns term == 0 with leader_hint set, and the client retries at that replica.

Request operation header (24 bytes):

offset size field notes
0 16 volume_id (uuid) which volume to establish the session on (the design's login(client_token, vol_id)); a multi-volume server routes by it, and it is the same id HELO presents
16 8 client_token (u64) ownership token from the caller

No request body.

Response operation header (56 bytes):

offset size field notes
0 8 term (u64) 0 = NOT_LEADER redirect; > 0 = session term
8 8 dLSN (i64) starting per-partition LSN for new IO
16 8 capacity (u64) volume size in bytes -- the device size presented to ublk
24 4 lba_size (u32) block size in bytes; alignment unit for addr/len
28 4 max_tx (u32) largest single IO payload (data) in bytes; caps ublk max_sectors. A READ_RSP frames its extent table on top of this, so it does not bound body_len directly -- see Sizing and limits
32 4 member_count (u32) number of member descriptors in the body
36 4 reserved (0)
40 16 leader_hint (uuid) non-nil iff redirect (term == 0); nil on success

Response body: member_count member descriptors, packed:

offset size field notes
0 16 id (uuid) replica id
16 2 addr_len (u16) length of the address string
18 addr_len addr (bytes) "host:port", not NUL-terminated

This member list is the only variable-length, non-overlayable structure in the protocol; it is parsed once, at login, off the hot path.

HELO (request op = 3, response op = 4)

Bind this connection to an existing session. Sent on every connection after the first LOGIN. Any replica answers (not leader-only). OK binds; otherwise WRONG_TOKEN / STALE_TERM / NOT_LEADER.

Request operation header (32 bytes):

offset size field notes
0 16 volume_id (uuid) which volume this connection serves
16 8 client_token (u64) session owner
24 8 term (u64) the session term the client believes is current

No request body. Response carries status only (no operation header, no body).

WRITE (request op = 5, response op = 6)

Append the client-assigned write at dlsn. An empty body (body_len == 0) is a zero write (WRITE_ZEROES; len is the range to unmap; reads back as a hole); a non-empty body is a data write of exactly len bytes.

Request operation header (40 bytes):

offset size field notes
0 16 common request header commit_lsn, all_committed_lsn
16 8 dlsn (i64) client-assigned slot
24 8 addr (u64) byte offset, block-aligned
32 8 len (u64) byte length, block-aligned; the write size and (for a zero write) the unmap range

Request body: len bytes of block data, or none for a zero write.

Response operation header (16 bytes) -- the achieved LSNs, piggybacked so the client refreshes the reclaim floor and recovery on every write without a separate keep_alive:

offset size field notes
0 8 commit_lsn (i64) replica's achieved commit_lsn
8 8 last_append_lsn (i64) replica's highest appended dLSN

No response body. ACK fires on journal-append completion.

READ (request op = 7, response op = 8)

Return the latest version <= the read horizon H for [addr, addr+len), filling the caller's buffer in place and describing the sparse layout. Entries above H are never served even if the replica holds them.

Request operation header (40 bytes):

offset size field notes
0 16 common request header commit_lsn, all_committed_lsn
16 8 read_lsn (i64) the read horizon H
24 8 addr (u64) byte offset, block-aligned
32 8 len (u64) byte length, block-aligned

No request body.

Response operation header (24 bytes):

offset size field notes
0 8 commit_lsn (i64) replica's achieved commit_lsn (piggybacked, as on WRITE_RSP)
8 8 last_append_lsn (i64) replica's highest appended dLSN
16 4 extent_count (u32) number of extent descriptors in the body
20 4 reserved (0)

Every IO response the client gets back -- WRITE_RSP, READ_RSP, KEEPALIVE_RSP -- leads with the replica's achieved {commit_lsn, last_append_lsn}, so any round-trip to a replica refreshes its watermark in the client's per-member model (its synced, the reclaim floor), not just writes and keep_alives. This is the response-side mirror of the client watermarks (commit_lsn, all_committed_lsn) every IO request carries.

Response body: extent_count extent descriptors (ascending by addr), then the concatenated data bytes for the non-hole extents in the same order. The descriptors come first on purpose: the receiver builds an iovec per data extent pointing at that extent's sub-range of dest and does one scatter recv, so the packed wire bytes land at their dest offsets in a single copy with no separate reassembly; the hole ranges are the gaps the iovecs do not cover and are zero-filled locally, so no zero bytes cross the wire. (Over kernel TCP that recv copy is unavoidable, so a read is single-copy, not zero-copy; only RDMA, writing each extent straight into dest, makes a read zero-copy too -- see transport.md.)

Extent descriptor (24 bytes):

offset size field notes
0 8 addr (u64) byte offset into the volume
8 8 len (u64) byte length
16 1 hole (u8) 1 = hole (reads as zero, no bytes in the body); 0 = data
17 7 reserved (0)

A hole is a resolved "reads as zero" and is NOT the same as Missing.

KEEPALIVE (request op = 9, response op = 10)

The dedicated commit carrier: advance the frontier toward commit_lsn, reset the session watchdog, and return the member's achieved LSNs. It is also how the client drives liveness without a timer (see the client README).

Request operation header (16 bytes): the common request header. No body.

Response operation header (16 bytes):

offset size field notes
0 8 commit_lsn (i64)
8 8 last_append_lsn (i64)

LOGOUT (request op = 11, response op = 12)

Explicit, term-fenced session teardown. Leader-only. The leader commits an InternalLogout that clears the session on every replica; later IO from this client is fenced STALE_TERM.

Request operation header (16 bytes): the common request header. No body. Response carries status only.

RESOLVE (request op = 13, response op = 14)

The client-requested resolution round (the design's client-request SyncRSCommitLSN trigger), fired after a failed (sub-quorum) write instead of waiting for the watchdog / periodic cadence. The round is LEADER work, but the client cannot know who leads mid-session (leadership may have moved since login), so it broadcasts the request to every member -- at most one outstanding per connection, like its keep_alive drive -- and whichever member is the current leader runs it; a non-leader answers NOT_LEADER (a server may equally forward the request to its leader). Term-fenced. The leader resolves every unresolved slot <= upto: it fetches each from a holder (the failed write completes late), or, on quorum-lacks evidence, verdicts it Empty. A slot <= upto that had not landed anywhere when the leader resolved is verdicted Empty, and a replica rejects a late arrival into an Empty slot (reconciliation: Empty beats data) -- so that write's own ack path sees deterministic rejects and fails, per the undefined-outcome contract for un-acked IO.

Request operation header (24 bytes):

offset size field notes
0 16 common request header commit_lsn, all_committed_lsn
16 8 upto (i64) resolve every unresolved slot at or below this dLSN

No request body.

Response operation header (16 bytes):

offset size field notes
0 8 resolved_upto (i64) everything at or below this is now resolved set-wide
8 4 empty_count (u32) number of Empty-verdict dLSNs in the body
12 4 reserved (0)

Response body: empty_count packed i64 dLSNs (ascending) -- the slots <= resolved_upto verdicted Empty, including verdicts from earlier rounds, so a client whose previous reply was lost still learns them. Every other formerly-unresolved slot <= resolved_upto was filled and is durable.

Authentication

Mutual CHAP over HMAC-SHA-256, as two request/response pairs at the start of a connection, before LOGIN / HELO. Each end proves it holds the replica's per-attach secret; the secret itself never goes on the wire. Like NVMe-oF in-band authentication (Authentication Send/Receive over ordinary command capsules), it uses the normal message framing rather than a separate transport PDU type. Admission and the credential lifecycle are in transport.md.

client                                     replica
  AUTH_START     {volume_id, username, Mc}  ->
                                            <-  AUTH_CHALLENGE  {Ms}         status != OK: closes
  AUTH_PROOF     {Rc}                       ->
                                            <-  AUTH_RESULT     {Rs}         status != OK: closes

Credential

A credential is {username, password}, provisioned per (attach, replica) by the control plane. Both are opaque byte strings, used exactly as provisioned (no decoding). username is 1-64 bytes and is not secret: it identifies the credential generation, so a replica holding a rotated credential rejects a stale one by name. password is the HMAC key and MUST carry at least 256 bits from a CSPRNG -- the scheme has no defense against offline guessing of a weaker one. With 32 random bytes delivered hex-encoded, the 64 ASCII characters themselves are the key, not the 32 bytes they encode.

Proofs

Rc = HMAC-SHA-256(password, "craft-auth-v1 client" | volume_id | u8 len(username) | username | Mc | Ms)
Rs = HMAC-SHA-256(password, "craft-auth-v1 server" | volume_id | u8 len(username) | username | Mc | Ms)

The labels are 20 ASCII bytes, no terminator. The role label stops a proof being reflected back as the other side's; covering both nonces stops a proof being replayed on another connection. Mc and Ms are 32 bytes each from a CSPRNG, fresh per connection. Proofs are compared in constant time. craft/auth.hpp implements both proofs; client and server MUST hash through it so they hash identical bytes.

AUTH_START (request op = 21, response op = 22 AUTH_CHALLENGE)

Request operation header (56 bytes):

offset size field notes
0 2 version (u16) auth scheme version; 1 = the HMAC-SHA-256 exchange above
2 2 reserved (0)
4 4 reserved (0)
8 16 volume_id (uuid) which volume's credential to check; the same id LOGIN / HELO must present
24 32 client_nonce (u8[32]) Mc

Request body: username, 1-64 bytes.

Response (AUTH_CHALLENGE) operation header (32 bytes):

offset size field notes
0 32 server_nonce (u8[32]) Ms; zero-filled on a non-OK status

No response body. Status: OK, NOT_EXPOSED (no credential for volume_id here), STALE_CREDENTIAL (username is not the current credential), or INVALID_ARGUMENT (unsupported version, or username length out of range).

AUTH_PROOF (request op = 23, response op = 24 AUTH_RESULT)

Request operation header (32 bytes): client_proof (u8[32]) = Rc. No body.

Response (AUTH_RESULT) operation header (32 bytes): server_proof (u8[32]) = Rs, zero-filled on a non-OK status. No body. Status: OK or AUTH_FAILED (Rc did not verify).

Rules

  • Order. A replica that enforces auth accepts only AUTH_START as a connection's first message, then only AUTH_PROOF. Any other op before success, or any AUTH op after it, closes the connection without a reply.
  • One attempt. A non-OK AUTH_CHALLENGE / AUTH_RESULT is sent and the replica closes. There is no retry on the same connection.
  • Mutual, fail-closed. A client given a credential completes the exchange and verifies Rs before sending LOGIN / HELO, on every connection including reconnects. A missing or wrong Rs, or any non-OK status, fails the connection; the client never falls back to an unauthenticated connection. A client with no credential skips AUTH; only a replica that does not enforce auth (the reference server, tests) admits it.
  • Volume binding. LOGIN / HELO on an authenticated connection must name the authenticated volume_id; a mismatch is INVALID_ARGUMENT.
  • Refusals are final for the credential. NOT_EXPOSED, STALE_CREDENTIAL, AUTH_FAILED, INVALID_ARGUMENT, or a bad Rs mean the credential cannot succeed against this replica again: a client stops connecting with it and fails fast until it is given new attach info. The client reports INVALID_ARGUMENT (the replica cannot accept this version or username) as AUTH_FAILED. To keep NOT_EXPOSED meaning "not exposed", a replica MUST restore its exposed credentials before it accepts CRAFT connections. Any other non-OK AUTH status (e.g. INTERNAL) fails only that attempt.
  • AUTH statuses on AUTH replies only. Statuses 8-10 are valid only on AUTH_CHALLENGE / AUTH_RESULT; a client treats one on any other reply as a protocol violation and classifies the op like a lost reply (it may have applied).
  • Stalls. The replica's accept-to-bound deadline covers a stalled exchange; AUTH adds no timer of its own.
  • Stable layout. The AUTH op layouts do not change with the wire version, so they can run independently of connection-init. A new scheme is a new AUTH_START.version, not a new layout.

Status codes

Carried in the message header status byte on responses. Values 1-6 are exactly craft_error (craft/types.hpp), so client-side mapping is a cast.

value name meaning
0 OK success
1 STALE_TERM IO term != session term (the protocol's ETERM)
2 NOT_LEADER leader-only op sent to a follower (LOGIN returns a redirect instead)
3 NO_QUORUM could not reach a quorum of live replicas
4 WRONG_TOKEN client_token is not the current owner
5 NOT_ELIGIBLE replica cannot serve this read (Missing overlap / below login dLSN)
6 REPLICA_DOWN addressed replica is down / unreachable
7 INVALID_ARGUMENT misaligned addr/len, or body_len over max_tx
8 NOT_EXPOSED AUTH: the volume is not exposed on this replica; get fresh attach info from the control plane
9 STALE_CREDENTIAL AUTH: the credential was rotated; get fresh attach info from the control plane
10 AUTH_FAILED AUTH: the client's proof did not verify
255 INTERNAL unexpected server-side failure

The TCP binding

Connection init

Before any CRAFT message, the two ends exchange a transport init (the analogue of NVMe/TCP ICReq/ICResp): the client offers its maximum wire version and its digest capabilities; the replica replies with the version to use and which digests are enabled. Because the client is always >= the replica in version, the chosen version is the replica's, and the client speaks its backward-compatible codec for that version. Digest negotiation is per-connection and fixed for its lifetime. Connection-init precedes AUTH.

Not implemented yet: there is no init exchange today, and both ends run wire version 1 with both digests off.

Framing

A CRAFT message frames from its 8-byte header: op implies the operation-header size for the connected version and body_len gives the payload, so the total is 8 + opHdrSize(op) + body_len (+ digests) -- no separate length prefix is required. The binding MAY prepend a 4-byte sync word for stream resynchronization; it is a framing aid, not part of the message. On a message transport (Homa) a message is one datagram and framing is free.

Digests

CRC32C (Castagnoli -- the SSE4.2 crc32 instruction), matching iSCSI and NVMe/TCP practice:

  • Header digest (HDGST), negotiated, to default on once connection-init exists: a u32 over the message header plus the operation header, placed immediately after the operation header and before the body -- so a corrupt header is rejected before the receiver commits to reading (or allocating for) the payload.
  • Data digest (DDGST), negotiated: a u32 over the body, placed after it. Enable it for wire-to-disk end-to-end integrity; it may be left off where the storage layer already carries a client-computed at-rest block checksum that covers the same path.

On the wire a framed message is therefore: [message header][op header][HDGST?][body][DDGST?], with the two digests present per the connection's negotiation.

Payload movement

  • WRITE request: build [message header][op header] in a small scratch and gather-send it with the data iovec pointing straight at the ublk buffer (SEND_ZC), so the block payload leaves zero-copy; it is consumed at submit, so a straggler send outliving the quorum ack is safe (requirement 6).
  • READ response: build an iovec per data extent (into the matching dest sub-ranges) and do one scatter recv, so the packed wire bytes are placed at their dest offsets in a single copy, with no reassembly; hole ranges are zero-filled locally. A kernel-TCP recv always copies socket buffer to userspace, so a read is single-copy, not zero-copy -- RDMA (writing each extent straight into the registered dest) is what makes a read zero-copy too.

Sizing and limits

max_tx is the volume's largest single IO payload -- the data of one transfer, the number LOGIN_RSP carries and a driver caps max_sectors to (iSCSI's 512 KiB payload with the header excluded is the analogue). It is not a bound on body_len. A WRITE request body is pure payload, so its body_len <= max_tx; but a READ_RSP body is the extent table then the data, so a full-payload read frames a body of max_tx data plus one extent_desc per block (worst case, a fully fragmented read). The transport therefore bounds a body at

framed_body_max(max_tx, lba_size) = max_tx + ceil(max_tx / lba_size) * sizeof(extent_desc)

-- the payload plus the largest extent table a read of it can carry (e.g. 512 KiB + 3 KiB = 515 KiB at a 4 KiB block). Both peers derive that bound from the payload agreed at LOGIN, so nothing extra is negotiated. A receiver MUST reject a body_len over the bound it applies -- a server bounds a request at max_tx (requests carry no extents), a client bounds a reply at framed_body_max -- to cap its buffer. There is no application-level fragmentation or reassembly.

Not covered yet

  • Peer-plane op layouts (15-20): allocated and defined in wire.hpp, not yet described here. Peer-plane connections are not authenticated yet; a replica serving them on its CRAFT port must not apply the AUTH ordering rule to them until they have a credential model.
  • Encryption: out of scope for an intra-DC v1. If needed it is TLS under the TCP framing, below and independent of the AUTH exchange.
  • Multi-partition: each partition is its own client with its own session and connections; this document describes a single partition's wire.