You are viewing an old version of this page. View the current version.

Compare with Current View Page History

« Previous Version 17 Next »

Status

Current state: Under discussion

Discussion thread:  

JIRA: KAFKA-20367 - Getting issue details... STATUS

1 Motivation

1.1 The Problem: Scaling Kafka-to-Kafka Pipelines Today

Kafka Connect sink connectors consume from Kafka topics using traditional consumer groups.

In this model, each partition is exclusively assigned to one task. This creates two problems for Kafka-to-Kafka (and Kafka-to-external) pipelines:

1. Scaling is coupled to partition count.

If a topic has 12 partitions, you can run at most 12 sink tasks. I

ncreasing parallelism beyond the partition count requires repartitioning the source topic -- an operationally expensive and disruptive change.

2. Slow tasks block partitions.

If one sink task is slow (e.g., network latency to a downstream system), the records on its assigned partitions back up.

Other idle tasks cannot help because partition ownership is exclusive. This creates head-of-line blocking at the partition level.

3. Rebalance storms cause processing gaps.

When tasks are added, removed, or crash, consumer group rebalances revoke and reassign partitions.

During a rebalance, no task processes records from revoked partitions. With cooperative sticky rebalancing this is mitigated but not eliminated.

1.2 How Share Groups Solve This

Share Groups (KIP-932) introduce queue semantics for Kafka consumers. Unlike consumer groups, Share Groups do not assign partitions exclusively.

Instead, records from a partition are acquired by any available consumer in the group. After processing, the consumer acknowledges the record (ACCEPT, RELEASE, ARCHIEVED, or REJECT).

This provides:

Elastic scaling independent of partition count.

50 tasks can process a 12-partition topic because records are distributed at the record level, not the partition level.
No head-of-line blocking.

If one task is slow, acquired records time out and are re-delivered to another task.
No rebalance disruption.

Share Groups have no partition assignment protocol. Adding or removing tasks does not trigger reassignment of partitions.

1.3 Kafka Connect as the Natural Integration Point

Kafka Connect is the standard framework for building data pipelines into and out of Kafka. Integrating Share Groups into Connect's sink connector

runtime gives every existing sink connector access to queue semantics with a configuration change only -- no connector code modifications required.

1.4 Delivery Semantics


ModeGuaranteeMechanism
At-least-once(default)Every record is delivered at least once; duplicates possible on failureShareConsumer.acknowledge(ACCEPT) after successful task.put(); records released on failure for re-delivery 
Exactly-onceEvery record is delivered exactly onceKIP-1289 transactional acknowledgments: producer.sendShareAcksToTransaction() binds acknowledgments to the output transaction

At-least-once is the initial target. Exactly-once requires KIP-1289 (Transactional Acknowledgments for Share Groups) to be implemented and is described as a future phase.

1.5 Error Handling / Delivery Semantics

This KIP integrates with KIP-1191. In Share Group mode, Connect uses `AcknowledgeType.REJECT` for fatal errors and relies on the broker-side DLQ configured by KIP-1191. Retriable failures use `AcknowledgeType.RELEASE`. If a DLQ is configured for the share group, the broker is the single source of DLQ records; Connect does not emit its own DLQ records in this mode.

EO Constraints

For exactly-once, records with pending transactional acknowledgments must not be re-delivered while the transaction is open; KIP-1289 must suppress or renew acquisition locks until commit/abort. Operationally, `share.acquisition.lock.timeout.ms` must exceed worst-case `task.put()` plus transaction commit latency, otherwise duplicates are possible even with EOS.

2. Scope

2.1 In Scope

- Add Share Group support to Kafka Connect sink connectors via a new `WorkerShareSinkTask`, with no changes required to the `SinkTask` API.
- Config-driven enablement (`consumer.override.group.protocol=share`) with per-connector overrides.
- At-least-once delivery for all sinks using Share Groups.
- Exactly-once delivery only for Kafka-to-Kafka pipelines within the same cluster, gated by KIP-1289 and a transactional producer.
- Share Group–specific metrics integrated into existing `sink-task-metrics` group.

2.2 Out of Scope

- Share Group support for source connectors and MirrorMaker 2 (separate effort).
- Exactly-once delivery for cross-cluster Kafka-to-Kafka pipelines (source cluster A → sink cluster B).
- External two-phase commit coordinators or cross-cluster transactional protocols.
- Changes to the public `SinkTask` API or connector implementations.

3. Public Interfaces

3.1 New Configuration Properties

3.1.1 Worker-level configuration (`connect-distributed.properties`)

PropertyTypeDefaultDescription
consumer.group.protocolstringconsumerExisting property. When set to share, the Connect worker creates a KafkaShareConsumer instead of a KafkaConsumer for sink tasks.


3.1.2 Connector-level configuration (per-connector JSON)


PropertyTypeDefaultDescription
consumer.override.group.protocolstring(inherited from worker)Per-connector override. Set to share to opt a single connector into queue semantics.
share.group.idstringconnect-<connector-name>The Share Group ID. Defaults to the same naming convention as consumer groups.
share.acknowledgement.modestringexplicitexplicit: worker calls acknowledge(ACCEPT) after task.put() succeeds. implicit: acknowledgments are sent on the next poll() (simpler, lower latency, weaker guarantee).
share.acquisition.lock.timeout.msint30000Maximum time a record remains in ACQUIRED state before the broker releases it for re-delivery. Must be greater than the expected task.put() latency.
share.delivery.semanticsstringat-least-onceat-least-once or exactly-once. Exactly-once requires KIP-1289 and a transactional producer.
share.max.delivery.countint5Maximum number of times a record can be re-delivered before being sent to the Dead Letter Queue (if configured). Maps to Share Group's group.share.record.lock.partition.limit.

3.2 New / Modified Java Interfaces


3.2.1 `WorkerShareSinkTask` (new class)


A new internal class in `org.apache.kafka.connect.runtime` that extends `WorkerTask` and drives the `SinkTask` using a `KafkaShareConsumer` instead of a `KafkaConsumer`. This is the core runtime change.

```
// New class: parallel to WorkerSinkTask but backed by ShareConsumer
class WorkerShareSinkTask extends WorkerTask<ConsumerRecord<byte[], byte[]>, SinkRecord> {
    private final ShareConsumer<byte[], byte[]> shareConsumer;
    private final SinkTask task;
    // ...
}
```

Note: The existing `SinkTask` interface is not modified. Connectors do not need code changes. The `put(Collection<SinkRecord>)` contract remains the same.

The difference is entirely in the worker runtime:

AspectWorkerSinkTask (today)WorkerShareSinkTask (proposed)
ConsumerKafkaConsumerKafkaShareConsumer
Subscriptionconsumer.subscribe(topics, rebalanceListener)shareConsumer.subscribe(topics)
Pollconsumer.poll()shareConsumer.poll()
Offset trackingcurrentOffsets map + consumer.commitSync()Per-record shareConsumer.acknowledge(record, ACCEPT) + shareConsumer.commitSync()
RebalanceConsumerRebalanceListener calling task.open()/close()No rebalances. task.open() called once at startup for all subscribed topics.
Failure handlingRetriableException -> pause consumer, retry batchRetriableException -> acknowledge(RELEASE) for batch, records re-delivered by broker

3.2.2`Worker.baseConsumerConfigs()` (modified)

The existing method that builds consumer properties is modified to detect `group.protocol=share` and construct `KafkaShareConsumer` configs instead of `KafkaConsumer` configs:

```
// In Worker.java
static Map<String, Object> baseConsumerConfigs(...) {
    Map<String, Object> consumerProps = new HashMap<>();
    
    String groupProtocol = // resolve from worker + connector config
    
    if ("share".equals(groupProtocol)) {
        consumerProps.put(ShareConsumerConfig.GROUP_ID_CONFIG, 
            connConfig.getString("share.group.id", SinkUtils.consumerGroupId(connName)));
        // Share consumer specific configs
        consumerProps.put(ShareConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, config.bootstrapServers());

    } else {
        // existing consumer group config path (unchanged)
        consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, SinkUtils.consumerGroupId(connName));
        consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");
        // ...
    }
    return consumerProps;
}
```

Metrics

These sensors are only registered by `WorkerShareSinkTask` -- they are not present when using a traditional `KafkaConsumer` via `WorkerSinkTask`.

This avoids publishing meaningless zeros and keeps dashboards clean. Operators can use the presence/absence of these metrics to confirm whether a connector is running in Share Group mode.

All metrics are registered under the existing `sink-task-metrics` group (same as `sinkTaskGroupName` in `ConnectMetricsRegistry`), tagged with `connector` and `task`.

This keeps them co-located with the existing `sink-record-read-total`, `sink-record-send-total`, etc. and avoids a separate metric namespace.

Sensor NameMetric NameTypeTraditional Consumer (group.protocol=consumer)Share Consumer (group.protocol=share)
sink-record-acquiresink-record-acquire-rateRatenot registeredRecords/sec acquired from the share group

sink-record-acquire-totalCumulativeSumnot registeredTotal records acquired from the share group
sink-record-acknowledgesink-record-acknowledge-rateRatenot registeredRecords/sec acknowledged (ACCEPT)

sink-record-acknowledge-totalCumulativeSumnot registeredTotal records acknowledged (ACCEPT)
sink-record-releasesink-record-release-rateRatenot registeredRecords/sec released (RELEASE) for re-delivery

sink-record-release-totalCumulativeSumnot registeredTotal records released for re-delivery
sink-record-rejectsink-record-reject-rateRatenot registeredRecords/sec rejected (REJECT) to DLQ

sink-record-reject-totalCumulativeSumnot registeredTotal records rejected to DLQ
acknowledge-timeacknowledge-time-maxMaxnot registeredMax time (ms) between poll() and acknowledge()

acknowledge-time-avgAvgnot registeredAvg time (ms) between poll() and acknowledge()
sink-record-redeliverysink-record-redelivery-totalCumulativeSumnot registeredTotal records with delivery count > 1

Conversely, the following existing `WorkerSinkTask` sensors have no Share Group equivalent and are not registered by `WorkerShareSinkTask`:

Existing SensorWhy not applicable to Share Groups
partition-countShare Groups don't assign partitions exclusively to tasks. All tasks consume from all subscribed partitions.
offset-seq-numberShare Groups don't use consumer offsets. Acknowledgments replace offset commits.
offset-commit-completionNo offset commits in Share Groups. Replaced by sink-record-acknowledge.
offset-commit-completion-skipNo offset commits to skip.


The existing sensors that are shared between both task types:

SensorBehavior
sink-record-readRegistered by both. Counts records polled (same semantics).
sink-record-sendRegistered by both. Counts records delivered to task.put().
sink-record-active-countRegistered by both. In Share Groups, this is the number of records currently ACQUIRED but not yet acknowledged.
put-batch-timeRegistered by both. Time spent in task.put().

Proposed Changes

At‑Least‑Once (Share Group → SinkTask → External Sink)

Exactly‑Once (Same‑Cluster Kafka‑to‑Kafka, KIP‑1289)



`WorkerShareSinkTask` Lifecycle

Initialization

```
void initialize() {
    // 1. Create KafkaShareConsumer with resolved configs
    this.shareConsumer = new KafkaShareConsumer<>(shareConsumerConfigs);
    
    // 2. Subscribe to configured topics
    List<String> topics = SinkConnectorConfig.parseTopicsList(taskConfig);
    shareConsumer.subscribe(topics);
    
    // 3. Open the task (no partition-level open/close with share groups)
    task.initialize(context);
    task.start(taskConfig);
}
```

Main Loop (iteration)


```
void iteration() {
    // 1. Poll records from share group
    ConsumerRecords<byte[], byte[]> records = shareConsumer.poll(Duration.ofMillis(pollTimeoutMs));
    
    if (records.isEmpty()) return;
    
    // 2. Convert to SinkRecords (same as today)
    List<SinkRecord> sinkRecords = convertMessages(records);
    
    // 3. Deliver to task
    try {
        task.put(sinkRecords);
        
        // 4a. Success: acknowledge all records as ACCEPT
        for (ConsumerRecord<byte[], byte[]> record : records) {
            shareConsumer.acknowledge(record, AcknowledgeType.ACCEPT);
        }
        
    } catch (RetriableException e) {
        // 4b. Retriable failure: RELEASE records for re-delivery
        for (ConsumerRecord<byte[], byte[]> record : records) {
            shareConsumer.acknowledge(record, AcknowledgeType.RELEASE);
        }
        log.warn("Retriable error, records released for re-delivery", e);
        
    } catch (Throwable t) {
        // 4c. Fatal failure: REJECT records (to DLQ if configured)
        for (ConsumerRecord<byte[], byte[]> record : records) {
            shareConsumer.acknowledge(record, AcknowledgeType.REJECT);
        }
        throw new ConnectException("Unrecoverable error", t);
    }
    
    // 5. Commit acknowledgments to broker
    if (shouldCommit()) {
        shareConsumer.commitSync();
    }
}
```

Ensuring No Data Loss (At-Least-Once)

The at-least-once guarantee is achieved through the following invariant:

> A record is acknowledged (ACCEPT) only after `task.put()` returns successfully.

If the task or worker crashes between `poll()` and `acknowledge()`:
- The record remains in ACQUIRED state on the broker
- The acquisition lock timer expires after `share.acquisition.lock.timeout.ms`
- The broker transitions the record back to AVAILABLE
- Another task acquires and processes it

If the worker crashes after `acknowledge(ACCEPT)` but before `commitSync()`:
- The implicit acknowledgment mode sends acks on the next `poll()`, so uncommitted acks may be lost
- The explicit mode (default) uses `commitSync()` which is durable. If the commit fails, the record stays in ACQUIRED and will time out and re-deliver.

Duplicate delivery can occur when a task successfully calls `task.put()` and `acknowledge(ACCEPT)` but crashes before the downstream system confirms persistence.

This is inherent to at-least-once semantics. Sink connectors targeting idempotent systems (databases with upsert, object stores with overwrite) naturally handle this.

Exactly-Once Semantics (Future Phase, requires KIP-1289)

For Kafka-to-Kafka pipelines (e.g., MirrorMaker2), exactly-once can be achieved by binding the Share Group acknowledgment to the producer's transaction:

```
// Exactly-once CTP pattern in WorkerShareSinkTask
void iterationExactlyOnce() {
    ConsumerRecords<byte[], byte[]> records = shareConsumer.poll(Duration.ofMillis(pollTimeoutMs));
    if (records.isEmpty()) return;
    
    producer.beginTransaction();
    
    try {
        // Produce transformed records to output topics
        for (SinkRecord record : convertMessages(records)) {
            producer.send(new ProducerRecord<>(outputTopic, record.key(), record.value()));
        }
        
        // Bind share acks to this transaction (KIP-1289)
        producer.sendShareAcksToTransaction(
            ShareAcknowledgements.fromRecords(records, AcknowledgeType.ACCEPT),
            shareConsumer.groupMetadata()
        );
        
        producer.commitTransaction();
        // Output records AND source acknowledgments commit atomically
        
    } catch (Exception e) {
        producer.abortTransaction();
        // Both output records AND source acknowledgments are rolled back
        // Records will be re-delivered by the broker
    }
}
```

 Configuration Resolution Order

```
Worker config (connect-distributed.properties)
    -> consumer.group.protocol=share          (global default)
    
Connector config (per-connector JSON)
    -> consumer.override.group.protocol=share  (per-connector override)
    -> share.group.id=my-custom-group          (explicit share group name)
    -> share.acknowledgement.mode=explicit     (ack behavior)
```

The existing `consumer.override.*` mechanism in Kafka Connect (governed by `connector.client.config.override.policy`) is reused. No new override mechanism is introduced.


4. Compatibility, Deprecation, and Migration Plan

4.1 Impact on Existing Users

No impact by default. The default `group.protocol` remains `consumer` (standard consumer group). Existing connectors continue to work identically.
Opt-in only. Share Groups are enabled per-connector or per-worker via configuration.
No connector code changes required. The `SinkTask` interface is unchanged. Any existing sink connector works with Share Groups without modification.

4.2 Migration Path

1. Pre-requisite: Kafka broker version must support Share Groups (4.0+).
2. Enable at worker level: Set `consumer.group.protocol=share` in `connect-distributed.properties` to make all sink connectors use Share Groups.
3. Or enable per-connector: Set `consumer.override.group.protocol=share` in the connector config JSON.
4. Tune acquisition lock timeout: Set `share.acquisition.lock.timeout.ms` to a value greater than the expected `task.put()` latency. The default of 30 seconds is suitable for most workloads.
5. Monitor: Use the new `share-sink-task.*` metrics to observe acknowledgment patterns and re-delivery rates.

4.3 Rollback

To revert, remove the `group.protocol=share` configuration. The connector will resume using standard consumer groups.

Note that Share Groups and consumer groups maintain separate offset tracking, so the consumer group will resume from its last committed offset

(which may be behind the Share Group's position).

4.4 Deprecation

No existing features are deprecated. This is purely additive.

5. Test Plan

5.1 Unit Tests

1. `WorkerShareSinkTaskTest`: Tests the core poll-put-acknowledge loop using a `MockShareConsumer`.
   - Verify ACCEPT after successful `task.put()`
   - Verify RELEASE after `RetriableException`
   - Verify REJECT after unrecoverable exception
   - Verify `commitSync()` is called at configured intervals

2. `WorkerTest` (modified): Verify that `baseConsumerConfigs()` returns correct configs for `group.protocol=share`.

3. `SinkConnectorConfigTest` (modified): Validate the new configuration properties and their defaults.

5.2 Integration Tests

1. Basic Share Group Sink: Deploy a sink connector with `group.protocol=share` and verify all records are delivered.
2. Elastic Scaling: Start with 2 tasks, scale to 6, verify no records are lost and throughput increases.
3. Task Failure and Re-delivery: Kill a task mid-processing, verify records are re-delivered to surviving tasks within `acquisition.lock.timeout.ms`.
4. No Duplicate Loss: Produce N records, consume with at-least-once Share Group sink, verify received count >= N.
5. Interoperability: Verify that standard consumer group connectors and Share Group connectors can coexist in the same Connect cluster.

5.3 System Tests

1. Long-running throughput test: Measure throughput and latency of Share Group vs. consumer group sink connectors under sustained load.
2. Chaos test: Randomly kill tasks and brokers, verify zero data loss with at-least-once semantics.

6. Rejected Alternatives

Alternative 1: Modify the SinkTask Interface to Add acknowledge()

We considered adding `acknowledge(SinkRecord)` and `release(SinkRecord)` methods to the `SinkTask` interface, giving connectors explicit control over acknowledgments. This was rejected because:
- It would break backward compatibility with all existing sink connectors
- Most connectors don't need per-record acknowledgment control
- The worker runtime can make correct acknowledgment decisions based on `put()` success/failure

Alternative 2: Use Share Groups Only for MirrorMaker2

We considered limiting Share Group support to the `MirrorSourceConnector` only, as Kafka-to-Kafka is the most obvious use case. This was rejected because:
- It would require changes to the MM2 `consumer.assign()` model, which is complex
- Generic sink connectors (e.g., JDBC, Elasticsearch, S3) benefit equally from elastic scaling
- Building it into the Connect runtime benefits all connectors automatically

Alternative 3: Exactly-Once from Day One

We considered requiring exactly-once semantics for the initial implementation. This was rejected because:
- KIP-1289 (transactional share acknowledgments) is not yet implemented
- At-least-once is sufficient for the majority of sink connector use cases
- Idempotent sinks (upsert to database, overwrite to S3) achieve effective exactly-once with at-least-once delivery
- Exactly-once can be added as a follow-up without breaking changes

  • No labels