Skip to content

Conversation

sobychacko
Copy link
Contributor

Share consumers (KIP-932) enable record-level load balancing where
multiple consumers can cooperatively process from the same partitions.
Unlike traditional consumer groups with exclusive partition ownership,
share groups distribute work at the broker level via the share group
coordinator.

This commit adds native concurrency support to the existing
ShareKafkaMessageListenerContainer rather than creating a separate
ConcurrentShareKafkaMessageListenerContainer. This design choice
avoids the parent/child container complexity that exists in the regular
consumer model, since share consumers fundamentally operate differently:

  • Work distribution happens at the broker level, not at the Spring layer
  • Multiple threads simply provide more capacity for the broker to distribute records across
  • No partition ownership model to coordinate between child containers

This approach provides:

  • Simpler architecture with a single container managing multiple threads
  • No parent/child context propagation concerns
  • Better alignment with share consumer semantics (record-level vs partition-level distribution)
  • Increased throughput for high-volume workloads
  • Better resource utilization across consumer threads

Users can configure concurrency at three levels:

  1. Per-listener via @KafkaListener(concurrency = N)
  2. Factory-level default via factory.setConcurrency(N)
  3. Programmatically via container.setConcurrency(N)

The feature works seamlessly with both implicit (auto-acknowledge) and
explicit (manual acknowledge/release/reject) acknowledgment modes, with
each consumer thread independently managing its own acknowledgments.

  Share consumers (KIP-932) enable record-level load balancing where
  multiple consumers can cooperatively process from the same partitions.
  Unlike traditional consumer groups with exclusive partition ownership,
  share groups distribute work at the broker level via the share group
  coordinator.

  This commit adds native concurrency support to the existing
  `ShareKafkaMessageListenerContainer` rather than creating a separate
  `ConcurrentShareKafkaMessageListenerContainer`. This design choice
  avoids the parent/child container complexity that exists in the regular
  consumer model, since share consumers fundamentally operate differently:

  - Work distribution happens at the broker level, not at the Spring layer
  - Multiple threads simply provide more capacity for the broker to
    distribute records across
  - No partition ownership model to coordinate between child containers

  This approach provides:

  - Simpler architecture with a single container managing multiple threads
  - No parent/child context propagation concerns
  - Better alignment with share consumer semantics (record-level vs
    partition-level distribution)
  - Increased throughput for high-volume workloads
  - Better resource utilization across consumer threads

  Users can configure concurrency at three levels:
  1. Per-listener via `@KafkaListener(concurrency = N)`
  2. Factory-level default via `factory.setConcurrency(N)`
  3. Programmatically via `container.setConcurrency(N)`

  The feature works seamlessly with both implicit (auto-acknowledge) and
  explicit (manual acknowledge/release/reject) acknowledgment modes, with
  each consumer thread independently managing its own acknowledgments.

Signed-off-by: Soby Chacko <soby.chacko@broadcom.com>
@sobychacko sobychacko added this to the 4.0.0-RC1 milestone Oct 7, 2025
Copy link
Member

@artembilan artembilan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!
Just couple nit-picks and questions to clarify.
Thanks

* There's low message volume
* The broker's distribution algorithm favors certain consumers
This is normal behavior. The key benefit of concurrency is having multiple consumer threads *available* to the share group coordinator for distribution.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One sentence per line.

====
**Work Distribution Behavior:**
With share consumers, record distribution is controlled by Kafka's share group coordinator at the broker level, not by Spring for Apache Kafka.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that it works same way as in many other message broker implementations for queues: the next record is pushed down to the next available consumer?
Or is that done in batches?
Where do we control that batch size then? min.bytes? max.record, max.wait?

Thanks

* attribute on {@code @KafkaListener}.
* @param concurrency the number of consumer threads (must be greater than 0)
*/
public void setConcurrency(Integer concurrency) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could be a primitive.

* Get the concurrency level (number of consumer threads).
* @return the concurrency level
*/
public int getConcurrency() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need getter?

// Wait for all consumer threads to complete
this.lifecycleLock.lock();
try {
for (CompletableFuture<Void> future : this.consumerFutures) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See CompletableFuture.allOf() instead.

)
class ShareKafkaMessageListenerContainerIntegrationTests {

private static final LogAccessor logger = new LogAccessor(LogFactory.getLog(ShareKafkaMessageListenerContainerIntegrationTests.class));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The LogAccessor has a ctor based on the class:

	/**
	 * Create a new accessor for the specified Commons Log category.
	 * @see LogFactory#getLog(Class)
	 */
	public LogAccessor(Class<?> logCategory) {

container.setBeanName("concurrencyBasicTest");
container.setConcurrency(concurrency);

assertThat(container.getConcurrency()).isEqualTo(concurrency);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is something very obvious and totally redundant.

.isTrue();

// Log thread distribution for debugging
logger.info(() -> "Thread distribution: " + threadCounts);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need logger in this test class just for these infos?
How is this useful for the whole test suite, esspecialy running on CI/CD?

int totalProcessed = threadCounts.values().stream()
.mapToInt(AtomicInteger::get)
.sum();
assertThat(totalProcessed).isEqualTo(numRecords);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks totally equal to the assertThat(receivedValues).hasSize(numRecords);.
Since we really don't care what thread has processed those records, there is just no need to track them.
Why over-complicate tests?

  - Use primitive int for concurrency in factory (consistent with phase field)
  - Remove unnecessary `getConcurrency()` getter (only used in trivial tests)
  - Use `HashMap` instead of `ConcurrentHashMap` in metrics() (already inside lock)
  - Use `CompletableFuture.allOf()` for cleaner shutdown coordination
  - Remove debug logging from tests (unnecessary noise in CI/CD)
  - Remove thread tracking from concurrency tests (over-complicates assertions)

  Clarify documentation based on KIP-932 specifications:

  - Add explicit note that concurrency is additive across application instances
  - Replace high-level distribution description with precise KIP-932 details
  - Document pull-based model, acquisition locks, and batch behavior
  - Explain `max.poll.records` as soft limit with complete batch preference
  - Set accurate expectations about broker-controlled record distribution

Signed-off-by: Soby Chacko <soby.chacko@broadcom.com>
@artembilan artembilan merged commit 85cd556 into spring-projects:main Oct 8, 2025
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants