Kafka queues
Share groups
Share groups (KIP-932) turn a topic into a queue: the broker hands single records to any member and expects an acknowledgement. wiretap tests them with the same publisher, seeds, assertions and readiness calls as classic listeners, and handles the two things that differ: the broker settings a one-node broker needs, and where a new share group starts reading.
What changes with a share group
A classic consumer group assigns whole partitions to members and tracks a committed offset. A share group does neither. The broker locks each record while a member processes it (group.share.record.lock.duration.ms, 30 seconds by default), the member acknowledges it as ACCEPT, RELEASE or REJECT, and a record that is released or times out is delivered again until group.share.delivery.count.limit (5) archives it. Three consequences for tests: there is no delivery order to assert, there is no offset to read consumption progress from, and a brand-new share group starts at the end of the topic unless told otherwise.
The application side
Spring Boot has no auto-configuration for share consumers, so the application declares a ShareConsumerFactory and a ShareKafkaListenerContainerFactory and points each queue listener at the factory. The groupId names a share group; concurrency is how many members this instance contributes.
@Bean
ShareConsumerFactory<String, OrderEvent> shareConsumerFactory() {
Map<String, Object> props = new HashMap<>();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
return new DefaultShareConsumerFactory<>(props);
}
@Bean
ShareKafkaListenerContainerFactory<String, OrderEvent> shareKafkaListenerContainerFactory(
ShareConsumerFactory<String, OrderEvent> shareConsumerFactory) {
return new ShareKafkaListenerContainerFactory<>(shareConsumerFactory);
}
@KafkaListener(topics = "orders.jobs", groupId = "order-workers",
containerFactory = "shareKafkaListenerContainerFactory", concurrency = "3")
void process(OrderEvent event) {
orderService.process(event);
}What wiretap does
- Embedded broker. The one-node broker starts with
share.coordinator.state.topic.replication.factor=1andshare.coordinator.state.topic.min.isr=1. Without them the share coordinator cannot create its state topic and share consumers receive nothing, with no error. - Readiness.
KafkaTestListenersreports share containers withkind() == SHAREand amembers()count read from the broker. A share listener is ready when it runs and at least one member holds an assignment for its topics.awaitAllReady()andawaitListenersForTopic(...)handle a registry that mixes both kinds. - Bean overrides. With
wiretap.test.bean-overrides.enabled=trueaDefaultShareConsumerFactoryis pointed at the test broker (the hard-codedlocalhost:9092above is fine), production security keys are removed, and share group ids get the per-context suffix, on the factory and on@KafkaListener(groupId). See bean overrides. - Start offset. wiretap sets
share.auto.offset.reset=earlieston every share group it suffixes, through the group configuration, before the containers start. That is what makes seeded fixtures and records published before the first fetch visible to share listeners. - Cleanup. Suffixed share groups are deleted between test methods with
deleteShareGroups; groups wiretap did not name are left alone. - Unchanged.
KafkaTestPublisher,@KafkaSeed,KafkaTopicReaderand everyassertThatTopic(...)assertion read and write topics, not groups.
id | topics | running | autoStartup | assigned partitions | kind | members
---|--------|---------|-------------|--------------------|------|--------
orderRouter | [orders.in] | true | true | [orders.in-0] | CLASSIC | 0
orderWorkers | [orders.jobs] | true | true | [] | SHARE | 3Properties
| Property | Default | Description |
|---|---|---|
wiretap.test.broker-properties.<kafka.property>map | the two share coordinator settings | Kafka broker properties applied before the embedded broker starts. The broker is a JVM singleton: the first context wins and later contexts with different keys log a WARN. Ignored in passthrough mode. |
wiretap.test.bean-overrides.share.auto-offset-resetearliest | latest | by_duration:<ISO-8601> | none | earliest | Value of share.auto.offset.reset written to each suffixed share group before its containers start. none leaves group configuration untouched. Requires bean overrides to be enabled. |
wiretap.test.broker-properties.group.share.record.lock.duration.ms=15000
wiretap.test.broker-properties.group.share.delivery.count.limit=3Writing the test
The shape is the one from the quick start: wait for the listener, publish, assert on the side effect.
@SpringBootTest
@EnableWiretapKafkaBroker(topics = "orders.jobs")
class OrderWorkersIT {
@Autowired OrderRepository repository;
@Test
void everyJobIsProcessedOnce() {
KafkaTestListeners.awaitListenersForTopic("orders.jobs");
Set<String> ids = Set.of("j-1", "j-2", "j-3", "j-4");
ids.forEach(id -> KafkaTestPublisher.send("orders.jobs", id, new OrderEvent(id)));
await().atMost(Duration.ofSeconds(10)).untilAsserted(() ->
assertThat(repository.findAllIds()).containsExactlyInAnyOrderElementsOf(ids));
}
}Acknowledgement modes
ShareAckMode.EXPLICIT (the default) acknowledges on the listener's behalf: ACCEPT when the method returns, and whatever the ShareConsumerRecordRecoverer decides when it throws (REJECT by default, RELEASING is the built-in alternative). MANUAL hands the listener a ShareAcknowledgment:
@KafkaListener(topics = "orders.jobs", containerFactory = "manualShareKafkaListenerContainerFactory")
void process(ConsumerRecord<String, OrderEvent> record, ShareAcknowledgment ack) {
try {
orderService.process(record.value());
ack.acknowledge(); // ACCEPT
} catch (TransientException e) {
ack.release(); // RELEASE: delivered again, counts toward the delivery limit
} catch (Exception e) {
ack.reject(); // REJECT: archived, never delivered again
}
}Test outcomes through behaviour rather than through the acknowledgement API: RELEASE is a second delivery, REJECT is the absence of one, and the delivery limit is a record that stops arriving. For any "not delivered again" claim use a held wait, not a sleep:
// RELEASE shows up as a second delivery; REJECT as no second delivery.
await().during(Duration.ofSeconds(3)).atMost(Duration.ofSeconds(5)).untilAsserted(() ->
assertThat(listener.deliveriesOf("bad-1")).isEqualTo(1));Mixed applications
Most services that adopt share groups keep classic listeners too, sometimes on the same topic with different group ids. Nothing needs configuring for that: readiness judges each container by its kind, bean overrides adjust both factory types, cleanup deletes both group types by the same suffix rule, and the assertion API does not care who consumed. The one thing to keep separate in your head is what "consumed" means: a classic listener's progress is an offset, a share listener's progress is a side effect.
Passthrough clusters
Against a cluster you already run, wiretap checks once, when the context declares a share factory or container, that the cluster's finalized share.version is at least 1. If it is not, the context fails with the remedy in the message (kafka-features.sh ... upgrade --feature share.version=1, plus the two replication settings on clusters with fewer than three brokers). wiretap never changes cluster features. A new Kafka 4.2 cluster, such as apache/kafka:4.2.2 in Testcontainers, finalizes share.version=1 by default, so the feature step is only for a 4.1 preview cluster or one upgraded in place; the two replication settings are still needed on a single node. On a locked-down cluster that refuses the group configuration write, the start-offset default is logged as a WARN and skipped.
wiretap.test.mode=passthrough
wiretap.test.passthrough.bootstrap-servers=localhost:9092
# Only when the cluster refuses group configuration changes:
# wiretap.test.bean-overrides.share.auto-offset-reset=noneWhat does not apply
@RetryableTopic,DefaultErrorHandlerandDeadLetterPublishingRecoverer. Share containers use the recoverer and the broker's delivery limit; there is no dead-letter topic unless the application publishes one.- Batch listeners, topic patterns, explicit partition assignment and message converters (Spring for Apache Kafka limitations).
hasReadyListenersis deprecated but still works for share containers; preferKafkaTestListeners.awaitListenersForTopic.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Share listener receives nothing, no exception | No share-state topic: a real cluster with default replication on fewer than three brokers, or wiretap older than 5.2 on the embedded broker | Set the two share.coordinator.state.topic.* settings, or upgrade wiretap |
Context fails and the message mentions share.version | Passthrough cluster older than Kafka 4.2, or the feature is not finalized | Run the kafka-features.sh command from the message |
| Only the last few published records arrive | The group started at latest while records were in flight | Keep the earliest default and call awaitListenersForTopic before publishing |
awaitAllReady times out with members = 0 | The share factory points at another broker (hard-coded address, overrides disabled) | Enable bean overrides, or read the address from spring.kafka.bootstrap-servers |
UnsupportedOperationException: This container doesn't support retrieving its assigned partitions | wiretap older than 5.2 with a share listener in the context | Upgrade wiretap |