Savonitar commented on code in PR #314:
URL: 
https://github.com/apache/flink-connector-kafka/pull/314#discussion_r4039525893


##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/sink/internal/TransactionAbortStrategyImpl.java:
##########
@@ -143,6 +143,18 @@ public void abortTransactions(Context context) {
             TransactionAborter transactionAborter = 
context.getTransactionAborter();
             for (String name : openTransactionsForSubtask) {
                 if (context.getPrecommittedTransactionalIds().contains(name)) {
+                    if (context.isPrecommittedTransactionSuperseded(name)) {
+                        // The broker holds a later transaction under this id 
than the one the
+                        // committer is about to commit. That commit will be 
fenced, and nobody
+                        // owns the open transaction; abort it so that it does 
not pin the last
+                        // stable offset until the transaction timeout.
+                        LOG.warn(
+                                "Aborting open transaction {}: the recovered 
transaction under this id was superseded by a newer epoch",
+                                name);
+                        context.abandonPrecommittedTransaction(name);

Review Comment:
   I think here we have a tricky issue:
   With unchanged prefix (the common/default case), this method frees the ID 
and `POOLING` reuses it immediately, however the restored committer's fenced 
commit still can send notification `TransactionFinished.erroneously (ID)` and 
`recycleByTransactionId` that matches by transactional id.
    Can that stale notification land on the newly reused transaction? If yes, 
it means in that case we will have a dataloss.
   
   To improve the robustness of this repository, I raised a separate 
**independent** PR with an additional test coverage 
https://github.com/apache/flink-connector-kafka/pull/322 
   It is a general test (not dedicated for this PR/ticket). But it catches this 
issue too.



##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/sink/internal/TransactionAbortStrategyContextImpl.java:
##########
@@ -111,7 +136,50 @@ public Set<String> getPrefixesToAbort() {
 
     @Override
     public Set<String> getPrecommittedTransactionalIds() {
-        return precommittedTransactionIds;
+        return Collections.unmodifiableSet(precommittedTransactions.keySet());
+    }
+
+    @Override
+    public boolean isPrecommittedTransactionSuperseded(String transactionalId) 
{

Review Comment:
   Is this method tested only by IT case tests? 



##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/sink/internal/TransactionAbortStrategyContextImpl.java:
##########
@@ -111,7 +136,50 @@ public Set<String> getPrefixesToAbort() {
 
     @Override
     public Set<String> getPrecommittedTransactionalIds() {
-        return precommittedTransactionIds;
+        return Collections.unmodifiableSet(precommittedTransactions.keySet());
+    }
+
+    @Override
+    public boolean isPrecommittedTransactionSuperseded(String transactionalId) 
{
+        CheckpointTransaction transaction = 
precommittedTransactions.get(transactionalId);
+        if (transaction == null || !transaction.hasKnownEpoch()) {
+            // state written before v3 did not record the epoch; keep the old 
behavior
+            return false;
+        }
+        TransactionDescription description = 
describePrecommitted().get(transactionalId);
+        if (description == null) {
+            // the broker does not know the id any more; nothing to abort
+            return false;
+        }
+        boolean superseded =
+                description.producerId() != transaction.getProducerId()
+                        || description.producerEpoch() > 
transaction.getEpoch();
+        if (superseded) {
+            LOG.info(
+                    "Recovered transaction {} was opened with producer id {} 
and epoch {}, but the broker now holds producer id {} and epoch {} in state {}",
+                    transactionalId,
+                    transaction.getProducerId(),
+                    transaction.getEpoch(),
+                    description.producerId(),
+                    description.producerEpoch(),
+                    description.state());
+        }
+        return superseded;
+    }
+
+    @Override
+    public void abandonPrecommittedTransaction(String transactionalId) {
+        precommittedTransactions.remove(transactionalId);
+        precommittedTransactionAbandoner.accept(transactionalId);
+    }
+
+    private Map<String, TransactionDescription> describePrecommitted() {
+        if (precommittedDescriptions == null) {
+            precommittedDescriptions =
+                    AdminUtils.describeTransactions(

Review Comment:
   just to confirm, the frequency of calling this method is not high? 
(according to PR description it is once). Is it once per subtask restart? 



##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/sink/internal/TransactionAbortStrategyContextImpl.java:
##########
@@ -111,7 +136,50 @@ public Set<String> getPrefixesToAbort() {
 
     @Override
     public Set<String> getPrecommittedTransactionalIds() {
-        return precommittedTransactionIds;
+        return Collections.unmodifiableSet(precommittedTransactions.keySet());
+    }
+
+    @Override
+    public boolean isPrecommittedTransactionSuperseded(String transactionalId) 
{
+        CheckpointTransaction transaction = 
precommittedTransactions.get(transactionalId);
+        if (transaction == null || !transaction.hasKnownEpoch()) {
+            // state written before v3 did not record the epoch; keep the old 
behavior
+            return false;
+        }
+        TransactionDescription description = 
describePrecommitted().get(transactionalId);
+        if (description == null) {
+            // the broker does not know the id any more; nothing to abort
+            return false;
+        }
+        boolean superseded =
+                description.producerId() != transaction.getProducerId()
+                        || description.producerEpoch() > 
transaction.getEpoch();
+        if (superseded) {
+            LOG.info(
+                    "Recovered transaction {} was opened with producer id {} 
and epoch {}, but the broker now holds producer id {} and epoch {} in state {}",

Review Comment:
   Should we use "the broker reported" instead of "the broker now holds" here 
and avoid calling the transaction "open" in the subsequent warning? 
   
   The `ONGOING` filter applies to the earlier `listTransactions()` result, 
while `describeTransactions()` is a separate call whose results are cached. The 
coordinator could abort the transaction between these observations or before 
cleanup, so **neither log guarantees** it is still **open**.
   Or am I missing something?



##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/sink/internal/ProducerPool.java:
##########
@@ -40,9 +40,23 @@ public interface ProducerPool extends AutoCloseable {
     FlinkKafkaInternalProducer<byte[], byte[]> getTransactionalProducer(
             String transactionalId, long checkpointId);
 
-    /** Returns a snapshot of all ongoing transactions. */
+    /**
+     * Returns a snapshot of all ongoing transactions. Transactions opened by 
this pool carry the
+     * producer id and epoch of their producer; transactions restored from 
state keep whatever the
+     * state recorded.
+     */
     Collection<CheckpointTransaction> getOngoingTransactions();
 
+    /**
+     * Stops tracking a transaction that was restored from state without 
touching a producer. Used
+     * on recovery when the broker no longer holds the restored transaction 
under its transactional
+     * id because the id was reused for a later checkpoint, so that the id can 
be aborted and
+     * reused.

Review Comment:
   > reused
   
   Maybe this comment should be clarified a bit after you check 
https://github.com/apache/flink-connector-kafka/pull/314/changes#r4039525893 
   (because then it can not be reused ASAP). 



##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/sink/KafkaWriterStateSerializer.java:
##########
@@ -31,11 +32,17 @@
 
 import static org.apache.flink.connector.kafka.sink.KafkaWriterState.UNKNOWN;
 
-/** A serializer used to serialize {@link KafkaWriterState}. */
+/**
+ * A serializer used to serialize {@link KafkaWriterState}.
+ *
+ * <p>Version 3 adds the producer id and epoch of every precommitted 
transaction, so that a recovery

Review Comment:
   Is a downgrade/rollback path considered or we expect forward-only for this 
bump? (I see PR says "backwards compatible on read").



##########
flink-connector-kafka/src/test/java/org/apache/flink/connector/kafka/sink/ExactlyOnceKafkaWriterITCase.java:
##########
@@ -275,6 +275,61 @@ void shouldNotAbortPrecommittedTransactions(int 
numCheckpointed) throws Exceptio
         }
     }
 
+    /**
+     * With {@code POOLING}, a committed transactional id is reused for a 
later checkpoint under a
+     * newer epoch. A recovery from the earlier checkpoint still lists the id 
as precommitted, and
+     * the committer's commit will be fenced. The open transaction under the 
newer epoch has no
+     * owner and must be aborted on recovery, also when the prefix changed and 
the id is never
+     * reused again (FLINK-40626).
+     */
+    @Test
+    void shouldAbortSupersededPrecommittedTransactionOnRecovery() throws 
Exception {
+        final KafkaWriterState stateOfCheckpoint1;
+        final CheckpointTransaction precommitted;
+        try (final ExactlyOnceKafkaWriter<Integer> failedWriter =
+                createWriter(this::withPooling, createInitContext())) {
+            Tuple2<KafkaWriterState, KafkaCommittable> checkpoint1 =
+                    onCheckpointBarrier(failedWriter, 1);
+            stateOfCheckpoint1 = checkpoint1.f0;
+            precommitted =
+                    
Iterables.getOnlyElement(stateOfCheckpoint1.getPrecommittedTransactionalIds());
+            assertThat(precommitted.hasKnownEpoch()).isTrue();
+            
assertThat(precommitted.getEpoch()).isEqualTo(checkpoint1.f1.getEpoch());
+
+            // the committer commits checkpoint 1 and hands the id back to the 
pool
+            checkpoint1.f1.getProducer().get().commitTransaction();
+            try (WritableBackchannel<TransactionFinished> backchannel =
+                    getBackchannel(failedWriter)) {
+                
backchannel.send(TransactionFinished.successful(precommitted.getTransactionalId()));
+            }
+            onCheckpointBarrier(failedWriter, 2);
+            // checkpoint 3 reuses the id of checkpoint 1 under a bumped epoch
+            KafkaCommittable checkpoint3 = onCheckpointBarrier(failedWriter, 
3).f1;
+            assertThat(checkpoint3.getTransactionalId())
+                    .isEqualTo(precommitted.getTransactionalId());
+            
assertThat(checkpoint3.getEpoch()).isGreaterThan(precommitted.getEpoch());
+            // the job fails here; the transactions of checkpoints 2 and 3 
linger on the broker
+        }
+
+        try (AdminClient admin = 
AdminClient.create(getKafkaClientConfiguration())) {
+            assertThat(AdminUtils.getOpenTransactionsForTopics(admin, 
Collections.singleton(topic)))
+                    .hasSize(2);
+
+            // recovery from checkpoint 1; the new writer gets a new prefix, 
so the old id is never

Review Comment:
   >  the new writer gets a new prefix,
   
   Do we test only changed prefix here? So, the reused prefix is never 
excerciced. Should we add a same-prefix test case with the restored commiter? 



##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/sink/internal/ProducerPoolImpl.java:
##########
@@ -254,7 +255,39 @@ public FlinkKafkaInternalProducer<byte[], byte[]> 
getTransactionalProducer(
 
     @Override
     public Collection<CheckpointTransaction> getOngoingTransactions() {
-        return new ArrayList<>(transactionalIdsByCheckpoint.keySet());
+        List<CheckpointTransaction> ongoing = new 
ArrayList<>(transactionalIdsByCheckpoint.size());
+        for (Map.Entry<CheckpointTransaction, String> entry :
+                transactionalIdsByCheckpoint.entrySet()) {
+            CheckpointTransaction transaction = entry.getKey();
+            ProducerEntry producerEntry = 
producerByTransactionalId.get(entry.getValue());
+            FlinkKafkaInternalProducer<byte[], byte[]> producer =
+                    producerEntry == null ? null : producerEntry.getProducer();
+            if (producer == null) {
+                // restored from state; keep what the state knows
+                ongoing.add(transaction);
+            } else {
+                ongoing.add(
+                        new CheckpointTransaction(
+                                transaction.getTransactionalId(),
+                                transaction.getCheckpointId(),
+                                producer.getProducerId(),
+                                producer.getEpoch()));
+            }
+        }
+        return ongoing;
+    }
+
+    @Override
+    public void abandonTransaction(String transactionalId) {
+        ProducerEntry producerEntry = 
producerByTransactionalId.get(transactionalId);
+        checkState(
+                producerEntry != null && producerEntry.getProducer() == null,
+                "Transaction %s is not a restored transaction without a 
producer: %s",
+                transactionalId,
+                producerEntry);
+        producerByTransactionalId.remove(transactionalId);

Review Comment:
   could we keep the "superseeded" transactionalId reserved after abandoning? 
Removing this entry allows `POOLING` to reuse it during writer initialization, 
meanwhile a completion notification for the old transaction may still be 
queued. 



##########
flink-connector-kafka/src/main/java/org/apache/flink/connector/kafka/sink/internal/TransactionAbortStrategyImpl.java:
##########
@@ -143,6 +143,18 @@ public void abortTransactions(Context context) {
             TransactionAborter transactionAborter = 
context.getTransactionAborter();
             for (String name : openTransactionsForSubtask) {
                 if (context.getPrecommittedTransactionalIds().contains(name)) {
+                    if (context.isPrecommittedTransactionSuperseded(name)) {
+                        // The broker holds a later transaction under this id 
than the one the
+                        // committer is about to commit. That commit will be 
fenced, and nobody
+                        // owns the open transaction; abort it so that it does 
not pin the last
+                        // stable offset until the transaction timeout.
+                        LOG.warn(

Review Comment:
   we have this WARN log and INFO log from [public boolean 
isPrecommittedTransactionSuperseded(String transactionalId) 
{](https://github.com/apache/flink-connector-kafka/pull/314/changes#diff-2b2784802b7feb204128c1f72e9523037a202f417cb74ff3698cd2fe6ce45017R143)
  
   > LOG.info(
                       "Recovered transaction {} was opened with producer id {} 
and epoch {}, but the broker now holds producer id {} and epoch {} in state {}",
   
   do we need to keep both? 



-- 
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