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
@@ -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 {
}