Malachi.Wire (malachi v0.8.13)

View Source

The binary wire protocol for the NorthGuard log client: a length-prefixed, request/response framing that replaces the JSON+base64 line protocol (measured ~29% fewer bytes and 9-17x less serialization CPU in benchmark/protocol_bench.exs).

Frame:     <<len::32, body::binary-size(len)>>
Request:   <<api_key::16, correlation_id::32, payload::binary>>
Response:  <<correlation_id::32, error_code::16, payload::binary>>

correlation_id lets a client pipeline (match each response to its request). Records on the wire carry no offset: the client never sees one; the opaque cursor carries position - so this is a distinct encoding from Malachi.Log.Record.encode/1 (the on-disk frame, which includes the offset). Keys and cursors are length-prefixed byte strings with a presence flag (nil vs empty are distinct). Pure, this module only encodes/decodes binaries; the socket wiring is B1b.

decode_frame/1 is tolerant (returns :incomplete for a partial frame), but the payload decoders (decode_request/1, decode_produce_req/1, …) assume a well-formed body and raise on a malformed one. A frame body comes from an untrusted client, so B1b must decode inside a try and answer an error (or close) on a raise: keeping the malformed-input handling at the connection boundary, not in the codec.

Stability and compatibility

This framing is the compatibility contract with every client: the Node CLI, the Elixir client, and any future SDK. Two things are stable and must stay so: the byte layout of each frame above, and the api_key numbers (currently 0..16, @auth through @list_acls). Clients are compiled against them, so a running cluster and its clients agree on the wire only as long as both hold.

A change is breaking (every deployed client must update in lockstep, so it cannot ship in a normal release) when it:

  • changes the layout or meaning of an existing api_key's request or response payload,
  • reuses or renumbers an api_key that already shipped, or
  • changes how error_code or its reason string is encoded.

Evolve the protocol additively instead: every new operation, and every extension of an existing one, takes the next free api_key number. Appending a field to an existing payload is not compatible here, because the decoders match a payload to its exact end ({value, <<>>} = take_str(rest)): trailing bytes raise a MatchError rather than being ignored, so an old peer cannot skip a field a newer one appended. A shipped frame is therefore frozen; a change means a new key. This mirrors the discipline the Apache Iggy project keeps around its own binary protocol: extend, do not rewrite.

Summary

Functions

Decodes an error response payload (see encode_error/2) back to its reason string.

Peels one frame off a buffer: {:ok, body, rest} or :incomplete if the frame is not all here.

Like decode_frame/1 but bounds the frame: as soon as the 4-byte length prefix is readable, a declared length over max_size is rejected with {:error, :frame_too_large}, before the body is buffered: so a hostile length prefix cannot force the server to accumulate unbounded memory.

An error response frame (error_code 1) whose payload is reason as a string.

Wraps a body in a length-prefixed frame.

A success response frame (error_code 0) for correlation_id carrying payload.

Encodes a record for the wire (no offset: the client never sees one).

Types

api_key()

@type api_key() :: 0..16

error_code()

@type error_code() :: non_neg_integer()

Functions

auth_key()

@spec auth_key() :: api_key()

change_password_key()

commit_key()

create_topic_key()

create_user_key()

decode_acl_req(payload)

decode_auth_req(payload)

decode_auth_resp(payload)

decode_change_password_req(payload)

decode_commit_req(payload)

decode_create_topic_req(payload)

decode_create_user_req(payload)

decode_delete_user_req(payload)

decode_error_reason(payload)

@spec decode_error_reason(binary()) :: String.t() | nil

Decodes an error response payload (see encode_error/2) back to its reason string.

decode_fetch_req(payload)

decode_fetch_resp(payload)

decode_frame(arg1)

@spec decode_frame(binary()) :: {:ok, binary(), binary()} | :incomplete

Peels one frame off a buffer: {:ok, body, rest} or :incomplete if the frame is not all here.

decode_frame(buffer, max_size)

@spec decode_frame(binary(), non_neg_integer()) ::
  {:ok, binary(), binary()} | :incomplete | {:error, :frame_too_large}

Like decode_frame/1 but bounds the frame: as soon as the 4-byte length prefix is readable, a declared length over max_size is rejected with {:error, :frame_too_large}, before the body is buffered: so a hostile length prefix cannot force the server to accumulate unbounded memory.

decode_leave_group_req(payload)

decode_list_acls_req(payload)

decode_list_acls_resp(arg)

decode_list_users_resp(arg)

decode_produce_req(payload)

decode_record(binary)

@spec decode_record(binary()) :: {Malachi.Log.Record.t(), binary()}

decode_request(arg)

@spec decode_request(binary()) :: {api_key(), non_neg_integer(), binary()}

decode_response(arg)

@spec decode_response(binary()) :: {non_neg_integer(), error_code(), binary()}

decode_stream_ack_req(payload)

decode_subscribe_req(payload)

decode_token_auth_req(payload)

delete_user_key()

encode_acl_req(username, operation, pattern)

encode_auth_req(username, password)

encode_auth_resp(token)

encode_change_password_req(username, new_password)

encode_commit_req(topic, group, cursor)

encode_create_topic_req(topic, keyspace_bits)

encode_create_user_req(username, password, permissions)

encode_delete_user_req(username)

encode_error(correlation_id, reason)

@spec encode_error(non_neg_integer(), term()) :: binary()

An error response frame (error_code 1) whose payload is reason as a string.

encode_fetch_req(topic, cursor, group, member, max, wait_ms)

encode_fetch_resp(records, next_cursor)

encode_frame(body)

@spec encode_frame(binary()) :: binary()

Wraps a body in a length-prefixed frame.

encode_leave_group_req(topic, group, member)

encode_list_acls_req(username)

encode_list_acls_resp(acls)

encode_list_users_resp(users)

encode_mtls_auth_req()

encode_ok(correlation_id, payload)

@spec encode_ok(non_neg_integer(), binary()) :: binary()

A success response frame (error_code 0) for correlation_id carrying payload.

encode_produce_req(topic, records)

encode_record(record)

@spec encode_record(Malachi.Log.Record.t()) :: binary()

Encodes a record for the wire (no offset: the client never sees one).

encode_request(api_key, correlation_id, payload)

@spec encode_request(api_key(), non_neg_integer(), binary()) :: binary()

encode_response(correlation_id, error_code, payload)

@spec encode_response(non_neg_integer(), error_code(), binary()) :: binary()

encode_stream_ack_req(topic, group, member, cursor, count)

encode_subscribe_req(topic, group, member, window, max)

encode_token_auth_req(jwt)

error_code()

fetch_key()

grant_acl_key()

leave_group_key()

list_acls_key()

list_users_key()

mtls_auth_key()

ok_code()

produce_key()

revoke_acl_key()

stream_ack_key()

subscribe_key()

token_auth_key()