This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-connectors-kafka.git


The following commit(s) were added to refs/heads/main by this push:
     new ec192ac3 replace waitForDraining (#623)
ec192ac3 is described below

commit ec192ac3a13e41a91a93782141e53b137a40a58c
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Aug 10 09:31:28 2026 +0100

    replace waitForDraining (#623)
---
 .../internal/TransactionalProducerStage.scala      | 30 ++++++-
 .../kafka/internal/TransactionalSources.scala      | 86 ++++++++++----------
 .../apache/pekko/kafka/internal/ProducerSpec.scala | 91 +++++++++++++++++++++-
 3 files changed, 157 insertions(+), 50 deletions(-)

diff --git 
a/core/src/main/scala/org/apache/pekko/kafka/internal/TransactionalProducerStage.scala
 
b/core/src/main/scala/org/apache/pekko/kafka/internal/TransactionalProducerStage.scala
index d41752f7..d34c093d 100644
--- 
a/core/src/main/scala/org/apache/pekko/kafka/internal/TransactionalProducerStage.scala
+++ 
b/core/src/main/scala/org/apache/pekko/kafka/internal/TransactionalProducerStage.scala
@@ -31,6 +31,7 @@ import org.apache.kafka.common.TopicPartition
 import scala.concurrent.Future
 import scala.concurrent.duration._
 import scala.jdk.CollectionConverters._
+import scala.util.control.NonFatal
 
 /**
  * INTERNAL API
@@ -231,12 +232,37 @@ private final class TransactionalProducerStageLogic[K, V, 
P](
   override def onCompletionSuccess(): Unit = {
     log.debug("Committing final transaction before shutdown")
     cancelTimer(commitSchedulerKey)
-    maybeCommitTransaction(beginNewTransaction = false, 
abortEmptyTransactionOnComplete = true)
+    setKeepGoing(true)
+    try {
+      batchOffsets match {
+        case batch: NonemptyTransactionBatch =>
+          commitTransaction(batch, beginNewTransaction = false)
+        case _: EmptyTransactionBatch =>
+          abortTransaction("Transaction is empty and stage is completing")
+        case _ =>
+          ()
+      }
+    } catch {
+      case NonFatal(ex) =>
+        log.error(ex, "Failed to commit final transaction, aborting")
+        try {
+          abortTransaction("Final transaction commit failed")
+        } catch {
+          case NonFatal(abortEx) =>
+            log.error(abortEx, "Failed to abort transaction after commit 
failure")
+        }
+        batchOffsets.committingFailed()
+    }
     super.onCompletionSuccess()
   }
 
   override def onCompletionFailure(ex: Throwable): Unit = {
-    abortTransaction("Stage failure")
+    try {
+      abortTransaction("Stage failure")
+    } catch {
+      case NonFatal(abortEx) =>
+        log.error(abortEx, "Failed to abort transaction during stage failure")
+    }
     batchOffsets.committingFailed()
     super.onCompletionFailure(ex)
   }
diff --git 
a/core/src/main/scala/org/apache/pekko/kafka/internal/TransactionalSources.scala
 
b/core/src/main/scala/org/apache/pekko/kafka/internal/TransactionalSources.scala
index c8ee5b43..907f88d9 100644
--- 
a/core/src/main/scala/org/apache/pekko/kafka/internal/TransactionalSources.scala
+++ 
b/core/src/main/scala/org/apache/pekko/kafka/internal/TransactionalSources.scala
@@ -36,7 +36,7 @@ import org.apache.kafka.clients.consumer.{ ConsumerConfig, 
ConsumerGroupMetadata
 import org.apache.kafka.common.{ IsolationLevel, TopicPartition }
 
 import scala.concurrent.duration.FiniteDuration
-import scala.concurrent.{ Await, ExecutionContext, Future }
+import scala.concurrent.{ ExecutionContext, Future }
 
 /** Internal API */
 @InternalApi
@@ -168,36 +168,31 @@ private[internal] abstract class 
TransactionalSourceLogic[K, V, Msg](shape: Sour
 
   override protected def addToPartitionAssignmentHandler(
       handler: PartitionAssignmentHandler): PartitionAssignmentHandler = {
-    val blockingRevokedCall = new PartitionAssignmentHandler {
+    val asyncRevokedCall = new PartitionAssignmentHandler {
       override def onAssign(assignedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit = ()
 
       // This is invoked in the KafkaConsumerActor thread when doing poll.
-      override def onRevoke(revokedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
-        if (waitForDraining(revokedTps)) {
-          sourceActor.ref.tell(Revoked(revokedTps.toList), consumerActor)
-        } else {
-          sourceActor.ref.tell(Failure(new Error("Timeout while draining")), 
consumerActor)
-          consumerActor.tell(KafkaConsumerActor.Internal.StopFromStage(id), 
consumerActor)
-        }
+      // Uses async ask to avoid blocking the poll thread, which would prevent 
heartbeats
+      // and could deadlock if the stage actor needs the consumer actor to 
process commits.
+      override def onRevoke(revokedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit = {
+        import pekko.pattern.ask
+        implicit val timeout: Timeout = Timeout(consumerSettings.commitTimeout)
+        ask(stageActor.ref, Drain(revokedTps, None, Drained))
+          .onComplete {
+            case scala.util.Success(_) =>
+              sourceActor.ref.tell(Revoked(revokedTps.toList), consumerActor)
+            case scala.util.Failure(_) =>
+              sourceActor.ref.tell(Failure(new Error("Timeout while 
draining")), consumerActor)
+              
consumerActor.tell(KafkaConsumerActor.Internal.StopFromStage(id), consumerActor)
+          }(ExecutionContext.parasitic)
+      }
 
       override def onLost(lostTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
         onRevoke(lostTps, consumer)
 
       override def onStop(revokedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit = ()
     }
-    new PartitionAssignmentHelpers.Chain(handler, blockingRevokedCall)
-  }
-
-  private def waitForDraining(partitions: Set[TopicPartition]): Boolean = {
-    import pekko.pattern.ask
-    implicit val timeout: Timeout = Timeout(consumerSettings.commitTimeout)
-    try {
-      Await.result(ask(stageActor.ref, Drain(partitions, None, Drained)), 
timeout.duration)
-      true
-    } catch {
-      case t: Throwable =>
-        false
-    }
+    new PartitionAssignmentHelpers.Chain(handler, asyncRevokedCall)
   }
 }
 
@@ -247,19 +242,31 @@ private[kafka] final class TransactionalSubSource[K, V](
 
       override protected def addToPartitionAssignmentHandler(
           handler: PartitionAssignmentHandler): PartitionAssignmentHandler = {
-        val blockingRevokedCall = new PartitionAssignmentHandler {
+        val asyncRevokedCall = new PartitionAssignmentHandler {
           override def onAssign(assignedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit = ()
 
           // This is invoked in the KafkaConsumerActor thread when doing poll.
+          // Uses async ask to avoid blocking the poll thread, which would 
prevent heartbeats
+          // and could deadlock if the stage actor needs the consumer actor to 
process commits.
           override def onRevoke(revokedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
-            if (revokedTps.isEmpty) ()
-            else if (waitForDraining(revokedTps)) {
-              subSources.values
-                .map(_.controlAndStageActor.stageActor)
-                .foreach(_.tell(Revoked(revokedTps.toList), stageActor.ref))
-            } else {
-              sourceActor.ref.tell(Status.Failure(new Error("Timeout while 
draining")), stageActor.ref)
-              
consumerActor.tell(KafkaConsumerActor.Internal.StopFromStage(id), 
stageActor.ref)
+            if (revokedTps.nonEmpty) {
+              import pekko.pattern.ask
+              implicit val timeout: Timeout = 
Timeout(txConsumerSettings.commitTimeout)
+              implicit val ec: ExecutionContext = executionContext
+              Future
+                .sequence(
+                  subSources.values.map(_.stageActor).map(ask(_, 
Drain(revokedTps, None, Drained))))
+                .onComplete {
+                  case scala.util.Success(_) =>
+                    subSources.values
+                      .map(_.controlAndStageActor.stageActor)
+                      .foreach(_.tell(Revoked(revokedTps.toList), 
stageActor.ref))
+                  case scala.util.Failure(_) =>
+                    sourceActor.ref.tell(
+                      Status.Failure(new Error("Timeout while draining")),
+                      stageActor.ref)
+                    
consumerActor.tell(KafkaConsumerActor.Internal.StopFromStage(id), 
stageActor.ref)
+                }
             }
 
           override def onLost(lostTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit =
@@ -267,22 +274,7 @@ private[kafka] final class TransactionalSubSource[K, V](
 
           override def onStop(revokedTps: Set[TopicPartition], consumer: 
RestrictedConsumer): Unit = ()
         }
-        new PartitionAssignmentHelpers.Chain(handler, blockingRevokedCall)
-      }
-
-      private def waitForDraining(partitions: Set[TopicPartition]): Boolean = {
-        import pekko.pattern.ask
-        implicit val timeout: Timeout = 
Timeout(txConsumerSettings.commitTimeout)
-        try {
-          val drainCommandFutures =
-            subSources.values.map(_.stageActor).map(ask(_, Drain(partitions, 
None, Drained)))
-          implicit val ec: ExecutionContext = executionContext
-          Await.result(Future.sequence(drainCommandFutures), timeout.duration)
-          true
-        } catch {
-          case t: Throwable =>
-            false
-        }
+        new PartitionAssignmentHelpers.Chain(handler, asyncRevokedCall)
       }
     }
   }
diff --git 
a/tests/src/test/scala/org/apache/pekko/kafka/internal/ProducerSpec.scala 
b/tests/src/test/scala/org/apache/pekko/kafka/internal/ProducerSpec.scala
index 53cbe2d1..e064baf0 100644
--- a/tests/src/test/scala/org/apache/pekko/kafka/internal/ProducerSpec.scala
+++ b/tests/src/test/scala/org/apache/pekko/kafka/internal/ProducerSpec.scala
@@ -30,7 +30,7 @@ import pekko.{ Done, NotUsed }
 import com.typesafe.config.ConfigFactory
 import org.apache.kafka.clients.consumer.{ ConsumerGroupMetadata, 
OffsetAndMetadata }
 import org.apache.kafka.clients.producer._
-import org.apache.kafka.common.TopicPartition
+import org.apache.kafka.common.{ KafkaException, TopicPartition }
 import org.apache.kafka.common.serialization.StringSerializer
 import org.mockito
 import org.mockito.Mockito
@@ -563,6 +563,80 @@ class ProducerSpec(_system: ActorSystem)
     client.verifyTxAbort()
     client.verifyClosed()
   }
+
+  it should "abort transaction and close producer when commitTransaction fails 
during shutdown" in {
+    val input = recordAndMetadata(1)
+
+    val client = {
+      val inputMap = Map(input)
+      new ProducerMock[K, V](ProducerMock.handlers.delayedMap(100.millis)(x => 
Try { inputMap(x) }))
+    }
+    val committedMarker = new CommittedMarkerMock
+
+    val (source, sink) = TestSource[TxMsg]()
+      .via(testTransactionProducerFlow(client))
+      .toMat(TestSink())(Keep.both)
+      .run()
+
+    val txMsg: TxMsg = toTxMessage(input, committedMarker.mock)
+    source.sendNext(txMsg)
+    sink.requestNext()
+
+    client.verifySend(atLeastOnce())
+
+    // Wait for the timer-based commit to succeed first
+    awaitAssert(client.verifyTxCommit(txMsg.passThrough), 2.second)
+
+    // Now make commitTransaction throw for the shutdown commit
+    Mockito
+      .doAnswer(_ => throw new KafkaException("commit failed"))
+      .when(client.mock)
+      .commitTransaction()
+
+    // Complete the source to trigger onCompletionSuccess
+    source.sendComplete()
+    sink.expectComplete()
+
+    // Verify: commitTransaction was called (and threw), then abortTransaction 
as fallback, then producer closed
+    client.verifyTxAbortAfterFailedCommit()
+  }
+
+  it should "close producer when abortTransaction fails during stage failure" 
in {
+    val input = recordAndMetadata(1)
+
+    val client = {
+      val inputMap = Map(input)
+      new ProducerMock[K, V](ProducerMock.handlers.delayedMap(100.millis)(x => 
Try { inputMap(x) }))
+    }
+    val committedMarker = new CommittedMarkerMock
+
+    val (source, sink) = TestSource[TxMsg]()
+      .via(testTransactionProducerFlow(client))
+      .toMat(Sink.lastOption)(Keep.both)
+      .run()
+
+    val txMsg = toTxMessage(input, committedMarker.mock)
+    source.sendNext(txMsg)
+
+    awaitAssert(client.verifyTxInitialized())
+
+    // Make abortTransaction throw
+    Mockito
+      .doAnswer(_ => throw new KafkaException("abort failed"))
+      .when(client.mock)
+      .abortTransaction()
+
+    // Trigger stage failure
+    source.sendError(new Exception("upstream failure"))
+
+    Await.ready(sink, remainingOrDefault)
+    sink.value should matchPattern {
+      case Some(Failure(_)) =>
+    }
+
+    // Even though abortTransaction throws, the producer should still be closed
+    client.verifyClosedAfterFailedAbort()
+  }
 }
 
 object ProducerMock {
@@ -672,6 +746,21 @@ class ProducerMock[K, V](handler: ProducerMock.Handler[K, 
V])(implicit ec: Execu
     inOrder.verify(mock).flush()
     
inOrder.verify(mock).close(mockito.ArgumentMatchers.any[java.time.Duration])
   }
+
+  def verifyTxAbortAfterFailedCommit() = {
+    val inOrder = Mockito.inOrder(mock)
+    inOrder.verify(mock).commitTransaction()
+    inOrder.verify(mock).abortTransaction()
+    inOrder.verify(mock).flush()
+    
inOrder.verify(mock).close(mockito.ArgumentMatchers.any[java.time.Duration])
+  }
+
+  def verifyClosedAfterFailedAbort() = {
+    val inOrder = Mockito.inOrder(mock)
+    inOrder.verify(mock).abortTransaction()
+    inOrder.verify(mock).flush()
+    
inOrder.verify(mock).close(mockito.ArgumentMatchers.any[java.time.Duration])
+  }
 }
 
 class CommittedMarkerMock {


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to