Search by

lisachenko / kafka-client

lisachenko

Native, pure-PHP implementation of the Apache Kafka wire protocol, with Producer, Consumer and Admin clients

Package info

github.com/lisachenko/kafka-client

pkg:composer/lisachenko/kafka-client

Fund package maintenance!

lisachenko

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 4

Open Issues: 6


README

GitHub Workflow Status Code Coverage Minimum PHP Version License

lisachenko/kafka-client is a native, pure-PHP implementation of the Apache Kafka wire protocol — no ext-rdkafka required. It ships a Producer, a Consumer and a low-level Admin client, designed to stay close in spirit to the official Java client's API while feeling natural in PHP.

This branch is the 4.x line and speaks the Apache Kafka 4.3.1 wire protocol — everything Kafka 4.0 to 4.3 added to the apis the client implements, the transaction protocol v2 of KIP-890 part 2, Produce and the group offsets by topic id among them, and the KIP-932 share consumer (Consumer\KafkaShareConsumer) with its admin methods — on top of the KIP-848 consumer protocol of the finished 3.x line (branched off as 3.x), built one Kafka minor at a time and verified against a 4.3.1 KRaft node; the versions Kafka 4.0 removed (KIP-896) are never sent to a 4.x node. The version of every api the client sends is listed under Supported Kafka protocol versions. main is the top of the cascade: the frozen protocol snapshots below it live on 3.x (Kafka 3.9.2), 2.x (Kafka 2.8.2), 1.x (Kafka 1.1.1), 0.11.x (Kafka 0.11.0.3), 0.10.x (Kafka 0.10.2.2), 0.9.x (Kafka 0.9.0.1) and 0.8.x (Kafka 0.8.2.2), and every wire vector those lines captured is replayed against the classes of this branch. The grammar this branch implements is written down, byte for byte, in docs/protocol/4.3.md, verified against a real Kafka 4.3.1 node running in KRaft mode (the frames of the 3.x line were captured on a 3.9.2 one); the record of the 4.x line — its release notes above the plan it was built from — is docs/handoff/main.md, and the records of the finished lines are docs/handoff/3.x.md, docs/handoff/2.x.md and docs/handoff/1.x.md.

Installation

composer require lisachenko/kafka-client:dev-main

PHP 8.4 or newer, and nothing elseext-openssl is needed only for SSL/SASL_SSL and ext-zlib (bundled with PHP) for gzip and ext-zstd for the zstd codec of Kafka 2.1; the snappy and lz4 codecs are implemented in PHP and use ext-snappy only when it happens to be installed. main is the branch the top of the cascade lives on, so it is installed by branch name; the frozen lines below it carry a numeric branch and are installed by constraint (^2.8@dev for 2.x, ^1.1@dev for 1.x, ^0.11@dev for 0.11.x, ^0.10@dev for 0.10.x, and so on). A line is frozen as a numeric branch when the line above it starts, so code that must keep speaking Kafka 2.8.2 pins the branch rather than dev-main.

Producer API

The Producer API sends streams of records to topics in the Kafka cluster.

use Protocol\Kafka\Common\Record\Record;
use Protocol\Kafka\Producer\KafkaProducer;
use Protocol\Kafka\Producer\ProducerConfig;
use Protocol\Kafka\Producer\RecordMetadata;

require __DIR__ . '/vendor/autoload.php';

$producer = new KafkaProducer([
    ProducerConfig::BOOTSTRAP_SERVERS => ['tcp://127.0.0.1:9092'],
    ProducerConfig::ACKS              => 1,
]);

$producer->send('test', new Record('foo'))->then(
    function (RecordMetadata $metadata): void {
        echo "Written to partition {$metadata->partition} at offset {$metadata->offset}\n";
        echo "The log holds the timestamp {$metadata->timestamp}\n";
        echo "The broker throttled the batch for {$metadata->throttleTimeMs} ms\n";
    }
);
$producer->flush();

send(string $topic, Record $record, ?int $partition = null): Promise buffers the record and returns a promise that is resolved with a RecordMetadata once flush() has sent the batch and the broker has acknowledged it. The only required option is ProducerConfig::BOOTSTRAP_SERVERS; for every other option see the constants documented on Protocol\Kafka\Producer\ProducerConfig and the producer configuration reference.

ProducerConfig::ACKS selects the durability of a write: 0 sends fire-and-forget (the broker sends no response at all for such a request, so the promise resolves with the offset -1), 1 waits for the leader's log and -1 for all in-sync replicas. Records are collected until they fill ProducerConfig::BATCH_SIZE bytes or ProducerConfig::LINGER_MS has passed, and a batch that fails with a retriable error is sent again ProducerConfig::RETRIES times — that option is the whole retry budget of a batch and defaults to no retry at all, like the Java producer. Compression is set with ProducerConfig::COMPRESSION_TYPE and applies to a whole batch: gzip, snappy, lz4 (Kafka 0.10.0) and zstd (Kafka 2.1, KIP-110, through ext-zstd), in the frame format of the Kafka producer including the KAFKA-3160 checksum quirk of a message format v0 frame.

Message formats, timestamps and headers. Kafka 0.10.0 gave every record a timestamp: Record::$timestamp (milliseconds since the epoch) and Record::$timestampType (TimestampType::CREATE_TIME, LOG_APPEND_TIME or NO_TIMESTAMP_TYPE), and Kafka 0.11 gave it headers (Record::withHeaders(), Common\Record\Header), a list of key-value pairs of metadata next to the key and the value. send() stamps the create time of every record that does not carry one, and ProducerConfig::MESSAGE_FORMAT_VERSION (message.format.version, 0.11.0 by default) selects the format a batch is written in — the record batch v2 by default, 0.10.x for a message set with timestamps and 0.9.0 for one without. The format decides the version of the Produce request: only the message format v2 travels in a Produce v12, and only it has a place for the headers, for the producer id of an idempotent producer and for a transaction; a message set is sent as a Produce v2, and a 2.8.2 broker answers 87 INVALID_RECORD for every partition of a Produce v3 or above that carries one. RecordMetadata::$timestamp reports what the log holds: the create time of the first record of the batch, or the LogAppendTime the broker answered with (Produce v2 and above) when the topic is configured with message.timestamp.type=LogAppendTime. Version 5 (Kafka 1.0) also reports the logStartOffset of every partition it answers — the first offset the log still holds after a retention run or a deleteRecords() — on ProduceResponsePartition.

Without a key a record is spread over the partitions that have a leader, with a key it goes to the partition that the murmur2 hash of the key selects, exactly as with the official Java client (Producer\DefaultPartitioner); an explicit partition can be passed to send().

RecordMetadata::$throttleTimeMs is the ThrottleTime that version 1 of the Produce API added in Kafka 0.9: the number of milliseconds the broker delayed the answer of that batch because the client.id exceeded its producer_byte_rate quota. Quotas never reject a write — the records are appended and only the response is held back — so the field is informational, and it is 0 on a broker without quotas as well as for a fire-and-forget batch (ACKS => 0), which is never answered. The consumer side is the same: every Common\FetchedPartition of Client::fetchPartitions() carries the throttleTimeMs of the Fetch answer it came in. Quotas are set per client id on a running broker, e.g.

$ kafka-configs.sh --bootstrap-server localhost:9092 --alter \
    --add-config 'producer_byte_rate=1024,consumer_byte_rate=2048' \
    --entity-type clients --entity-name my-application

A runnable version of this is examples/producer.php.

Idempotent producer

Kafka 0.11 added a delivery guarantee that no release before it had, and one option turns it on:

$producer = new KafkaProducer([
    ProducerConfig::BOOTSTRAP_SERVERS  => ['tcp://127.0.0.1:9092'],
    ProducerConfig::ENABLE_IDEMPOTENCE => true,
]);

With enable.idempotence the producer asks a broker for a producer id before its first batch (InitProducerId, key 22) and numbers the batch of every topic-partition with a gapless sequence number. A batch that has to be sent again — a lost acknowledgement, a leader that moved — goes out with the very same producer id, epoch and sequence numbers, and the broker recognises it as the batch it already holds: it answers the offset of the original append and writes nothing. The records of a partition therefore reach the log exactly once and in order, however often the client had to retry, and nothing about the API changes: send() and flush() work as before.

The guarantee implies acks = all and a non-zero retries; both are set for you when you did not set them (retries becomes 3, where the Java producer, which has a background sender, uses an unbounded budget), and a configuration that contradicts them — acks of 0 or 1, or retries of 0 — is refused with an InvalidConfigurationException. The Java requirement of max.in.flight.requests.per.connection = 1 needs no option here: this client sends one produce request at a time.

It holds within one producer session: a new KafkaProducer gets a new producer id and cannot deduplicate against what the previous one wrote, and a record your application sends a second time is a new batch, which the broker has no way of recognising. Deduplication across sessions is what a transactional.id is for.

A Kafka 1.x broker widens the guarantee in two ways that need no option: it recognises a duplicate of any of the last five batches of a producer and partition, not only of the very last one, so a producer whose acknowledgements of several batches in a row were lost is still answered with the original offsets instead of being thrown out of sequence; and it tells a client when it has lost the state of a producer altogether, which a 0.11 broker could not.

Three error codes of the broker say something about the producer state itself. 47 (ProducerFencedException) means another producer took the producer id over: the producer is finished and refuses every further send. 45 (OutOfOrderSequenceException) means the producer and the broker no longer agree on what is in the log: the batch that hit it is reported to the caller, and the producer starts over with a new producer id — everything written under the old one loses its deduplication. 59 (UnknownProducerIdException, Kafka 1.0, a subclass of the previous one) means the broker has no state of this producer for that partition — because every record it wrote there was deleted by deleteRecords() or by a retention run. That one the producer repairs by itself: the logStartOffset that Produce v5 added to the answer shows that the records fell below the start of the log, so the partition is numbered from the sequence 0 again and the batch is sent once more, under the same producer id and without touching any other partition. All three are documented, with what a real 1.1.1 broker answers, in docs/protocol/4.3.md, section "The idempotent producer".

Transactions

A transactional.id turns the idempotent producer into a transactional one: the records of several partitions — and the committed offsets of a consumer group — become one unit that a read_committed consumer either sees whole or does not see at all, and the guarantee survives a restart of the producer, because the id is what the broker remembers it by.

use Protocol\Kafka\Common\Errors\KafkaException;
use Protocol\Kafka\Common\Record\Record;
use Protocol\Kafka\Producer\KafkaProducer;
use Protocol\Kafka\Producer\ProducerConfig;

$producer = new KafkaProducer([
    ProducerConfig::BOOTSTRAP_SERVERS => ['tcp://127.0.0.1:9092'],
    ProducerConfig::TRANSACTIONAL_ID  => 'orders-etl-1',   // implies enable.idempotence
]);

$producer->initTransactions();          // once, before the first send

$producer->beginTransaction();
try {
    $producer->send('orders', Record::fromValue('one'));
    $producer->send('audit',  Record::fromValue('one accepted'));
    $producer->commitTransaction();     // flushes what is buffered, then EndTxn
} catch (KafkaException $error) {
    $producer->abortTransaction();      // the only way out of a failed transaction
}

initTransactions() asks the transaction coordinator of the id for a producer id and an epoch one higher than the previous incarnation used, which fences that incarnation for good and rolls back whatever transaction it left open — so a transactional id must be used by one producer at a time, and a crashed producer never blocks a reader for longer than its transaction.timeout.ms. A send() outside a transaction is refused, and so is one after an error that only an abort can clean up; commitTransaction() flushes the buffer before it ends the transaction and abortTransaction() throws it away.

The read side is one consumer option:

$consumer = new KafkaConsumer([
    ConsumerConfig::BOOTSTRAP_SERVERS  => ['tcp://127.0.0.1:9092'],
    ConsumerConfig::GROUP_ID           => 'orders-readers',
    ConsumerConfig::ISOLATION_LEVEL    => ConsumerConfig::ISOLATION_LEVEL_READ_COMMITTED,
]);

With read_committed the broker answers only up to the last stable offset of a partition, so nothing of a transaction that is still open is shown, endOffsets() reports the offset such a reader can really reach, and the records of transactions the broker names as aborted are dropped by the consumer before poll() returns — the broker sends them and only names them. The COMMIT and ABORT control batches of a transaction never reach an application in either level.

The consume-transform-produce loop is what all of this exists for: the consumer hands its offsets to the producer instead of committing them itself, so reading the input and writing the output either both happen or neither does.

$producer->beginTransaction();
foreach ($consumer->poll(1000)['input'][0] ?? [] as $record) {
    $producer->send('output', Record::fromValue(strtoupper((string) $record->value)));
}
$producer->flush();
$producer->sendOffsetsToTransaction(['input' => [0 => $consumer->position('input', 0)]], 'my-group');
$producer->commitTransaction();

The consumer of that loop runs with enable.auto.commit = false and read_committed. A runnable version is examples/transactional-producer.php; the wire protocol behind it — the five apis 24 to 28, the control batches and the last stable offset — is in docs/protocol/4.3.md, section "Transactions".

Consumer API

The Consumer API reads streams of records from topics in the Kafka cluster. Kafka 0.9 moved the coordination of a consumer group into the broker, so a consumer can simply subscribe to topics and let the group hand out the partitions:

use Protocol\Kafka\Common\ClientConfig;
use Protocol\Kafka\Consumer\ConsumerConfig;
use Protocol\Kafka\Consumer\KafkaConsumer;
use Protocol\Kafka\Consumer\OffsetResetStrategy;

$consumer = new KafkaConsumer([
    ClientConfig::BOOTSTRAP_SERVERS => ['tcp://127.0.0.1:9092'],
    // A JoinGroup is answered only once the whole rebalance is over, so this has to exceed both
    // session.timeout.ms and max.poll.interval.ms; the consumer defaults are those of the Java
    // consumer of 0.10.1: 305000, 10000 and 300000
    ClientConfig::REQUEST_TIMEOUT_MS => 305000,

    ConsumerConfig::GROUP_ID                      => 'kafka-daemon',
    ConsumerConfig::PARTITION_ASSIGNMENT_STRATEGY => 'range', // or 'roundrobin', or your own class
    ConsumerConfig::SESSION_TIMEOUT_MS            => 10000,
    ConsumerConfig::MAX_POLL_INTERVAL_MS          => 300000,
    ConsumerConfig::HEARTBEAT_INTERVAL_MS         => 3000,
    ConsumerConfig::AUTO_OFFSET_RESET             => OffsetResetStrategy::EARLIEST,
]);

// Nothing is sent yet: the group is joined by the first poll(), which brings the assignment
$consumer->subscribe(['test']);

while (true) {
    // [topic][partition] => records, in offset order
    foreach ($consumer->poll(1000) as $topic => $partitions) {
        foreach ($partitions as $partition => $records) {
            foreach ($records as $record) {
                // $record->timestamp and $record->timestampType come from message format v1
                echo $topic, ':', $partition, '@', $record->offset, ' ', $record->value, PHP_EOL;
            }
        }
    }
    $consumer->commitSync();
}

$consumer->close(); // commits once more and leaves the group with a LeaveGroup request

subscribe() names the topics and assignment() reports the partitions the group gave this member; assign() still picks partitions by hand and joins no group at all, and the two are mutually exclusive, exactly as in the Java client. commitSync() stores the position of the group — carrying the member id and the generation of this consumer, so a coordinator refuses a commit of a generation that is over — and seek()/seekToBeginning()/seekToEnd() move the position. unsubscribe() leaves the group without committing, close() commits first.

The heartbeat is sent from poll(), because PHP has no background thread. A consumer that does not poll for longer than session.timeout.ms is dropped by the coordinator and its partitions are given to the other members; the next poll() sees that in the error code of its heartbeat and joins the group again. Keep the processing of a batch well below the session timeout, or raise session.timeout.ms — within the group.min.session.timeout.ms and group.max.session.timeout.ms of the broker.

max.poll.interval.ms (Kafka 0.10.1, KIP-62) is the second half of that: it is sent as the rebalance_timeout of the JoinGroup v1 request and tells the coordinator how long it should wait for this member to rejoin a rebalance — the group waits that long instead of the session timeout, which is what lets a member process a batch for minutes without holding up nothing but its own rejoin. In a PHP consumer it does not evict anything the way it does in a Java one: without a heartbeat thread, an application that stops polling stops heartbeating and is dropped when its session timeout expires. request.timeout.ms has to exceed both timeouts, because a JoinGroup blocks the connection for the whole rebalance, and subscribe() refuses a value that does not.

Record headers and the isolation level are what Kafka 0.11 adds on this side. A record read out of a record batch v2 carries the headers the producer wrote (ConsumerRecord::$headers, a list of Common\Record\Header), next to the key, the value, the timestamp and its type; a topic whose message.format.version is older simply has none. ConsumerConfig::ISOLATION_LEVEL (isolation.level, read_uncommitted by default) is sent as the isolation level of the Fetch v16 request: with read_committed the broker answers only up to the last stable offset — the first record of a transaction that has neither committed nor aborted — and names the aborted transactions of the answer, whose records the consumer drops. The control batches of the transaction protocol are never handed to an application in either level. The option travels in the Offsets v6 request as well, so endOffsets(), position() and seekToEnd() of a read_committed consumer answer the last stable offset instead of the log end offset — a consumer that compares its position against the end of a partition compares it against the offset it can really reach. AdminClient::listOffsets() stays at read_uncommitted on purpose: an administrator asks what is in the log.

Incremental fetch sessions (Kafka 1.1, KIP-227) are what the consumer adds on this side. Every broker it reads from holds a fetch session for it: the first request states the whole assignment and opens the session, every following one states only the partitions whose position moved and lets the broker fill in the rest, a partition that leaves the assignment — a rebalance, pause(), a topic that is gone — is dropped from the session with the forgotten_topics_data of the next request, and the answer carries only the partitions that have news. A consumer of many partitions therefore stops repeating its partition list in every fetch, and the broker stops answering partitions that have nothing to say. None of it is visible in poll(): the error codes 70 (FetchSessionIdNotFound, the broker no longer knows the session) and 71 (InvalidFetchSessionEpoch, a request or an answer was lost) are answered with a full fetch by the client itself, in the same call, and a broker that hands out no session at all — one below Kafka 1.1, or one whose cache of 1000 sessions is full — leaves the consumer on plain full fetches. Client::fetchPartitions() keeps its session-less behaviour for callers that want one request and one answer; the consumer fetches through Client::fetchPartitionsWithSessions().

Offsets by timestamp (Kafka 0.10.1, KIP-79) are what the record timestamps buy on the consumer side: offsetsForTimes(['test' => [0 => $millis]]) answers the first record of each partition whose timestamp is at or after the given one, as an OffsetAndTimestamp (or null when the partition holds no such record), and beginningOffsets() / endOffsets() are the two special timestamps -2 and -1. All three are pure queries and need no assignment, exactly as in the Java consumer.

The partitions are distributed by the member the coordinator elected as the leader of the generation: partition.assignment.strategy selects range (the default) or roundrobin — both with the ordering rules of the Java client of 0.9.0.1, so a PHP member can lead a group of Java members and the other way round — or names a class that implements Consumer\PartitionAssignorInterface. Every member of a group has to offer the same one, a coordinator that finds no common protocol refuses the join with the error 23. A Consumer\ConsumerRebalanceListener passed to subscribe() is called with the partitions that each rebalance takes away and hands over, which is where a consumer with enable.auto.commit off commits what it has consumed.

group.protocol = consumer switches the consumer to the new consumer protocol of KIP-848 (Kafka 3.5). The four apis of the classic membership protocol are then replaced by the single ConsumerGroupHeartbeat (key 68), and three things change for an application: the coordinator computes the assignment, so partition.assignment.strategy has nothing to say and group.remote.assignor names a server-side assignor instead; the heartbeat interval is dictated by the broker (group.consumer.heartbeat.interval.ms) rather than by heartbeat.interval.ms; and a rebalance is incremental — a member gives up only the partitions it really loses, so onPartitionsRevoked() sees exactly those and onPartitionsAssigned() only the ones that were added, where the classic protocol hands the whole assignment back and forth on every rebalance. The member epoch takes the place of the generation and travels in the OffsetCommit v10 and OffsetFetch v10 of that member (v9 and v9 below Kafka 4.2). A group is of one protocol or the other: a heartbeat for a classic group is refused with the 69, and AdminClient::describeConsumerGroups() (key 69) describes the new groups where describeGroups() (key 15) describes the classic ones.

examples/consumer-group.php is a runnable version of this — start it twice and watch the two members split the partitions — and examples/consumer.php is the same thing with assign().

See the consumer configuration reference for the full set of options.

Share consumer API

A share group (KIP-932, Kafka 4.1) is a group whose members do not own partitions but share them record by record: every record of a subscribed topic is delivered to one member at a time, which holds it under an acquisition lock (the group config share.record.lock.duration.ms, 30 s by default) and acknowledges it. Consumer\KafkaShareConsumer is the KafkaShareConsumer of the Java client @ 4.3.1:

use Protocol\Kafka\Consumer\AcknowledgeType;
use Protocol\Kafka\Consumer\ConsumerConfig;
use Protocol\Kafka\Consumer\KafkaShareConsumer;

$consumer = new KafkaShareConsumer([
    ConsumerConfig::BOOTSTRAP_SERVERS          => ['tcp://127.0.0.1:9092'],
    ConsumerConfig::GROUP_ID                   => 'my-share-group',
    ConsumerConfig::SHARE_ACKNOWLEDGEMENT_MODE => ConsumerConfig::SHARE_ACKNOWLEDGEMENT_MODE_EXPLICIT,
]);
$consumer->setAcknowledgementCommitCallback(function (array $offsets, $exception) {
    // [topic][partition] => offsets, and the error of the node or null - once per topic-partition
});
$consumer->subscribe(['my-topic']);

while (true) {
    foreach ($consumer->poll(1000) as $topic => $partitions) {        // joins the group on the first call
        foreach ($partitions as $partition => $records) {
            foreach ($records as $record) {                          // ConsumerRecord, with $record->deliveryCount
                $consumer->acknowledge($record, process($record) ? AcknowledgeType::ACCEPT : AcknowledgeType::RELEASE);
            }
        }
    }
    $results = $consumer->commitSync();                              // [topic][partition] => ?KafkaException
}
$consumer->close();                  // closes the share sessions (the rest is released) and leaves the group

An acknowledgement is one of four: ACCEPT (processed, never delivered again), RELEASE (delivered again - to this member or another one - with a delivery count one higher, until the group config share.delivery.count.limit archives it), REJECT (archived at once) and RENEW (Kafka 4.2, KIP-1222: still being processed - the lock starts over, and the next poll() returns the record again). share.acknowledgement.mode is implicit by default: the next poll() or commit then accepts everything the last poll() returned, and acknowledge() is refused; in the explicit mode every record has to be acknowledged before the next poll(). Acknowledgements travel with the ShareFetch of the next poll(), or in a ShareAcknowledge of commitSync()/commitAsync(); acknowledge($topic, $partition, $offset, $type) is the form for a record a deserializer failed on. share.acquire.mode = record_limit (KIP-1206) has the node acquire no more than max.poll.records records per fetch, where the default batch_optimized hands out whole record batches, and acquisitionLockTimeoutMs() reports the lock of the last answer.

Where to start and what to show are group configs, not options of the consumer: a share group reads from latest unless its share.auto.offset.reset says earliest, and its isolation level is share.isolation.level - set them with IncrementalAlterConfigs on the config resource type 32 (ConfigResource.Type.GROUP) before the first member joins. The constructor refuses auto.offset.reset, enable.auto.commit, group.instance.id, isolation.level, partition.assignment.strategy, session.timeout.ms, heartbeat.interval.ms, group.protocol and group.remote.assignor, as the Java one does.

PHP has no background thread, so the heartbeat of a share member is sent from poll() (at the interval the coordinator dictates), commitAsync() sends its request before it returns and reports only to the callback, and the callback runs inside the call that sent the acknowledgements. A member keeps one share session per leader: the ShareFetch of the epoch 0 opens it, and a session the node lost - it lives on its connection, so a dropped connection takes it along - is answered 122 or 123, after which the consumer opens a new one by itself; the acknowledgements of the lost session are reported as failed to the callback, and its records are delivered again. AdminClient::describeShareGroups() describes the group, and deleteConsumerGroups() deletes it once it is empty.

examples/share-consumer.php is a runnable version of this — start it twice and watch two members share one topic. The protocol document has the frames and what the node answered in "The share consumer (KIP-932)".

Admin API

The Admin API exposes the low-level cluster operations a 1.1.1 broker can serve:

use Protocol\Kafka\Admin\AdminClient;
use Protocol\Kafka\Admin\ConfigResource;
use Protocol\Kafka\Admin\NewTopic;
use Protocol\Kafka\Admin\RecordsToDelete;
use Protocol\Kafka\Common\ClientConfig;
use Protocol\Kafka\Common\Cluster;
use Protocol\Kafka\Protocol\Request\OffsetsRequest;

$configuration = [ClientConfig::BOOTSTRAP_SERVERS => ['tcp://127.0.0.1:9092']];
$admin         = new AdminClient(Cluster::bootstrap($configuration), $configuration);

$brokers  = $admin->findAllBrokers();                       // Node[], indexed by the node id
$apis     = $admin->getApiVersions($brokers[0]);            // api key => ApiVersionsResponseMetadata
$topics   = $admin->listTopics();                           // string[]
$metadata = $admin->describeTopics(['test']);               // TopicMetadata[], indexed by the topic
$offsets  = $admin->listOffsets(['test' => [0, 1, 2]]);     // topic => partition => offset
$earliest = $admin->listOffsets(['test' => [0]], OffsetsRequest::EARLIEST);

$controller = $admin->findController();                     // Node, from the controller_id of Metadata v1
$created    = $admin->createTopics([new NewTopic('test-2', 3, 1)]);   // topic => ?KafkaException
$deleted    = $admin->deleteTopics(['test-2']);                      // topic => ?KafkaException

$purged = $admin->deleteRecords(['test' => [0 => 100]]);    // topic => partition => DeletedRecords (low watermark)
$purged = $admin->deleteRecords(['test' => [0 => RecordsToDelete::allRecords()]]);

$topicResource = ConfigResource::topic('test');
$configs       = $admin->describeConfigs([$topicResource]); // resource key => Config
echo $configs[$topicResource->key()]->value('retention.ms');
$altered = $admin->alterConfigs([                           // resource key => ?KafkaException
    $topicResource->key() => ['retention.ms' => '3600000'] + $configs[$topicResource->key()]->nonDefaultValues(),
]);

$coordinator = $admin->findCoordinator('kafka-daemon');     // Node that holds the group offsets
$committed   = $admin->listGroupOffsets('kafka-daemon');    // every topic the group committed (v2)

$groups = $admin->listAllGroups();                          // group id => ListGroupResponseProtocol
$groups = $admin->listGroups($coordinator);                 // only the groups of that one broker

$group = $admin->describeGroup('kafka-daemon');             // DescribeGroupResponseMetadata
echo $group->state;                                         // Stable, CompletingRebalance, PreparingRebalance, Empty or Dead
echo $group->protocol;                                      // the assignor, only while the group is stable
foreach ($group->members as $memberId => $member) {
    echo $memberId, ' ', $member->clientId, ' ', $member->clientHost, PHP_EOL;
    // $member->memberMetadata and $member->memberAssignment are the opaque bytes of the protocol type
}
Method Wire API Notes
getApiVersions() ApiVersions v4 The version range of every api of one broker, indexed by api key; version 1 carries the throttle time, version 3 the features of KIP-584 and version 4 the ones whose minimum version is 0 (KAFKA-17011, Kafka 3.9)
findAllBrokers() Metadata v13 An empty result means "the cluster is not ready yet", see below
listTopics() / describeTopics() Metadata v13 Asks with allow_auto_topic_creation = false, so an unknown topic is answered 3 and not created; describeTopics([]) asks for every topic (the null array of v1); every partition reports its offlineReplicas (v5, KIP-112/113)
describeTopicsByIds() Metadata v13 Names the topics by their topic id (KIP-516, Kafka 3.1), the describeTopics(TopicCollection.ofTopicIds(...)) of the Java admin client; an id the cluster does not host is answered 100 UnknownTopicId with a null name
findController() Metadata v13 The controller_id of the answer; the two topic apis below need it
createTopics() CreateTopics v7 NewTopic with partitions/factor or an explicit assignment, plus topic configs; validateOnly checks without creating
deleteTopics() DeleteTopics v6 Needs delete.topic.enable=true on the broker
listOffsets() Offsets v10 Earliest, latest, by message timestamp, OffsetsRequest::MAX_TIMESTAMP, OffsetsRequest::EARLIEST_LOCAL_TIMESTAMP or OffsetsRequest::LATEST_TIERED_TIMESTAMP; one offset per partition, sent to the partition leader, with the isolation level read_uncommitted
listMaxTimestampOffsets() Offsets v10 The offset and the timestamp of the record with the largest timestamp of every partition (KIP-734, Kafka 3.0), null for an empty log — the end of the log only while a log's timestamps rise with its offsets
listEarliestLocalOffsets() Offsets v11 The local log start offset of every partition (the target time -4 of KIP-405, Kafka 3.5): the first offset still on the broker's own disk once older segments moved to tiered storage; on a broker without remote storage it equals the earliest offset (the node answers 0 with the timestamp -1 for an empty log)
listLatestTieredOffsets() Offsets v11 The last tiered offset of every partition (the target time -5 of KIP-1005, Kafka 3.9): the last offset that has been moved to remote storage, the upper end of the range listEarliestLocalOffsets() names the lower end of; on a broker without remote storage it is -1 with the error code 0 — "nothing of this partition is tiered", on a filled log as on an empty one
listEarliestPendingUploadOffsets() Offsets v11 The earliest pending upload offset of every partition (the target time -6 of KIP-1023, Kafka 4.2, the OffsetSpec.earliestPendingUpload() of the Java admin client): the first offset not yet copied to remote storage; on a broker without remote storage it is -1 with the error code 0, and a version below 11 answers the 35
findCoordinator() GroupCoordinator v6 Retries the codes 15 and 14 while the coordinator warms up; version 1 also looks a transactional id up (coordinator_type = 1); one key travels as a one-element batch of the v4 of KIP-699 (Kafka 3.0), and Client::getGroupCoordinators() / getTransactionCoordinators() look several up at once; the v5 of KIP-890 (Kafka 3.8) and the v6 of KIP-932 (Kafka 3.9) added no field — the v6 only widens what coordinator_type may say, and the share type 2 it legalises is answered 15 by a node that has no share coordinator, so this client never sends it
listGroupOffsets() OffsetFetch v10 Without a partition list it asks for every topic the group committed (null topics of v2); one group travels as a one-element batch of v8 (Kafka 3.0), with the member_id / member_epoch of KIP-848 at their defaults (v9, Kafka 3.7), and every topic named by its id (v10, Kafka 4.2; v9 for a topic the cluster gives no id)
listConsumerGroupOffsets() OffsetFetch v10 The committed offsets of several groups in one request per coordinator (Kafka 3.0), each group with its own topic array, its own member of KIP-848 (v9, Kafka 3.7) and its own error code, the topics named by id (v10, Kafka 4.2); an empty batch is refused client-side, because a 3.9.2 node answers it with nothing at all
alterConsumerGroupOffsets() OffsetCommit v10 The commit of an administrator (generation -1) for a group, one error or null per partition (Kafka 4.2 sends the topics by id); a group with live members refuses it with the 25
listGroups() / listAllGroups() ListGroups v5 A broker only knows its own groups; listAllGroups() merges them all; an optional state filter (KIP-518) and type filter (KIP-848, Kafka 3.8) bound the answer
describeGroup() / describeGroups() DescribeGroups v6 Sent to the coordinator of the group; one whose last member left is Empty, and an unknown group or a group of the new consumer protocol, which this api cannot describe, throws GroupIdNotFoundException (the 69 of KIP-1043, Kafka 4.0; the 3.x line answered Dead)
describeConsumerGroup() / describeConsumerGroups() ConsumerGroupDescribe v1 The KIP-848 half of the question (Kafka 3.7): the group epoch, the assignment epoch, the server-side assignor and per member its member epoch, its subscription as plain topic names, both assignments and the member type of KIP-1099 (Kafka 4.0: ConsumerGroupMemberDescription::upgraded()), as an Admin\ConsumerGroupDescription; a classic group is the 69 GroupIdNotFound here
electLeaders() ElectLeaders v2 Asks the controller to move partitions back to their preferred replica (KIP-183, Kafka 2.2); per-partition results, 84 for a partition that already has the right leader; ElectionType::UNCLEAN needs the v1 of KIP-460
deleteRecords() DeleteRecords v2 Moves the low watermark of a partition forward (KIP-107); sent to the partition leader, answers a DeletedRecords per partition
describeConfigs() DescribeConfigs v4 The configuration of a topic or a broker (KIP-133); every entry says which ConfigSource its value comes from and, with $includeSynonyms, every place the broker looked (KIP-226). A broker resource is only answered by that broker, and a sensitive value comes back null
alterConfigs() AlterConfigs v2 Replaces the whole configuration of a resource (Config::ownValues() is the set to send back); a 1.1 broker takes a broker resource too — the dynamic options of KIP-226, per broker or cluster-wide with ConfigResource::defaultBroker() — and refuses the ones it cannot change at runtime with 42
incrementalAlterConfigs() IncrementalAlterConfigs v1 Changes single options of a topic or a broker (KIP-339, Kafka 2.3) and leaves the ones it does not name alone — AlterConfigOp::set(), delete(), append() and subtract(); the api Kafka 2.3 put in place of alterConfigs()
describeClientQuotas() DescribeClientQuotas v1 The quotas of users, client ids and their defaults, filtered by entity (KIP-546, Kafka 2.6); ClientQuotaFilter and ClientQuotaEntity as in the Java admin client
alterClientQuotas() AlterClientQuotas v1 Sets or removes the producer, consumer and request quotas of an entity (KIP-546); one error per entity, validateOnly checks without writing
listConsumerGroups() ListGroups v5 The groups of the consumer protocol type of every broker, with their state (KIP-518, Kafka 2.6) and their typeclassic or consumer — plus an optional state and type filter (KIP-848, Kafka 3.8)
describeUserScramCredentials() DescribeUserScramCredentials v0 The SCRAM mechanisms and iteration counts of users (KIP-554, Kafka 2.7); the credentials themselves never travel
alterUserScramCredentials() AlterUserScramCredentials v0 Upserts and deletes SCRAM credentials of users (KIP-554); the salted password is computed by the client, one error per user
describeFeatures() / updateFeatures() ApiVersions v4 / UpdateFeatures v2 The finalized and supported feature versions of the cluster and their upgrade or downgrade on the controller (KIP-584, Kafka 2.7; the UpgradeType and the validateOnly dry run of KIP-778, Kafka 3.3; the answer without per-feature results of Kafka 4.0: a 4.x controller refuses the whole request with one top-level 95)
describeMetadataQuorum() DescribeQuorum v2 The leader, the epoch, the high watermark and every voter and observer of the metadata quorum of a KRaft cluster (KIP-595, Kafka 2.7; the two replica timestamps of KIP-836, Kafka 3.3; the directory id of a replica, the two error messages and the Admin\QuorumNode endpoints of KIP-853, Kafka 3.9)
describeCluster() DescribeCluster v2 The brokers, the controller and the cluster id of a cluster, with the authorized operations of KIP-430 on request (KIP-700, Kafka 2.8), the fenced brokers of KIP-1073 with $includeFencedBrokers (Kafka 4.0), and the endpoint_type of KIP-919 (Kafka 3.7): Admin\EndpointType::Broker by default, Controller to ask a controller listener for the controllers — a broker listener refuses that with 114; describeClusterFromMetadata() asks Metadata instead, as every line below did
addRaftVoter() / removeRaftVoter() AddRaftVoter v1 / RemoveRaftVoter v0 Adds or removes a voter of the metadata quorum by its key (the id and the directory id) on a kraft.version 1 cluster (KIP-853, Kafka 3.9; the admin api of Kafka 4.0): a voter id the quorum has is the 126, a key it does not hold the 127, and a voter that is not reachable is the 7 and never added; the $ackWhenCommitted of AddRaftVoter v1 (Kafka 4.2) changes no refusal
listConfigResources() ListConfigResources v1 The config resources of the cluster by type — topics, brokers, broker loggers, client-metrics subscriptions, groups (KIP-1142, Kafka 4.1); 35 for a type the node does not list
describeShareGroup() / describeShareGroups() ShareGroupDescribe v1 The share groups of KIP-932 (Kafka 4.1): state, group and assignment epochs, the assignor simple, and per member its epoch, subscription and assignment; an unknown group or a group of another type is the 69
listShareGroups() ListGroups v5 The share groups of KIP-932 of every broker with their state — listGroups(ListGroupsOptions.forShareGroups()) of the Java client, the types_filter share
listShareGroupOffsets() DescribeShareGroupOffsets v1 Start offset, leader epoch and lag (KIP-1226, Kafka 4.2) of every share partition of share groups, one request per coordinator (Admin\SharePartitionOffsetInfo, null where the group holds no start offset); an unknown, classic or KIP-848 group is no error
alterShareGroupOffsets() AlterShareGroupOffsets v0 Sets the start offsets of an empty share group and creates it if it does not exist (Kafka 4.1); one error per partition — the 69 of another group type and the 68 of a group with a member on every partition
deleteShareGroupOffsets() DeleteShareGroupOffsets v0 Makes a share group forget whole topics; one error per topic, the 69 or 68 of the group thrown
deleteShareGroups() DeleteGroups v2 Deletes empty share groups and their state; the 68 with a member, the 69 unknown — DeleteGroups deletes an empty group of any type, as in the Java client
describeTransactions() DescribeTransactions v0 The state, producer id and epoch, timeout, start time and partitions of transactional ids, each from its transaction coordinator (Kafka 3.0); an id the coordinator does not know is TransactionalIdNotFoundException (105) in its place
listTransactions() ListTransactions v2 The transactions of the cluster, asked of every broker and merged, filtered by state, by producer id, — since Kafka 3.8 (KIP-994) — by the age of the transaction in milliseconds and — since Kafka 4.1 (KIP-1152) — by a RE2/J pattern of the whole transactional id (128 InvalidRegularExpressionException for one the node cannot compile); the state filters no coordinator knew come back through the third parameter
describeTopicPartitions() DescribeTopicPartitions v0 The topics of a cluster page by page (KIP-966, Kafka 3.8), with the eligible leader replicas and the last known ELR of every partition and the authorized operations of the topic; an empty topic list is every topic, the client walks the next_cursor until the listing is complete, and a topic the cluster does not host is UnknownTopicOrPartitionException (3) in its place
describeProducers() DescribeProducers v0 The active producers of partitions: producer id, epoch, last sequence and timestamp, and the start offset of an open transaction (KIP-664, Kafka 2.8)
createTopicsWithResults() CreateTopics v7 The same creation, answered with what the broker made of it (KIP-525, Kafka 2.4): CreatedTopic with the partition count, the replication factor and every configuration entry of the new topic; NewTopic::withBrokerDefaults() asks for num.partitions and default.replication.factor (KIP-464)
alterPartitionReassignments() AlterPartitionReassignments v1 Moves the replicas of partitions to other brokers, or cancels a move with null (KIP-455, Kafka 2.4); sent to the controller, one error per partition; a change of the replication factor only with $allowReplicationFactorChange (Kafka 4.1)
listPartitionReassignments() ListPartitionReassignments v0 The reassignments in flight, with the target, adding and removing replica lists of each partition (KIP-455)
removeMembersFromConsumerGroup() LeaveGroup v5 Removes members of a group by hand, a static one by its group.instance.id (KIP-345, Kafka 2.4); one error per member, MemberToRemove::byInstanceId()/byMemberId(); since v5 (Kafka 3.2) every entry names a reason, member was removed by an admin unless the caller gives one
deleteConsumerGroupOffsets() OffsetDelete v0 Deletes the committed offsets of single partitions of a group (KIP-496, Kafka 2.4); an Empty group hands over everything, a live consumer group answers 86 for the topics it consumes, another protocol type 68 and an unknown group 69
describeLogDirs() DescribeLogDirs v5 What each log directory of a broker holds (KIP-113); broker-local, so it takes a list of broker ids — a null selection asks for every replica, an empty one only for the directories; since v3 (Kafka 3.2) a refusal of the whole request is a top-level error code and is thrown (31 for a principal that may not describe the cluster); since v4 (Kafka 3.3) every directory reports the total and usable bytes of its volume (KIP-827); since v5 (Kafka 4.3) every directory says whether it is cordoned (KIP-1066, LogDirInfo::$isCordoned)
describeAcls() / createAcls() / deleteAcls() DescribeAcls v3 / CreateAcls v3 / DeleteAcls v3 The acls of the cluster (Kafka 3.3, the first line of this package to speak them): a Common\AclBinding is a resource pattern (LITERAL or PREFIXED, the USER resource of KIP-373 included) and an access control entry; a describe or a delete names an AclBindingFilter whose fields may be wildcards, MATCH asks which acls apply to a resource; measured against the StandardAuthorizer of the node with the principal acltest
alterReplicaLogDirs() AlterReplicaLogDirs v2 Moves a replica to another log directory of the broker that hosts it (KIP-113); the answer only says the move was accepted, describeLogDirs() says when it is done
createPartitions() CreatePartitions v3 Raises the partition count of topics that exist (KIP-195); controller-only like createTopics(), and it can only ever grow a topic (37 otherwise)
deleteConsumerGroups() DeleteGroups v2 Makes the coordinator forget groups and their committed offsets (KIP-229); a group with a live member is 68, one the coordinator does not know 69
createDelegationToken() CreateDelegationToken v3 Issues a token to the principal of the connection (KIP-48), or to another principal with the $owner of KIP-373 (Kafka 3.3; 65 without the CREATE_TOKENS acl); needs an authenticated channel, otherwise 64
renewDelegationToken() RenewDelegationToken v2 Extends a token named by its raw HMAC; only its owner or one of its renewers may, otherwise 63
expireDelegationToken() ExpireDelegationToken v2 Moves the expiry forward, or removes the token when the period is negative
describeDelegationToken() DescribeDelegationToken v2 The tokens of the given owners, null for every token the principal may see; the answer carries their HMACs

The three topic apis — createTopics(), deleteTopics() and createPartitions() — are served by the controller alone: AdminClient looks it up in the controller_id of a Metadata answer, and repeats the request once against a freshly looked up controller when a topic comes back with the error code 41 (NotController). None of them throws for a topic: the result has one entry per requested topic, in the order of the request, null when it worked and the exception of its error code — with the error_message the controller sent in the context — when it did not, because one topic of a batch says nothing about the others. deleteConsumerGroups() reports its groups the same way, and sends one request to the coordinator of each of them.

getApiVersions() is what Kafka 0.10.0 added: it asks one broker for the version range of every api it serves and returns them indexed by the api key, which is the only way to tell one release of the protocol from another without guessing. Kafka 0.11 raised it to version 1, whose answer carries a trailing throttle_time_ms — the one api of KIP-124 that appends the field instead of prepending it, because an unknown version is still answered in the version 0 layout. Every broker answers for itself, so a rolling upgrade shows up as brokers that report different ranges. Client::apiVersions() returns the whole response, with supports() and maxVersionOf() on it.

The group apis are what Kafka 0.9 added when it moved the consumer groups out of ZooKeeper, and Kafka 0.10.1 gave them one more state: a group exists on its coordinator from the first JoinGroup until its committed offsets expire, so listGroups() shows it even after its last member has left, and describeGroup() reports its state, the assignor its members agreed on and one entry per member, with the Subscription and MemberAssignment of the consumer protocol as opaque byte arrays. A group with no members left is Empty, not Dead; asking about a group that does not exist is still not an error, the coordinator answers the state Dead with the error code 0.

Creating a topic no longer means writing to ZooKeeper: CreateTopics (key 19) and DeleteTopics (key 20) arrived with Kafka 0.10.1, and AdminClient::createTopics() sends the version 7 of the first one, with validate_only, the per-topic error_message, the shape and configuration of the new topic in the answer (KIP-525) and the topic id of KIP-516 next to them. The implicit creation by a Metadata request of an unknown topic still works when the broker runs with auto.create.topics.enable=true, and still answers the topic error code 5 (LeaderNotAvailable) with an empty partition list until the controller has elected the leaders — but the admin client no longer triggers it: Metadata v4 (Kafka 0.11, KIP-4) added allow_auto_topic_creation, and every request of AdminClient sends it as false, so describing a topic that does not exist is answered with the code 3 and creates nothing. createTopics() is the explicit alternative that reports what went wrong. Metadata v5 (Kafka 1.0, KIP-112/113) is what this client sends today, so every partition it describes also carries its offlineReplicas — the replicas whose broker is down or whose log directory failed.

Metadata v1 and v2 also gave the cluster an identity of its own: Cluster::clusterId() is the cluster_id the broker generated (the /cluster/id znode), Cluster::controller() the node the controller_id names, Common\Node::$rack the broker.rack of a broker, and Cluster::topics() hides __consumer_offsets unless exclude.internal.topics is turned off.

Records and configuration through the protocol are what Kafka 0.11 added to the admin surface. deleteRecords() (KIP-107) moves the low watermark of a partition forward — everything below the offset becomes unreadable at once, the record at the offset stays — and answers the new watermark of every partition as an Admin\DeletedRecords; the offset is a plain integer or an Admin\RecordsToDelete (beforeOffset(), or allRecords() for the -1 of the wire, i.e. up to the high watermark). It is served by the leader of each partition, so the request is split per leader and a partial failure is reported as a TopicPartitionRequestException. describeConfigs() and alterConfigs() (KIP-133) read and write the configuration of a topic or of a broker without going through ZooKeeper: a resource is an Admin\ConfigResource (topic() / broker()) and is addressed in the result by its key(), because PHP cannot use an object as an array key. alterConfigs() replaces the whole configuration of a topic — an option that is left out is reset to its default, which is what Config::ownValues() exists for (nonDefaultValues() is the 0.11 name and, since KIP-226, also reports options that only the broker configuration sets). A broker resource is where this line differs from the one below it: KIP-226 made a 1.1 broker accept one and validate it per option, so an option it cannot change at runtime comes back as the error code 42 with Cannot update these configs dynamically: Set(log.retention.hours) while a dynamic one is applied, where a 0.11 broker refused every broker resource outright. Reading such a resource changed too — the is_default of an entry is derived from the KIP-226 config source and is_read_only means "not dynamically updatable". This client sends DescribeConfigs v1, which reports that source directly and, with $includeSynonyms, every place the broker looked for the value; the version 0 frame is kept for the vectors of the line below and derives the source back from the boolean, which is lossy.

The disks of a broker and the tokens of a principal are the two api families Kafka 1.x added on top of that. describeLogDirs() and alterReplicaLogDirs() (KIP-113) say which log.dirs entry a replica lives in and move it to another one — both broker-local, so they are addressed by broker id and by an Admin\TopicPartitionReplica rather than by a partition leader. The four token apis of KIP-48 issue, renew, expire and describe a delegation token over an authenticated connection; what they cannot do is use one, because authenticating with a token is a SASL/SCRAM login.

examples/admin.php, examples/create-topic.php, examples/admin-configs.php, examples/admin-log-dirs.php and examples/delegation-tokens.php run all of it against the broker of docker-compose.yml.

Network client

One connection per broker is opened on demand and kept open for the requests that follow, the way a Kafka connection is meant to be used: it is an ordered request/response channel, and every request carries a correlation id that the broker echoes back. The client generates that id, checks it on every answer and drops a connection whose answer does not match — its stream position would be unknown from then on. Protocol\Kafka\Common\Node::closeConnections() closes every connection of the process, which a long-running worker can call when it goes idle.

Three options steer this:

  • connections.max.idle.ms — a cached connection that was unused for longer is re-opened instead of handed out, because the broker closes idle connections on its side and a half-closed socket would only surface mid-request.
  • metadata.max.age.ms — how long the cluster metadata (and a metadata.cache.file, if configured) stays valid before it is fetched again.
  • retries and retry.backoff.ms — how often a request that failed with something a metadata refresh can cure is refreshed and sent again: the error codes 3 (UnknownTopicOrPartition, e.g. a topic that was only just auto-created), 5 (LeaderNotAvailable, an election is in progress) and 6 (NotLeaderForPartition, the cached leader moved), plus a dropped connection. Every other error is final and reaches the caller straight away.

A request that fans out over several partition leaders can fail for some partitions and succeed for others. That is reported as a Common\Errors\TopicPartitionRequestException, which carries both halves: getPartialResult() holds the topic-partitions that did work and getExceptions() the exception of each one that did not, indexed by topic and partition.

The Admin API uses the same connections and the same correlation id checks; it does not retry, but every request that any broker can answer — Metadata and DescribeCluster — is tried on the brokers of the cluster in turn until one of them answers. controlledShutdown() is gone from this line: ControlledShutdown (key 7) is served on the controller listener of a KRaft node only, never on a client listener.

PHP-specific configuration

A few configuration options exist purely to make the client work well under PHP's process-per-request model:

  • metadata.cache.file — file used to cache cluster metadata; effectively cached by opcache in production.
  • stream.async.connect — whether to connect to brokers asynchronously.
  • stream.persistent.connection — whether to keep a persistent connection to the cluster.

For publishing from web requests, enabling persistent connections together with a metadata cache file keeps producing as fast as possible.

The offsets of a consumer group always go to the coordinator of the group, with OffsetCommit v10 and OffsetFetch v10 (topics named by id, Kafka 4.2), and live in the __consumer_offsets topic: the commit carries the member id, the generation and the group.instance.id of a group member and the leader epoch of every offset, but no RetentionTime any more (KIP-211 took the field out at version 5, so offsets.retention.minutes of the broker alone decides), and the fetch is the one that can ask for every topic the group committed and the one that can insist on stable offsets (KIP-447). The ZooKeeper storage of Kafka 0.8.1 (the version 0 of both apis, the offsets.storage option of the lines below) is gone from this line: a KRaft node answers both v0 requests with 35.

Configuration reference

Every option is a plain array key of the configuration passed to KafkaProducer, KafkaConsumer or AdminClient; the constants are documented one by one on Common\ClientConfig, Consumer\ConsumerConfig and Producer\ProducerConfig. The options the 0.10 line adds are marked (0.10).

Client (Common\ClientConfig, shared by all three)

Option Default Meaning
bootstrap.servers list of tcp://host:port entries, the only required option
client.id PHP/Kafka name of the application; the broker uses it for quotas and it is the prefix of a group member id
security.protocol PLAINTEXT PLAINTEXT, SSL, (0.10) SASL_PLAINTEXT, SASL_SSL
sasl.mechanism (0.10) PLAIN the only implemented mechanism; GSSAPI and the two SCRAM ones are refused with the reason
sasl.username / sasl.password (0.10) credentials of the PLAIN token, required for a SASL transport
ssl.protocol, ssl.enabled.protocols, ssl.ca.cert.location, ssl.client.cert.location, ssl.key.location, ssl.key.password see "Security" below TLS transport
request.timeout.ms 30000 (consumer: 305000) read timeout of a single request
metadata.fetch.timeout.ms 60000 how long Cluster::bootstrap() and the coordinator lookup keep retrying
metadata.max.age.ms 300000 how long cluster metadata stays valid
connections.max.idle.ms 540000 a cached connection older than this is re-opened
retries / retry.backoff.ms 2 / 100 retry budget for the codes 3, 5, 6 and a dropped connection
reconnect.backoff.ms 50 pause before a reconnect
receive.buffer.bytes / send.buffer.bytes 32768 / 131072 socket buffers
metadata.cache.file, stream.async.connect, stream.persistent.connection – / false / false the PHP-specific options above

Consumer (Consumer\ConsumerConfig)

Option Default Meaning
group.id '' group to join with subscribe(), and the group a commit belongs to
group.protocol (3.5) classic classic (JoinGroup, SyncGroup, Heartbeat, LeaveGroup) or consumer, the KIP-848 protocol: one ConsumerGroupHeartbeat, an assignment computed by the coordinator and an incremental rebalance
group.remote.assignor (3.5) null the server-side assignor of a group.protocol=consumer member — uniform or range on a 3.9.2 node — null lets the coordinator pick; a name the broker does not have is the 112
partition.assignment.strategy range range, roundrobin or a PartitionAssignorInterface class; not used at all by group.protocol=consumer, where the broker assigns
session.timeout.ms 10000 how long the coordinator waits for a heartbeat; the Java 0.10.1 default
max.poll.interval.ms (0.10) 300000 the rebalance_timeout of JoinGroup v1: how long the group waits for this member to rejoin a rebalance
heartbeat.interval.ms 3000 how often poll() sends a heartbeat
request.timeout.ms 305000 has to exceed both timeouts above, because a JoinGroup blocks
fetch.min.bytes / fetch.max.wait.ms 1 / 500 when the broker answers a fetch
fetch.max.bytes (0.10) 52428800 request-level max_bytes of Fetch v3, the bound of a whole answer
isolation.level (0.11) read_uncommitted read_uncommitted or read_committed: what a Fetch v4 and above and an Offsets v2 make of transactional records
max.partition.fetch.bytes 65536 per-partition bound; from Fetch v3 on the first partition is served whole even if it exceeds both
auto.offset.reset latest latest or earliest, used when a partition has no committed offset
enable.auto.commit / auto.commit.interval.ms true / 0 commit from poll(); 0 means "after every poll"
offset.retention.ms -1 RetentionTime of an OffsetCommit up to v4; KIP-211 removed the field in v5, so the broker's offsets.retention.minutes alone decides and the version this client sends ignores the option
exclude.internal.topics true hides __consumer_offsets from Cluster::topics()
check.crcs true verify the CRC of every message
key.deserializer / value.deserializer class names; a poll then returns ConsumerRecords
share.acknowledgement.mode (4.1) implicit share consumer only: implicit (the next poll() or commit accepts what the last poll() returned) or explicit (every record through acknowledge() before the next poll())
share.acquire.mode (4.2) batch_optimized share consumer only: batch_optimized (whole record batches) or record_limit (no more than max.poll.records records, KIP-1206)
max.poll.records 500 share consumer only: the max_records and batch_size of every ShareFetch

Producer (Producer\ProducerConfig)

Option Default Meaning
acks 1 0 fire-and-forget, 1 the leader's log, -1 all in-sync replicas
timeout.ms 2000 how long the broker waits for the replicas of a batch
batch.size / linger.ms 0 / 0 when a batch is sent
compression.type none none, gzip, snappy, (0.10) lz4, (2.1) zstd (needs ext-zstd)
message.format.version (0.10) 0.11.0 format a batch is written in, and with it the Produce version: 0.9.0 and below format v0, 0.10.x format v1 with timestamps (both a Produce v2, which only a 3.x broker still serves: against a 4.x node the client refuses it with InvalidConfigurationException, KIP-896), 0.11.0 the record batch v2 with headers (a Produce v13)
max.request.size 1048576 biggest record this client will buffer
retries / retry.backoff.ms 0 / 100 retry budget of a batch; 3 when enable.idempotence is on and it was not set
enable.idempotence (0.11) false exactly once and in order per partition; implies acks = all and a non-zero retries
transactional.id (0.11) turns the producer into a transactional one and implies enable.idempotence
transaction.timeout.ms (0.11) 60000 how long the coordinator lets a transaction of this producer stay open
partitioner.class DefaultPartitioner murmur2 of the key, round robin without one

Security / SSL

Kafka 0.9 is the release that added transport security and Kafka 0.10.0 the release that made authentication part of the protocol: a broker binds one listener per security protocol (listeners=PLAINTEXT://…,SSL://…,SASL_PLAINTEXT://…,SASL_SSL://…) and every listener answers the identical request set, so the transport changes and never a single byte of a request. Point bootstrap.servers at the listener and set security.protocol:

use Protocol\Kafka\Common\ClientConfig;
use Protocol\Kafka\Common\Security\SecurityProtocol;
use Protocol\Kafka\Producer\KafkaProducer;

$producer = new KafkaProducer([
    ClientConfig::BOOTSTRAP_SERVERS    => ['tcp://kafka-1.example.com:9093'],
    ClientConfig::SECURITY_PROTOCOL    => SecurityProtocol::SSL,
    ClientConfig::SSL_CA_CERT_LOCATION => '/etc/kafka/ca.pem',
]);
Option Default Meaning
security.protocol PLAINTEXT PLAINTEXT, SSL, SASL_PLAINTEXT or SASL_SSL — all four work on this branch
ssl.protocol TLS TLS version to offer: TLS (any), TLSv1_1, TLSv1_2, SSL, SSLv2, SSLv3
ssl.enabled.protocols list of the values above; when set it wins over ssl.protocol
ssl.ca.cert.location PEM file with the certificates the broker certificate is verified against (the ssl.truststore.location of the Java client); without it the certificate stores of the system are used
ssl.client.cert.location PEM file with the client certificate, for a broker running ssl.client.auth=required
ssl.key.location private key of that client certificate
ssl.key.password passphrase of the private key

The certificate of the broker is always verified, and its subject has to match the host the connection was made to — a self-signed broker certificate therefore needs ssl.ca.cert.location pointing at it. The handshake happens right after connect() and is bounded by the connection timeout of the stream, not by request.timeout.ms.

Metadata over a listener. Every version of the Metadata api has room for exactly one host/port per broker, and the broker fills it with the endpoint of the listener the request arrived on. A client that bootstraps over TLS therefore learns the TLS endpoints of the whole cluster and keeps talking TLS to every broker it discovers; one that bootstraps in plaintext learns the plaintext ones, one that authenticates learns the SASL ones. They never mix, and there is no way to ask one listener about another.

examples/ssl.php produces and consumes over the SSL listener of docker-compose.yml, whose self-signed certificate is checked in as docker/kafka-4.3.1/ssl/broker.crt.

SASL/PLAIN works on this branch. Kafka 0.9 did have SASL, but only GSSAPI (Kerberos) and negotiated outside the Kafka protocol; Kafka 0.10.0 (KIP-43) added the SaslHandshake request (api key 17) and the PLAIN mechanism, which is what makes authentication implementable in pure PHP, and Kafka 1.0 (KIP-152) added the SaslAuthenticate request (api key 36), which is what makes a refused password reportable:

$producer = new KafkaProducer([
    ClientConfig::BOOTSTRAP_SERVERS => ['tcp://kafka-1.example.com:9094'],
    ClientConfig::SECURITY_PROTOCOL => SecurityProtocol::SASL_PLAINTEXT, // or SASL_SSL
    ClientConfig::SASL_MECHANISM    => SaslMechanism::PLAIN,
    ClientConfig::SASL_USERNAME     => 'kafkatest',
    ClientConfig::SASL_PASSWORD     => 'kafkatest-secret',
]);

The handshake and the token exchange happen inside connect(), before the first ordinary request: one SaslHandshake frame naming the mechanism, then the PLAIN token \0<username>\0<password>, answered with an empty token. Kafka 1.0 (KIP-152) gave that token a request of its ownSaslAuthenticate, api key 36 — and version 1 of the handshake is how a client asks for it; this client sends v1, so the token travels as an ordinary framed request and a refused credential comes back as the error code 58 with the message of the broker (Authentication failed: Invalid username or password) instead of a silently closed socket. The raw, unframed exchange of a v0 handshake is still implemented and still served by a 1.1.1 broker — it is what the four lines below speak. PLAIN sends the password in clear text, so use SASL_SSL outside a trusted network: the very same exchange, inside the TLS channel. Either way a refusal is a SaslAuthenticationException — carrying the code and the message when there is one — which leaves every retry loop of the client, because nothing about the connection would be different next time. GSSAPI and the SCRAM mechanisms of 0.10.2 are refused with an explanation before a socket is opened. See examples/sasl.php, the "SASL/PLAIN" section of the protocol document and its "SaslAuthenticate API (key 36, v0)" section.

Supported Kafka protocol versions

This branch is the 4.x line, built towards the Kafka 4.3.1 wire protocol on top of the finished 3.x line (its record is docs/handoff/3.x.md). Today the client speaks Kafka 4.3 — everything Kafka 3.0 to 3.9 and 4.0 to 4.3 added, and the KIP-848 consumer protocol — against a 4.3.1 node, and the table below lists the version of every api the client sends; every minor of the line is in, and the KIP-932 share consumer is its last wave (the plan is docs/handoff/main.md). The frozen protocol snapshots of the lines below live on 3.x (Kafka 3.9.2), 2.x (Kafka 2.8.2), 1.x (Kafka 1.1.1), 0.11.x (Kafka 0.11.0.3), 0.10.x (Kafka 0.10.2.2), 0.9.x (Kafka 0.9.0.1) and 0.8.x (Kafka 0.8.2.2).

Kafka 0.10.0 added the ApiVersions request (key 18), so this line does not have to guess what its broker speaks. The "Versions served" column is the literal answer of the 4.3.1 container — a KRaft node, broker and controller in one process — on its client listeners: 75 apis, the keys 0–3, 8–51, 55, 57, 60, 61, 64–66, 68, 69, 74–81 and 83–92. The ZooKeeper apis 4–7 have no version any more, the controller-only apis 52–54, 56, 58, 59, 62, 63, 67, 70, 73 and 82 live on the controller listener, and the client-metrics apis 71 and 72 are hidden while the node has no telemetry plugin. Kafka 4.0 removed the versions below the Kafka 2.1 baseline (KIP-896): nineteen apis of the client listener lost their lowest versions and eighteen rows start above v0 (Produce's still starts at 0), and every removed version — Produce v0 to v2 too, which the answer still lists — closes the connection. The answer is read with Client::apiVersions() and pinned by tests/Integration/ApiVersionProbeTest.php, which sends one real frame of every key at its maximum version, one above it and one below every minimum.

The "main" column lists the versions this client has a class for; the one in bold is the version it sends; every version the node serves to a client is spoken, and what is left out says so in its row. The streams-group apis 88 and 89 are out by decision (the "leaves out" table below). The 2.x column is where the 3.x line started.

Api key API Versions served by 4.3.1 Client-facing 2.x main (4.x, Kafka 4.3.1)
0 Produce v3 … v13 (v0 … v2 listed, refused) yes v0 … v8, v9 (v2 for message.format.version below 0.11.0) v0 … v12, v13 (Kafka 4.1, KIP-516: every topic named by its topic id, resolved through Cluster::topicIdsOf(), the 100 UnknownTopicId of a stale id retried; sent through Client::produceVersion(); ProduceRequestV12/ProduceResponseV12 keep the v12 of Kafka 4.0, the v11 frame of KIP-890 part 2; ProduceRequestV11 keeps the version a transaction of the protocol v1 is capped at, and the 120 TransactionAbortable of KIP-890, Kafka 3.8, it is refused with for a partition the coordinator has not verified; ProduceRequestV10/ProduceResponseV10 keep the leader discovery of KIP-951; v2 for message.format.version below 0.11.0, refused client-side against a 4.x node)
1 Fetch v4 … v18 yes v0 … v11, v12 (session-less in fetchPartitions(), with an incremental fetch session per broker in the consumer) v0 … v17 and v18 (Kafka 4.1, KIP-1166: the tagged high_watermark of a follower, FetchRequestTopicPartition::$highWatermark, left off the wire by a consumer, whose frame is the v17 frame; FetchRequestV17/FetchResponseV17 keep the version below; the tagged replica_directory_id of KIP-853, Kafka 3.9, in every partition entry — the log directory a follower fetches for, left off the wire by a consumer whose zero uuid is the default of the field; the tagged node_endpoints of the answer, KIP-951, Kafka 3.7, which the node writes for the 6 of a follower fetch; every topic named by its topic id, KIP-516, Kafka 3.1; session-less in fetchPartitions(), with an incremental fetch session per broker in the consumer; FetchRequestV12 keeps the frame that names its topics, FetchRequestV13 to FetchRequestV16 the versions in between)
2 Offsets (ListOffsets) v1 … v11 yes v0 … v5, v6 v0 … v10, v11 (Kafka 4.2, the earliest pending upload offset -6 of KIP-1023, AdminClient::listEarliestPendingUploadOffsets(); OffsetsRequestV10 keeps the timeout_ms of KIP-1075, Kafka 4.0; OffsetsRequestV9 keeps the last tiered offset -5 of KIP-1005, Kafka 3.9, AdminClient::listLatestTieredOffsets(), OffsetsRequestV8 the local log start offset -4 of KIP-405, Kafka 3.5, AdminClient::listEarliestLocalOffsets())
3 Metadata v0 … v13 yes v0 … v10, v11 (v10 with the topic ids of KIP-516) v0 … v12, v13 (Kafka 4.0, the top-level error code of KIP-1102 and a rebootstrap in Cluster::reload(); MetadataRequestV12 keeps a request by topic id, MetadataRequest::byTopicIds(), KIP-516, Kafka 3.1)
4 LeaderAndIsr none — removed with ZooKeeper in 4.0 broker→broker no no
5 StopReplica none — removed with ZooKeeper in 4.0 broker→broker no no
6 UpdateMetadata none — removed with ZooKeeper in 4.0 broker→broker no no
7 ControlledShutdown none — removed with ZooKeeper in 4.0 controller v0 … v2, v3 v0 … v2, v3 — wire only: the classes and the vectors stay, controlledShutdown() is gone from the admin client
8 OffsetCommit v2 … v10 yes v0 … v7, v8 (v0 for offsets.storage = zookeeper) v0 … v9, v10 (Kafka 4.2, KIP-848: every topic named by its topic id, the 100 of an unknown or deleted id, v9 for a topic the cluster gives no id; OffsetCommitRequestV9 keeps the v8 frame of Kafka 3.6 that may carry the 69 of an unknown group and the 113 of a KIP-848 member epoch, OffsetCommitRequestV8 the flexible v8)
9 OffsetFetch v1 … v10 yes v0 … v6, v7 (v0 for offsets.storage = zookeeper) v0 … v9, v10 (Kafka 4.2, KIP-848: every topic named by its topic id, the answer named back, v9 for a topic the cluster gives no id; OffsetFetchRequestV9 keeps the member id and member epoch of KIP-848, Kafka 3.7, OffsetFetchRequestV8 the batch of several groups of Kafka 3.0)
10 GroupCoordinator (FindCoordinator) v0 … v6 yes v0 … v2, v3 v0 … v3, v4 (the coordinator_keys batch of KIP-699, Kafka 3.0; GroupCoordinatorRequestV3 keeps the single key), v5 (the same frame with the promise of the code 120 of KIP-890, Kafka 3.8), v6 (the third coordinator type of KIP-932, Kafka 3.9, the version this client sends: the node answers a key that is not a share-partition key <group id>:<topic id>:<partition> the 42, a valid one the leader of its __share_group_state partition (the 15 while that topic does not exist yet), and a share lookup below v6 the 42; GroupCoordinatorRequestV5/ResponseV5 and the V4 pair keep the versions below)
11 JoinGroup v0 … v9 yes v0 … v6, v7 v0 … v8, v9 (the reason of KIP-800 at v8, the skip_assignment of KIP-814 at v9, Kafka 3.2; JoinGroupRequestV7/V8 keep the versions below)
12 Heartbeat v0 … v4 yes v0 … v3, v4 v0 … v3, v4
13 LeaveGroup v0 … v5 yes v0 … v3, v4 v0 … v4, v5 (the reason of KIP-800 per member, Kafka 3.2; LeaveGroupRequestV4 keeps the version below)
14 SyncGroup v0 … v5 yes v0 … v4, v5 v0 … v4, v5
15 DescribeGroups v0 … v6 yes v0 … v4, v5 v0 … v5, v6 (Kafka 4.0, KIP-1043: the 69 of an unknown group; DescribeGroupsRequestV5 keeps the version below)
16 ListGroups v0 … v5 yes v0 … v3, v4 v0 … v3, v4 (the states filter and the group state of KIP-518), v5 (the types_filter of the request and the group_type of every entry, KIP-848, Kafka 3.8, the version this client sends; ListGroupsRequestV4/ResponseV4 and ListGroupResponseProtocolV4 keep the version below)
17 SaslHandshake v0, v1 yes v0, v1 v0, v1
18 ApiVersions v0 … v4 yes v0 … v2, v3 v0 … v3, v4 (Kafka 3.9, KAFKA-17011: the v3 frame, and the answer reports a supported feature whose min_version is 0 — kraft.version on a KRaft node; ApiVersionsRequestV3/ResponseV3 keep the version below)
19 CreateTopics v2 … v7 controller v0 … v6, v7 v0 … v6, v7
20 DeleteTopics v1 … v6 controller v0 … v5, v6 v0 … v5, v6
21 DeleteRecords v0, v1, v2 yes v0, v1, v2 v0, v1, v2
22 InitProducerId v0 … v5 yes v0 … v3, v4 v0 … v4, v5 (the code 120 of KIP-890, Kafka 3.8, which a 3.9.2 coordinator never answers here — a fenced producer is still the 90; InitProducerIdRequestV4/ResponseV4 keep the frame of KIP-588)
23 OffsetForLeaderEpoch v2, v3, v4 broker→broker v0 … v3, v4 (classes, vectors and the consumer's truncation detection) v0 … v3, v4 (classes, vectors and the consumer's truncation detection)
24 AddPartitionsToTxn v0 … v5 yes up to v3, broker→broker from v4 v0 … v2, v3 v0 … v2, v3 (the frame Client::addPartitionsToTxn() sends), v4 and v5 (classes and vectors only — Kafka 3.5/3.8, KIP-890 made it a broker api: the node authorizes every version from 4 on as CLUSTER_ACTION and answers a client the top-level 31 at both, and the 120 of a verify_only at both; AddPartitionsToTxnRequestV4/ResponseV4 keep the version below the v5)
25 AddOffsetsToTxn v0 … v4 yes v0 … v2, v3 v0 … v3, v4 (the code 120 of KIP-890, Kafka 3.8, which this api never carries: it adds a partition instead of writing into one, and an unknown producer id is the 49; AddOffsetsToTxnRequestV3/ResponseV3 keep the version below)
26 EndTxn v0 … v5 yes v0 … v2, v3 v0 … v4, v5 (Kafka 4.0, KIP-890 part 2: the epoch bumped by every transaction of the protocol v2; EndTxnRequestV4/ResponseV4 keep the protocol v1 and the code 120 of KIP-890, Kafka 3.8)
27 WriteTxnMarkers v1, v2 broker→broker v0, v1 (classes and vectors; a broker→broker api, probed only) v0, v1, v2 (Kafka 4.2, KIP-1228: the transaction version of a marker; classes and vectors of a broker→broker api, measured on the client listener; WriteTxnMarkersRequestV1/ResponseV1 keep the version 1)
28 TxnOffsetCommit v0 … v5 yes v0 … v2, v3 v0 … v4, v5 (Kafka 4.0, KIP-890 part 2: enrols the offsets partition of a transaction of the protocol v2, answered 0 where the v4 without AddOffsetsToTxn is the 120; TxnOffsetCommitRequestV4/ResponseV4 keep the protocol v1)
29 DescribeAcls v1, v2, v3 yes no, see below v3 (Kafka 3.3, AdminClient::describeAcls())
30 CreateAcls v1, v2, v3 yes no, see below v3 (Kafka 3.3, AdminClient::createAcls())
31 DeleteAcls v1, v2, v3 yes no, see below v3 (Kafka 3.3, AdminClient::deleteAcls())
32 DescribeConfigs v1 … v4 yes v0 … v3, v4 v0 … v3, v4
33 AlterConfigs v0, v1, v2 yes v0, v1, v2 v0, v1, v2
34 AlterReplicaLogDirs v1, v2 yes v0, v1, v2 v0, v1, v2
35 DescribeLogDirs v1 … v5 yes v0, v1, v2 v0 … v4, v5 (the top-level error code of Kafka 3.2, the volume sizes of KIP-827, Kafka 3.3, the cordon flag of KIP-1066, Kafka 4.3; DescribeLogDirsRequestV4 keeps the version below)
36 SaslAuthenticate v0, v1, v2 yes v0, v1, v2 v0, v1, v2
37 CreatePartitions v0 … v3 controller v0 … v2, v3 v0 … v2, v3
38 CreateDelegationToken v1, v2, v3 yes v0, v1, v2 v0, v1, v2, v3 (a token for another principal, KIP-373, Kafka 3.3)
39 RenewDelegationToken v1, v2 yes v0, v1, v2 v0, v1, v2
40 ExpireDelegationToken v1, v2 yes v0, v1, v2 v0, v1, v2
41 DescribeDelegationToken v1, v2, v3 yes v0, v1, v2 v0, v1, v2, v3 (the token requester, KIP-373, Kafka 3.3)
42 DeleteGroups v0, v1, v2 yes v0, v1, v2 v0, v1, v2
43 ElectLeaders v0, v1, v2 controller v0, v1, v2 v0, v1, v2
44 IncrementalAlterConfigs v0, v1 yes v0 (Kafka 2.3), v1 v0 (Kafka 2.3), v1
45 AlterPartitionReassignments v0, v1 controller v0 (Kafka 2.4) v0 (Kafka 2.4), v1 (Kafka 4.1: allow_replication_factor_change, the $allowReplicationFactorChange of alterPartitionReassignments(); AlterPartitionReassignmentsRequestV0 keeps the version below)
46 ListPartitionReassignments v0 controller v0 (Kafka 2.4) v0 (Kafka 2.4)
47 OffsetDelete v0 yes v0 (Kafka 2.4) v0 (Kafka 2.4)
48 DescribeClientQuotas v0, v1 yes v0, v1 v0, v1
49 AlterClientQuotas v0, v1 yes v0, v1 v0, v1
50 DescribeUserScramCredentials v0 yes v0 (Kafka 2.7) v0 (Kafka 2.7)
51 AlterUserScramCredentials v0 yes v0 (Kafka 2.7) v0 (Kafka 2.7)
52–54, 58, 59, 62, 63, 67, 70, 73 The controller apis (Vote, BeginQuorumEpoch, EndQuorumEpoch, Envelope, FetchSnapshot, BrokerRegistration, BrokerHeartbeat, AllocateProducerIds, ControllerRegistration, AssignReplicasToDirs) not on the client listener of a KRaft node controller no no — controller apis, probed at every version (Vote v2 Kafka 4.0, BrokerHeartbeat v2 Kafka 4.3); every one of them closes a client connection
55 DescribeQuorum v0, v1, v2 broker (a raft api the client listener serves) v0, v1, v2 (AdminClient::describeMetadataQuorum(), Kafka 2.7 / 3.3 / 3.9; the nodes, the directory ids and the two error messages of KIP-853, Admin\QuorumNode and Admin\RaftVoterEndpoint; DescribeQuorumRequestV1/ResponseV1 and the V0 classes keep the versions below)
56 AlterIsr not on the client listener of a KRaft node broker→controller no — broker→controller, probed only no — broker→controller, probed only
57 UpdateFeatures v0, v1, v2 controller v0 (Kafka 2.7) v0, v1, v2 (Kafka 2.7 / 3.3 / 4.0: the upgrade_type and the validate_only of KIP-778, and the answer without per-feature results)
60 DescribeCluster v0, v1, v2 yes v0 (Kafka 2.8) v0, v1, v2 (Kafka 2.8 / 3.7 / 4.0: the endpoint_type of KIP-919 and the codes 114 and 115, the fenced brokers of KIP-1073; DescribeClusterRequestV0/V1 keep the frames below)
61 DescribeProducers v0 yes v0 (Kafka 2.8) v0 (Kafka 2.8)
64 UnregisterBroker v0 controller no — a controller api, probed only (Kafka 2.8)
65 DescribeTransactions v0 yes v0 (AdminClient::describeTransactions(), Kafka 3.0)
66 ListTransactions v0, v1, v2 yes v0, v1, v2 (AdminClient::listTransactions(), Kafka 3.0; the duration filter of KIP-994, Kafka 3.8; the $transactionalIdPattern of KIP-1152, Kafka 4.1, and its 128; ListTransactionsRequestV1 and V0 keep the versions below)
68 ConsumerGroupHeartbeat v0, v1 yes v0 (Kafka 3.5, KIP-848, ConsumerGroupHeartbeatRequestV0), v1 (Kafka 4.0: the regex subscription of KIP-848, KafkaConsumer::subscribeByPattern(), and the member id the client generates itself, KIP-1082) — the whole membership protocol of the new consumer in one api, in place of JoinGroup, SyncGroup, Heartbeat and LeaveGroup: a consumer with group.protocol=consumer sends it from Consumer\Internals\ConsumerGroupHeartbeatCoordinator through Client::joinConsumerGroup(), ::consumerGroupHeartbeat() and ::leaveConsumerGroup(), with ConsumerGroupHeartbeatRequest::forJoin()/forHeartbeat()/forLeave() for the delta encoding of the frame
69 ConsumerGroupDescribe v0, v1 yes v0 (Kafka 3.7, KIP-848, ConsumerGroupDescribeRequestV0), v1 (Kafka 4.0: the member type of KIP-1099) — the DescribeGroups of the new protocol (AdminClient::describeConsumerGroups(), ::describeConsumerGroup()): the group and assignment epochs, the server-side assignor and both assignments of every member. Key 15 answers a group of this type the state Dead, so an admin client routes by the group_type of a ListGroups v5
71 GetTelemetrySubscriptions hidden — not in the answer without a telemetry plugin (v0 answered) yes v0, wire only (Kafka 3.7, KIP-714) — classes and vectors, no client method
72 PushTelemetry hidden — not in the answer without a telemetry plugin (v0 answered) yes v0, wire only (Kafka 3.7, KIP-714)
74 ListClientMetricsResources (ListConfigResources since 4.1) v0, v1 yes v0 (Kafka 3.7, KIP-714, ListClientMetricsResourcesRequestV0), v1 (Kafka 4.1, KIP-1142: AdminClient::listConfigResources(), the config resources of the cluster by type)
75 DescribeTopicPartitions v0 yes v0 (AdminClient::describeTopicPartitions(), the paging api of KIP-966, Kafka 3.8)
76 ShareGroupHeartbeat v1 yes (share.version 1) v1 (Kafka 4.1, KIP-932: Client::joinShareGroup(), shareGroupHeartbeat(), leaveShareGroup(); the v0 — unstable in 3.9, the early access of 4.0 — is gone)
77 ShareGroupDescribe v1 yes v1 (Kafka 4.1: AdminClient::describeShareGroups(), describeShareGroup())
78 ShareFetch v1, v2 yes v1 (Kafka 4.1: Client::shareFetch(), the share session and the acquired records with their delivery count), v2 (Kafka 4.2, KIP-1206 and KIP-1222: the acquire mode and the renew fetch; ShareFetchRequestV1 keeps the version 1)
79 ShareAcknowledge v1, v2 yes v1 (Kafka 4.1: Client::shareAcknowledge(), the close of the share session with the epoch -1), v2 (Kafka 4.2, KIP-1222: the renew acknowledgement and the lock timeout of the answer; ShareAcknowledgeRequestV1 keeps the version 1)
80 AddRaftVoter v0, v1 yes (a raft api the client listener serves, forwarded to the controller) v0 (Kafka 3.9, KIP-853; AdminClient::addRaftVoter(), the admin api of Kafka 4.0 on the kraft.version 1 node: the 126 of a voter id the quorum has, the 104, the 42, and the 7 of an unreachable voter, which is never added), v1 (Kafka 4.2: ack_when_committed, the trailing $ackWhenCommitted of addRaftVoter(); AddRaftVoterRequestV0 keeps the version 0)
81 RemoveRaftVoter v0 yes (a raft api the client listener serves, forwarded to the controller) v0 (Kafka 3.9, KIP-853; AdminClient::removeRaftVoter(): the 127 of a key the quorum does not hold, the 104, the 42)
82 UpdateRaftVoter not on the client listener of a KRaft node controller no — a controller api, probed only (Kafka 3.9); every version of it closes a client connection
83–87 The share-group state apis (KIP-932) v0 (85 and 87: v0, v1) yes (the share coordinator's own apis) v0, wire only (Kafka 4.1): classes and vectors, no client method; v1 of 85 and 87 (Kafka 4.2, the DeliveryCompleteCount of KIP-1226)
88, 89 StreamsGroupHeartbeat, StreamsGroupDescribe (KIP-1071) v0 yes (streams.version 1) no — out by decision: the constants and a probe
90 DescribeShareGroupOffsets v0, v1 yes v0, v1, wire classes (Kafka 4.1, KIP-932; v1 Kafka 4.2, the share-partition lag of KIP-1226; DescribeShareGroupOffsetsRequestV0 keeps the version 0); AdminClient::listShareGroupOffsets()
91, 92 AlterShareGroupOffsets, DeleteShareGroupOffsets v0 yes v0, wire classes (Kafka 4.1, KIP-932); AdminClient::alterShareGroupOffsets(), deleteShareGroupOffsets()

The lower versions of every api are kept because their frames are what the wire vectors of the lines below replay.

The three ACL apis (29, 30, 31) are implemented on this line, at the version 3 of Kafka 3.3. The lines below left them out because they do nothing on a broker without an authorizer.class.name (such a broker answers all three with the error code 54, SecurityDisabled) and every wire vector of this repository is captured from a real broker. The 3.9.2 node of this line runs the StandardAuthorizer of KRaft with super.users=User:ANONYMOUS;User:admin;User:kafkatest, so AdminClient::describeAcls(), createAcls() and deleteAcls() were measured against a real authorizer: an acl is a Common\AclBinding — a ResourcePattern (LITERAL or PREFIXED, with the USER resource type that Kafka 3.3 added for KIP-373) and an AccessControlEntry (a principal, a host, an AclOperation and ALLOW/DENY) — and a describe or a delete names an AclBindingFilter, in which every field may be a wildcard and the pattern type MATCH asks which acls apply to a resource. The client sends the version 3 and keeps no lower one: an api that starts on this line gets the versions the node was measured at.

The four delegation-token apis (38 to 41) are implemented, and a token cannot be used to authenticate. AdminClient::createDelegationToken(), renewDelegationToken(), expireDelegationToken() and describeDelegationToken() speak them over an authenticated channel — one of the SASL listeners, because KIP-48 derives the owner of a token from the principal of the connection and answers the error code 64 on a PLAINTEXT or one-way-SSL one. What is missing is the other half of KIP-48: using a token means a SASL/SCRAM login whose user name is the token id and whose password is the base64 HMAC, and this client speaks SASL/PLAIN only. The four apis are verified against the real broker, the login with their result is not implemented. examples/delegation-tokens.php runs one token's whole life against the SASL listener of docker-compose.yml.

What the lines can do beyond the api versions themselves (the main column is the state of the current milestone):

Feature Arrived in 0.8.x 0.9.x 0.10.x 0.11.x 1.x 2.x main
Message format v0 (no timestamps) 0.8 yes yes yes yes yes yes yes
Message format v1 (timestamps, relative inner offsets) 0.10.0 yes yes yes yes yes
Record batch v2 (headers, varints, CRC-32C) 0.11 yes yes yes yes
Compression gzip, snappy 0.8 yes yes yes yes yes yes yes
Compression lz4 0.10.0 yes yes yes yes yes
Transport PLAINTEXT 0.8 yes yes yes yes yes yes yes
Transport SSL 0.9 yes yes yes yes yes yes
Transport SASL_PLAINTEXT / SASL_SSL (PLAIN) 0.10.0 yes yes yes yes yes
Consumer groups (subscribe(), assignors) 0.9 yes yes yes yes yes yes
The group state Empty 0.10.1 yes yes yes yes yes
The group state CompletingRebalance (AwaitingSync below) 1.0 yes yes yes
Client quotas and their throttle_time_ms 0.9 yes yes yes yes yes yes
throttle_time_ms in the group and admin apis 0.11 yes yes yes yes
controller_id, broker rack, is_internal 0.10.0 yes yes yes yes yes
cluster_id of Metadata v2 0.10.1 yes yes yes yes yes
Offsets by timestamp, offsetsForTimes() 0.10.1 yes yes yes yes yes
fetch.max.bytes of Fetch v3 0.10.1 yes yes yes yes yes
max.poll.interval.ms and the rebalance_timeout 0.10.1 yes yes yes yes yes
Admin: create and delete topics through the protocol 0.10.1 yes yes yes yes yes
Admin: getApiVersions() 0.10.0 yes yes, v1 yes, v1 yes, v2 yes, v4
Admin: DeleteRecords, DescribeConfigs/AlterConfigs 0.11 yes yes yes yes
Record headers end to end (KIP-82) 0.11 yes yes yes yes
OffsetForLeaderEpoch, allow_auto_topic_creation 0.11 yes yes yes yes
Idempotent producer (enable.idempotence) 0.11 yes yes yes yes
Transactional producer, isolation.level 0.11 yes yes yes yes
Framed SASL exchange (SaslAuthenticate, KIP-152) 1.0 yes yes yes
log_start_offset of a produce answer, offline_replicas 1.0 yes yes yes
The five-batch duplicate window of a producer id 1.0 yes yes yes
UnknownProducerId (59) repaired from the log_start_offset 1.0 yes yes — a 2.8.2 broker no longer sends 59 on the produce path yes — a 2.8.2 broker no longer sends 59 on the produce path
Incremental fetch sessions (KIP-227) 1.1 yes yes, one session per broker in the consumer yes, one session per broker in the consumer
Dynamic broker configuration, config sources and synonyms (KIP-226) 1.1 yes yes yes
Admin: createPartitions(), deleteConsumerGroups() 1.0 / 1.1 yes yes yes
Admin: describeLogDirs(), alterReplicaLogDirs() 1.0 yes yes yes
Delegation tokens (KIP-48) 1.1 issued, renewed, expired, described issued, renewed, expired, described issued, renewed, expired, described
KIP-219: the client waits out throttle_time_ms (throttle.wait) 2.0 yes yes
KIP-279: the leader_epoch of an OffsetForLeaderEpoch answer 2.0 yes yes
KIP-283: message.downconversion.enable, measured 2.0 yes (35 per partition) yes (35 per partition)
KIP-320: leader epochs in Fetch, ListOffsets, Metadata, OffsetCommit/OffsetFetch, OffsetForLeaderEpoch; truncation detection in the consumer 2.1 yes (LogTruncationException with auto.offset.reset=none) yes (LogTruncationException with auto.offset.reset=none)
KIP-110: the zstd codec (compression.type=zstd) 2.1 yes, through ext-zstd (76 without it) yes, through ext-zstd (76 without it)
KIP-211: OffsetCommit v5 without a per-commit retention 2.1 yes yes
KIP-394: the second join (79 on a first JoinGroup v4 without a member id) 2.2 yes, the consumer rejoins by itself yes, the consumer rejoins by itself
KIP-207: 78 OffsetNotAvailable of ListOffsets v5 2.2 yes (documented from the sources: one broker never lags) yes (documented from the sources: one broker never lags)
KIP-368: the SASL session lifetime of SaslAuthenticate v1 2.2 reported (re-authentication is 2.5's) reported (re-authentication is 2.5's)
KIP-183: electLeaders() (ElectLeaders v0) 2.2 yes (preferred elections; unclean from v1) yes (preferred elections; unclean from v1)
KIP-380: the broker epoch of ControlledShutdown v2 2.2 yes (controlledShutdown()) wire only — the classes and the vectors stay, controlledShutdown() is gone: a KRaft node serves the api on its controller listener only
KIP-345: static membership (group.instance.id, 82 fences the older instance) 2.3 yes — a static consumer keeps its partitions across a restart and does not leave on close() yes — a static consumer keeps its partitions across a restart and does not leave on close()
KIP-430: authorized operations of Metadata v8 and DescribeGroups v3 (Common\AclOperation) 2.3 yes (the supported operations on a broker without an authorizer) yes (the supported operations on a broker without an authorizer)
KIP-392: reading from a follower (client.rack, preferred_read_replica of Fetch v11) 2.3 wire only — one broker never names another replica wire only — one broker never names another replica
KIP-339: incrementalAlterConfigs() (IncrementalAlterConfigs v0) 2.3 yes (SET, DELETE, APPEND, SUBTRACT) yes (SET, DELETE, APPEND, SUBTRACT)
KIP-482: flexible versions and tagged fields (compact strings, bytes and arrays, request header v2, response header v1) 2.4 yes — the 2.4 versions were the first (ApiVersions v3, Metadata v9, the ten group apis, CreateTopics v5, DeleteTopics v4, ElectLeaders v2, IncrementalAlterConfigs v1, ControlledShutdown v3, InitProducerId v2, CreateDelegationToken v2), and every flexible version Kafka 2.5 to 2.8 added is sent that way too — DeleteRecords v2 (2.6), Fetch v12 and the four transaction apis (2.7), Produce v9, ListOffsets v6, OffsetForLeaderEpoch v4 and Metadata v10/v11 (2.8) — so that SaslHandshake v1 and OffsetDelete v0 are the only requests this client still sends in a plain frame yes — the 2.4 versions were the first (ApiVersions v3, Metadata v9, the ten group apis, CreateTopics v5, DeleteTopics v4, ElectLeaders v2, IncrementalAlterConfigs v1, ControlledShutdown v3, InitProducerId v2, CreateDelegationToken v2), and every flexible version Kafka 2.5 to 2.8 added is sent that way too — DeleteRecords v2 (2.6), Fetch v12 and the four transaction apis (2.7), Produce v9, ListOffsets v6, OffsetForLeaderEpoch v4 and Metadata v10/v11 (2.8) — so that SaslHandshake v1 and OffsetDelete v0 are the only requests this client still sends in a plain frame
KIP-455: partition reassignments (alterPartitionReassignments(), listPartitionReassignments()) 2.4 yes yes
KIP-496: deleteConsumerGroupOffsets() (OffsetDelete v0) 2.4 yes yes
KIP-345: static members removed by hand (removeMembersFromConsumerGroup(), LeaveGroup v3) and the group_instance_id of DescribeGroups v4 2.4 yes yes
KIP-464 / KIP-525: topics created with the broker defaults (NewTopic::withBrokerDefaults()) and answered with their configuration (createTopicsWithResults()) 2.4 yes yes
KIP-460: unclean leader election (ElectionType::UNCLEAN, ElectLeaders v1) 2.4 wire only — a one-broker cluster has no partition whose leader is gone wire only — a one-broker cluster has no partition whose leader is gone
KIP-467: the record errors of a refused batch (Produce v8, InvalidRecordException names the records) 2.4 yes yes
KIP-360: the epoch bump of a transactional producer (InitProducerId v3 with the producer's own id and epoch; an abortable error no longer ends the producer) 2.5 yes yes
KIP-447: exactly-once with a consumer group (sendOffsetsToTransaction() with ConsumerGroupMetadata, TxnOffsetCommit v3; require_stable of OffsetFetch v7 and the 88, read by a read_committed consumer) 2.5 yes yes
KIP-559: the protocol type and name of a generation (JoinGroup v7, SyncGroup v5) 2.5 yes yes
KIP-546: client quotas over the wire (describeClientQuotas(), alterClientQuotas()) 2.6 yes yes
KIP-518: the states of listConsumerGroups() (ListGroups v4) 2.6 yes yes
KIP-569: the type and documentation of a configuration entry (DescribeConfigs v3, ConfigType) 2.6 yes yes
KIP-599: throttled topic creation (the 89 THROTTLING_QUOTA_EXCEEDED of CreateTopics v6, DeleteTopics v5 and CreatePartitions v3, retried after the throttle) 2.7 yes yes
KIP-588: a fenced producer is 90 (InitProducerId v4, TransactionalProducerFencedException) 2.7 yes yes
KIP-595: epoch validation in the fetch itself (Fetch v12, last_fetched_epoch and the diverging_epoch of the answer) 2.7 yes yes
KIP-554: SCRAM credentials over the wire (describeUserScramCredentials(), alterUserScramCredentials()) 2.7 yes — the credentials can be managed, the SCRAM login itself is still not spoken yes — the credentials can be managed, the SCRAM login itself is still not spoken
KIP-584: feature versions (describeFeatures(), updateFeatures()) 2.7 yes yes
KIP-516: topic ids (Common\Uuid, Metadata v10 and v11, TopicMetadata::$topicId, CreateTopics v7 and DeleteTopics v6 with CreatedTopic::$topicId and the 100 UnknownTopicId) 2.8 yes — a deleted and re-created topic of the same name gets a new id yes — a deleted and re-created topic of the same name gets a new id
KIP-482 on the last plain apis (the flexible v3 of AddPartitionsToTxn, AddOffsetsToTxn and EndTxn, DescribeConfigs v4, AlterConfigs v2, AlterReplicaLogDirs v2, WriteTxnMarkers v1) 2.8 yes yes
KIP-700: the cluster-wide authorized operations leave Metadata (gone from the request and the answer of Metadata v11, asked with describeCluster()) and KIP-664: describeProducers() 2.8 yes yes
KIP-664: describeTransactions() / listTransactions() (DescribeTransactions v0, ListTransactions v0) 3.0 yes
KIP-734: the max timestamp (OffsetsRequest::MAX_TIMESTAMP, ListOffsets v7) 3.0 yes (maxTimestampOffsets(), listMaxTimestampOffsets())
KIP-699: several coordinators in one FindCoordinator (v4) and several groups in one OffsetFetch (v8) 3.0 yes (getGroupCoordinators(), listConsumerGroupOffsets())
KIP-516, the request side: topics named by their id (Fetch v13, Metadata v12; Cluster::topicIdOf()/topicNameById(), describeTopicsByIds(); the 106 of a fetch session that mixes ids and names) 3.1 yes
KIP-800: the reason of a join and of a leave (JoinGroup v8, LeaveGroup v5; Client::joinGroup()/leaveGroup(), KafkaConsumer::unsubscribe(), removeMembersFromConsumerGroup()) and KIP-814: skip_assignment (JoinGroup v9; a static leader that returns to a Stable group keeps its assignment) 3.2 yes
DescribeLogDirs v3: the top-level error code of a refused request (thrown by describeLogDirs()) 3.2 yes
The ACL apis at v3 (DescribeAcls, CreateAcls, DeleteAcls; describeAcls(), createAcls(), deleteAcls(), Common\AclBinding) 3.3 yes — the first line of this package to speak them, against a real authorizer
KIP-778: the upgrade type and the dry run of a feature update (Admin\UpgradeType, updateFeatures(..., validateOnly: true), UpdateFeatures v1) 3.3 yes — the safe and the unsafe downgrade are two frames, and a dry run writes nothing
KIP-836: the lag of a voter (describeMetadataQuorum(), the LastFetchTimestamp and LastCaughtUpTimestamp of DescribeQuorum v1) 3.3 yes — the one-node quorum reports the leader's own current time in both
KIP-827: the volume sizes of a log directory (DescribeLogDirs v4, LogDirInfo::$totalBytes/$usableBytes) and KIP-373: a token for another principal (CreateDelegationToken v3, DescribeDelegationToken v3, createDelegationToken(..., $owner), TokenInformation::$tokenRequester) 3.3 yes
KIP-405, the client side of tiered storage (Fetch v14 and the error code 109, ListOffsets v8 and the target time -4; OffsetsRequest::EARLIEST_LOCAL_TIMESTAMP, listEarliestLocalOffsets()) 3.5 yes — the wire; the node has no remote storage, so -4 is the earliest offset and the 109 stays declared
KIP-903: the replica state of a follower fetch (Fetch v15, the tagged replica_state in the place of the top-level replica_id; Data\FetchRequestReplicaState, FetchRequest::$replicaEpoch) 3.5 yes — a consumer writes nothing and its frame is four bytes shorter; a one-node cluster answers a follower 75 or 6 before the epoch is looked at
KIP-890, part 1: AddPartitionsToTxn v4, the batched broker version (AddPartitionsToTxnRequest::forTransactions(), Data\AddPartitionsToTxnTransaction, Data\AddPartitionsToTxnResult) 3.5 wire only — the node answers a client the 31 of CLUSTER_ACTION; Client::addPartitionsToTxn() keeps the v3 until the v5 of Kafka 3.8
KIP-848, the first version a classic client sends: OffsetCommit v9 (the v8 frame; the 69 GroupIdNotFound of an unknown group and the 113 StaleMemberEpoch of a member epoch; OffsetCommitRequestV8/ResponseV8 keep the v8) 3.6 yes — the 113 observed with a hand-built KIP-848 member; the consumer protocol itself is the last wave of the line
KIP-951: leader discovery (Produce v10, Fetch v16; the tagged current_leader of a refused partition and the node_endpoints of the answer, ProduceResponsePartition::$currentLeader, ProduceResponse::$nodeEndpoints, FetchResponse::$nodeEndpoints, handed to the caller in the exception context as currentLeaderId/Epoch/Host/Port) 3.7 yes — the wire and the hint; the client still refreshes its metadata instead of following the endpoint
KIP-919: the endpoint type of a DescribeCluster (v1, Admin\EndpointType, describeCluster(..., EndpointType::Controller), the codes 114 and 115) 3.7 yes — a broker listener answers the 114 for the controllers, the controller listener of the node is not exposed
KIP-848, the fetch half: the member id and epoch of an OffsetFetch (v9; Client::fetchGroupOffsetsAsMember(), OffsetFetchRequest::forMember(), Data\OffsetFetchRequestGroup::$memberId/$memberEpoch; the 113 and the 25 as group-level codes) 3.7 yes — measured with a hand-built KIP-848 member; the consumer protocol itself is the last wave of the line
KIP-714: client metrics (GetTelemetrySubscriptions 71, PushTelemetry 72, ListClientMetricsResources 74) 3.7 wire only — classes and vectors of a node without a receiver plugin, no client method, no emitter (owner decision)
KIP-966: the eligible leader replicas over the wire (DescribeTopicPartitions, key 75; describeTopicPartitions(), Admin\TopicDescription, Admin\TopicPartitionInfo, Protocol\NullableStruct) and KIP-994: the duration filter of listTransactions() (ListTransactions v1) 3.8 yes — the api pages, and the node answers the two ELR arrays empty
KIP-890 (part 1): the abortable transaction error (Produce v11; the 120 TransactionAbortable a transactional batch of an unverified partition is refused with, TransactionAbortableException, which makes the transaction abortable instead of fatal) 3.8 yes — the wire and the producer state machine; the transaction protocol v2 of part 2 is not in 3.9 (the node finalizes no transaction.version)
KIP-848 (the group types of a listing) (ListGroups v5, group_type + types_filter) and KIP-890 (the code 120 reaches FindCoordinator) (v5, no field) 3.8 yes (listGroups($node, $states, $types), ListGroupResponseProtocol::TYPE_*); the 120 is produced at AddPartitionsToTxn and Produce, never at FindCoordinator
KIP-848, the consumer protocol itself (ConsumerGroupHeartbeat 68, ConsumerGroupDescribe 69, group.protocol=consumer, group.remote.assignor) 3.5 / 3.7 yes — one api in place of four, the assignment computed by the coordinator, the heartbeat interval dictated by the broker, an incremental rebalance, the member epoch on OffsetCommit v9 / OffsetFetch v9, the static leave of the epoch -2, and the codes 110, 111, 112 and the 69 of a classic group observed on the node
KIP-890, part 2 (the wire half): the abortable transaction error (InitProducerId v5, AddPartitionsToTxn v5, AddOffsetsToTxn v4, EndTxn v4, TxnOffsetCommit v4, and the error code 120 TransactionAbortableException) 3.8 yes — the four client-facing versions are sent; AddPartitionsToTxn v5 stays a broker version. The node finalizes no transaction.version, so only the partition verification of part 1 produces the 120: a TxnOffsetCommit v4 whose offsets partition the transaction does not hold, where the v3 is answered 48
KAFKA-17011: a supported feature with the minimum version 0 (ApiVersions v4, no field; kraft.version 0…1 appears in the answer of a v4 and in no answer below it) and KIP-853: the reconfigurable quorum over the wire (DescribeQuorum v2, the Nodes array, the ReplicaDirectoryId of a replica state and the two ErrorMessage fields; Admin\QuorumNode, Admin\RaftVoterEndpoint, ReplicaState::$replicaDirectoryId, and the uint16 the engine gained for a listener port) 3.9 yes — the wire of both; the node runs a static voter set (kraft.version finalized at 0), so every directory id it reports is the zero uuid and the reconfiguration apis 80 and 81 refuse every frame with the 35
KIP-853: the directory id of a follower fetch (Fetch v17, the tagged replica_directory_id of every partition entry; Data\FetchRequestTopicPartition::$replicaDirectoryId, FetchRequest::getReplicaDirectoryId()) 3.9 yes — the wire; a consumer writes nothing and its frame does not change, and a fetch of an ordinary topic never reads the field: only KafkaRaftClient does, for __cluster_metadata on the controller listener
KIP-1005: the last tiered offset (ListOffsets v9, the target time -5; OffsetsRequest::LATEST_TIERED_TIMESTAMP, AdminClient::listLatestTieredOffsets()) 3.9 yes — the wire; without remote storage the node answers the offset -1 with the error code 0, on a filled log as on an empty one
KIP-932 (the coordinator type of a share group) (FindCoordinator v6, COORDINATOR_TYPE_SHARE) 3.9 wire only — the version and the constant; the type 2 is legal from v6 and answered the 15 CoordinatorNotAvailable by a 3.9.2 node, which has no share coordinator, and the 42 below it. Share groups themselves arrive at Kafka 4.1 (below)
KIP-896: the versions below the Kafka 2.1 baseline removed (Produce v0–v2, Fetch v0–v3, ListOffsets v0, OffsetCommit v0/v1, OffsetFetch v0, OffsetForLeaderEpoch v0/v1, CreateTopics v0/v1, DeleteTopics v0, DescribeConfigs v0, the ACL and token apis v0) 4.0 measured — every one closes the connection on a 4.x node (Produce v0–v2 too, although still advertised, KAFKA-18659); the classes and vectors stay, the client never sends one below the node's table, and a message.format.version below 0.11.0 is refused client-side
KIP-890 part 2 (the behaviour half): the transaction protocol v2 (Produce v12, EndTxn v5 with the epoch bump, TxnOffsetCommit v5, no AddPartitionsToTxn/AddOffsetsToTxn on a cluster with transaction.version 2; TransactionManager::isTransactionV2Enabled(), Client::produceVersion()) 4.0 yes — the v1 path kept below level 2 and for a 3.x node, where a transactional Produce is capped at v11
KIP-1102: the top-level error code of a Metadata answer (Metadata v13, MetadataResponse::$errorCode, the 129 RebootstrapRequiredException) 4.0 yesCluster::reload() asks the next bootstrap server on it; no 4.3.1 broker writes the 129
KIP-1075: the timeout of a ListOffsets (v10, timeout_ms) 4.0 yesrequest.timeout.ms; without remote storage the answer does not change
KIP-1043: the 69 of an unknown group in DescribeGroups (v6, error_message) 4.0 yesdescribeGroup(s)() throws GroupIdNotFoundException where the 3.x line answered Dead
KIP-848 (the regex subscription) and KIP-1082 (the member id the client generates) (ConsumerGroupHeartbeat v1, KafkaConsumer::subscribeByPattern(SubscriptionPattern), the 128 InvalidRegularExpressionException) 4.0 yesgroup.protocol=consumer only
KIP-1099: the member type of a KIP-848 group (ConsumerGroupDescribe v1, ConsumerGroupMemberDescription::upgraded()) 4.0 yes
KIP-1073: the fenced brokers of a DescribeCluster (v2, $includeFencedBrokers, ClusterDescription::isFenced()) 4.0 yes — a one-node cluster never reports a fenced broker
KIP-853: the admin api of the reconfigurable quorum (AdminClient::addRaftVoter(), removeRaftVoter(), AddRaftVoter/RemoveRaftVoter v0) 3.9 / 4.0 yes — on the kraft.version 1 node of the line: the 126, the 127, and the 7 of an unreachable voter
KIP-516 on the produce path: topics named by their id (Produce v13, Cluster::topicIdsOf(), the 100 UnknownTopicId retried by RetryPolicy) 4.1
KIP-1166: the high watermark of a follower (Fetch v18, the tagged high_watermark, FetchRequestTopicPartition::$highWatermark) 4.1
KIP-1152: the transactional id pattern of listTransactions() (ListTransactions v2, the 128 of a pattern the node cannot compile) 4.1
KIP-1142: the config resources of a cluster by type (ListConfigResources, key 74 v1, AdminClient::listConfigResources()) 4.1
The replication factor change of a reassignment (AlterPartitionReassignments v1, allow_replication_factor_change) 4.1
KIP-932: the share-group wire (ShareGroupHeartbeat, ShareGroupDescribe, ShareFetch, ShareAcknowledge v1; Client::joinShareGroup(), shareGroupHeartbeat(), leaveShareGroup(), shareFetch(), shareAcknowledge(), AdminClient::describeShareGroups(); the state apis 83–87 and the offset apis 90–92 at v0 as wire classes) 4.1
KIP-1023: the earliest pending upload offset (ListOffsets v11, the target time -6; OffsetsRequest::EARLIEST_PENDING_UPLOAD_TIMESTAMP, AdminClient::listEarliestPendingUploadOffsets()) 4.2
KIP-848: the group offsets by topic id (OffsetCommit v10, OffsetFetch v10; AdminClient::alterConsumerGroupOffsets()) 4.2
KIP-1206 and KIP-1222: the acquire mode and the renew acknowledgement of a share fetch (ShareFetch v2, ShareAcknowledge v2, ShareAcknowledgementBatch::RENEW) 4.2
KIP-1226: the delivery-complete count and the share-partition lag (WriteShareGroupState v1, ReadShareGroupStateSummary v1, DescribeShareGroupOffsets v1) 4.2
KIP-1228: the transaction version of a marker (WriteTxnMarkers v2) and ack_when_committed (AddRaftVoter v1) 4.2
KIP-1066: the cordon flag of a log directory (DescribeLogDirs v5, LogDirInfo::$isCordoned) 4.3
KIP-932: the share consumer and its admin methods (Consumer\KafkaShareConsumer: implicit and explicit acknowledgement, AcknowledgeType ACCEPT/RELEASE/REJECT/RENEW, share.acquire.mode; AdminClient::listShareGroups(), listShareGroupOffsets(), alterShareGroupOffsets(), deleteShareGroupOffsets(), deleteShareGroups()) 4.1 / 4.2
Error codes -1 … 20 -1 … 31 -1 … 44 -1 … 55 -1 … 71 -1 … 104 (the constants of 2.8.2; 72 is 2.0's) -1 … 133 (the constants of 4.3.1, declared by the foundation of the 4.x line; 105 is 3.0's, 128 is 4.0's)

What this line leaves out by design (the owner's decisions for the 3.x and the 4.x line; everything else the 4.3.1 node serves to a client is implemented):

Feature Arrived in On this branch
SASL/SCRAM, SASL/GSSAPI and SASL/OAUTHBEARER 0.10.2 / 0.9 / 2.0 no — PLAIN only, which is why a delegation token can be issued but not used
ACL apis DescribeAcls/CreateAcls/DeleteAcls 0.11 yes — at the version 3 of Kafka 3.3, against the StandardAuthorizer of the 3.9.2 KRaft node (AdminClient::describeAcls(), createAcls(), deleteAcls())
Replication apis LeaderAndIsr/StopReplica/UpdateMetadata/AlterIsr 0.8 / 2.7 no — only a ZooKeeper controller sends them, and a KRaft node does not even list them on its client listeners
The KRaft controller apis (52–54, 58, 59, 62–64, 67, 70, 73, 80–82) 2.7 … 3.9 no — probed only; of the ones a KRaft node lists on its client listeners, 64 answers the 102 of an unknown broker id, and the rest live on the controller listener. DescribeQuorum (55) is implemented, at v0 to v2, and AddRaftVoter (80) and RemoveRaftVoter (81) get admin methods on the 4.x line (the 4.3.1 node runs a dynamic quorum, kraft.version 1, and answers the 126 and the 127 of KIP-853) — not yet implemented
Share groups (76–79, 90–92 and their state apis 83–87, KIP-932) 3.9 (early access) / 4.1 / GA 4.2 in on the 4.x line: the wire of 76–79 at v1 (4.1 milestone, Client::joinShareGroup()shareAcknowledge(), AdminClient::describeShareGroups()) and the wire classes of 90–92; the v2 of 78 and 79 at the 4.2 milestone, and Consumer\KafkaShareConsumer with the admin methods listShareGroups(), listShareGroupOffsets(), alterShareGroupOffsets(), deleteShareGroupOffsets() and deleteShareGroups() as the last wave; the state apis 83–87 wire only. The 4.3.1 node serves all twelve (share.version 1) and answers a ShareFetch of an unknown session with the 122
Streams groups (88, 89, KIP-1071) 4.1 / GA 4.2 no — out by decision: declared and probed, the codes 130 to 132 declared
Client metrics (71, 72, 74, KIP-714) 3.7 the wire classes are implementedGetTelemetrySubscriptionsRequest/Response, PushTelemetryRequest/Response, ListClientMetricsResourcesRequest/Response and Data\ClientMetricsResource, with 26 vectors of what a node without a telemetry receiver plugin answers (the generated client instance id, the 300000 ms default interval, the 89 of asking twice, the 117 of a foreign subscription id, the 118 of an oversized blob); there is no client method and no telemetry emitter, so nothing of this package ever sends them
Tiered storage (KIP-405, KIP-1005) 3.5, 3.9 the wire halves are implemented — Fetch v14 (the error code 109 OffsetMovedToTieredStorage, Errors\OffsetMovedToTieredStorageException), ListOffsets v8 (the target time -4, AdminClient::listEarliestLocalOffsets()) and ListOffsets v9 (the target time -5, AdminClient::listLatestTieredOffsets()); the container has no remote storage, so the 109 is declared, -4 equals -2 and -5 answers the offset -1 with the error code 0 on it; the remote-storage RPCs of KIP-405 are broker-internal and have no client-facing api key, so nothing of the feature is left out
offsets.storage = zookeeper (OffsetCommit/OffsetFetch v0) 0.8.1 removed from this line — the option and its code path are gone; the classes stay for the wire vectors of the lines below, and a KRaft node answers both v0 requests with 35 UnsupportedVersion
controlledShutdown() (ControlledShutdown, key 7) 0.8 removed from this line — the method is gone from the admin client; the classes and the vectors stay, and a KRaft node serves the api on its controller listener only

Five properties of a 3.9.2 node (and of every broker since 1.0) regularly surprise clients, and this implementation deals with all of them explicitly:

  • An api the broker does not serve costs the connection. A request whose api key or version a 3.9.2 node cannot parse — and a body that does not match the schema of a version it does serve — makes it close the socket: Closing socket for … because of error in the broker log, and the end of the stream for the client, reported as a NetworkException. A 0.9.0.1 broker only dropped such a frame and kept the connection open, so code ported from that line waits for a timeout that will never come. There is exactly one exception: ApiVersions answers an unknown version with the error code 35 and survives. Up to Kafka 0.11 there was a second, ControlledShutdown, which the broker still parsed with a Scala class that never looked at the version; Kafka 1.0 moved that api to the schemas of the Java client, so it now serves v0 and v1 and hangs up on anything above. This client only ever sends the api versions of the table above.
  • A group whose last member leaves does not disappear. Since Kafka 0.10.1 it stays in the state Empty with its committed offsets until offsets.retention.minutes expires them, is still listed by AdminClient::listGroups() and is described as Empty, not Dead. That is what lets a restarted consumer of the same group resume where the group committed — and it is a behaviour change against 0.9, where the coordinator dropped such a group at once.
  • A fetch answer may be empty although the partition has data — and larger than the limit it asked for. Fetch v3 (Kafka 0.10.1) added a request-level fetch.max.bytes, and the broker spends it on the partitions in the order of the request: a partition behind an exhausted budget comes back empty with its high water mark above the fetch offset, and the next poll, which rotates the served partitions to the back, picks it up. The other half of the same rule is that the first non-empty partition is always served whole, even when the single message exceeds the limit — which is why a consumer of this branch can no longer get stuck on a record that is bigger than max.partition.fetch.bytes.
  • The coordinator of a group is not available right away. The first GroupCoordinator request for any group makes the broker create the internal __consumer_offsets topic and is answered with the error code 15 while that happens; code 14 means the coordinator is still reading the offsets of the group out of it. Both are retried with retry.backoff.ms until metadata.fetch.timeout.ms by Common\CoordinatorLookup, which AdminClient::findCoordinator() and Client::getGroupCoordinator() use.
  • The broker remembers the last five batches of a producer, and it drops record headers when it converts a batch down. Both are Kafka 1.0 changes against the 0.11 line and both are invisible in the frame: a duplicate of any of the last five batches of a producer id and partition is answered as the original append (0.11 remembered one batch and answered 45 for anything older), and a Fetch below v4 of a partition whose records carry headers now succeeds with the headers silently removed, where a 0.11 broker refused the whole partition with the error code -1.

One thing that a 0.10 broker no longer does: a broker without a single topic answers Metadata with its brokers, where 0.8 and 0.9 answered an empty broker array until some topic existed. An empty broker array is still "not ready, retry" and never "the cluster has no brokers", and tests/Fixture/ClusterReadinessProbe.php still treats it that way, but it is no longer the normal state of a fresh cluster.

Two changes against the 0.8.2.2 line show up in single apis, and both are worth knowing when porting code between the branches. OffsetFetch v1 no longer validates the partition: asking for a partition the cluster does not host answers offset -1 with the error code 0 — "nothing committed" — where 0.8.2.2 answered 3 (UnknownTopicOrPartition), so Metadata is the only api that says whether a partition exists. And a ControlledShutdown for a broker id the controller does not know was answered with 8 (BrokerNotAvailable) from 0.9 on, the code a 0.8.2.2 broker turned into -1 (Unknown) by mapping the cause of an exception that has none — a measurement of the lines below, since a KRaft node does not serve the api on a client listener. The protocol document has the details.

Testing & Contributing

composer install
composer check   # coding standards + static analysis + PHPUnit

The suite is split in three — 1717 unit tests, 321 compliance tests replaying the 314 documented wire vectors, and 540 integration tests against a real broker over its four listeners, without a single skip:

vendor/bin/phpunit --testsuite unit          # pure unit tests, no broker
vendor/bin/phpunit --testsuite compliance    # replays the documented wire vectors

docker compose up -d                         # Kafka 1.1.1: PLAINTEXT 9092, SSL 9093,
                                             #                 SASL_PLAINTEXT 9094, SASL_SSL 9095
KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:9092 vendor/bin/phpunit --testsuite integration

The integration suite is skipped unless KAFKA_BOOTSTRAP_SERVERS points at a running broker, and each of the other three listeners has an environment variable of its own — a test that needs one is skipped when it is unset:

Variable Default What it runs
KAFKA_BOOTSTRAP_SERVERS the whole integration suite, over the PLAINTEXT listener
KAFKA_SSL_BOOTSTRAP_SERVERS 127.0.0.1:9093 SslTransportTest, against the certificate the container was built with (docker/kafka-4.3.1/ssl/broker.crt)
KAFKA_SASL_BOOTSTRAP_SERVERS the SASL/PLAIN tests over SASL_PLAINTEXT (127.0.0.1:9094)
KAFKA_SASL_SSL_BOOTSTRAP_SERVERS the same exchange inside TLS (127.0.0.1:9095)
KAFKA_CONTAINER kafka-4-3-1 the container the log dumps and the topic tools of a few tests run their scripts in (the quotas are set through the wire since this line)

The compliance suite replays every wire vector of docs/protocol/vectors — frames that a real Kafka broker sent or accepted — through the request and response classes and checks that the annotated dumps of docs/protocol/4.3.md still hold the same bytes, and that every @see docs/protocol/4.3.md, section "…" of the sources names a heading that exists, so the document and the code cannot drift apart.

Examples

Every file in examples/ is runnable against the container of docker-compose.yml:

Example What it shows
producer.php batching, compression, keys and partitions, the RecordMetadata of a batch
consumer.php assign(), seek(), deserializers, the timestamps of a record
consumer-group.php subscribe(), the rebalance listener, max.poll.interval.ms — start it twice
share-consumer.php KafkaShareConsumer (KIP-932): a share group, explicit acknowledgements, a release and its redelivery, the callback — start it twice
record-headers.php the record headers of Kafka 0.11 (KIP-82), written and read back end to end
idempotent-producer.php enable.idempotence: the producer id, the sequence numbers and what a duplicate batch answers
transactional-producer.php transactional.id, the consume-transform-produce loop and a read_committed consumer
admin.php brokers, cluster id and controller, topics, offsets, groups, and deleteConsumerGroups() (KIP-229)
create-topic.php createTopics() / deleteTopics() with validateOnly, and createPartitions() growing a topic (KIP-195)
admin-configs.php describeConfigs() with the config sources and synonyms of KIP-226, alterConfigs() on a topic and on a broker resource, and deleteRecords()
admin-log-dirs.php describeLogDirs() and alterReplicaLogDirs() — the disks of a broker and a replica moved between them (KIP-113)
delegation-tokens.php the four token apis of KIP-48 over a SASL listener: create, describe, renew and expire
offsets-for-times.php offsetsForTimes(), beginningOffsets(), endOffsets()
ssl.php the SSL listener, 9093
sasl.php SASL/PLAIN over 9094, and over 9095 with KAFKA_SASL_SSL_BOOTSTRAP_SERVERS

Issues and pull requests are welcome.

License

Released under the MIT license.