showuon commented on code in PR #22937:
URL: https://github.com/apache/kafka/pull/22937#discussion_r3710038832


##########
server/src/test/java/org/apache/kafka/server/UncleanLeaderElectionTest.java:
##########
@@ -139,6 +152,126 @@ public void testUncleanLeaderElectionEnabledConsumer() 
throws Exception {
         testUncleanLeaderElectionEnabled(GroupProtocol.CONSUMER);
     }
 
+    @ClusterTest(
+        serverProperties = {
+            @ClusterConfigProperty(key = 
TopicConfig.UNCLEAN_LEADER_ELECTION_ENABLE_CONFIG, value = "true"),
+            @ClusterConfigProperty(key = 
TransactionLogConfig.TRANSACTIONS_TOPIC_REPLICATION_FACTOR_CONFIG, value = "1"),
+            @ClusterConfigProperty(key = 
TransactionLogConfig.TRANSACTIONS_TOPIC_MIN_ISR_CONFIG, value = "1")
+        }
+    )
+    public void 
testUncleanLeaderElectionCanLeaveReadCommittedConsumerAtLastStableOffset() 
throws Exception {
+        disableEligibleLeaderReplicas();
+
+        String transactionalId = "unclean-election-" + UUID.randomUUID();
+        Map<String, Object> producerConfig = Map.of(
+                ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, 
cluster.bootstrapServers(),
+                ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName(),
+                ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName(),
+                ProducerConfig.ACKS_CONFIG, "all",
+                ProducerConfig.TRANSACTIONAL_ID_CONFIG, transactionalId
+        );
+
+        try (KafkaProducer<String, String> producer = new 
KafkaProducer<>(producerConfig)) {
+            producer.initTransactions();
+
+            var transactionStateTopic = 
admin.describeTopics(Set.of(Topic.TRANSACTION_STATE_TOPIC_NAME))
+                    
.allTopicNames().get().get(Topic.TRANSACTION_STATE_TOPIC_NAME);
+            int transactionPartitionId = Utils.abs(transactionalId.hashCode()) 
% transactionStateTopic.partitions().size();
+            int transactionCoordinatorId = 
transactionStateTopic.partitions().stream()
+                    .filter(partition -> partition.partition() == 
transactionPartitionId)
+                    .findFirst()
+                    .orElseThrow(() -> new AssertionError("Transaction state 
partition is missing"))
+                    .leader().id();
+            int followerId = transactionCoordinatorId == BROKER_ID_0 ? 
BROKER_ID_1 : BROKER_ID_0;
+
+            NewTopic newTopic = new NewTopic(TOPIC, Map.of(PARTITION_ID, 
List.of(transactionCoordinatorId, followerId)))
+                    .configs(Map.of(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, 
"1"));
+            admin.createTopics(List.of(newTopic)).all().get();
+            try {
+                admin.electLeaders(ElectionType.PREFERRED, 
Set.of(TOPIC_PARTITION)).all().get();
+            } catch (ExecutionException e) {
+                assertInstanceOf(ElectionNotNeededException.class, 
e.getCause());
+            }
+
+            int leaderId = awaitLeaderChange(cluster, TOPIC_PARTITION, 
Optional.of(transactionCoordinatorId));
+            KafkaBroker leader = cluster.brokers().get(leaderId);
+            KafkaBroker follower = cluster.brokers().get(followerId);
+
+            produceMessage(cluster, TOPIC, "before");
+            waitForCondition(
+                    () -> {
+                        var log = 
follower.replicaManager().localLog(TOPIC_PARTITION);
+                        return log.isDefined() && log.get().logEndOffset() >= 
1;
+                    },
+                    DEFAULT_MAX_WAIT_MS,
+                    "Timed out waiting for the follower to replicate the 
non-transactional record"
+            );
+
+            producer.beginTransaction();
+            producer.send(new ProducerRecord<>(TOPIC, "transactional")).get();
+
+            // The follower must contain the transaction data, but must be 
stopped before the marker is committed.
+            waitForCondition(
+                    () -> {
+                        var log = 
follower.replicaManager().localLog(TOPIC_PARTITION);
+                        return log.isDefined() && log.get().logEndOffset() >= 
2;
+                    },
+                    DEFAULT_MAX_WAIT_MS,
+                    "Timed out waiting for the follower to replicate the 
transactional record"
+            );
+            follower.shutdown();
+            follower.awaitShutdown();
+            waitForCondition(
+                    () -> {
+                        var leaderAndIsr = 
leader.replicaManager().metadataCache().getLeaderAndIsr(TOPIC, PARTITION_ID);
+                        return leaderAndIsr.isPresent() && 
leaderAndIsr.get().isr().equals(Set.of(leaderId));
+                    },
+                    DEFAULT_MAX_WAIT_MS,
+                    "Timed out waiting for the follower to leave the ISR"
+            );
+
+            // The marker is now written only to the leader. The follower will 
retain the open transaction after failover.
+            producer.commitTransaction();
+
+            // Close the producer while its coordinator is still available; no 
client cleanup should depend on the old leader.
+            producer.close();
+            leader.shutdown();
+            leader.awaitShutdown();
+            follower.startup();
+            awaitLeaderChange(cluster, TOPIC_PARTITION, 
Optional.of(followerId));
+
+            Map<String, Object> consumerConfig = Map.of(
+                    ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 
cluster.bootstrapServers(),
+                    ConsumerConfig.GROUP_ID_CONFIG, 
"unclean-election-consumer-" + UUID.randomUUID(),
+                    ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class.getName(),
+                    ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class.getName(),
+                    ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest",
+                    ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false,
+                    ConsumerConfig.ISOLATION_LEVEL_CONFIG, 
IsolationLevel.READ_COMMITTED.toString()
+            );
+
+            try (Consumer<String, String> consumer = new 
KafkaConsumer<>(consumerConfig)) {
+                consumer.assign(List.of(TOPIC_PARTITION));
+                consumer.seekToBeginning(List.of(TOPIC_PARTITION));
+                List<String> values = new ArrayList<>();
+                waitForCondition(
+                        () -> {
+                            for (ConsumerRecord<String, String> record : 
consumer.poll(Duration.ofMillis(100))) {
+                                values.add(record.value());
+                            }
+                            return !values.isEmpty();
+                        },
+                        DEFAULT_MAX_WAIT_MS,
+                        "Timed out waiting for the read_committed consumer to 
read the non-transactional record"
+                );
+
+                assertEquals(List.of("before"), values);
+                assertEquals(1, consumer.position(TOPIC_PARTITION));
+                assertTrue(consumer.poll(Duration.ofMillis(500)).isEmpty());
+            }
+        }

Review Comment:
   ```suggestion
   
               // produce a non-transactional record to the follower node 
(current leader)
               produceMessage(cluster, TOPIC, "after");
   
               Map<String, Object> consumerConfig = Map.of(
                       ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 
cluster.bootstrapServers(),
                       ConsumerConfig.GROUP_ID_CONFIG, 
"unclean-election-consumer-" + UUID.randomUUID(),
                       ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class.getName(),
                       ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class.getName(),
                       ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest",
                       ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false,
                       ConsumerConfig.ISOLATION_LEVEL_CONFIG, 
IsolationLevel.READ_COMMITTED.toString()
               );
   
               // create a consumer to read committed records
               try (Consumer<String, String> consumer = new 
KafkaConsumer<>(consumerConfig)) {
                   consumer.assign(List.of(TOPIC_PARTITION));
                   consumer.seekToBeginning(List.of(TOPIC_PARTITION));
                   List<String> values = new ArrayList<>();
                   waitForCondition(
                           () -> {
                               for (ConsumerRecord<String, String> record : 
consumer.poll(Duration.ofMillis(100))) {
                                   values.add(record.value());
                               }
                               return !values.isEmpty();
                           },
                           DEFAULT_MAX_WAIT_MS,
                           "Timed out waiting for the read_committed consumer 
to read the non-transactional record"
                   );
   
                   // verify we can only read the 1st non-txn record because 
the 2nd txn record is not committed in the log
                   assertEquals(List.of("before"), values);
                   assertEquals(1, consumer.position(TOPIC_PARTITION));
                   assertTrue(consumer.poll(Duration.ofMillis(500)).isEmpty());
               }
               
               // try to abort the txn
               var describeProducerResult = 
admin.describeProducers(List.of(TOPIC_PARTITION), new 
DescribeProducersOptions()).all().get();
               // find the hanging txn by describeProducer
               List<ProducerState> hangingTxnProducerStates = 
describeProducerResult.get(TOPIC_PARTITION).activeProducers().stream().filter(ap
 -> ap.currentTransactionStartOffset().isPresent()).toList();
               assertEquals(1, hangingTxnProducerStates.size());
               // abort the hanging txn
               admin.abortTransaction(new AbortTransactionSpec(
                       TOPIC_PARTITION,
                       hangingTxnProducerStates.get(0).producerId(),
                       (short) hangingTxnProducerStates.get(0).producerEpoch(),
                       
hangingTxnProducerStates.get(0).coordinatorEpoch().orElse(0))).all().get();
   
               // create another consumer to read committed records again
               try (Consumer<String, String> consumer = new 
KafkaConsumer<>(consumerConfig)) {
                   consumer.assign(List.of(TOPIC_PARTITION));
                   consumer.seekToBeginning(List.of(TOPIC_PARTITION));
                   List<String> values = new ArrayList<>();
                   waitForCondition(
                           () -> {
                               for (ConsumerRecord<String, String> record : 
consumer.poll(Duration.ofMillis(100))) {
                                   values.add(record.value());
                               }
                               return !values.isEmpty();
                           },
                           DEFAULT_MAX_WAIT_MS,
                           "Timed out waiting for the read_committed consumer 
to read the non-transactional record"
                   );
   
                   // verify we can now read the 2nd non-txn record because the 
txn record is aborted in the log
                   assertEquals(List.of("before", "after"), values);
                   assertEquals(4, consumer.position(TOPIC_PARTITION));
                   assertTrue(consumer.poll(Duration.ofMillis(500)).isEmpty());
               }
           }
   ```



##########
server/src/test/java/org/apache/kafka/server/UncleanLeaderElectionTest.java:
##########
@@ -139,6 +152,126 @@ public void testUncleanLeaderElectionEnabledConsumer() 
throws Exception {
         testUncleanLeaderElectionEnabled(GroupProtocol.CONSUMER);
     }
 
+    @ClusterTest(
+        serverProperties = {
+            @ClusterConfigProperty(key = 
TopicConfig.UNCLEAN_LEADER_ELECTION_ENABLE_CONFIG, value = "true"),
+            @ClusterConfigProperty(key = 
TransactionLogConfig.TRANSACTIONS_TOPIC_REPLICATION_FACTOR_CONFIG, value = "1"),
+            @ClusterConfigProperty(key = 
TransactionLogConfig.TRANSACTIONS_TOPIC_MIN_ISR_CONFIG, value = "1")
+        }
+    )
+    public void 
testUncleanLeaderElectionCanLeaveReadCommittedConsumerAtLastStableOffset() 
throws Exception {
+        disableEligibleLeaderReplicas();
+
+        String transactionalId = "unclean-election-" + UUID.randomUUID();
+        Map<String, Object> producerConfig = Map.of(
+                ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, 
cluster.bootstrapServers(),
+                ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName(),
+                ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName(),
+                ProducerConfig.ACKS_CONFIG, "all",
+                ProducerConfig.TRANSACTIONAL_ID_CONFIG, transactionalId
+        );
+
+        try (KafkaProducer<String, String> producer = new 
KafkaProducer<>(producerConfig)) {
+            producer.initTransactions();
+
+            var transactionStateTopic = 
admin.describeTopics(Set.of(Topic.TRANSACTION_STATE_TOPIC_NAME))
+                    
.allTopicNames().get().get(Topic.TRANSACTION_STATE_TOPIC_NAME);
+            int transactionPartitionId = Utils.abs(transactionalId.hashCode()) 
% transactionStateTopic.partitions().size();
+            int transactionCoordinatorId = 
transactionStateTopic.partitions().stream()
+                    .filter(partition -> partition.partition() == 
transactionPartitionId)
+                    .findFirst()
+                    .orElseThrow(() -> new AssertionError("Transaction state 
partition is missing"))
+                    .leader().id();
+            int followerId = transactionCoordinatorId == BROKER_ID_0 ? 
BROKER_ID_1 : BROKER_ID_0;
+
+            NewTopic newTopic = new NewTopic(TOPIC, Map.of(PARTITION_ID, 
List.of(transactionCoordinatorId, followerId)))
+                    .configs(Map.of(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, 
"1"));
+            admin.createTopics(List.of(newTopic)).all().get();
+            try {
+                admin.electLeaders(ElectionType.PREFERRED, 
Set.of(TOPIC_PARTITION)).all().get();
+            } catch (ExecutionException e) {
+                assertInstanceOf(ElectionNotNeededException.class, 
e.getCause());
+            }
+
+            int leaderId = awaitLeaderChange(cluster, TOPIC_PARTITION, 
Optional.of(transactionCoordinatorId));
+            KafkaBroker leader = cluster.brokers().get(leaderId);
+            KafkaBroker follower = cluster.brokers().get(followerId);
+
+            produceMessage(cluster, TOPIC, "before");
+            waitForCondition(
+                    () -> {
+                        var log = 
follower.replicaManager().localLog(TOPIC_PARTITION);
+                        return log.isDefined() && log.get().logEndOffset() >= 
1;
+                    },
+                    DEFAULT_MAX_WAIT_MS,
+                    "Timed out waiting for the follower to replicate the 
non-transactional record"
+            );
+
+            producer.beginTransaction();

Review Comment:
   Let's add a comment here, ex: `// produce a transactional record`



##########
server/src/test/java/org/apache/kafka/server/UncleanLeaderElectionTest.java:
##########
@@ -139,6 +152,126 @@ public void testUncleanLeaderElectionEnabledConsumer() 
throws Exception {
         testUncleanLeaderElectionEnabled(GroupProtocol.CONSUMER);
     }
 
+    @ClusterTest(
+        serverProperties = {
+            @ClusterConfigProperty(key = 
TopicConfig.UNCLEAN_LEADER_ELECTION_ENABLE_CONFIG, value = "true"),
+            @ClusterConfigProperty(key = 
TransactionLogConfig.TRANSACTIONS_TOPIC_REPLICATION_FACTOR_CONFIG, value = "1"),
+            @ClusterConfigProperty(key = 
TransactionLogConfig.TRANSACTIONS_TOPIC_MIN_ISR_CONFIG, value = "1")
+        }
+    )
+    public void 
testUncleanLeaderElectionCanLeaveReadCommittedConsumerAtLastStableOffset() 
throws Exception {
+        disableEligibleLeaderReplicas();
+
+        String transactionalId = "unclean-election-" + UUID.randomUUID();
+        Map<String, Object> producerConfig = Map.of(
+                ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, 
cluster.bootstrapServers(),
+                ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName(),
+                ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName(),
+                ProducerConfig.ACKS_CONFIG, "all",
+                ProducerConfig.TRANSACTIONAL_ID_CONFIG, transactionalId
+        );
+
+        try (KafkaProducer<String, String> producer = new 
KafkaProducer<>(producerConfig)) {
+            producer.initTransactions();
+
+            var transactionStateTopic = 
admin.describeTopics(Set.of(Topic.TRANSACTION_STATE_TOPIC_NAME))
+                    
.allTopicNames().get().get(Topic.TRANSACTION_STATE_TOPIC_NAME);
+            int transactionPartitionId = Utils.abs(transactionalId.hashCode()) 
% transactionStateTopic.partitions().size();
+            int transactionCoordinatorId = 
transactionStateTopic.partitions().stream()
+                    .filter(partition -> partition.partition() == 
transactionPartitionId)
+                    .findFirst()
+                    .orElseThrow(() -> new AssertionError("Transaction state 
partition is missing"))
+                    .leader().id();
+            int followerId = transactionCoordinatorId == BROKER_ID_0 ? 
BROKER_ID_1 : BROKER_ID_0;
+
+            NewTopic newTopic = new NewTopic(TOPIC, Map.of(PARTITION_ID, 
List.of(transactionCoordinatorId, followerId)))
+                    .configs(Map.of(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, 
"1"));
+            admin.createTopics(List.of(newTopic)).all().get();
+            try {
+                admin.electLeaders(ElectionType.PREFERRED, 
Set.of(TOPIC_PARTITION)).all().get();
+            } catch (ExecutionException e) {
+                assertInstanceOf(ElectionNotNeededException.class, 
e.getCause());
+            }
+
+            int leaderId = awaitLeaderChange(cluster, TOPIC_PARTITION, 
Optional.of(transactionCoordinatorId));
+            KafkaBroker leader = cluster.brokers().get(leaderId);
+            KafkaBroker follower = cluster.brokers().get(followerId);
+
+            produceMessage(cluster, TOPIC, "before");

Review Comment:
   Let's add a comment here, ex: `// 1. produce a non-transactional record`



##########
clients/src/main/java/org/apache/kafka/common/config/TopicConfig.java:
##########
@@ -199,7 +199,16 @@ public class TopicConfig {
     public static final String UNCLEAN_LEADER_ELECTION_ENABLE_CONFIG = 
"unclean.leader.election.enable";
     public static final String UNCLEAN_LEADER_ELECTION_ENABLE_DOC = "Indicates 
whether to enable replicas " +
         "not in the ISR set to be elected as leader as a last resort, even 
though doing so may result in data " +
-        "loss.<p>Note: In KRaft mode, when enabling this config dynamically, 
it needs to wait for the unclean leader election" +
+        "loss. Enabling this configuration for a topic that uses transactions 
is incompatible with exactly-once " +
+        "semantics. An unclean election can remove a transaction's COMMIT or 
ABORT marker from the elected replica, " +
+        "causing consumers with <code>isolation.level=read_committed</code> to 
stop at the last stable offset. " +
+        "If this occurs, use <code>kafka-transactions.sh find-hanging</code> 
with <code>--topic</code> or " +
+        "<code>--broker-id</code> to look for an open transaction whose marker 
is missing. If it reports a transaction " +
+        "whose producer state is still available, use 
<code>kafka-transactions.sh abort</code> with its topic, partition, " +
+        "and start offset to abort it. These commands cannot restore records 
or markers lost by an unclean election. " +
+        "Verify the transaction and partition before aborting it, since an 
unclean election may already have caused " +
+        "data loss." +

Review Comment:
   ```suggestion
           
   ```



##########
clients/src/main/java/org/apache/kafka/common/config/TopicConfig.java:
##########
@@ -199,7 +199,16 @@ public class TopicConfig {
     public static final String UNCLEAN_LEADER_ELECTION_ENABLE_CONFIG = 
"unclean.leader.election.enable";
     public static final String UNCLEAN_LEADER_ELECTION_ENABLE_DOC = "Indicates 
whether to enable replicas " +
         "not in the ISR set to be elected as leader as a last resort, even 
though doing so may result in data " +
-        "loss.<p>Note: In KRaft mode, when enabling this config dynamically, 
it needs to wait for the unclean leader election" +
+        "loss. Enabling this configuration for a topic that uses transactions 
is incompatible with exactly-once " +
+        "semantics. An unclean election can remove a transaction's COMMIT or 
ABORT marker from the elected replica, " +
+        "causing consumers with <code>isolation.level=read_committed</code> to 
stop at the last stable offset. " +
+        "If this occurs, use <code>kafka-transactions.sh find-hanging</code> 
with <code>--topic</code> or " +
+        "<code>--broker-id</code> to look for an open transaction whose marker 
is missing. If it reports a transaction " +
+        "whose producer state is still available, use 
<code>kafka-transactions.sh abort</code> with its topic, partition, " +
+        "and start offset to abort it. These commands cannot restore records 
or markers lost by an unclean election. " +

Review Comment:
   ```suggestion
           "If this occurs, use <code>kafka-transactions.sh find-hanging</code> 
" +
           "to look for an open transaction whose marker is missing. If it 
reports a transaction " +
           "whose producer state is still available, use 
<code>kafka-transactions.sh abort</code> " +
           "to abort it. "
   ```



##########
server/src/test/java/org/apache/kafka/server/UncleanLeaderElectionTest.java:
##########
@@ -139,6 +152,126 @@ public void testUncleanLeaderElectionEnabledConsumer() 
throws Exception {
         testUncleanLeaderElectionEnabled(GroupProtocol.CONSUMER);
     }
 
+    @ClusterTest(
+        serverProperties = {
+            @ClusterConfigProperty(key = 
TopicConfig.UNCLEAN_LEADER_ELECTION_ENABLE_CONFIG, value = "true"),
+            @ClusterConfigProperty(key = 
TransactionLogConfig.TRANSACTIONS_TOPIC_REPLICATION_FACTOR_CONFIG, value = "1"),
+            @ClusterConfigProperty(key = 
TransactionLogConfig.TRANSACTIONS_TOPIC_MIN_ISR_CONFIG, value = "1")
+        }
+    )
+    public void 
testUncleanLeaderElectionCanLeaveReadCommittedConsumerAtLastStableOffset() 
throws Exception {
+        disableEligibleLeaderReplicas();
+
+        String transactionalId = "unclean-election-" + UUID.randomUUID();
+        Map<String, Object> producerConfig = Map.of(
+                ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, 
cluster.bootstrapServers(),
+                ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName(),
+                ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
StringSerializer.class.getName(),
+                ProducerConfig.ACKS_CONFIG, "all",
+                ProducerConfig.TRANSACTIONAL_ID_CONFIG, transactionalId
+        );
+
+        try (KafkaProducer<String, String> producer = new 
KafkaProducer<>(producerConfig)) {
+            producer.initTransactions();
+
+            var transactionStateTopic = 
admin.describeTopics(Set.of(Topic.TRANSACTION_STATE_TOPIC_NAME))
+                    
.allTopicNames().get().get(Topic.TRANSACTION_STATE_TOPIC_NAME);
+            int transactionPartitionId = Utils.abs(transactionalId.hashCode()) 
% transactionStateTopic.partitions().size();
+            int transactionCoordinatorId = 
transactionStateTopic.partitions().stream()
+                    .filter(partition -> partition.partition() == 
transactionPartitionId)
+                    .findFirst()
+                    .orElseThrow(() -> new AssertionError("Transaction state 
partition is missing"))
+                    .leader().id();
+            int followerId = transactionCoordinatorId == BROKER_ID_0 ? 
BROKER_ID_1 : BROKER_ID_0;
+
+            NewTopic newTopic = new NewTopic(TOPIC, Map.of(PARTITION_ID, 
List.of(transactionCoordinatorId, followerId)))
+                    .configs(Map.of(TopicConfig.MIN_IN_SYNC_REPLICAS_CONFIG, 
"1"));
+            admin.createTopics(List.of(newTopic)).all().get();
+            try {
+                admin.electLeaders(ElectionType.PREFERRED, 
Set.of(TOPIC_PARTITION)).all().get();
+            } catch (ExecutionException e) {
+                assertInstanceOf(ElectionNotNeededException.class, 
e.getCause());
+            }
+
+            int leaderId = awaitLeaderChange(cluster, TOPIC_PARTITION, 
Optional.of(transactionCoordinatorId));
+            KafkaBroker leader = cluster.brokers().get(leaderId);
+            KafkaBroker follower = cluster.brokers().get(followerId);
+
+            produceMessage(cluster, TOPIC, "before");
+            waitForCondition(
+                    () -> {
+                        var log = 
follower.replicaManager().localLog(TOPIC_PARTITION);
+                        return log.isDefined() && log.get().logEndOffset() >= 
1;
+                    },
+                    DEFAULT_MAX_WAIT_MS,
+                    "Timed out waiting for the follower to replicate the 
non-transactional record"
+            );
+
+            producer.beginTransaction();
+            producer.send(new ProducerRecord<>(TOPIC, "transactional")).get();
+
+            // The follower must contain the transaction data, but must be 
stopped before the marker is committed.
+            waitForCondition(
+                    () -> {
+                        var log = 
follower.replicaManager().localLog(TOPIC_PARTITION);
+                        return log.isDefined() && log.get().logEndOffset() >= 
2;
+                    },
+                    DEFAULT_MAX_WAIT_MS,
+                    "Timed out waiting for the follower to replicate the 
transactional record"
+            );
+            follower.shutdown();
+            follower.awaitShutdown();
+            waitForCondition(
+                    () -> {
+                        var leaderAndIsr = 
leader.replicaManager().metadataCache().getLeaderAndIsr(TOPIC, PARTITION_ID);
+                        return leaderAndIsr.isPresent() && 
leaderAndIsr.get().isr().equals(Set.of(leaderId));
+                    },
+                    DEFAULT_MAX_WAIT_MS,
+                    "Timed out waiting for the follower to leave the ISR"
+            );
+
+            // The marker is now written only to the leader. The follower will 
retain the open transaction after failover.
+            producer.commitTransaction();
+
+            // Close the producer while its coordinator is still available; no 
client cleanup should depend on the old leader.
+            producer.close();
+            leader.shutdown();
+            leader.awaitShutdown();
+            follower.startup();
+            awaitLeaderChange(cluster, TOPIC_PARTITION, 
Optional.of(followerId));
+
+            Map<String, Object> consumerConfig = Map.of(
+                    ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 
cluster.bootstrapServers(),
+                    ConsumerConfig.GROUP_ID_CONFIG, 
"unclean-election-consumer-" + UUID.randomUUID(),
+                    ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class.getName(),
+                    ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
StringDeserializer.class.getName(),
+                    ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest",
+                    ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false,
+                    ConsumerConfig.ISOLATION_LEVEL_CONFIG, 
IsolationLevel.READ_COMMITTED.toString()
+            );
+
+            try (Consumer<String, String> consumer = new 
KafkaConsumer<>(consumerConfig)) {
+                consumer.assign(List.of(TOPIC_PARTITION));
+                consumer.seekToBeginning(List.of(TOPIC_PARTITION));
+                List<String> values = new ArrayList<>();
+                waitForCondition(
+                        () -> {
+                            for (ConsumerRecord<String, String> record : 
consumer.poll(Duration.ofMillis(100))) {
+                                values.add(record.value());
+                            }
+                            return !values.isEmpty();
+                        },
+                        DEFAULT_MAX_WAIT_MS,
+                        "Timed out waiting for the read_committed consumer to 
read the non-transactional record"
+                );
+
+                assertEquals(List.of("before"), values);
+                assertEquals(1, consumer.position(TOPIC_PARTITION));
+                assertTrue(consumer.poll(Duration.ofMillis(500)).isEmpty());
+            }
+        }

Review Comment:
   Let's write a 3rd record with non-txn record. So that we can use the 
consumer to verify the committed read is blocked. Then after aborting the 
hanging txn, we can then verify it's unblocked.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to