Consumer-group APIs
Per-API reference — see the API support matrix for the generated version table.
Every API on this page except FindCoordinator is answered
only by the group's coordinator broker; any other broker returns
NOT_COORDINATOR (16) and the client re-resolves. Coordinator-of-G is
two-tier: an explicit assignment.json.consumerGroups[] entry wins, otherwise
a deterministic FNV-1a 32-bit hash of the group ID mod the full broker set
(not the alive count), with a deterministic fallback into the alive subset
when the preferred slot is down (crates/kaas-broker/src/group_hash.rs).
Apache answers the same question with __consumer_offsets partition
leadership; kaas has no __consumer_offsets topic, so it hashes directly into
the broker set — see Consumer-group
coordination for the full routing
story.
Where the state lives: membership, generation, and rebalance state are
in-memory on the coordinator (crates/kaas-coordinator/src/group.rs) —
lost on coordinator failover, after which consumers rejoin and rebalance,
which is exactly what an Apache coordinator move looks like to clients.
Committed offsets are durable per-group JSON files at
/data/__cluster/__consumer_offsets/<groupID>.json on the shared volume
(crates/kaas-coordinator/src/offset_store.rs); the next coordinator reads
the same file. The offsets file is lazily loaded into memory when a group
first joins on a broker (Manager::get_or_create) — a caveat for
manual-assignment consumers is noted under OffsetFetch.
During the boot window before the coordinator Manager is installed,
handlers return NOT_COORDINATOR / COORDINATOR_NOT_AVAILABLE so clients
retry — no request is silently dropped.
OffsetCommit
Persists a group's consumed positions. Key 8.
Versions: v2–v8 (flexible from v8)
Handling: the handler flattens the nested (topic, partition) request
into a topic/partition → offset map plus a parallel metadata map and hands
both to Manager::offset_commit, which merges them into the group's in-memory
cache and atomically rewrites __consumer_offsets/<groupID>.json
(tmp + fsync + rename). The resulting group-level error code is stamped
uniformly across every partition in the response. Per-partition
committed_metadata strings round-trip (gh #21); empty strings clear the
entry and come back as the wire null sentinel.
Deviations from Apache 3.7:
- The advertised floor is v2, not v0 — the v0/v1 shapes were never decoded
correctly and are not offered. Additionally, v2–v4's
retention_time_msfield is not decoded, so v2–v4 requests that carry it mis-parse; this is an acknowledged divergence tracked as follow-up incrates/kaas-codec/src/api/offset_commit.rs. In practice every modern client negotiates v8 via ApiVersions and never hits it. generation_id,member_id, andgroup.instance.idare decoded but not validated against the group state machine — noILLEGAL_GENERATION,UNKNOWN_MEMBER_ID, orFENCED_INSTANCE_IDfencing on the commit path. A zombie commit from an evicted member is accepted.- Commits are best-effort durable: a disk-write failure is logged but still
reported as success (
Manager::offset_commit). committed_leader_epoch(v6+) is decoded but not persisted; fetches always return-1for it.
Source: crates/kaas-broker/src/handlers/offset_commit.rs,
crates/kaas-codec/src/api/offset_commit.rs,
crates/kaas-coordinator/src/offset_store.rs.
Verified by: crates/kaas-broker/tests/group_dispatch.rs (full lifecycle
round trip), commit_then_fetch_roundtrips / commit_with_metadata_persists
in offset_store.rs, scripts/kafka-consumer-groups.sh (--reset-offsets --execute drives OffsetCommit), scripts/kafka-console-consumer.sh.
OffsetFetch
Reads a group's committed positions back. Key 9.
Versions: v1–v8 (flexible from v6)
Handling: v1–v7 carry a single group; v8+ batches multiple groups in one
request (groups[]), and the handler resolves each group independently —
per-group NOT_COORDINATOR for groups owned elsewhere. Lookups read the
coordinator's in-memory cache; partitions without a committed offset return
the -1 sentinel. Offsets staged by transactional producers via
TxnOffsetCommit live in a separate pending
layer and are invisible here until the transaction commits — an aborted
transaction's offsets are never observable.
Deviations from Apache 3.7:
- The "fetch everything" sentinel (null topics list) returns an empty
result instead of enumerating all committed offsets. Symptom:
kafka-consumer-groups --describeon a group with no active members shows nothing even though offsets are on disk (noted inscripts/kafka-consumer-groups.sh, which dropped its--shift-byscenario because of it). Follow-up tracked in the handler. require_stable(v7+, KIP-447) is accepted and ignored. kaas never returnsUNSTABLE_OFFSET_COMMIT; it returns the last durably-committed offset. Reads can never see dirty in-flight offsets (the pending layer guarantees that), but a caller asking to wait out an in-flight commit doesn't get the retriable error Apache would send.- Committed offsets are only loaded from disk when the group first joins
on this broker. A standalone manual-assignment consumer (
assign()+commitSync, no JoinGroup) that fetches after a coordinator change hits a cold cache and gets-1until its next commit. committed_leader_epochis always-1.- Advertised floor is v1, not v0 (v0 was the ZooKeeper-era offsets path).
Source: crates/kaas-broker/src/handlers/offset_fetch.rs,
crates/kaas-codec/src/api/offset_fetch.rs,
crates/kaas-coordinator/src/offset_store.rs.
Verified by: crates/kaas-broker/tests/group_dispatch.rs,
pending_invisible_to_fetch_until_commit_pending /
discard_pending_drops_unmaterialised_offsets in offset_store.rs,
bins/kaas/tests/eos_v2.rs (transactional offsets become visible only after
EndTxn), scripts/kafka-consumer-groups.sh.
FindCoordinator
Resolves which broker coordinates a group (or a transactional ID). Key 10. The one group API any broker answers.
Versions: v0–v4 (flexible from v3)
Handling: key_type 0 routes through the group-assignment source
(explicit assignment.json entry, else the FNV-1a hash — see the preamble),
key_type 1 through the transaction-assignment source (gh #91, same hash
machinery); any other value gets INVALID_REQUEST (42). The resolved broker
ID is mapped to (node_id, host, port) via the EndpointSlice-backed broker
registry (bins/kaas/src/cluster.rs, crates/kaas-k8s/src/endpoints.rs); in
dev mode the lookup resolves self only. No manager installed, no txn source
yet, or no alive broker for the slot → COORDINATOR_NOT_AVAILABLE (15) and
the client retries. v4+ batches multiple coordinator_keys[] into one
request; v0–v3 use the legacy single-key shape (at v3 the legacy shape merely
gains flexible encoding — the array form is strictly v4+).
Deviations from Apache 3.7:
- The response is not listener-aware: the advertised port is the broker's primary client port (the first configured listener / the headless Service's Kafka port), regardless of which listener the request arrived on. Metadata got per-listener advertisement in gh #125; FindCoordinator has not, so on a multi-listener cluster a client that bootstrapped on a secondary listener is handed the first listener's port here.
Source: crates/kaas-broker/src/handlers/find_coordinator.rs,
crates/kaas-codec/src/api/find_coordinator.rs,
crates/kaas-coordinator/src/manager.rs, crates/kaas-broker/src/group_hash.rs.
Verified by: find_coordinator_resolves_self /
find_coordinator_txn_with_no_source_is_unavailable /
find_coordinator_unknown_key_type_is_invalid_request in manager.rs,
hash-routing tests in group_hash.rs,
crates/kaas-broker/tests/group_dispatch.rs,
scripts/kafka-consumer-groups.sh.
JoinGroup
Enters a member into the group and drives the rebalance state machine
(Empty → PreparingRebalance → CompletingRebalance → Stable). Key 11.
Versions: v2–v9 (flexible from v6)
Handling: the handler translates the codec request into the coordinator's
JoinRequest and parks the connection on a oneshot until the rebalance round
completes. Joining an Empty group starts the initial rebalance with Apache's
3 s group.initial.rebalance.delay.ms (extended per new arrival, capped at
the max member rebalance_timeout_ms — gh #111); joining a Stable group
bounces it back to PreparingRebalance and cancels any in-flight sync round.
The leader is the first joiner of the round; protocol selection is
leader-first-mutual (first protocol the leader declares that every member also
lists). Dynamic members that fail to rejoin within the rebalance timeout are
evicted at round completion (gh #113); static members
(KIP-345 group.instance.id) survive a missed rejoin.
Deviations from Apache 3.7:
- KIP-394's v4+ two-step handshake is not
implemented: kaas never returns
MEMBER_ID_REQUIRED(79). An emptymember_idis assigned inline and the join proceeds in one round trip — the legacy pre-v4 path, explicitly marked as a follow-up incrates/kaas-coordinator/src/group.rs. Clients work fine (they simply skip a retry), but Apache's ghost-member protection is absent. - KIP-345 is partial:
group.instance.idis plumbed through join/sync/heartbeat/leave and static members survive the non-rejoin eviction, but there is noFENCED_INSTANCE_IDfencing — two members presenting the same instance ID are treated as two members — and a static rejoin triggers a full rebalance rather than Apache's return-cached-assignment fast path (skip_assignmentis alwaysfalse). - KIP-800
reasonstrings (v8+) are decoded and discarded, not logged. - The advertised floor is v2, not v0 — v0/v1 lack
rebalance_timeout_msand were never decoded correctly. - Member IDs are generated as
<principal>-<counter-hex>from the connection's authenticated principal (empty for anonymous listeners), not Apache's<client.id>-<UUID>— cosmetic, but visible in--describe.
Source: crates/kaas-broker/src/handlers/join_group.rs,
crates/kaas-codec/src/api/join_group.rs,
crates/kaas-coordinator/src/group.rs.
Verified by: single_member_rebalance_completes_via_initial_delay /
shutdown_fires_pending_joiners_with_unknown_member in group.rs,
crates/kaas-broker/tests/group_dispatch.rs,
scripts/kafka-consumer-groups.sh, scripts/kafka-verifiable-consumer.sh.
Heartbeat
Keeps a member's session alive and signals rebalances in progress. Key 12.
Versions: v0–v4 (flexible from v4)
Handling: each heartbeat re-arms the member's session-timeout task (a
tokio timer against the member's session_timeout_ms); expiry evicts the
member and bounces a Stable group into PreparingRebalance. Checks run in
Apache's order: unknown member (or Empty/Dead group) →
UNKNOWN_MEMBER_ID (25); generation mismatch → ILLEGAL_GENERATION (22);
group mid-rebalance → the timer is still reset, then
REBALANCE_IN_PROGRESS (27) tells the member to rejoin; otherwise success.
Deviations from Apache 3.7:
group.instance.id(v3+, KIP-345) is decoded but not used for fencing — noFENCED_INSTANCE_IDhere either.
Source: crates/kaas-broker/src/handlers/heartbeat.rs,
crates/kaas-codec/src/api/heartbeat.rs,
crates/kaas-coordinator/src/group.rs.
Verified by: heartbeat_unknown_member_for_empty_group in group.rs,
not_coordinator_on_offset_commit_when_source_says_no in manager.rs (also
covers heartbeat's NOT_COORDINATOR path),
crates/kaas-broker/tests/group_dispatch.rs, any live consumer in
scripts/kafka-console-consumer.sh / scripts/kafka-verifiable-consumer.sh.
LeaveGroup
Removes one or more members from a group. Key 13.
Versions: v0–v5 (flexible from v4)
Handling: the handler collects member IDs from both the legacy single
member_id field (v0–v2) and the v3+ members[] batch
(KIP-345 admin removal shape), then removes them in one
pass: each known member is dropped (its session timer aborted, any parked
JoinGroup waiter woken), unknown members get per-member
UNKNOWN_MEMBER_ID (25). The last member leaving returns the group to
Empty; a leave from Stable triggers PreparingRebalance.
Deviations from Apache 3.7:
- On the legacy v0–v2 single-member shape the per-member result is
dropped: the top-level
error_codeis 0 whenever this broker coordinates the group, so an unknown-member leave at v0–v2 reports success. The v3+ batch shape carries per-member codes correctly. - Leaving a group the broker coordinates but holds no state for returns
top-level success with an empty member list rather than per-member
UNKNOWN_MEMBER_ID. - KIP-800 per-member
reasonstrings (v5) are decoded and discarded.
Source: crates/kaas-broker/src/handlers/leave_group.rs,
crates/kaas-codec/src/api/leave_group.rs,
crates/kaas-coordinator/src/group.rs.
Verified by: leave_drops_state_back_to_empty in group.rs,
v5_java_client_fixture_with_reason in the codec module (byte-level fixture
captured from a real Java client),
crates/kaas-broker/tests/group_dispatch.rs,
scripts/kafka-consumer-groups.sh.
SyncGroup
Distributes the leader-computed assignment to every member of a rebalance round. Key 14.
Versions: v0–v5 (flexible from v4)
Handling: the leader's SyncGroup stores per-member assignments on the
current sync round, flips the group to Stable, and wakes every parked
follower; followers park until the leader delivers (or a fresh JoinGroup
cancels the round, in which case they wake with
REBALANCE_IN_PROGRESS (27) instead of a bogus empty assignment — gh #111).
Checks: unknown member → UNKNOWN_MEMBER_ID (25); generation mismatch →
ILLEGAL_GENERATION (22). A follower's sync is valid in both
CompletingRebalance and Stable (the leader may finish first — gh #111).
Members the leader omitted receive a zero-byte assignment.
Deviations from Apache 3.7:
- v5's
protocol_type/protocol_namerequest fields are accepted but not cross-checked against the group — kaas never returnsINCONSISTENT_GROUP_PROTOCOLfrom SyncGroup. - Omitted members get raw empty bytes; encoding a valid empty
ConsumerProtocolAssignmentstruct instead is a tracked follow-up (gh #111 layer 4). - No
FENCED_INSTANCE_IDfor static members (KIP-345), same as the rest of the group surface.
Source: crates/kaas-broker/src/handlers/sync_group.rs,
crates/kaas-codec/src/api/sync_group.rs,
crates/kaas-coordinator/src/group.rs.
Verified by: sync_returns_leader_supplied_assignment in group.rs,
join_then_sync_then_offset_commit_then_fetch_roundtrip in manager.rs,
crates/kaas-broker/tests/group_dispatch.rs,
scripts/kafka-verifiable-consumer.sh.
DescribeGroups
Snapshots named groups: state, protocol, members, assignments. Key 15.
Versions: v0–v5 (flexible from v5)
Handling: Manager::describe_groups answers per group and filters by
ownership — a group coordinated elsewhere gets a per-group
NOT_COORDINATOR (16) entry, which matters because the Java AdminClient
unions results across brokers (gh #89: without the filter, one broker's stale
in-memory entry reappeared cluster-wide). Owned groups return their live
snapshot; group-state strings match Apache's exactly (Empty,
PreparingRebalance, CompletingRebalance, Stable, Dead).
Deviations from Apache 3.7:
- A group this broker coordinates but has no state for is described as
Emptywith no members; Apache describes an unknown group asDead. - Per-member
client_hostandmember_metadataare returned empty (the coordinator tracks the host but the snapshot doesn't carry it yet);member_assignmentis populated. authorized_operations(v3+) is always 0 — kaas neither computes the operations bitmap nor returns Apache'sINT32_MIN"not requested" sentinel.
Source: crates/kaas-broker/src/handlers/describe_groups.rs,
crates/kaas-codec/src/api/describe_groups.rs,
crates/kaas-coordinator/src/manager.rs.
Verified by: scripts/kafka-consumer-groups.sh (--describe scenario).
There is no dedicated unit test for the DescribeGroups handler; the
underlying Group::describe snapshot is exercised by the lifecycle tests in
crates/kaas-coordinator/src/group.rs.
ListGroups
Enumerates the groups a broker coordinates. Key 16.
Versions: v0–v4 (flexible from v3)
Handling: snapshots every in-memory group and filters by coordinator
ownership — the same gh #89 filter as DescribeGroups, so the AdminClient's
cross-broker union never shows a group twice or shows stale orphans. The v4+
states_filter is applied broker-side by exact state-string match. With no
coordinator manager installed (boot window) the response is an empty list
with error_code = 0, mirroring an idle Apache broker.
Deviations from Apache 3.7:
- Only in-memory groups are listed. A group that exists solely as a
committed-offsets file — no live members since the coordinator restarted or
the group moved — doesn't appear in
--listuntil a member joins again. Apache materialises such groups from__consumer_offsetsand lists them asEmpty.
Source: crates/kaas-broker/src/handlers/list_groups.rs,
crates/kaas-codec/src/api/list_groups.rs,
crates/kaas-coordinator/src/manager.rs,
crates/kaas-broker/src/group_takeover.rs (the orphan sweep that keeps the
list honest).
Verified by: crates/kaas-broker/tests/group_dispatch.rs,
scripts/kafka-consumer-groups.sh (--list before and after --delete —
the group must appear and then actually vanish).
DeleteGroups
Drops a group's coordinator state and committed offsets. Key 42, gh #89.
Versions: v0–v2 (flexible from v2)
Handling: per group, in order: not this broker's group →
NOT_COORDINATOR (16); no in-memory state and no offsets file →
GROUP_ID_NOT_FOUND (69); live state that isn't Empty/Dead →
NON_EMPTY_GROUP (67); otherwise the in-memory group is shut down and the
__consumer_offsets/<groupID>.json file deleted. A disk-delete failure after
the in-memory wipe is swallowed — the stale file is harmless and the
operator's startup sweep re-cleans it. A group whose only trace is the
offsets file (all members long gone) counts as existing and is deletable,
matching Apache.
Deviations from Apache 3.7: None known.
Source: crates/kaas-broker/src/handlers/delete_groups.rs,
crates/kaas-codec/src/api/delete_groups.rs,
crates/kaas-coordinator/src/manager.rs.
Verified by: delete_group_non_empty_when_state_is_stable /
delete_group_unknown_returns_group_id_not_found in manager.rs,
scripts/kafka-consumer-groups.sh scenario 5 (--delete must succeed and
the group must vanish from a subsequent --list).
OffsetDelete
Drops specific (topic, partition) committed offsets without deleting the
group. Key 47, gh #100. Drives
kafka-consumer-groups.sh --delete-offsets and
AdminClient.deleteConsumerGroupOffsets().
Versions: v0 only (not flexible)
Handling: the handler builds the canonical topic/partition key list and
calls Manager::delete_offsets, which removes the entries from the group's
cache and rewrites the offsets file. Per-partition results: removed → 0;
no committed entry under that key → UNKNOWN_TOPIC_OR_PARTITION (3).
Per-partition errors are suppressed (0) whenever the group-level error is
non-zero. Wire-shape quirk faithfully reproduced: the group-level
error_code precedes throttle_time_ms — the opposite field order from
DeleteGroups.
Deviations from Apache 3.7:
- The only group-level errors kaas produces are
NOT_COORDINATOR(16) and success. An unknown group returns group-level 0 with every partition markedUNKNOWN_TOPIC_OR_PARTITION, where Apache returnsGROUP_ID_NOT_FOUND(69). - No subscription guard: Apache refuses to delete offsets for topics a
Stableconsumer-protocol group is actively subscribed to (GROUP_SUBSCRIBED_TO_TOPIC, 86). kaas deletes them regardless of group state.
Source: crates/kaas-broker/src/handlers/offset_delete.rs,
crates/kaas-codec/src/api/offset_delete.rs,
crates/kaas-coordinator/src/offset_store.rs.
Verified by: delete_partitions_removes_only_requested_keys in
offset_store.rs; the shell-tool path rides
scripts/kafka-consumer-groups.sh.