Add share-groups module: Kafka 4.3.1 share groups with Spring for Apache Kafka 4.1.1

Six consumers on two partitions under a consumer group, a share group with the default
share.acquire.mode and a share group with record_limit; the three acknowledgement types and a
record that is released until Kafka stops redelivering it; a new share group starting at latest;
the ordering a share group gives up; and Spring Kafka's share containers in implicit and manual
acknowledgement modes. Tests run against a real broker (scripts/broker-up.sh) and write output/.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Lm655hdgDo5xsSLNomgdsy
This commit is contained in:
Claude
2026-10-13 02:43:36 +00:00
parent 4a5d3318bf
commit 5db56e70a0
23 changed files with 889 additions and 2 deletions
+3 -2
View File
@@ -3,8 +3,8 @@
Companion code for the messaging series on [ankurm.com](https://ankurm.com). Each directory is a Companion code for the messaging series on [ankurm.com](https://ankurm.com). Each directory is a
self-contained Maven project for one article, with its own `pom.xml` and its own captured self-contained Maven project for one article, with its own `pom.xml` and its own captured
output, regenerated from the test suite rather than typed by hand. Most modules keep that output, regenerated from the test suite rather than typed by hand. Most modules keep that
output under `docs/output/` alongside numbered documentation chapters; `saga/` keeps it directly output under `docs/output/` alongside numbered documentation chapters; `saga/` and `share-groups/` keep it
under `output/` instead, with the equivalent depth in the article's own accordion sections. directly under `output/` instead, with the equivalent depth in the article's own accordion sections.
| Module | Article | What it demonstrates | | Module | Article | What it demonstrates |
|---|---|---| |---|---|---|
@@ -15,6 +15,7 @@ under `output/` instead, with the equivalent depth in the article's own accordio
| [`protocol-comparison/`](protocol-comparison/README.md) | [RSocket vs gRPC vs WebSocket on Spring Boot 4.1: When Each One Wins](https://ankurm.com/rsocket-vs-grpc-vs-websocket-spring-boot-4-1/) | One application serving the same two operations over all three, benchmarked in one JVM, ending in the only measurement that transfers off the box: what each server produces for a consumer that asked for a hundred | | [`protocol-comparison/`](protocol-comparison/README.md) | [RSocket vs gRPC vs WebSocket on Spring Boot 4.1: When Each One Wins](https://ankurm.com/rsocket-vs-grpc-vs-websocket-spring-boot-4-1/) | One application serving the same two operations over all three, benchmarked in one JVM, ending in the only measurement that transfers off the box: what each server produces for a consumer that asked for a hundred |
| [`broker-comparison/`](broker-comparison/README.md) | [Kafka vs RabbitMQ vs Pulsar for Java Teams: A Decision Framework with Benchmarks](https://ankurm.com/kafka-vs-rabbitmq-vs-pulsar-java-decision-framework/) | The three brokers measured side by side on ordering, replay, consumer scaling and operational footprint, ending in a decision table where every row has a transcript behind it | | [`broker-comparison/`](broker-comparison/README.md) | [Kafka vs RabbitMQ vs Pulsar for Java Teams: A Decision Framework with Benchmarks](https://ankurm.com/kafka-vs-rabbitmq-vs-pulsar-java-decision-framework/) | The three brokers measured side by side on ordering, replay, consumer scaling and operational footprint, ending in a decision table where every row has a transcript behind it |
| [`saga/`](saga/README.md) | Saga Pattern in Spring Boot: Orchestration vs Choreography with Kafka | The same order/payment/inventory saga built twice against a real broker &mdash; choreography and orchestration &mdash; driven through the same inventory failure so the compensating transaction can be compared side by side, plus a real duplicate-delivery test for the idempotent-consumer problem | | [`saga/`](saga/README.md) | Saga Pattern in Spring Boot: Orchestration vs Choreography with Kafka | The same order/payment/inventory saga built twice against a real broker &mdash; choreography and orchestration &mdash; driven through the same inventory failure so the compensating transaction can be compared side by side, plus a real duplicate-delivery test for the idempotent-consumer problem |
| [`share-groups/`](share-groups/README.md) | [Queues for Kafka: Share Groups with Spring for Apache Kafka 4](https://ankurm.com/kafka-share-groups-spring-kafka-4-queues-for-kafka/) | Share groups against a real Kafka 4.3.1 broker: six consumers on two partitions, the acquire mode that decides whether they really share, the three acknowledgement types, a group that starts at the end of the topic, and the ordering you give up |
The brokers make an instructive set. Kafka's consumer holds an offset and the broker remembers The brokers make an instructive set. Kafka's consumer holds an offset and the broker remembers
nothing about individual records; RabbitMQ's broker owns the message until it is acknowledged and nothing about individual records; RabbitMQ's broker owns the message until it is acknowledged and
+2
View File
@@ -0,0 +1,2 @@
target/
.kafka/
+59
View File
@@ -0,0 +1,59 @@
# share-groups
Companion code for *Queues for Kafka: Share Groups with Spring for Apache Kafka 4* on
[ankurm.com](https://ankurm.com/kafka-share-groups-spring-kafka-4-queues-for-kafka/).
What a Kafka **share group** (KIP-932) does differently from a consumer group, measured against a
real broker: how many consumers can share one partition, what the three acknowledgement types do to a
record, why a new share group sees nothing, and what ordering you give up. The same ideas are then run
through Spring Kafka's share containers.
## Versions
| Artifact | Version |
|---|---|
| Spring Boot | 4.1.1 (GA; `4.2.0-M2` is a milestone) |
| Spring Kafka | 4.1.1, managed by the Boot BOM (share consumer support is already in this GA release) |
| kafka-clients | 4.2.1, managed by the Boot BOM |
| Kafka broker | 4.3.1, started by `scripts/broker-up.sh` (`share.version` is finalized at level 1 by default; no feature flag was set) |
| JDK | 25 (LTS) |
## Quickstart
```
scripts/broker-up.sh # downloads Kafka 4.3.1 (checksum-verified), starts KRaft on localhost:9092
scripts/run-all.sh # runs every test and rewrites output/*.txt
scripts/broker-down.sh
```
The tests do not start a broker themselves. Use `-Dbroker=host:port` if yours is elsewhere. Each test
creates its own uniquely named topic and group, so reruns never see earlier records.
## What each test is
| Test | Role |
|---|---|
| `WorkSpreadTest` | 6 consumers, a 2-partition topic, 60 records at 50 ms each: consumer group, share group with the default `share.acquire.mode`, and share group with `record_limit` |
| `AckTypesTest` | ACCEPT, RELEASE and REJECT through the raw `KafkaShareConsumer`, including a record that is released every time |
| `OffsetResetTest` | A new share group starts at the latest offset; `share.auto.offset.reset=earliest` is a group config set through the Admin API |
| `OrderingTest` | One key, one partition, three consumers of different speed: arrival order is not produce order |
| `SpringShareListenerTest` | `ShareKafkaListenerContainerFactory` in implicit and manual acknowledgement modes with `@KafkaListener` |
`ShareConfig` is the hand-written configuration: Spring Boot 4.1.1 has no auto-configuration for share
consumers.
## Captured output (`output/`)
| File | What it captures |
|---|---|
| `01-work-spread.txt` | Which of six consumers received records under each of the three setups, with indicative timings |
| `02-ack-types.txt` | Every delivery of four records: accepted, released once, rejected, released until Kafka stops redelivering |
| `03-share-group-starts-at-latest.txt` | 3 records produced first: 0 received by default, 3 received after switching the group to earliest |
| `04-no-ordering-guarantee.txt` | The order 40 same-key records were processed in, and the number of out-of-order neighbours |
| `05-spring-share-listeners.txt` | Listener threads under implicit mode, and per-record release, reject and acknowledge under manual mode |
Timings and the exact split of records between consumers differ from run to run. The shape of each
result is stable; the milliseconds are not.
No `docs/` chapter directory in this repository -- the intermediate and reference-depth material is in
accordion sections inside the WordPress post.
+29
View File
@@ -0,0 +1,29 @@
topic partitions = 2, consumers = 6, records = 60, work per record = 50 ms
== consumer group (6 KafkaConsumers, same group.id)
consumer-0 processed 26 records
consumer-1 processed 34 records
consumer-2 processed 0 records
consumer-3 processed 0 records
consumer-4 processed 0 records
consumer-5 processed 0 records
total processed = 60 in 1993 ms
== share group (6 ShareConsumers, default share.acquire.mode)
consumer-0 processed 26 records
consumer-1 processed 34 records
consumer-2 processed 0 records
consumer-3 processed 0 records
consumer-4 processed 0 records
consumer-5 processed 0 records
total processed = 60 in 1778 ms
== share group (6 ShareConsumers, share.acquire.mode=record_limit)
consumer-0 processed 9 records
consumer-1 processed 12 records
consumer-2 processed 10 records
consumer-3 processed 8 records
consumer-4 processed 9 records
consumer-5 processed 12 records
total processed = 60 in 681 ms
consumer group : consumers that received records = 2
share group, default share.acquire.mode: consumers that received records = 2
share group, acquire.mode=record_limit : consumers that received records = 6
(elapsed times vary from run to run; the shape, not the milliseconds, is the result)
+13
View File
@@ -0,0 +1,13 @@
ok delivery #1 -> accept
retry-once delivery #1 -> release
reject-me delivery #1 -> reject
always-release delivery #1 -> release
retry-once delivery #2 -> accept
always-release delivery #2 -> release
always-release delivery #3 -> release
always-release delivery #4 -> release
always-release delivery #5 -> release
deliveries of ok = 1
deliveries of retry-once = 2
deliveries of reject-me = 1
deliveries of always-release = 5
@@ -0,0 +1,3 @@
produced 3 records before any group existed
new group, default share.auto.offset.reset -> received 0 records
new group, share.auto.offset.reset=earliest -> received 3 records
@@ -0,0 +1,3 @@
produced : 0..39 in order, one key, one partition
processed : [4, 5, 6, 7, 12, 0, 13, 14, 15, 16, 17, 18, 19, 1, 20, 8, 21, 22, 23, 24, 2, 25, 26, 27, 28, 29, 30, 3, 31, 36, 9, 37, 38, 39, 32, 33, 10, 34, 35, 11]
adjacent pairs that arrived out of order = 9
@@ -0,0 +1,10 @@
== implicit mode, concurrency 4, 24 records on one topic partition
records handled = 24
distinct listener threads that handled records = 2
implicit-C-1
implicit-C-2
== manual mode (ShareAckMode.MANUAL), listener takes a ShareAcknowledgment
again delivery #1 -> release
again delivery #2 -> acknowledge
bad delivery #1 -> reject
ok delivery #1 -> acknowledge
+52
View File
@@ -0,0 +1,52 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<!-- Same convention as the rest of this repo: inherit spring-boot-starter-parent. Boot 4.1.1
manages Spring Kafka 4.1.1 and kafka-clients 4.2.1. -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>share-groups</artifactId>
<version>1.0</version>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.awaitility</groupId>
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Stops the broker started by broker-up.sh (matched by its main class, not by a broad pattern).
for p in $(ps -eo pid,cmd | grep '[k]afka.Kafka' | awk '{print $1}'); do kill "$p"; done
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Downloads Apache Kafka 4.3.1 (checksum-verified), formats a single-node KRaft cluster and starts it.
# Usage: scripts/broker-up.sh [client-port] (default 9092; the controller uses client-port + 1)
set -euo pipefail
VERSION=4.3.1
PORT="${1:-9092}"
CTRL=$((PORT + 1))
HOME_DIR="${KAFKA_DIR:-$(cd "$(dirname "$0")/.." && pwd)/.kafka}"
TGZ="kafka_2.13-${VERSION}.tgz"
mkdir -p "$HOME_DIR" && cd "$HOME_DIR"
if [ ! -d "kafka_2.13-${VERSION}" ]; then
curl -fsSL -o "$TGZ" "https://downloads.apache.org/kafka/${VERSION}/${TGZ}"
curl -fsSL -o "${TGZ}.sha512" "https://downloads.apache.org/kafka/${VERSION}/${TGZ}.sha512"
# The .sha512 file is "name: HEX HEX HEX ..." in groups, so compare after stripping spaces and the name.
expected="$(sed 's/^[^:]*: *//' "${TGZ}.sha512" | tr -d ' \n' | tr 'A-F' 'a-f')"
actual="$(sha512sum "$TGZ" | cut -d' ' -f1)"
[ "$expected" = "$actual" ] || { echo "checksum mismatch for $TGZ" >&2; exit 1; }
tar -xzf "$TGZ"
fi
cd "kafka_2.13-${VERSION}"
sed -i "s#^listeners=.*#listeners=PLAINTEXT://:${PORT},CONTROLLER://:${CTRL}#" config/server.properties
sed -i "s#^advertised.listeners=.*#advertised.listeners=PLAINTEXT://localhost:${PORT}#" config/server.properties
sed -i "s#^controller.quorum.bootstrap.servers=.*#controller.quorum.bootstrap.servers=localhost:${CTRL}#" config/server.properties
sed -i "s#^log.dirs=.*#log.dirs=${HOME_DIR}/data-${PORT}#" config/server.properties
if [ ! -d "${HOME_DIR}/data-${PORT}" ]; then
bin/kafka-storage.sh format --standalone -t "$(bin/kafka-storage.sh random-uuid | tail -1)" -c config/server.properties
fi
export KAFKA_HEAP_OPTS="${KAFKA_HEAP_OPTS:--Xmx512m -Xms512m}"
setsid nohup bin/kafka-server-start.sh config/server.properties > "${HOME_DIR}/broker-${PORT}.log" 2>&1 < /dev/null &
for _ in $(seq 1 60); do
bin/kafka-topics.sh --bootstrap-server "localhost:${PORT}" --list > /dev/null 2>&1 && break
sleep 2
done
echo "broker up on localhost:${PORT}; share.version feature:"
bin/kafka-features.sh --bootstrap-server "localhost:${PORT}" describe | grep share.version
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Regenerates every file in output/ from the tests. Needs a broker: scripts/broker-up.sh [port], then
# pass -Dbroker=localhost:<port> if it is not 9092.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q test "$@"
ls -1 output
@@ -0,0 +1,54 @@
package com.ankurm.sharegroups;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.ShareAcknowledgment;
import org.springframework.stereotype.Component;
/** Two listeners on share containers. They only record what they saw; the tests read the lists. */
@Component
public class PaymentListeners {
public final List<String> implicitSeen = new CopyOnWriteArrayList<>();
public final List<String> manualSeen = new CopyOnWriteArrayList<>();
/** Implicit mode: returning normally acknowledges (ACCEPT); throwing makes the container reject or release. */
@KafkaListener(id = "implicit", topics = "${share.topic.implicit}", groupId = "${share.group.implicit}",
containerFactory = "implicitShareFactory")
void implicit(ConsumerRecord<String, String> record) throws InterruptedException {
Thread.sleep(20);
implicitSeen.add(Thread.currentThread().getName() + " <- " + record.value());
}
/** Manual mode: the listener decides per record. "bad" is rejected, "again" is released once, the rest accepted. */
@KafkaListener(id = "manual", topics = "${share.topic.manual}", groupId = "${share.group.manual}",
containerFactory = "explicitShareFactory")
void manual(ConsumerRecord<String, String> record, ShareAcknowledgment ack) {
int attempt = record.deliveryCount().orElse((short) 1);
switch (record.value()) {
case "bad" -> {
manualSeen.add("bad delivery #" + attempt + " -> reject");
ack.reject();
}
case "again" -> {
if (attempt == 1) {
manualSeen.add("again delivery #1 -> release");
ack.release();
}
else {
manualSeen.add("again delivery #" + attempt + " -> acknowledge");
ack.acknowledge();
}
}
default -> {
manualSeen.add(record.value() + " delivery #" + attempt + " -> acknowledge");
ack.acknowledge();
}
}
}
}
@@ -0,0 +1,53 @@
package com.ankurm.sharegroups;
import java.util.HashMap;
import java.util.Map;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.annotation.EnableKafka;
import org.springframework.kafka.config.ShareKafkaListenerContainerFactory;
import org.springframework.kafka.core.DefaultShareConsumerFactory;
import org.springframework.kafka.core.ShareConsumerFactory;
import org.springframework.kafka.listener.ContainerProperties.ShareAckMode;
/**
* Spring Boot 4.1.1 does not auto-configure share consumers, so the factory and the listener container
* factories are plain beans. Two container factories: implicit (the container acknowledges for you) and
* explicit (the listener must call ShareAcknowledgment).
*/
@Configuration
@EnableKafka
public class ShareConfig {
@Bean
ShareConsumerFactory<String, String> shareConsumerFactory(@Value("${broker:localhost:9092}") String bootstrap) {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrap);
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put("share.acquire.mode", "record_limit");
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "3");
return new DefaultShareConsumerFactory<>(props);
}
@Bean
ShareKafkaListenerContainerFactory<String, String> implicitShareFactory(ShareConsumerFactory<String, String> factory) {
ShareKafkaListenerContainerFactory<String, String> f = new ShareKafkaListenerContainerFactory<>(factory);
f.setConcurrency(4);
f.getContainerProperties().setShareAckMode(ShareAckMode.IMPLICIT);
return f;
}
@Bean
ShareKafkaListenerContainerFactory<String, String> explicitShareFactory(ShareConsumerFactory<String, String> factory) {
ShareKafkaListenerContainerFactory<String, String> f = new ShareKafkaListenerContainerFactory<>(factory);
f.setConcurrency(2);
f.getContainerProperties().setShareAckMode(ShareAckMode.MANUAL);
return f;
}
}
@@ -0,0 +1,9 @@
package com.ankurm.sharegroups;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Marker application class so the Spring tests have something to bootstrap. Nothing here starts a broker. */
@SpringBootApplication
public class ShareGroupsApplication {
}
@@ -0,0 +1,75 @@
package com.ankurm.sharegroups;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import org.apache.kafka.clients.consumer.AcknowledgeType;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaShareConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** What ACCEPT, RELEASE and REJECT do to a record, and what happens to a record that is released forever. */
class AckTypesTest {
@Test
void acknowledgementTypes() throws Exception {
Transcript t = new Transcript("02-ack-types.txt");
String topic = Broker.freshTopic("acks", 1);
String group = topic + "-g";
Broker.shareGroupFromEarliest(group);
Broker.produce(topic, List.of(new String[] { "k", "ok" }, new String[] { "k", "retry-once" },
new String[] { "k", "reject-me" }, new String[] { "k", "always-release" }));
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, Broker.BOOTSTRAP);
p.put(ConsumerConfig.GROUP_ID_CONFIG, group);
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put("share.acknowledgement.mode", "explicit");
List<String> deliveries = new ArrayList<>();
long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(40);
long lastRecord = System.nanoTime();
try (KafkaShareConsumer<String, String> c = new KafkaShareConsumer<>(p)) {
c.subscribe(List.of(topic));
while (System.nanoTime() < end && System.nanoTime() - lastRecord < TimeUnit.SECONDS.toNanos(15)) {
for (ConsumerRecord<String, String> r : c.poll(Duration.ofMillis(500))) {
lastRecord = System.nanoTime();
int attempt = r.deliveryCount().orElse((short) -1);
AcknowledgeType type = switch (r.value()) {
case "ok" -> AcknowledgeType.ACCEPT;
case "retry-once" -> attempt == 1 ? AcknowledgeType.RELEASE : AcknowledgeType.ACCEPT;
case "reject-me" -> AcknowledgeType.REJECT;
default -> AcknowledgeType.RELEASE;
};
c.acknowledge(r, type);
String line = String.format("%-15s delivery #%d -> %s", r.value(), attempt, type);
deliveries.add(line);
t.line("%s", line);
}
c.commitSync();
}
}
t.line("deliveries of ok = %d", count(deliveries, "ok "));
t.line("deliveries of retry-once = %d", count(deliveries, "retry-once"));
t.line("deliveries of reject-me = %d", count(deliveries, "reject-me"));
t.line("deliveries of always-release = %d", count(deliveries, "always-release"));
t.write();
assertThat(count(deliveries, "ok ")).isEqualTo(1);
assertThat(count(deliveries, "retry-once")).isEqualTo(2);
assertThat(count(deliveries, "reject-me")).isEqualTo(1);
assertThat(count(deliveries, "always-release")).isGreaterThan(1);
}
private long count(List<String> lines, String prefix) {
return lines.stream().filter(l -> l.startsWith(prefix)).count();
}
}
@@ -0,0 +1,67 @@
package com.ankurm.sharegroups;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import org.apache.kafka.clients.admin.AdminClient;
import org.apache.kafka.clients.admin.AdminClientConfig;
import org.apache.kafka.clients.admin.AlterConfigOp;
import org.apache.kafka.clients.admin.ConfigEntry;
import org.apache.kafka.clients.admin.NewTopic;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.config.ConfigResource;
import org.apache.kafka.common.serialization.StringSerializer;
/**
* Helpers for the real broker. The tests do not start a broker: run scripts/broker-up.sh first
* (Kafka 4.3.1, KRaft, localhost:9092). Override with -Dbroker=host:port.
*/
final class Broker {
static final String BOOTSTRAP = System.getProperty("broker", "localhost:9092");
private Broker() {
}
static Properties admin() {
Properties p = new Properties();
p.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP);
return p;
}
/** A fresh topic per test run (unique suffix), so reruns never see old records. */
static String freshTopic(String base, int partitions) throws Exception {
String name = base + "-" + System.currentTimeMillis();
try (AdminClient admin = AdminClient.create(admin())) {
admin.createTopics(List.of(new NewTopic(name, partitions, (short) 1))).all().get(30, TimeUnit.SECONDS);
}
return name;
}
/** Share groups start at the latest offset by default; flip a group to earliest before it first subscribes. */
static void shareGroupFromEarliest(String group) throws Exception {
ConfigResource resource = new ConfigResource(ConfigResource.Type.GROUP, group);
try (AdminClient admin = AdminClient.create(admin())) {
admin.incrementalAlterConfigs(Map.of(resource,
List.of(new AlterConfigOp(new ConfigEntry("share.auto.offset.reset", "earliest"), AlterConfigOp.OpType.SET))))
.all().get(30, TimeUnit.SECONDS);
}
}
static void produce(String topic, List<String[]> keyValues) {
Properties p = new Properties();
p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, BOOTSTRAP);
p.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
p.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
try (KafkaProducer<String, String> producer = new KafkaProducer<>(p)) {
for (String[] kv : keyValues) {
producer.send(new ProducerRecord<>(topic, kv[0], kv[1]));
}
producer.flush();
}
}
}
@@ -0,0 +1,60 @@
package com.ankurm.sharegroups;
import java.time.Duration;
import java.util.List;
import java.util.Properties;
import org.apache.kafka.clients.consumer.AcknowledgeType;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaShareConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** The first surprise: a brand-new share group starts at the end of the topic, not the beginning. */
class OffsetResetTest {
@Test
void newShareGroupSeesNothingUntilItIsToldToStartEarly() throws Exception {
Transcript t = new Transcript("03-share-group-starts-at-latest.txt");
String topic = Broker.freshTopic("reset", 1);
Broker.produce(topic, List.of(new String[] { "k", "a" }, new String[] { "k", "b" }, new String[] { "k", "c" }));
t.line("produced 3 records before any group existed");
int byDefault = drain(topic, topic + "-default", t);
t.line("new group, default share.auto.offset.reset -> received %d records", byDefault);
String early = topic + "-early";
Broker.shareGroupFromEarliest(early);
int withEarliest = drain(topic, early, t);
t.line("new group, share.auto.offset.reset=earliest -> received %d records", withEarliest);
t.write();
assertThat(byDefault).isZero();
assertThat(withEarliest).isEqualTo(3);
}
private int drain(String topic, String group, Transcript t) {
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, Broker.BOOTSTRAP);
p.put(ConsumerConfig.GROUP_ID_CONFIG, group);
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put("share.acknowledgement.mode", "explicit");
int received = 0;
try (KafkaShareConsumer<String, String> c = new KafkaShareConsumer<>(p)) {
c.subscribe(List.of(topic));
long end = System.currentTimeMillis() + 12_000;
while (System.currentTimeMillis() < end) {
for (ConsumerRecord<String, String> r : c.poll(Duration.ofMillis(500))) {
c.acknowledge(r, AcknowledgeType.ACCEPT);
received++;
}
c.commitSync();
}
}
return received;
}
}
@@ -0,0 +1,92 @@
package com.ankurm.sharegroups;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.kafka.clients.consumer.AcknowledgeType;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaShareConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** 40 records, one key, one partition, three consumers with uneven speed: arrival order is not the produce order. */
class OrderingTest {
@Test
void sameKeyDoesNotMeanSameOrder() throws Exception {
Transcript t = new Transcript("04-no-ordering-guarantee.txt");
String topic = Broker.freshTopic("order", 1);
String group = topic + "-g";
Broker.shareGroupFromEarliest(group);
List<String[]> records = new ArrayList<>();
for (int i = 0; i < 40; i++) {
records.add(new String[] { "same-key", String.valueOf(i) });
}
List<Integer> arrival = Collections.synchronizedList(new ArrayList<>());
AtomicInteger done = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(3);
int[] workMillis = { 5, 40, 90 };
for (int id = 0; id < 3; id++) {
int work = workMillis[id];
pool.submit(() -> {
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, Broker.BOOTSTRAP);
p.put(ConsumerConfig.GROUP_ID_CONFIG, group);
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "4");
p.put("share.acknowledgement.mode", "explicit");
p.put("share.acquire.mode", "record_limit");
try (KafkaShareConsumer<String, String> c = new KafkaShareConsumer<>(p)) {
c.subscribe(List.of(topic));
while (done.get() < 40 && !Thread.currentThread().isInterrupted()) {
for (ConsumerRecord<String, String> r : c.poll(Duration.ofMillis(300))) {
Thread.sleep(work);
arrival.add(Integer.valueOf(r.value()));
c.acknowledge(r, AcknowledgeType.ACCEPT);
done.incrementAndGet();
}
c.commitSync();
}
}
catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
Thread.sleep(6000); // let all three consumers join the group before any record exists
Broker.produce(topic, records);
long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(60);
while (done.get() < 40 && System.nanoTime() < end) {
Thread.sleep(100);
}
pool.shutdownNow();
pool.awaitTermination(15, TimeUnit.SECONDS);
List<Integer> snapshot = new ArrayList<>(arrival);
long inversions = 0;
for (int i = 1; i < snapshot.size(); i++) {
if (snapshot.get(i) < snapshot.get(i - 1)) {
inversions++;
}
}
t.line("produced : 0..39 in order, one key, one partition");
t.line("processed : %s", snapshot);
t.line("adjacent pairs that arrived out of order = %d", inversions);
t.write();
assertThat(snapshot).hasSize(40);
assertThat(inversions).isGreaterThan(0);
}
}
@@ -0,0 +1,71 @@
package com.ankurm.sharegroups;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/** The same ideas through Spring Kafka 4.1.1's share containers instead of the raw client. */
@SpringBootTest(classes = { ShareGroupsApplication.class })
class SpringShareListenerTest {
static String implicitTopic;
static String manualTopic;
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) throws Exception {
implicitTopic = Broker.freshTopic("spring-implicit", 1);
manualTopic = Broker.freshTopic("spring-manual", 1);
Broker.shareGroupFromEarliest(implicitTopic + "-g");
Broker.shareGroupFromEarliest(manualTopic + "-g");
r.add("share.topic.implicit", () -> implicitTopic);
r.add("share.group.implicit", () -> implicitTopic + "-g");
r.add("share.topic.manual", () -> manualTopic);
r.add("share.group.manual", () -> manualTopic + "-g");
r.add("spring.kafka.listener.auto-startup", () -> "true");
}
@Autowired
PaymentListeners listeners;
@Test
void implicitAndManualAcknowledgement() throws Exception {
Transcript t = new Transcript("05-spring-share-listeners.txt");
List<String[]> batch = new ArrayList<>();
for (int i = 0; i < 24; i++) {
batch.add(new String[] { "k" + i, "p" + i });
}
Broker.produce(implicitTopic, batch);
await().atMost(Duration.ofSeconds(60)).until(() -> listeners.implicitSeen.size() >= 24);
Set<String> threads = new TreeSet<>();
listeners.implicitSeen.forEach(s -> threads.add(s.substring(0, s.indexOf(" <- "))));
t.line("== implicit mode, concurrency 4, 24 records on one topic partition");
t.line("records handled = %d", listeners.implicitSeen.size());
t.line("distinct listener threads that handled records = %d", threads.size());
threads.forEach(n -> t.line(" %s", n));
Broker.produce(manualTopic, List.of(new String[] { "k", "ok" }, new String[] { "k", "again" }, new String[] { "k", "bad" }));
await().atMost(Duration.ofSeconds(60)).until(() -> listeners.manualSeen.size() >= 4);
Thread.sleep(8000);
t.line("== manual mode (ShareAckMode.MANUAL), listener takes a ShareAcknowledgment");
listeners.manualSeen.stream().sorted().forEach(s -> t.line("%s", s));
t.write();
assertThat(listeners.implicitSeen).hasSize(24);
assertThat(listeners.manualSeen).anyMatch(s -> s.startsWith("again delivery #2"));
assertThat(listeners.manualSeen.stream().filter(s -> s.startsWith("bad")).count()).isEqualTo(1);
}
}
@@ -0,0 +1,30 @@
package com.ankurm.sharegroups;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/** Collects the lines a test prints and writes them to output/NN-name.txt, so the article quotes real output. */
final class Transcript {
private final String file;
private final List<String> lines = new ArrayList<>();
Transcript(String file) {
this.file = file;
}
synchronized void line(String format, Object... args) {
String l = String.format(format, args);
lines.add(l);
System.out.println(l);
}
void write() throws IOException {
Path dir = Path.of("output");
Files.createDirectories(dir);
Files.write(dir.resolve(file), lines);
}
}
@@ -0,0 +1,146 @@
package com.ankurm.sharegroups;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.kafka.clients.consumer.AcknowledgeType;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.KafkaShareConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Six consumers, a two-partition topic, 60 records that each take 50 ms to process.
* A consumer group can keep at most two of the six busy; a share group can keep more busy, but only when
* share.acquire.mode lets max.poll.records bound what one consumer takes (see the default-mode result).
*/
class WorkSpreadTest {
static final int CONSUMERS = 6;
static final int RECORDS = 60;
static final int PARTITIONS = 2;
record Result(ConcurrentHashMap<Integer, AtomicInteger> perConsumer, long millis) {
}
@Test
void consumerGroupVersusShareGroup() throws Exception {
Transcript t = new Transcript("01-work-spread.txt");
t.line("topic partitions = %d, consumers = %d, records = %d, work per record = 50 ms", PARTITIONS, CONSUMERS, RECORDS);
Result classic = run("classic", t);
Result shareDefault = run("share-default", t);
Result share = run("share-record-limit", t);
long activeClassic = classic.perConsumer().values().stream().filter(c -> c.get() > 0).count();
long activeShare = share.perConsumer().values().stream().filter(c -> c.get() > 0).count();
long activeDefault = shareDefault.perConsumer().values().stream().filter(c -> c.get() > 0).count();
t.line("consumer group : consumers that received records = %d", activeClassic);
t.line("share group, default share.acquire.mode: consumers that received records = %d", activeDefault);
t.line("share group, acquire.mode=record_limit : consumers that received records = %d", activeShare);
t.line("(elapsed times vary from run to run; the shape, not the milliseconds, is the result)");
t.write();
assertThat(activeClassic).isEqualTo(PARTITIONS);
assertThat(activeShare).isGreaterThan(PARTITIONS);
assertThat(activeDefault).isGreaterThanOrEqualTo(1);
}
private Result run(String variant, Transcript t) throws Exception {
boolean share = variant.startsWith("share");
String topic = Broker.freshTopic("spread-" + variant, PARTITIONS);
String group = topic + "-g";
if (share) {
Broker.shareGroupFromEarliest(group);
}
ConcurrentHashMap<Integer, AtomicInteger> counts = new ConcurrentHashMap<>();
AtomicInteger total = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(CONSUMERS);
for (int i = 0; i < CONSUMERS; i++) {
int id = i;
counts.put(id, new AtomicInteger());
pool.submit(() -> consume(variant, topic, group, id, counts, total));
}
Thread.sleep(6000); // let every consumer join before the records arrive
List<String[]> records = new ArrayList<>();
for (int i = 0; i < RECORDS; i++) {
records.add(new String[] { "key-" + i, "r" + i });
}
long start = System.nanoTime();
Broker.produce(topic, records);
while (total.get() < RECORDS && System.nanoTime() - start < TimeUnit.SECONDS.toNanos(60)) {
Thread.sleep(50);
}
long millis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
pool.shutdownNow();
pool.awaitTermination(15, TimeUnit.SECONDS);
t.line("== %s", switch (variant) {
case "classic" -> "consumer group (6 KafkaConsumers, same group.id)";
case "share-default" -> "share group (6 ShareConsumers, default share.acquire.mode)";
default -> "share group (6 ShareConsumers, share.acquire.mode=record_limit)";
});
counts.forEach((id, c) -> t.line("consumer-%d processed %d records", id, c.get()));
t.line("total processed = %d in %d ms", total.get(), millis);
return new Result(counts, millis);
}
private void consume(String variant, String topic, String group, int id, ConcurrentHashMap<Integer, AtomicInteger> counts,
AtomicInteger total) {
Properties p = new Properties();
p.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, Broker.BOOTSTRAP);
p.put(ConsumerConfig.GROUP_ID_CONFIG, group);
p.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
p.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "3");
boolean share = variant.startsWith("share");
if (variant.equals("share-record-limit")) {
p.put("share.acquire.mode", "record_limit");
}
try {
if (share) {
p.put("share.acknowledgement.mode", "explicit");
try (KafkaShareConsumer<String, String> c = new KafkaShareConsumer<>(p)) {
c.subscribe(List.of(topic));
while (!Thread.currentThread().isInterrupted()) {
for (ConsumerRecord<String, String> r : c.poll(Duration.ofMillis(300))) {
Thread.sleep(50);
c.acknowledge(r, AcknowledgeType.ACCEPT);
counts.get(id).incrementAndGet();
total.incrementAndGet();
}
c.commitSync();
}
}
}
else {
p.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
try (KafkaConsumer<String, String> c = new KafkaConsumer<>(p)) {
c.subscribe(List.of(topic));
while (!Thread.currentThread().isInterrupted()) {
for (ConsumerRecord<String, String> r : c.poll(Duration.ofMillis(300))) {
Thread.sleep(50);
counts.get(id).incrementAndGet();
total.incrementAndGet();
}
c.commitSync();
}
}
}
}
catch (InterruptedException | org.apache.kafka.common.errors.InterruptException e) {
Thread.currentThread().interrupt();
}
}
}
@@ -0,0 +1,8 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder><pattern>%d{HH:mm:ss.SSS} %-5level %logger{20} - %msg%n</pattern></encoder>
</appender>
<logger name="org.apache.kafka" level="OFF"/>
<logger name="org.springframework.kafka" level="WARN"/>
<root level="WARN"><appender-ref ref="STDOUT"/></root>
</configuration>