Consumers

Group membership, offsets, isolation levels, and dead-letter routing with a single-threaded poll loop.

Consumer<K, V> (package jbroker.broker.client.consumer) is single-threaded: the application drives the state machine by calling poll repeatedly, and each poll sends one group heartbeat, fires rebalance callbacks when the assignment changed, and fetches from every assigned partition. Dependency setup is covered in Install & deploy.

A minimal group consumer

import java.time.Duration;
import java.util.List;
import jbroker.broker.client.consumer.Consumer;
import jbroker.broker.client.consumer.ConsumerConfig;
import jbroker.broker.client.consumer.RebalanceListener;
import jbroker.broker.client.consumer.StringDeserializer;

var config = ConsumerConfig.builder("workers", "localhost", 9092).build();
try (var consumer = new Consumer<>(config, new StringDeserializer(), new StringDeserializer())) {
    consumer.subscribe(List.of("orders"), RebalanceListener.NO_OP);
    while (running) {
        var records = consumer.poll(Duration.ofMillis(500));
        for (var rec : records) {
            System.out.println(rec.tp().getTopic() + "-" + rec.tp().getPartition()
                    + "@" + rec.offset() + ": " + rec.value());
        }
        consumer.commitSync(); // persist positions for everything just processed
    }
}

Every member started with group id "workers" and the same subscription shares the topic's partitions; the coordinator assigns each partition to exactly one member. An empty ConsumerRecords means "nothing this tick" — the group may still be forming — so callers simply loop. close() leaves the group cleanly, triggering an immediate rebalance of its partitions to the survivors.

Two members of one group splitting a two-partition topic: each owns one partition and receives only its records.

The admin UI's Groups page shows the live membership and per-partition lag of every group:

Consumer groups page of the admin UI showing members and per-partition lag

ConsumerConfig

Configuration is an immutable object built with chained setters. ConsumerConfig.builder(groupId, host, port) is the single-endpoint entry point; ConsumerConfig.builder(groupId) is for the cluster-aware path where discovery comes from a ClusterClient and the bootstrap host/port are unused.

Builder methodDefaultMeaning
instanceId(String)"" (dynamic)Static membership: a member rejoining with the same instance id preserves its slot and assignment instead of triggering a rebalance.
sessionTimeoutMs(int)45000How long the coordinator waits without a heartbeat before evicting the member.
rebalanceTimeoutMs(int)60000How long the coordinator waits for members to finish a rebalance stage.
fetchMaxBytes(int)1048576 (1 MiB)Server-side bound on one fetch response, per partition per poll.
maxPollRecords(int)500Upper bound on records one poll returns. Surplus already fetched stays buffered client-side, in order, for the next poll — never dropped.
pollFetchDeadline(Duration)5sPer-fetch gRPC deadline inside a poll tick (also the per-tick retry budget on the cluster path).
deadLetterPolicy(DeadLetterPolicy)null (disabled)Retry-then-dead-letter routing for handler-driven polls; see Dead-letter routing.
tls(TlsConfig)TlsConfig.DISABLEDTLS / mTLS settings; see Security.
isolationLevel(IsolationLevel)READ_UNCOMMITTEDWhat the consumer may see of transactional data; see read_committed.

Rebalancing

Rebalances are cooperative and incremental: instead of a stop-the-world revoke of every partition, the coordinator moves only the partitions that actually change hands, in stages. The consumer reports its owned_partitions on each heartbeat, the coordinator advances the stages, and the RebalanceListener fires on the actual diffs — onPartitionsRevoked with partitions leaving this member, onPartitionsAssigned with partitions arriving. Members that keep a partition through the rebalance never stop consuming it.

import java.util.Collection;
import jbroker.proto.common.TopicPartition;

consumer.subscribe(List.of("orders"), new RebalanceListener() {
    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> revoked) {
        consumer.commitSync(); // last chance to persist progress for these partitions
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> added) {
        // prime any per-partition state
    }
});

Commit inside onPartitionsRevoked: after the callback returns, the next heartbeat reports the post-revoke owned set, and the next owner resumes from whatever was last committed. Applications that don't need callbacks pass RebalanceListener.NO_OP. On a newly assigned partition, the consumer primes its position from the group's committed offset (or 0 if never committed).

Manual control

Four levers adjust consumption without touching group membership — heartbeats keep flowing throughout:

// Reprocess partition 0 from a checkpoint while holding partition 1 back.
consumer.pause("orders", 1);
consumer.seek("orders", 0, checkpointOffset);
var replayed = consumer.poll(Duration.ofMillis(500));
consumer.resume("orders", 1);

Commits

The consumer tracks a position per assigned partition, advanced only for records a poll has actually returned — buffered-but-unreturned records never advance it. Nothing is committed automatically in the plain poll loop; you choose when:

import java.util.Map;
import jbroker.broker.client.consumer.OffsetAndMetadata;

var tp = TopicPartition.newBuilder().setTopic("orders").setPartition(0).build();

consumer.commitSync(Map.of(tp, new OffsetAndMetadata(1234L))); // explicit checkpoint
var async = consumer.commitAsync();                            // non-blocking, ordered
OffsetAndMetadata last = consumer.committed(tp);               // group's committed view

The handler-driven poll(Duration, RecordHandler) variant (below) is the exception: it auto-commits the tick's resulting positions with one synchronous commit after all handlers ran.

read_committed isolation

With isolationLevel(READ_COMMITTED), the consumer only sees records whose transaction outcome is decided and committed:

var config = ConsumerConfig.builder("auditors", "localhost", 9092)
        .isolationLevel(ConsumerConfig.IsolationLevel.READ_COMMITTED)
        .build();

Three mechanisms combine:

The default, READ_UNCOMMITTED, returns every produced record, decided or not. Note that filtering can make offsets appear sparse to a read_committed reader — aborted records and markers still occupy offsets.

Dead-letter routing

The handler-driven poll turns "poison message" handling into configuration. Attach a DeadLetterPolicy and process records with a RecordHandler; the handler signals a retriable failure by throwing RetryableException:

import jbroker.broker.client.consumer.DeadLetterPolicy;
import jbroker.broker.client.consumer.RetryableException;

var config = ConsumerConfig.builder("workers", "localhost", 9092)
        .deadLetterPolicy(new DeadLetterPolicy("orders-dlt", /*maxAttempts*/ 3, Duration.ofMillis(200)))
        .build();

// inside the poll loop:
consumer.poll(Duration.ofMillis(500), rec -> {
    if (!process(rec)) {
        throw new RetryableException("downstream rejected " + rec.offset());
    }
});

Semantics, in order:

The DLT topic is not auto-created — create it up front with at least as many partitions as the source topic, since records land on the same partition number.

Consuming through failover

The cluster-aware constructor routes every consumer call through a shared ClusterClient (which the application owns and may also feed a producer):

import jbroker.broker.client.ClusterClient;

try (var cluster = new ClusterClient(List.of("localhost:9092", "localhost:9093", "localhost:9094"))) {
    var config = ConsumerConfig.builder("workers").build(); // no bootstrap host/port needed
    try (var consumer = new Consumer<>(config, new StringDeserializer(), new StringDeserializer(), cluster)) {
        consumer.subscribe(List.of("orders"), RebalanceListener.NO_OP);
        // poll/commit as usual — leader and coordinator failover are absorbed here
    }
}

What the cluster path absorbs, per the routing layer's contract:

The single-endpoint constructor keeps the original behavior: one bootstrap broker plus a cached coordinator channel, with errors surfacing directly — fine for development, not for riding through broker restarts.

Python consumer

The Python reference client's consumer is one group member with the same heartbeat/assignment/fetch/commit cycle — single endpoint, no failover routing. poll() returns a list of ConsumerRecord dataclasses (topic, partition, offset, key, value, headers); an empty list means "nothing this tick", so callers loop:

from jbroker import Consumer

with Consumer("127.0.0.1", 9092, group_id="workers", topics=["orders"]) as consumer:
    records = []
    while len(records) < 3:          # poll() returns [] while the group forms
        records.extend(consumer.poll())
    for record in records:
        print(record.offset, record.value)
    consumer.commit()                 # next member of "workers" resumes here

Keyword options mirror the Java knobs where implemented: instance_id (static membership, default ""), rebalance_timeout_ms (default 30000), fetch_max_bytes (default 1 MiB). commit() commits current positions for every assigned partition and committed(topic, partition) reads the group's committed offset back. See the package README for install and stub generation.

Next

Producing the records this page consumes — batching, idempotence, transactions — is covered in Producers; day-two topics like lag monitoring live in Operations.