This page is meant as a template for writing a KIP. To create a KIP choose Tools->Copy on this page and modify with your content and replace the heading with the next KIP number and a description of your issue. Replace anything in italics with your own description.
Current state: Under Discussion
Discussion thread: here
JIRA: [KAFKA-19883]()
Please keep the discussion on the mailing list rather than commenting on the wiki (wiki discussions get unwieldy fast).
A record's side effects (output writes) and its source acknowledgment must be committed atomically. Either both succeed, or neither does.
ACQUIRED.ACKNOWLEDGED (not redelivered) -> data loss.RecordStateSharePartition.acknowledge()Goal: Enable exactly-once read semantics via transactional acknowledgements.
We will be following the similar existing pattern we have in Kafka Producer Transactions.
Frameworks:
- Apache Flink: Exactly-once checkpointing with Share Group sources
- Apache Spark: Structured Streaming with Share Group consumers
- Any coordinator-worker streaming framework requiring atomic acknowledgements
Share groups (KIP-932) allow multiple consumers to read from the same partition concurrently, with the broker controlling per-record delivery via an acquisition-lock mechanism. Each record passes through a state machine on the broker:
AVAILABLE → ACQUIRED → ACKNOWLEDGED (terminal)
→ ARCHIVED (terminal, rejected or max delivery exceeded)
→ AVAILABLE (released for redelivery)
Today, share groups support two acknowledgment modes:
poll() are automatically acknowledged on the next poll().acknowledge(record, AcknowledgeType) for each record, then commitSync().In both modes, acknowledgments are committed immediately and irrevocably. Once a record enters ACKNOWLEDGED state in the SharePartition (managed on the broker), it is never redelivered.
Relevant existing code:
RecordState enum: kafka/server/src/main/java/org/apache/kafka/server/share/fetch/RecordState.javaSharePartition.acknowledge(): kafka/core/src/main/java/kafka/server/share/SharePartition.javaShareConsumer interface: kafka/clients/src/main/java/org/apache/kafka/clients/consumer/ShareConsumer.javaAcknowledgeType enum (ACCEPT/RELEASE/REJECT/RENEW): kafka/clients/src/main/java/org/apache/kafka/clients/consumer/AcknowledgeType.javaWith traditional consumer groups, Flink avoids this problem by replaying from a saved offset:
KafkaSourceReader.snapshotState() saves offsets in Flink's state.consumer.seek(savedOffset) replays from the checkpoint.sendOffsetsToTransaction()) are cosmetic — Flink state is the source of truth.Share groups have no seek(). The broker controls which records are delivered. Once acknowledged, records are gone. Therefore, acknowledgment itself must become the transactional boundary, not just a cosmetic side-effect.
sendOffsetsToTransaction()Kafka already solves the identical problem for consumer-group offsets via KafkaProducer.sendOffsetsToTransaction():
This KIP applies the same pattern to share-group acknowledgments. Instead of committing to __consumer_offsets, we commit to __share_group_state.
Existing code this KIP mirrors:
KafkaProducer.sendOffsetsToTransaction(): kafka/clients/src/main/java/org/apache/kafka/clients/producer/KafkaProducer.javaAddOffsetsToTxnRequest.json: kafka/clients/src/main/resources/common/message/AddOffsetsToTxnRequest.jsonGroupCoordinator.completeTransaction(): handles WriteTxnMarkers for __consumer_offsetsShareCoordinatorShard.replayEndTransactionMarker(): already exists, handles transaction markers for __share_group_stateAn application reads from a share group, transforms records, and produces output to another Kafka topic. Both output and acknowledgments must commit atomically.
producer.beginTransaction();
ConsumerRecords<K,V> records = shareConsumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<K,V> record : records) {
ProducerRecord<K,V> output = transform(record);
producer.send(output);
}
// Bind share acks to this transaction (NEW API)
producer.sendShareAcksToTransaction(
ShareAcknowledgements.fromRecords(records, AcknowledgeType.ACCEPT),
shareConsumer.groupMetadata()
);
producer.commitTransaction();
// Output records AND share acks commit atomically |
A streaming framework reads from a share group as a source. There is no Kafka producer in the pipeline — the output may go to a database, filesystem, or another system. The framework needs to commit share acks transactionally, coordinated with its own checkpointing.
TransactionalShareAcknowledger acknowledger = new TransactionalShareAcknowledger(props); acknowledger.initTransactions(); // On checkpoint complete: acknowledger.commitAcknowledgements(bufferedAcks, shareGroupId); // Internally: beginTransaction → sendShareAcksToTransaction → commitTransaction |
When a Flink pipeline reads from a Kafka share group and writes to a Kafka sink topic, we achieve end-to-end exactly-once by binding both sink output and source acknowledgments to the same Kafka transaction:
Checkpoint lifecycle:
prepareCommit() → flush sink records, pre-commit Kafka txn
→ include share acks in the same transaction
snapshotState() → save txn metadata + buffered acks
notifyCheckpointComplete() → commitTransaction() (acks + output atomically)
On failure → abortTransaction() (acks + output both rolled back) |
| New API | Mirrors | Why Needed |
|---|---|---|
sendShareAcksToTransaction() | sendOffsetsToTransaction() | Acks are stored in __share_group_state, not __consumer_offsets |
AddShareAcksToTxnRequest | AddOffsetsToTxnRequest | Transaction coordinator must track __share_group_state partitions |
TxnShareAcknowledgeRequest | TxnOffsetCommitRequest | Ack semantics are per‑record state, not per‑offset |
// In org.apache.kafka.clients.producer.KafkaProducer:
/**
* Sends share-group acknowledgments as part of the current transaction.
* Mirrors sendOffsetsToTransaction() but writes to __share_group_state
* instead of __consumer_offsets.
*
* @param acknowledgements Map of TopicPartition to list of acknowledgment batches
* @param groupMetadata The share group metadata (group ID, member ID, generation)
* @throws IllegalStateException if no transaction is in progress
* @throws ProducerFencedException if the producer is fenced
*/
public void sendShareAcksToTransaction(
Map<TopicPartition, ShareAcknowledgements> acknowledgements,
ShareGroupMetadata groupMetadata
) throws ProducerFencedException; |
This mirrors sendOffsetsToTransaction(). The reason a new method is needed (instead of reusing the existing one) is that:
__share_group_state, not __consumer_offsets.ShareCoordinator handles ack persistence, not GroupCoordinator.For frameworks that do not use a KafkaProducer in the pipeline:
public class TransactionalShareAcknowledger implements Closeable {
public TransactionalShareAcknowledger(Properties config);
/** Initialize the internal transactional producer. Call once. */
public void initTransactions();
/**
* Atomically commit share acknowledgments.
* Internally executes: beginTransaction → sendShareAcksToTransaction → commitTransaction.
* This is NOT a single RPC. It orchestrates the standard 2PC protocol.
*/
public void commitAcknowledgements(
Map<TopicPartition, ShareAcknowledgements> acks,
String groupId
);
/** Abort any in-progress transactional acknowledgment. */
public void abortAcknowledgements();
public void close(); |
}
Clarification: commitAcknowledgements() is a convenience wrapper. It internally calls three operations in sequence:
beginTransaction()sendShareAcksToTransaction(acks, groupMetadata)commitTransaction()It does NOT introduce a new single-RPC path. It uses the standard 2PC protocol.
public class ShareGroupMetadata {
private final String groupId;
private final String memberId;
private final int generationId;
// constructor, getters |
}
public class ShareAcknowledgements {
private final List<ShareAcknowledgementBatch> batches;
public static ShareAcknowledgements fromRecords(
ConsumerRecords<?, ?> records, AcknowledgeType type);
// Each batch: firstOffset, lastOffset, acknowledgeType |
}
| Metric Name | Type | Description |
|---|---|---|
share-transaction-active | Gauge | Number of active share-group transactions |
share-transaction-prepare-time-ms | Histogram | Time to prepare share ack transaction |
share-transaction-commit-time-ms | Histogram | Time to commit share ack transaction |
share-transaction-abort-total | Counter | Total aborted share ack transactions |
share-transaction-timeout-total | Counter | Total timed-out share ack transactions |
This KIP reuses Kafka's existing two-phase commit protocol. No new coordinator type or consensus protocol is introduced.
Transaction lifecycle (identical to existing):
EMPTY → ONGOING → PREPARE_COMMIT → COMPLETE_COMMIT
→ PREPARE_ABORT → COMPLETE_ABORT
What changes is which partitions are added to the transaction:
| API Call | Partition Added to Transaction | Storage Topic |
|---|---|---|
producer.send(record) | Data topic partition | User topic |
sendOffsetsToTransaction() | __consumer_offsets partition for group | __consumer_offsets |
sendShareAcksToTransaction() (NEW) | __share_group_state partition for group+topic | __share_group_state |
The WriteTxnMarkers request dispatches commit/abort markers to all partitions in the transaction set. If only sendShareAcksToTransaction() was called, markers go only to __share_group_state. If both sendOffsetsToTransaction() and sendShareAcksToTransaction() were called in the same transaction, markers go to both. The transaction coordinator does not distinguish between these — it just tracks partition sets.
New request: AddShareAcksToTxnRequest (mirrors AddOffsetsToTxnRequest)
{
"apiKey": TBD,
"type": "request",
"name": "AddShareAcksToTxnRequest",
"validVersions": "0",
"fields": [
{ "name": "TransactionalId", "type": "string", "versions": "0+" },
{ "name": "ProducerId", "type": "int64", "versions": "0+" },
{ "name": "ProducerEpoch", "type": "int16", "versions": "0+" },
{ "name": "GroupId", "type": "string", "versions": "0+" },
{ "name": "Topics", "type": "[]AddShareAcksToTxnTopic", "versions": "0+",
"fields": [
{ "name": "Name", "type": "string", "versions": "0+" },
{ "name": "Partitions", "type": "[]int32", "versions": "0+" }
]
}
]
} |
Purpose: Tells the TransactionCoordinator to add the __share_group_state partition(s) for the given {groupId, topicPartition} pair(s) to the producer's ongoing transaction. The partition is determined by ShareCoordinator.partitionFor(groupId, topicPartition), where topicPartition refers to the original data topic partition being acknowledged, and the function maps it to the corresponding __share_group_state internal partition that stores the state for that share-group + data-partition combination.
New request: TxnShareAcknowledgeRequest (mirrors TxnOffsetCommitRequest)
{
"apiKey": TBD,
"type": "request",
"name": "TxnShareAcknowledgeRequest",
"validVersions": "0",
"fields": [
{ "name": "GroupId", "type": "string", "versions": "0+" },
{ "name": "TransactionalId", "type": "string", "versions": "0+" },
{ "name": "ProducerId", "type": "int64", "versions": "0+" },
{ "name": "ProducerEpoch", "type": "int16", "versions": "0+" },
{ "name": "Topics", "type": "[]TxnShareAcknowledgeTopic", "versions": "0+",
"fields": [
{ "name": "TopicId", "type": "uuid", "versions": "0+" },
{ "name": "Partitions", "type": "[]TxnShareAcknowledgePartition", "versions": "0+",
"fields": [
{ "name": "Partition", "type": "int32", "versions": "0+" },
{ "name": "AcknowledgementBatches", "type": "[]AcknowledgementBatch", "versions": "0+",
"fields": [
{ "name": "FirstOffset", "type": "int64", "versions": "0+" },
{ "name": "LastOffset", "type": "int64", "versions": "0+" },
{ "name": "AcknowledgeType", "type": "int8", "versions": "0+" }
]
}
]
}
]
}
]
} |
Purpose: Sent to the ShareCoordinator to write acknowledgments as pending (uncommitted) within the transaction. The acks become visible only when the transaction commits.
The ShareCoordinator must handle transaction completion, mirroring GroupCoordinator.completeTransaction():
// In ShareCoordinatorShard (NEW method):
public CoordinatorResult<Void, CoordinatorRecord> completeTransaction(
long producerId,
short producerEpoch,
TransactionResult result // COMMIT or ABORT
) {
if (result == TransactionResult.COMMIT) {
// Materialize pending transactional acks into share-group state
return applyPendingAcknowledgements(producerId, producerEpoch);
} else {
// Discard pending transactional acks
return discardPendingAcknowledgements(producerId, producerEpoch);
}
} |
The existing hook ShareCoordinatorShard.replayEndTransactionMarker() already exists for replaying transaction markers during log recovery. This KIP extends it to also handle live transaction completion.
Transactional acks are written to __share_group_state using CoordinatorRuntime.scheduleTransactionalWriteOperation(). This follows the same pattern used by GroupCoordinator for transactional offset commits to __consumer_offsets.
Records in __share_group_state are written with the producer's producerId and producerEpoch, making them part of the transaction. They become readable by other consumers only after the transaction commits and the WriteTxnMarkers COMMIT marker is written.
CLIENT --> BROKER communication (over the Kafka wire protocol):
Share Consumer -----> Share Group Coordinator
ShareFetch, ShareAcknowledge, ShareGroupHeartbeat
TransactionManager --> Transaction Coordinator
InitProducerId, AddPartitionsToTxn, AddShareAcksToTxn(NEW), EndTxn
TransactionManager --> Share Group Coordinator
TxnShareAcknowledgeRequest (NEW)
Producer ------------> Data Partition Leaders
ProduceRequest
BROKER --> BROKER communication (internal, not client-visible):
Transaction Coordinator --> Data Partition Leaders
WriteTxnMarkers (COMMIT/ABORT control record)
Transaction Coordinator --> Group Coordinator (via __consumer_offsets leader)
WriteTxnMarkers (materializes transactional offset commits)
Transaction Coordinator --> Share Group Coordinator (via __share_group_state leader) [NEW]
WriteTxnMarkers (materializes transactional share acks) |
Abort case:

beginTransaction()poll() -> records [0..9] ACQUIRED
<CRASH>
What happens:
share.record.lock.duration.ms)send() but BEFORE commitTransaction()poll() -> records [0..9] ACQUIRED
beginTransaction()
send(output for 0-9) ← records written to broker, but transactional
<CRASH>
What happens:
read_committed consumers skip uncommitted)EndTxnRequest was senttransaction.timeout.ms), auto-abortsWriteTxnMarkers(ABORT) -> output records get ABORT marker, permanently invisiblesendShareAcksToTransaction() but BEFORE commitTransaction()poll() -> records [0..9] ACQUIRED
beginTransaction()
send(output)
sendShareAcksToTransaction() ← acks written to SGC as PENDING
<CRASH>
What happens:
WriteTxnMarkers(ABORT) to both data partitions AND __share_group_stateEndTxn(COMMIT) sent but BEFORE WriteTxnMarkers completespoll() -> records [0..9] ACQUIRED
beginTransaction()
send(output)
sendShareAcksToTransaction()
commitTransaction() ← EndTxn sent, PREPARE_COMMIT logged
<TC crashes or broker restart>
What happens:
PREPARE_COMMIT is durable in __transaction_state log__transaction_state partition), it replays the logWriteTxnMarkers(COMMIT) to all partitionsProducerFencedException (zombie detection)Instance A: beginTransaction(), send(), ...
Instance B: initTransactions() with same transactional.id
← TC bumps epoch, A is now a zombie
Instance A: commitTransaction()
← TC rejects: ProducerFencedException
What happens:
abortTransaction() called explicitlypoll() -> records [0..9] ACQUIRED
beginTransaction()
send(output for 0-4)
record 5 fails validation
abortTransaction() ← explicit abort
What happens:
WriteTxnMarkers(ABORT)beginTransaction()
send("enriched-orders-0", rec1) ← success
send("enriched-orders-1", rec2) ← network error, KafkaException
What happens:
KafkaException, calls abortTransaction()| Scenario | Output Records | Share Acks | Records Re-delivered? | Data Loss? | Duplicates? |
|---|---|---|---|---|---|
| Happy path (commit) | Visible | Materialized | No | No | No |
| Crash before txn | Never written | Never sent | Yes (lock expiry) | No | No |
| Crash mid-txn | Aborted (invisible) | Discarded | Yes (lock expiry) | No | No |
| Crash after PREPARE_COMMIT | Committed on recovery | Materialized on recovery | No | No | No |
| Zombie fenced | Aborted | Discarded | Yes (new instance) | No | No |
| Explicit abort | Aborted (invisible) | Discarded | Yes (lock expiry) | No | No |
| TC crash after PREPARE | Completed on failover | Completed on failover | No | No | No |
The fundamental property: at no point can output records be visible while share acks are uncommitted, or vice versa. They are in the same transaction and share the same COMMIT/ABORT fate.
TxnShareAcknowledgeRequest, fallback to current explicit/implicit ack with warning.1. Phase 1: Add `transactional` acknowledgement mode (backward compatible)
2. Phase 2: Streaming frameworks (Flink, Spark) implement transactional sources
3. Phase 3: Documentation and best practices for exactly-once semantics
- No deprecation of existing modes planned
- `transactional` mode recommended for exactly-once use cases
- Transaction state machine transitions
- Idempotency of all transaction operations
- Timeout handling and auto-abort
- Record state transitions (ACQUIRED → ACKNOWLEDGED on commit, ACQUIRED → AVAILABLE on abort/timeout)
- End-to-end transaction commit/abort flows
- Multi-consumer transactions within same group
- Coordinator failure and recovery
- Network partition scenarios
- Flink checkpoint integration with transactional Share Groups
- Exactly-once delivery verification under failures
- Performance benchmarks comparing transactional vs non-transactional modes
- Stress testing with concurrent transactions
- Broker failure during PREPARE state
- Coordinator crash before/after prepare
- Consumer crash during transaction
- Network partitions between coordinator and brokers
Include exactly‑once recovery tests similar to Flink e2e:
sendShareAcksToTransaction but before commit.notifyCheckpointComplete.If there are alternative ways of accomplishing the same thing, what were they? The purpose of this section is to motivate why the design is the way it is and not some other way.