Skip to content
chiltepin

Generated from: “Show how the order stream is partitioned and what happens to the consumer group when we add or remove instances.

Order stream partitioning and consumer group rebalance

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTDESIGN

Order stream partitioning and consumer group rebalance

How the orders topic is split, how the fulfilment group shares it, and what a join or leave does to the assignment.

SECTION 01 · Note

Assumptions

Note
The stream is the Kafka topic orders with 6 partitions. One consumer group, fulfilment-workers, reads it with 3 instances at steady state. The group uses the cooperative sticky assignor, so a rebalance revokes only the partitions that move. Offsets are committed after each processed batch.

Who writes where, who reads what

The Orders API hashes order_id to choose a partition. All events for one order land on one partition, so one instance sees them in order. Each partition has exactly one reader inside the group; a fourth partition per instance is never split.

SECTION 02 · Architecture
EVENT
Block diagram: 10 nodes, 12 connectionsorders topic (6 partitions)fulfilment-workers consumer groupOrders APIorder.placedPRODUCERpartition 0QUEUEpartition 1QUEUEpartition 2QUEUEpartition 3QUEUEpartition 4QUEUEpartition 5QUEUEfulfilment-1CONSUMERfulfilment-2CONSUMERfulfilment-3CONSUMERkey = hash(order_id) mod 6
LegendPRODUCERproducerQUEUEqueueCONSUMERconsumercallsentry point

The event on the stream

The partition key is the contract. A consumer can assume every event for one order_id arrives on the same partition in publish order; it cannot assume anything about order across two orders.

SECTION 03 · Event contract
EVENT·v2order.placedchannelorders

Checkout accepted an order and it is ready for fulfilment.

Producers (1)
orders-api
Consumers (1)
fulfilment-workers
deliveryat-least-onceorderingper-keykeyorder_idretention7d
Payload
FieldTypeDescription
#order_iduuidPartition key; all events for this order share a partition
customer_iduuidThe buyer
warehouse_idstringWhere the items ship from
linesarrayItems, quantities, and unit prices
placed_attimestampWhen checkout accepted the order
# partition key
Headers
FieldTypeDescription
trace_idstringW3C trace id
Example
{ "order_id": "ord_8f21", "customer_id": "cus_402", "warehouse_id": "ams-1", "placed_at": "2026-09-13T09:41:00Z" }
Errors
ErrorWhen
DuplicateDeliverythe same order_id and offset were already processed after a rebalance replay

Consumers must be idempotent on order_id. A rebalance can redeliver the last uncommitted batch.

Assignment at each group size

Six partitions cap the group at six useful instances. A seventh instance joins the group, gets no partition, and idles until another instance leaves.

SECTION 04 · Comparison
Partition2 instances3 instances4 instances6 instances7 instances
partition 0fulfilment-1fulfilment-1fulfilment-1fulfilment-1fulfilment-1
partition 1fulfilment-1fulfilment-1fulfilment-4fulfilment-2fulfilment-2
partition 2fulfilment-1fulfilment-2fulfilment-2fulfilment-3fulfilment-3
partition 3fulfilment-2fulfilment-2fulfilment-4fulfilment-4fulfilment-4
partition 4fulfilment-2fulfilment-3fulfilment-3fulfilment-5fulfilment-5
partition 5fulfilment-2fulfilment-3fulfilment-3fulfilment-6fulfilment-6
idle instances00001 (fulfilment-7)

Warn cells are the partitions that move when fulfilment-4 joins a 3-instance group. The sticky assignor keeps every other partition where it was.

Adding an instance

A join triggers a cooperative rebalance in two rounds. Only the partitions that move are revoked, so fulfilment-3 keeps consuming through the whole rebalance. The owners of the moving partitions commit their offsets before they give the partitions up. The new instance resumes from the committed offset and replays at most one batch.

SECTION 05 · Sequence
SEQUENCE
Sequence diagram: 19 messages between 4 actorsfulfilment-4new instanceGroup coordinatorbrokerfulfilment-1group leaderfulfilment-21JoinGroup(fulfilment-workers)2rebalance required, rejoin3rebalance required, rejoin4JoinGroup (owns 0, 1)5JoinGroup (owns 2, 3)6members: 1, 2, 3, 4 and their owned partitions7SyncGroup: 1 keeps 0; 2 keeps 2; 3 keeps 4, 5; 4 gets nothing yet8assignment: none9assignment: 0 (revoke 1)10assignment: 2 (revoke 3)11commit offset for partition 112commit offset for partition 313JoinGroup (owns 0)14JoinGroup (owns 2)15JoinGroup (owns none)16SyncGroup: 4 gets 1, 317assignment: 1, 318fetch committed offsets for 1, 319offsets: 1 at 48210, 3 at 51977
Legendcallresponsethe answer the caller getsactive
Rounds: 2 (cooperative)Partitions paused: 1 and 3 onlyPause length: under 5 s in normal operation

Removing an instance

The group notices a leave in one of two ways. A clean shutdown sends LeaveGroup and the rebalance starts at once. A crash sends nothing. The coordinator waits for session.timeout.ms (45 s) without a heartbeat and then evicts the member. Its partitions sit unread for up to 45 s. In both cases the survivors take over the orphaned partitions from the last committed offset.

SECTION 06 · State machine
STATE
State machine: 6 states, 10 transitionsEmptyPreparingRebalanceCompletingRebalanceStable12345678910
Legendstartstatewaitingtransitionerror exiterror transition
FromEventGuardTo
1s0group createdEmpty
2Emptyfirst instance joinsPreparingRebalance
3PreparingRebalanceall members rejoined or rebalance timeoutCompletingRebalance
4CompletingRebalanceleader sends SyncGroup assignmentStable
5Stableinstance joinsPreparingRebalance
6StableLeaveGroup (clean shutdown)PreparingRebalance
7Stableheartbeat missedsession.timeout.ms elapsedPreparingRebalance
8Stablepartition count changesPreparingRebalance
9PreparingRebalancelast member leavesEmpty
10Emptyoffsets expireoffsets.retention.minutes elapsedDead

A Stable group handles a join and a leave the same way. It moves to PreparingRebalance, the leader recomputes the assignment, and it returns to Stable. Scale the group in steps of one instance and wait for Stable between steps; two overlapping changes double the number of partitions that move.

View the Markdown
```meta
title: Order stream partitioning and consumer group rebalance
subtitle: How the orders topic is split, how the fulfilment group shares it, and what a join or leave does to the assignment.
tag: DESIGN
```

```callout
tone: note
title: Assumptions
body: "The stream is the Kafka topic orders with 6 partitions. One consumer group, fulfilment-workers, reads it with 3 instances at steady state. The group uses the cooperative sticky assignor, so a rebalance revokes only the partitions that move. Offsets are committed after each processed batch."
```

## Who writes where, who reads what

The Orders API hashes `order_id` to choose a partition. All events for one order land on one partition, so one instance sees them in order. Each partition has exactly one reader inside the group; a fourth partition per instance is never split.

```block
preset: event
id: topology
groups:
  - { id: t, col: 2, row: 1, cols: 1, rows: 6, label: orders topic (6 partitions) }
  - { id: cg, col: 3, row: 1, cols: 1, rows: 6, label: fulfilment-workers consumer group }
nodes:
  - { id: prod, col: 1, row: 3, h: 2, kind: producer, name: Orders API, tech: order.placed }
  - { id: p0, col: 2, row: 1, kind: queue, name: partition 0 }
  - { id: p1, col: 2, row: 2, kind: queue, name: partition 1 }
  - { id: p2, col: 2, row: 3, kind: queue, name: partition 2 }
  - { id: p3, col: 2, row: 4, kind: queue, name: partition 3 }
  - { id: p4, col: 2, row: 5, kind: queue, name: partition 4 }
  - { id: p5, col: 2, row: 6, kind: queue, name: partition 5 }
  - { id: c1, col: 3, row: 1, h: 2, kind: consumer, name: fulfilment-1 }
  - { id: c2, col: 3, row: 3, h: 2, kind: consumer, name: fulfilment-2 }
  - { id: c3, col: 3, row: 5, h: 2, kind: consumer, name: fulfilment-3 }
edges:
  - "prod -> p0: key = hash(order_id) mod 6"
  - prod -> p1
  - prod -> p2
  - prod -> p3
  - prod -> p4
  - prod -> p5
  - p0 -> c1
  - p1 -> c1
  - p2 -> c2
  - p3 -> c2
  - p4 -> c3
  - p5 -> c3
```

## The event on the stream

The partition key is the contract. A consumer can assume every event for one `order_id` arrives on the same partition in publish order; it cannot assume anything about order across two orders.

```eventcontract
id: order-placed
name: order.placed
version: v2
channel: orders
summary: Checkout accepted an order and it is ready for fulfilment.
producers: [orders-api]
consumers: [fulfilment-workers]
delivery: at-least-once
ordering: per-key
key: order_id
retention: 7d
schema:
  - order_id uuid required — Partition key; all events for this order share a partition
  - customer_id uuid required — The buyer
  - warehouse_id string required — Where the items ship from
  - lines array required — Items, quantities, and unit prices
  - placed_at timestamp required — When checkout accepted the order
headers:
  - trace_id string required — W3C trace id
example: |
  { "order_id": "ord_8f21", "customer_id": "cus_402", "warehouse_id": "ams-1", "placed_at": "2026-09-13T09:41:00Z" }
errors:
  - DuplicateDelivery — the same order_id and offset were already processed after a rebalance replay
note: Consumers must be idempotent on order_id. A rebalance can redeliver the last uncommitted batch.
```

## Assignment at each group size

Six partitions cap the group at six useful instances. A seventh instance joins the group, gets no partition, and idles until another instance leaves.

```table
id: assignment
columns: [Partition, 2 instances, 3 instances, 4 instances, 6 instances, 7 instances]
rows:
  - [partition 0, fulfilment-1, fulfilment-1, fulfilment-1, fulfilment-1, fulfilment-1]
  - [partition 1, fulfilment-1, fulfilment-1, { v: fulfilment-4, tone: warn }, fulfilment-2, fulfilment-2]
  - [partition 2, fulfilment-1, fulfilment-2, fulfilment-2, fulfilment-3, fulfilment-3]
  - [partition 3, fulfilment-2, fulfilment-2, { v: fulfilment-4, tone: warn }, fulfilment-4, fulfilment-4]
  - [partition 4, fulfilment-2, fulfilment-3, fulfilment-3, fulfilment-5, fulfilment-5]
  - [partition 5, fulfilment-2, fulfilment-3, fulfilment-3, fulfilment-6, fulfilment-6]
  - [idle instances, "0", "0", "0", "0", { v: "1 (fulfilment-7)", tone: neg }]
note: Warn cells are the partitions that move when fulfilment-4 joins a 3-instance group. The sticky assignor keeps every other partition where it was.
```

## Adding an instance

A join triggers a cooperative rebalance in two rounds. Only the partitions that move are revoked, so fulfilment-3 keeps consuming through the whole rebalance. The owners of the moving partitions commit their offsets before they give the partitions up. The new instance resumes from the committed offset and replays at most one batch.

```sequence
id: scale-out
actors:
  - { id: c4, name: fulfilment-4, sub: new instance }
  - { id: coord, name: Group coordinator, sub: broker }
  - { id: c1, name: fulfilment-1, sub: group leader }
  - { id: c2, name: fulfilment-2 }
messages:
  - c4 -> +coord: JoinGroup(fulfilment-workers)
  - coord -> c1: rebalance required, rejoin
  - coord -> c2: rebalance required, rejoin
  - c1 -> coord: JoinGroup (owns 0, 1)
  - c2 -> coord: JoinGroup (owns 2, 3)
  - coord --> c1: "members: 1, 2, 3, 4 and their owned partitions"
  - c1 -> coord: "SyncGroup: 1 keeps 0; 2 keeps 2; 3 keeps 4, 5; 4 gets nothing yet"
  - coord --> -c4: "assignment: none"
  - coord --> c1: "assignment: 0 (revoke 1)"
  - coord --> c2: "assignment: 2 (revoke 3)"
  - c1 -> coord: commit offset for partition 1
  - c2 -> coord: commit offset for partition 3
  - c1 -> +coord: JoinGroup (owns 0)
  - c2 -> coord: JoinGroup (owns 2)
  - c4 -> coord: JoinGroup (owns none)
  - c1 -> coord: "SyncGroup: 4 gets 1, 3"
  - coord --> -c4: "assignment: 1, 3"
  - c4 -> coord: fetch committed offsets for 1, 3
  - coord --> c4: "offsets: 1 at 48210, 3 at 51977"
foot:
  - { label: Rounds, value: "2 (cooperative)" }
  - { label: Partitions paused, value: "1 and 3 only" }
  - { label: Pause length, value: "under 5 s in normal operation" }
```

## Removing an instance

The group notices a leave in one of two ways. A clean shutdown sends `LeaveGroup` and the rebalance starts at once. A crash sends nothing. The coordinator waits for `session.timeout.ms` (45 s) without a heartbeat and then evicts the member. Its partitions sit unread for up to 45 s. In both cases the survivors take over the orphaned partitions from the last committed offset.

```state
id: group-lifecycle
dir: LR
states:
  - { id: s0, col: 1, row: 1, kind: start }
  - { id: empty, col: 2, row: 1, kind: wait, name: Empty }
  - { id: preparing, col: 3, row: 1, kind: active, name: PreparingRebalance }
  - { id: completing, col: 4, row: 1, kind: active, name: CompletingRebalance }
  - { id: stable, col: 5, row: 1, kind: active, name: Stable }
  - { id: dead, col: 6, row: 1, kind: terminal, name: Dead }
transitions:
  - s0 -> empty: group created
  - empty -> preparing: first instance joins
  - preparing -> completing: all members rejoined or rebalance timeout
  - completing -> stable: leader sends SyncGroup assignment
  - stable -> preparing: instance joins
  - stable -> preparing: LeaveGroup (clean shutdown)
  - { from: stable, to: preparing, event: heartbeat missed, guard: "session.timeout.ms elapsed" }
  - stable -> preparing: partition count changes
  - preparing -> empty: last member leaves
  - { from: empty, to: dead, event: offsets expire, guard: "offsets.retention.minutes elapsed" }
```

A `Stable` group handles a join and a leave the same way. It moves to `PreparingRebalance`, the leader recomputes the assignment, and it returns to `Stable`. Scale the group in steps of one instance and wait for `Stable` between steps; two overlapping changes double the number of partitions that move.