The Wire Protocol — Framing, Message Types, and a Protobuf Codec

The Wire Protocol — Framing, Message Types, and a Protobuf Codec

Every request and response in the system — produce, fetch, topic administration, leader discovery — travels over one TCP connection as a length-prefixed frame with a protobuf-encoded payload. In this section you'll define the frame format, the full set of message types, the Go-native request/response structs the rest of the codebase actually programs against, and the Codec that translates between the two. All of this lives in api/protocol/.

A design choice worth calling out up front: this is not gRPC. There's no .proto service definition and no generated client/server stubs. Protobuf here is used purely as a compact, versionable payload format inside a hand-rolled framing protocol — you get schema evolution and fast (de)serialization without pulling in a full RPC framework.

Step 1: Define the frame format

Frame header

Every message on the wire starts with a 6-byte header — a 2-byte message type and a 4-byte payload length — followed by the protobuf-encoded payload:

┌───────────────┬────────────────┬─────────────────────┐
│  Message Type │  Payload Size  │  Payload (protobuf) │
│  2 bytes      │  4 bytes       │  PayloadSize bytes  │
│  (uint16)     │  (uint32)      │                     │
└───────────────┴────────────────┴─────────────────────┘
// api/protocol/frame.go
package protocol

import "encoding/binary"

type MessageType uint16

var byteOrder = binary.BigEndian

const messageTypeSize = 2
const messageSizeSize = 4

// frameHeaderSize is the length prefix size (2 bytes for message type, 4 bytes for message size)
const frameHeaderSize = messageTypeSize + messageSizeSize

// MaxFrameSize is the maximum allowed frame payload size (4MB) to avoid abuse.
const MaxFrameSize = 4 * 1024 * 1024

A fixed-size, big-endian header keeps framing trivial: read 6 bytes, decode a type and a length, then read exactly that many more bytes for the payload. MaxFrameSize caps payloads at 4MB so a corrupt or malicious length field can't make the reader allocate an unbounded buffer.

MessageType enum

Every distinct request or response gets its own constant, generated with iota:

const (
    MsgReplicateStream MessageType = iota
    MsgReplicateResp
    MsgProduce
    MsgProduceResp
    MsgProduceBatch
    MsgProduceBatchResp
    MsgFetch
    MsgFetchResp
    MsgFetchBatch
    MsgFetchBatchResp
    MsgFetchStream
    MsgFetchStreamResp
    MsgCommitOffset
    MsgCommitOffsetResp
    MsgFetchOffset
    MsgFetchOffsetResp
    MsgCreateTopic
    MsgCreateTopicResp
    MsgDeleteTopic
    MsgDeleteTopicResp
    MsgRPCError
    MsgFindTopicLeader
    MsgFindTopicLeaderResp
    MsgFindRaftLeader
    MsgFindRaftLeaderResp
    MsgListTopics
    MsgListTopicsResp
    MsgApplyIsrUpdateEvent
    MsgApplyIsrUpdateEventResp
)

MsgRPCError is special: it isn't paired with a specific request. Any handler can return it in place of the expected response type when something goes wrong (Step 5 covers this).

Step 2: Write the protobuf schema

mlog.proto

Every message type on the wire has a matching protobuf message, defined in one .proto file:

// api/protocol/proto/mlog.proto
syntax = "proto3";

package mlog.protocol;

option go_package = "github.com/mohitkumar/mlog/api/protocol/pb";

message LogEntry {
  uint64 offset = 1;
  bytes value = 2;
}

message ProduceRequest {
  string topic = 1;
  bytes value = 2;
  int32 acks = 3;
}

message ProduceResponse {
  uint64 offset = 1;
}

message FetchBatchRequest {
  string topic = 1;
  string id = 2;
  uint64 offset = 3;
  uint32 max_count = 4;
  string replica_node_id = 5;
}

message FetchBatchResponse {
  repeated LogEntry entries = 1;
}

// ... one message per request/response type, plus topic administration,
// metadata events, and Raft snapshot messages (Step 6).

Generate the Go types from it:

protoc --go_out=. --go_opt=paths=source_relative \
    api/protocol/proto/mlog.proto

This produces api/protocol/pb/mlog.pb.go — plain generated structs (pb.ProduceRequest, pb.FetchBatchResponse, and so on) with proto.Marshal/proto.Unmarshal support. Nothing outside api/protocol ever imports pb directly; it's an implementation detail of the codec you'll write in Step 4.

Step 3: Define the Go-native request/response types

Why a separate set of types from the generated protobuf structs

Handler signatures, client code, and tests all work with a set of plain Go structs — not the generated pb.* types directly. Keeping them separate means callers never see generated-struct internals (unexported fields, XXX_ bookkeeping) and the wire encoding can change without touching a single call site:

// api/protocol/types.go
package protocol

type LogEntry struct {
    Offset uint64
    Value  []byte
}

type AckMode int32

const (
    AckNone   AckMode = 0
    AckLeader AckMode = 1
    AckAll    AckMode = 2
)

type ProduceRequest struct {
    Topic string
    Value []byte
    Acks  AckMode
}
type ProduceResponse struct {
    Offset uint64
}

type ProduceBatchRequest struct {
    Topic  string
    Values [][]byte
    Acks   AckMode
}
type ProduceBatchResponse struct {
    BaseOffset uint64
    LastOffset uint64
    Count      uint32
}

type FetchRequest struct {
    Topic         string
    Id            string
    Offset        uint64
    ReplicaNodeID string // when set, leader uses ReadUncommitted and stores this offset as replica LEO
}
type FetchResponse struct {
    Entry *LogEntry
}

type FetchBatchRequest struct {
    Topic         string
    Id            string
    Offset        uint64
    MaxCount      uint32
    ReplicaNodeID string
}
type FetchBatchResponse struct {
    Entries []*LogEntry
}

Topic administration and discovery messages follow the same pattern — CreateTopicRequest/CreateTopicResponse, DeleteTopicRequest/DeleteTopicResponse, FindTopicLeaderRequest/FindTopicLeaderResponse, FindRaftLeaderRequest/FindRaftLeaderResponse, ListTopicsRequest/ListTopicsResponse (carrying TopicInfo/ReplicaInfo), and CommitOffsetRequest/FetchOffsetRequest for consumer offset tracking. ApplyIsrUpdateEventRequest/Response is the one message sent to the Raft leader specifically to propose an ISR change.

Converting between Go types and protobuf types

api/protocol/convert.go holds the small helper functions that translate between the two representations — one pair per nested type that needs it (a bare struct with only scalar fields, like ProduceRequest, converts inline in the codec; something with a slice of pointers, like a list of LogEntry, gets its own helper):

// api/protocol/convert.go
package protocol

func logEntryToPB(e *LogEntry) *pb.LogEntry {
    if e == nil {
        return nil
    }
    return &pb.LogEntry{Offset: e.Offset, Value: e.Value}
}

func logEntryFromPB(m *pb.LogEntry) *LogEntry {
    if m == nil {
        return nil
    }
    return &LogEntry{Offset: m.Offset, Value: m.Value}
}

func logEntriesToPB(es []*LogEntry) []*pb.LogEntry {
    if es == nil {
        return nil
    }
    out := make([]*pb.LogEntry, len(es))
    for i, e := range es {
        out[i] = logEntryToPB(e)
    }
    return out
}

topicInfosToPB/topicInfosFromPB and replicaInfosToPB/replicaInfosFromPB follow the identical shape for ListTopicsResponse's nested TopicInfo/ReplicaInfo slices.

Step 4: Build the Codec

Encode: Go struct → protobuf → frame

Codec.Encode type-switches on the concrete Go type passed in, builds the matching pb.* message, marshals it, and writes it as a framed message:

// api/protocol/codec.go
package protocol

type Codec struct{}

func (c *Codec) Encode(w io.Writer, msg any) error {
    var mType MessageType
    var payload []byte
    var err error
    switch v := msg.(type) {
    case ProduceRequest:
        mType = MsgProduce
        payload, err = proto.Marshal(&pb.ProduceRequest{Topic: v.Topic, Value: v.Value, Acks: int32(v.Acks)})
    case *ProduceRequest:
        mType = MsgProduce
        payload, err = proto.Marshal(&pb.ProduceRequest{Topic: v.Topic, Value: v.Value, Acks: int32(v.Acks)})
    case ProduceResponse:
        mType = MsgProduceResp
        payload, err = proto.Marshal(&pb.ProduceResponse{Offset: v.Offset})
    // ... one case (value and pointer) per message type ...
    case FetchBatchResponse:
        mType = MsgFetchBatchResp
        payload, err = proto.Marshal(&pb.FetchBatchResponse{Entries: logEntriesToPB(v.Entries)})
    default:
        return ErrUnknownMessageType(mType)
    }
    if err != nil {
        return err
    }
    return c.encodeFrame(w, mType, payload)
}

Each case appears twice — once for the value type, once for the pointer type — since callers pass both (tc.Call(req) with a value, but some code builds requests as pointers). That's mechanical repetition, not a design choice worth agonizing over: the switch is written by hand once and rarely touched again as new message types are added at the end.

encodeFrame and decodeFrame

The actual byte-level framing is two small helpers:

func (c *Codec) encodeFrame(w io.Writer, mType MessageType, payload []byte) error {
    length := uint32(len(payload))
    if length > MaxFrameSize {
        return ErrFrameTooLarge
    }
    // Stack-allocated header avoids a heap allocation per frame.
    var header [frameHeaderSize]byte
    byteOrder.PutUint16(header[:], uint16(mType))
    byteOrder.PutUint32(header[messageTypeSize:], length)
    // Single write: combine header + payload to avoid two syscalls.
    buf := make([]byte, frameHeaderSize+len(payload))
    copy(buf, header[:])
    copy(buf[frameHeaderSize:], payload)
    _, err := w.Write(buf)
    return err
}

func (c *Codec) decodeFrame(r io.Reader) (mType MessageType, payload []byte, err error) {
    var header [frameHeaderSize]byte
    if _, err := io.ReadFull(r, header[:]); err != nil {
        return 0, nil, err
    }
    mType = MessageType(byteOrder.Uint16(header[:]))
    length := byteOrder.Uint32(header[messageTypeSize:])
    if length > MaxFrameSize {
        return 0, nil, ErrFrameTooLarge
    }
    payload = make([]byte, length)
    if _, err := io.ReadFull(r, payload); err != nil {
        return 0, nil, err
    }
    return mType, payload, nil
}

Two small performance details worth noting: the frame header is a fixed-size array ([frameHeaderSize]byte), so building it never touches the heap, and the header and payload are copied into one combined buffer before the single Write call — sending them separately would cost an extra syscall per message on a non-buffered connection.

Decode: frame → protobuf → Go struct

Decode is the mirror image — read a frame, look at its message type, unmarshal into the matching pb.* struct, and convert back to the plain Go type:

func (c *Codec) Decode(r io.Reader) (MessageType, any, error) {
    mType, payload, err := c.decodeFrame(r)
    if err != nil {
        return 0, nil, err
    }
    switch mType {
    case MsgProduce:
        var m pb.ProduceRequest
        if err := proto.Unmarshal(payload, &m); err != nil {
            return mType, nil, err
        }
        return mType, ProduceRequest{Topic: m.Topic, Value: m.Value, Acks: AckMode(m.Acks)}, nil
    case MsgFetchBatchResp:
        var m pb.FetchBatchResponse
        if err := proto.Unmarshal(payload, &m); err != nil {
            return mType, nil, err
        }
        return mType, FetchBatchResponse{Entries: logEntriesFromPB(m.Entries)}, nil
    // ... one case per message type ...
    case MsgRPCError:
        var m pb.RPCErrorResponse
        if err := proto.Unmarshal(payload, &m); err != nil {
            return mType, nil, err
        }
        return mType, RPCErrorResponse{Code: m.Code, Message: m.Message}, nil
    default:
        return 0, nil, ErrUnknownMessageType(mType)
    }
}

The caller (the transport layer, next chapter) gets back the MessageType alongside the decoded value so it can dispatch to the right handler without a second type assertion.

Step 5: Define RPC error codes

Error codes clients can act on

Beyond a bare error message, the server needs to tell a client what kind of failure occurred, so the client can decide whether to retry, reconnect, or give up. Each error condition gets a numeric code:

// api/protocol/types.go
const (
    CodeUnknown int32 = iota
    CodeTopicRequired
    CodeTopicNameRequired
    CodeTopicNotFound
    CodeNotTopicLeader
    CodeValuesRequired
    CodeReadOffset
    CodeCommitOffset
    CodeRecoverOffsets
    CodeReplicaCountInvalid
    CodeLeaderAddrRequired
    CodeRaftLeaderUnavailable
    CodeTopicExists
    CodeNotEnoughNodes
    CodeCannotReachLeader
    CodeInvalidAckMode
    CodeTimeoutCatchUp
)

// RPCErrorResponse is sent by the server when a handler returns an error.
type RPCErrorResponse struct {
    Code    int32  `json:"code"`
    Message string `json:"message"`
}

// RPCError is returned by the transport client when the server sends an RPCErrorResponse.
type RPCError struct {
    Code    int32
    Message string
}

func (e *RPCError) Error() string { return e.Message }

A handler that hits, say, "this node isn't the leader for this topic" doesn't just return a generic error — it returns (or the RPC layer wraps it into) an RPCErrorResponse{Code: CodeNotTopicLeader, ...}, encoded as MsgRPCError instead of the expected response type. The client's Codec.Decode sees MsgRPCError and hands back an *RPCError, which calling code can type-assert on.

ShouldReconnect: deciding when to abandon a connection

Given an error, ShouldReconnect answers "is this connection worth keeping, or should the caller find a new address and redial?":

func ShouldReconnect(err error) bool {
    if err == nil {
        return false
    }
    // Server returned a structured RPC error code: reconnect on leader/topic/raft changes.
    var rpcErr *RPCError
    if errors.As(err, &rpcErr) {
        switch rpcErr.Code {
        case CodeNotTopicLeader, CodeTopicNotFound, CodeRaftLeaderUnavailable:
            return true
        default:
            return false
        }
    }
    // EOF means the connection was closed by the peer.
    if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
        return true
    }
    // Syscall-level connection errors: reset, broken pipe, refused.
    if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNREFUSED) {
        return true
    }
    // net.Error covers timeouts and other transient network failures.
    var netErr net.Error
    if errors.As(err, &netErr) {
        return true
    }
    // net.ErrClosed: use of closed network connection.
    if errors.Is(err, net.ErrClosed) {
        return true
    }
    return false
}

This one function is the shared decision point behind every reconnect-on-failure path in the system — the producer and consumer client libraries (covered later) and the broker's own replication engine all call it after an RPC fails, rather than each re-implementing "is this error connection-shaped?" independently.

IsTopicNotReady: a narrower, non-reconnecting retry signal

A related but distinct case: right after CreateTopic returns, a given node may not have opened the topic's local log yet (it's on a poll — see the topic management chapter). That's not a reason to reconnect anywhere; the same node will become ready shortly, so the right response is to retry the same request after a short pause:

func IsTopicNotReady(err error) bool {
    var rpcErr *RPCError
    if errors.As(err, &rpcErr) {
        return rpcErr.Code == CodeTopicNotFound || rpcErr.Code == CodeNotTopicLeader
    }
    return false
}

Notice CodeTopicNotFound/CodeNotTopicLeader appear in both ShouldReconnect and IsTopicNotReady — the two functions answer different questions about the same codes. Whether a caller treats a given error as "redial" or "retry in place" depends on which of the two it checks, and where in the retry stack it's sitting (client libraries check both, in that order — the producer/consumer chapter covers the full retry stack).

Step 6: Encode Raft metadata events

The event envelope

Cluster metadata changes (create a topic, change a topic's leader, update ISR membership) are proposed through Raft as small, typed events. api/protocol/metadata.go defines the envelope and the encode/decode helpers for each event payload:

// api/protocol/metadata.go
package protocol

type MetadataEventType uint16

const (
    MetadataEventTypeCreateTopic MetadataEventType = iota
    MetadataEventTypeDeleteTopic
    MetadataEventTypeLeaderChange
    MetadataEventTypeIsrUpdate
    MetadataEventTypeAddNode
    MetadataEventTypeRemoveNode
    MetadataEventTypeUpdateNode
)

type MetadataEvent struct {
    EventType MetadataEventType
    Data      []byte
}

func EncodeMetadataEvent(ev *MetadataEvent) ([]byte, error) {
    return proto.Marshal(&pb.MetadataEvent{EventType: uint32(ev.EventType), Data: ev.Data})
}

func DecodeMetadataEvent(data []byte) (*MetadataEvent, error) {
    var m pb.MetadataEvent
    if err := proto.Unmarshal(data, &m); err != nil {
        return nil, err
    }
    return &MetadataEvent{EventType: MetadataEventType(m.EventType), Data: m.Data}, nil
}

type CreateTopicEvent struct {
    Topic          string
    ReplicaCount   uint32
    LeaderNodeID   string
    LeaderEpoch    int64
    ReplicaNodeIds []string
}

func EncodeCreateTopicEvent(e CreateTopicEvent) ([]byte, error) {
    return proto.Marshal(&pb.CreateTopicEvent{
        Topic:        e.Topic,
        ReplicaCount: e.ReplicaCount,
        LeaderId:     e.LeaderNodeID,
        LeaderEpoch:  e.LeaderEpoch,
        Replicas:     e.ReplicaNodeIds,
    })
}

DeleteTopicEvent, LeaderChangeEvent, and IsrUpdateEvent follow the same Encode*/Decode* shape — a small Go struct, a matching pb.* message, proto.Marshal/proto.Unmarshal. AddNodeEvent, RemoveNodeEvent, and UpdateNodeEvent are defined here too, for representing node membership as a Raft-replicated event; the cluster chapter covers how membership is actually derived directly from Raft's own voter configuration plus Serf's gossiped tags, so these three types are defined without a caller that applies them.

The Raft-facing package that drives consensus (broker/cluster/raft) defines its own copy of this same envelope and event set locally rather than importing this one — worth knowing if you go looking for where EncodeCreateTopicEvent is actually called from in the cluster chapter and land in this file first instead.

Step 7: Encode replication batches

Why a separate binary format

Client-facing RPCs (produce, fetch) go through the protobuf codec above. But when the replication engine streams raw log data from a leader's segment files, it uses its own compact binary format instead — closer to Kafka's own record-batch format, and avoiding a protobuf message per record when a batch might contain thousands of them:

Batch header (25 bytes):
┌──────────────┬──────────────┬──────────────┬──────────┬────────────┬──────────────────┐
│ Base Offset  │ Batch Length │ Leader Epoch │   CRC    │ Attributes │ Last Offset Delta│
│  8 bytes     │  4 bytes     │  4 bytes     │ 4 bytes  │  1 byte    │  4 bytes         │
└──────────────┴──────────────┴──────────────┴──────────┴────────────┴──────────────────┘

Followed by records:
┌──────────────┬──────────────┬──────────────────────┐
│   Offset     │    Size      │    Value             │
│   8 bytes    │   4 bytes    │   Size bytes         │
├──────────────┼──────────────┼──────────────────────┤
│   Offset     │    Size      │    Value             │
│   ...        │   ...        │   ...                │
└──────────────┴──────────────┴──────────────────────┘
// api/protocol/replication_batch.go
package protocol

const (
    LeaderEpoch                 = 0
    CompressionNone             = 0
    replicationBatchHeaderSize  = 8 + 4 + 4 + 4 + 1 + 4 // 25 bytes
    replicationRecordHeaderSize = 8 + 4                 // offset + size
)

type ReplicationRecord struct {
    Offset int64
    Value  []byte
}

func EncodeReplicationBatch(records []ReplicationRecord) ([]byte, error) {
    if len(records) == 0 {
        return nil, nil
    }
    baseOffset := records[0].Offset
    lastOffset := records[len(records)-1].Offset
    lastOffsetDelta := int32(lastOffset - baseOffset)

    var recordBuf []byte
    for _, r := range records {
        recordBuf = append(recordBuf, encodeRecord(r)...)
    }
    batchLength := int32(len(recordBuf))
    crc := crc32.ChecksumIEEE(recordBuf)

    buf := make([]byte, replicationBatchHeaderSize+len(recordBuf))
    off := 0
    replicationByteOrder.PutUint64(buf[off:off+8], uint64(baseOffset))
    off += 8
    replicationByteOrder.PutUint32(buf[off:off+4], uint32(batchLength))
    off += 4
    replicationByteOrder.PutUint32(buf[off:off+4], uint32(LeaderEpoch))
    off += 4
    replicationByteOrder.PutUint32(buf[off:off+4], crc)
    off += 4
    buf[off] = byte(CompressionNone)
    off++
    replicationByteOrder.PutUint32(buf[off:off+4], uint32(lastOffsetDelta))
    off += 4
    copy(buf[off:], recordBuf)
    return buf, nil
}

Decoding and verifying a batch

DecodeReplicationBatch reverses the encoding and, critically, verifies the CRC before trusting the record bytes:

func DecodeReplicationBatch(data []byte) ([]ReplicationRecord, error) {
    if len(data) < replicationBatchHeaderSize {
        return nil, io.ErrUnexpectedEOF
    }
    baseOffset := int64(replicationByteOrder.Uint64(data[0:8]))
    batchLength := int32(replicationByteOrder.Uint32(data[8:12]))
    crcStored := replicationByteOrder.Uint32(data[16:20])
    recordBuf := data[replicationBatchHeaderSize:]
    if int32(len(recordBuf)) != batchLength {
        return nil, io.ErrUnexpectedEOF
    }
    if crc32.ChecksumIEEE(recordBuf) != crcStored {
        return nil, ErrReplicationBatchCRC
    }
    var records []ReplicationRecord
    p := recordBuf
    for len(p) >= replicationRecordHeaderSize {
        offset := int64(replicationByteOrder.Uint64(p[0:8]))
        size := int32(replicationByteOrder.Uint32(p[8:12]))
        if size < 0 || int(replicationRecordHeaderSize)+int(size) > len(p) {
            break
        }
        value := make([]byte, size)
        copy(value, p[12:12+size])
        records = append(records, ReplicationRecord{Offset: offset, Value: value})
        p = p[12+size:]
    }
    return records, nil
}

The CRC covers only the record bytes, not the header — this catches corruption in the payload (a truncated write, a flipped bit on disk) without needing to checksum fields that are already validated structurally (length, offset ordering).

Step 8: Define protocol-level errors

// api/protocol/error.go
package protocol

var (
    ErrFrameTooLarge       = errors.New("protocol: frame exceeds max size")
    ErrReplicationBatchCRC = errors.New("protocol: replication batch CRC mismatch")
)

func ErrUnknownMessageType(mType MessageType) error {
    return fmt.Errorf("protocol: can not encode type: %d", mType)
}

These three cover the failure modes intrinsic to the wire format itself — as opposed to RPCError's codes, which describe application-level failures (topic not found, not the leader, and so on).

Summary

Component File Purpose
Frame format frame.go 6-byte header (2-byte type + 4-byte length) + payload. MessageType enum.
Go-native types types.go Request/response structs every handler, client, and test actually programs against.
Protobuf schema proto/mlog.proto, generated pb/mlog.pb.go Wire encoding for every message type, plus Raft metadata events and snapshot messages.
convert.go convert.go Small helpers translating nested Go types (LogEntry, TopicInfo) to/from their pb.* counterparts.
Codec codec.go Encode/Decode: type-switch each Go struct to/from protobuf, framed with encodeFrame/decodeFrame.
RPCError / ShouldReconnect / IsTopicNotReady types.go Structured error codes, and the two decision functions clients use to choose between reconnecting and retrying in place.
Metadata events metadata.go Envelope and per-event-type encode/decode for Raft-proposed cluster changes.
Replication batch format replication_batch.go Compact binary (non-protobuf) format for bulk record transfer during replication, with a CRC32 check.

With the wire protocol defined, the next page builds the transport and RPC layer — the TCP server and client that actually move these frames over the network, and the handlers that turn decoded requests into calls against the topic manager.