Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The connection actor

One socket, two tasks, one correlation map. Connection is the whole of kafka-conn's runtime surface: hand it a request, get a response, with many requests in flight at once.

graph LR
    caller1["caller A"] -->|encoded frame| tx
    caller2["caller B"] -->|encoded frame| tx
    tx["writer task"] -->|"framed bytes"| sock[("TCP / TLS")]
    sock -->|"framed bytes"| rx["reader task"]
    rx -->|"by correlation id"| pending{{"pending map<br/>i32 → oneshot"}}
    pending -->|response bytes| caller1
    pending -->|response bytes| caller2

Decoding happens on the calling task

The reader task does exactly one thing with a response frame: look up its correlation id and hand the raw Bytes to whoever is waiting. It does not decode.

That placement is deliberate. If the reader decoded, a single response that failed to parse — a version negotiated wrongly, a schema that drifted — would be an error in the reader task, and the reader task is shared by every request on that connection. Decoding on the calling task means a malformed DescribeConfigs response fails describe_configs and nothing else.

Framing

A 4-byte big-endian length prefix, then header and body. That is LengthDelimitedCodec's default configuration, and crates/kafka-conn/src/codec.rs states it explicitly anyway so that a future edit cannot quietly change endianness.

Frames are capped at DEFAULT_MAX_FRAME_BYTES — 100 MiB, matching Kafka's own socket.request.max.bytes default. A frame larger than that is a protocol desync rather than a big fetch, and reading it would be an unbounded allocation driven by the peer.

Two header traps

Both produce an off-by-a-few-bytes failure rather than a clear error, which is what makes them expensive:

The response header version is not the request's api version. It is a per-api, per-version mapping. The code asks ApiKey::response_header_version rather than deriving it, because deriving it is how you end up two bytes into the body wondering why a string length is nonsense.

ApiVersions responses always use response header v0, even once the connection is flexible. This is a real special case in the protocol — a chicken-and-egg escape hatch, because the client does not yet know what the broker speaks when it sends the first request. Get it wrong and your very first round trip fails. kafka-protocol encodes this in ApiVersionsResponse::header_version, which is another reason to go through the helper rather than compute it.

Pipelining

max_in_flight defaults to 5, matching Kafka's own default. The broker processes one connection's requests in order regardless, so this is about pipelining rather than parallelism — raising it trades head-of-line blocking for memory.

A permit is acquired before writing and released by a guard, so a dropped future cannot leak one. with_max_in_flight(0) is clamped to 1: zero permits is a deadlock, not a configuration, and there is a unit test asserting it.

This default becomes load-bearing the moment a producer exists. Five requests in flight is only safe with idempotence enabled; without it, a retried produce batch can land after a later one and silently reorder the log. Nothing in the workspace retries a write today, so it is currently harmless — see Roadmap, where wiring this to the idempotence setting is called out as a milestone requirement.

Cancel safety

Rule 5: dropping a send future must never leave the socket half-read. It cannot here, and the reason is structural rather than careful — the caller never touches the socket.

Drop the future and the oneshot receiver goes away. The request is still written by the writer task, the response is still read by the reader task, and the reader discards it on finding no waiter. The in-flight permit is released by its guard. The connection stays perfectly consistent; the only cost is one wasted round trip.

Cancel safety covers what this means for the layers above.

Death

When the socket dies, every pending caller resolves to Error::ConnectionClosed and every subsequent send fails immediately.

The alternative — futures that hang — is much worse than it sounds for this library's use case. A UI backend that leaks one hung future per dead broker degrades into a process that appears to be working while doing nothing, and the symptom shows up far from the cause.

Bootstrapping ApiVersions

The first request on a connection is a bootstrapping problem: you cannot know the broker's supported range until you have asked, and asking requires picking a version.

The connection sends at our max and treats error code 35 UNSUPPORTED_VERSION as data rather than as a handshake failure — the broker still returns its version table in that error response, so the correct reaction is to read it and retry at v0. Treating it as fatal is a client that cannot talk to any broker older than itself.

See Version negotiation for what happens with that table afterwards.

Per-connection counters

Every connection tracks bytes and requests sent and received (crates/kafka-conn/src/stats.rs). These exist from the beginning rather than being added when something needed them, because two acceptance criteria depend on them: the backward-scan test asserts that reading the last 500 records of a 100k-record partition fetches less than 5% of the partition, and that assertion is unverifiable without a byte counter.