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 80170dff Clear lastRevoked after use to avoid wiping buffers under
cooperative rebalancing (#617)
80170dff is described below
commit 80170dfff3ab0ffc237b855c5fe7cdaf0bbde6d8
Author: Arvind Raghavan <[email protected]>
AuthorDate: Sun Aug 9 07:00:15 2026 -0500
Clear lastRevoked after use to avoid wiping buffers under cooperative
rebalancing (#617)
* Clear lastRevoked after use to avoid wiping buffers under cooperative
rebalancing
* With the cooperative rebalance protocol onPartitionsRevoked is only
invoked on
members that actually revoke partitions, while onPartitionsAssigned is
invoked
on every member in every rebalance. A stale lastRevoked set from an
earlier
rebalance was re-applied on such assignment callbacks and filtered
buffered
records of partitions that remained assigned, losing records without them
being processed or committed.
* Reset lastRevoked after the flush in SingleSourceLogic and SubSourceLogic.
* Run the RebalanceSpec buffer tests against both the eager and cooperative
protocols using a cooperative variant of the test assignor.
* Add CooperativeRebalanceSpec reproducing the record loss for plain and
partitioned sources; both tests fail without the fix.
Fixes #616
Co-Authored-By: Claude Fable 5 <[email protected]>
* Address review: standard ASF header for new file, drop final vals and
test logging, apply scalafmt
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
---
.../pekko/kafka/internal/SingleSourceLogic.scala | 4 +-
.../pekko/kafka/internal/SubSourceLogic.scala | 4 +-
.../kafka/scaladsl/CooperativeRebalanceSpec.scala | 174 ++++++++
.../pekko/kafka/scaladsl/RebalanceSpec.scala | 439 ++++++++++++---------
4 files changed, 439 insertions(+), 182 deletions(-)
diff --git
a/core/src/main/scala/org/apache/pekko/kafka/internal/SingleSourceLogic.scala
b/core/src/main/scala/org/apache/pekko/kafka/internal/SingleSourceLogic.scala
index 283a0c4c..351848f6 100644
---
a/core/src/main/scala/org/apache/pekko/kafka/internal/SingleSourceLogic.scala
+++
b/core/src/main/scala/org/apache/pekko/kafka/internal/SingleSourceLogic.scala
@@ -95,8 +95,10 @@ import scala.concurrent.{ Future, Promise }
override def onRevoke(revokedTps: Set[TopicPartition], consumer:
RestrictedConsumer): Unit =
lastRevoked = revokedTps
- override def onAssign(assignedTps: Set[TopicPartition], consumer:
RestrictedConsumer): Unit =
+ override def onAssign(assignedTps: Set[TopicPartition], consumer:
RestrictedConsumer): Unit = {
filterRevokedPartitionsCB.invoke(lastRevoked -- assignedTps)
+ lastRevoked = Set.empty
+ }
override def onLost(lostTps: Set[TopicPartition], consumer:
RestrictedConsumer): Unit =
filterRevokedPartitionsCB.invoke(lostTps)
diff --git
a/core/src/main/scala/org/apache/pekko/kafka/internal/SubSourceLogic.scala
b/core/src/main/scala/org/apache/pekko/kafka/internal/SubSourceLogic.scala
index c09acb31..ec70d534 100644
--- a/core/src/main/scala/org/apache/pekko/kafka/internal/SubSourceLogic.scala
+++ b/core/src/main/scala/org/apache/pekko/kafka/internal/SubSourceLogic.scala
@@ -298,11 +298,13 @@ private class SubSourceLogic[K, V, Msg](
override def onRevoke(revokedTps: Set[TopicPartition], consumer:
RestrictedConsumer): Unit =
lastRevoked = revokedTps
- override def onAssign(assignedTps: Set[TopicPartition], consumer:
RestrictedConsumer): Unit =
+ override def onAssign(assignedTps: Set[TopicPartition], consumer:
RestrictedConsumer): Unit = {
for {
tp <- lastRevoked -- assignedTps
control <- subSources.get(tp)
} control.filterRevokedPartitionsCB.invoke(Set(tp))
+ lastRevoked = Set.empty
+ }
override def onLost(lostTps: Set[TopicPartition], consumer:
RestrictedConsumer): Unit =
for {
diff --git
a/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/CooperativeRebalanceSpec.scala
b/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/CooperativeRebalanceSpec.scala
new file mode 100644
index 00000000..482e5122
--- /dev/null
+++
b/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/CooperativeRebalanceSpec.scala
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.kafka.scaladsl
+
+import org.apache.pekko
+import pekko.Done
+import pekko.kafka._
+import pekko.kafka.scaladsl.Consumer.Control
+import pekko.kafka.testkit.scaladsl.TestcontainersKafkaLike
+import pekko.stream.scaladsl.{ Keep, Source }
+import pekko.stream.testkit.scaladsl.StreamTestKit.assertAllStagesStopped
+import pekko.stream.testkit.scaladsl.TestSink
+import pekko.testkit.TestProbe
+import org.apache.kafka.clients.consumer.{ ConsumerConfig, ConsumerRecord }
+import org.apache.kafka.common.TopicPartition
+import org.scalatest.Inside
+
+import scala.concurrent.duration._
+import scala.util.Random
+
+/**
+ * With the cooperative rebalance protocol `onPartitionsRevoked` is only
invoked on members that
+ * actually revoke partitions, while `onPartitionsAssigned` is invoked on
every member in every
+ * rebalance, possibly with an empty set. These tests reproduce rebalances
where the revoke
+ * callback is skipped and assert that buffered records of partitions that
remain assigned are
+ * still delivered (at-least-once).
+ *
+ * Reproduces the scenario of
https://github.com/apache/pekko-connectors-kafka/issues/616:
+ * 1. a partition is revoked from consumer 1 (its `lastRevoked` state becomes
non-empty),
+ * 2. the partition is later re-assigned to consumer 1,
+ * 3. an unrelated member joins, completing a rebalance in which consumer 1
revokes nothing
+ * (`onPartitionsRevoked` is not invoked) and gains nothing
(`onPartitionsAssigned` with an
+ * empty set) while records for the re-assigned partition sit in the
source stage buffer.
+ *
+ * The test runs against `plainSource` and `plainPartitionedSource` (flattened
with
+ * `flatMapMerge` so both shapes expose a single stream of records) as the
underlying stages
+ * implement the revoked-buffer bookkeeping independently. The merge stage of
the flattened
+ * variant buffers a few records of its own that survive a revoke as
duplicates, so the tests
+ * assert complete delivery rather than an exact sequence.
+ */
+class CooperativeRebalanceSpec extends SpecBase with TestcontainersKafkaLike
with Inside {
+
+ implicit override val patienceConfig: PatienceConfig =
PatienceConfig(30.seconds, 500.millis)
+
+ val partition1 = 1
+ val consumerClientId1 = "consumer-1"
+ val consumerClientId2 = "consumer-2"
+ val consumerClientId3 = "consumer-3"
+
+ private def cooperativeSettings(group: String) =
+ consumerDefaults
+ .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500")
+ .withProperty(
+ ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
+ classOf[CooperativePekkoConnectorsAssignor].getName)
+ .withGroupId(group)
+
+ private def awaitAssigned(rebalanceActor: TestProbe,
+ subscription: AutoSubscription,
+ tps: Set[TopicPartition]): Unit =
+ rebalanceActor.fishForMessage(10.seconds) {
+ case TopicPartitionsAssigned(`subscription`, assigned) if assigned ==
tps => true
+ case TopicPartitionsAssigned(`subscription`, assigned) if
assigned.isEmpty => false
+ }
+
+ sealed trait SourceCase {
+ def label: String
+ def source(settings: ConsumerSettings[String, String],
+ subscription: AutoSubscription): Source[ConsumerRecord[String,
String], Control]
+ }
+
+ case object PlainCase extends SourceCase {
+ override val label = "plain source"
+ override def source(settings: ConsumerSettings[String, String],
+ subscription: AutoSubscription): Source[ConsumerRecord[String,
String], Control] =
+ Consumer.plainSource(settings, subscription)
+ }
+
+ case object PartitionedCase extends SourceCase {
+ override val label = "partitioned source"
+ override def source(settings: ConsumerSettings[String, String],
+ subscription: AutoSubscription): Source[ConsumerRecord[String,
String], Control] =
+ Consumer
+ .plainPartitionedSource(settings, subscription)
+ .flatMapMerge(breadth = 8, { case (_, records) => records })
+ }
+
+ "Buffered records of partitions that stay assigned" must {
+
+ List(PlainCase, PartitionedCase).foreach { mode =>
+ s"be delivered after a rebalance without revocation (${mode.label})" in
assertAllStagesStopped {
+ val count = 100L
+ val topicSuffix = Random.nextInt()
+ val topic1 = createTopic(topicSuffix, partitions = 2)
+ val group1 = createGroupId(1)
+ val tp0 = new TopicPartition(topic1, partition0)
+ val tp1 = new TopicPartition(topic1, partition1)
+ val consumerSettings = cooperativeSettings(group1)
+
+ def joinConsumer(clientId: String) = {
+ val rebalanceActor = TestProbe()
+ val subscription =
Subscriptions.topics(topic1).withRebalanceListener(rebalanceActor.ref)
+ val (control, probe) = Consumer
+ .plainSource(consumerSettings.withClientId(clientId), subscription)
+ .toMat(TestSink())(Keep.both)
+ .run()
+ (control, probe, rebalanceActor, subscription)
+ }
+
+ awaitProduce(produce(topic1, 0 to count.toInt, partition1))
+
+
PekkoConnectorsAssignor.clientIdToPartitionMap.set(Map(consumerClientId1 ->
Set(tp0, tp1)))
+
+ val probe1rebalanceActor = TestProbe()
+ val probe1subscription =
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
+ val (control1, probe1) = mode
+ .source(consumerSettings.withClientId(consumerClientId1),
probe1subscription)
+ .toMat(TestSink())(Keep.both)
+ .run()
+
+
probe1rebalanceActor.expectMsg(TopicPartitionsAssigned(probe1subscription,
Set(tp0, tp1)))
+ probe1.requestNext()
+
+ PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+ Map(consumerClientId1 -> Set(tp0), consumerClientId2 -> Set(tp1)))
+ val (control2, probe2, probe2rebalanceActor, probe2subscription) =
joinConsumer(consumerClientId2)
+
+
probe1rebalanceActor.expectMsg(TopicPartitionsRevoked(probe1subscription,
Set(tp1)))
+ awaitAssigned(probe2rebalanceActor, probe2subscription, Set(tp1))
+
+
PekkoConnectorsAssignor.clientIdToPartitionMap.set(Map(consumerClientId1 ->
Set(tp0, tp1)))
+ probe2.cancel()
+ control2.isShutdown.futureValue shouldBe Done
+ awaitAssigned(probe1rebalanceActor, probe1subscription, Set(tp1))
+
+ probe1.requestNext()
+
+ PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+ Map(consumerClientId1 -> Set(tp0, tp1), consumerClientId3 ->
Set.empty[TopicPartition]))
+ val (control3, probe3, probe3rebalanceActor, probe3subscription) =
joinConsumer(consumerClientId3)
+
+
probe1rebalanceActor.expectMsg(TopicPartitionsAssigned(probe1subscription,
Set.empty))
+
probe3rebalanceActor.expectMsg(TopicPartitionsAssigned(probe3subscription,
Set.empty))
+
+ // give the asynchronous buffer filter of the rebalance a chance to
apply before demanding
+ probe1.expectNoMessage(500.millis)
+
+ probe1.request(count * 3)
+ val values = probe1.receiveWithin(5.seconds).map(_.value)
+ values should contain allElementsOf (1 to count.toInt).map(_.toString)
+
+ probe1.cancel()
+ probe3.cancel()
+ control1.isShutdown.futureValue shouldBe Done
+ control3.isShutdown.futureValue shouldBe Done
+ }
+ }
+ }
+}
diff --git
a/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/RebalanceSpec.scala
b/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/RebalanceSpec.scala
index 21fca2b2..c3e476f4 100644
--- a/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/RebalanceSpec.scala
+++ b/tests/src/test/scala/org/apache/pekko/kafka/scaladsl/RebalanceSpec.scala
@@ -45,197 +45,241 @@ class RebalanceSpec extends SpecBase with
TestcontainersKafkaLike with Inside {
final val consumerClientId1 = "consumer-1"
final val consumerClientId2 = "consumer-2"
+ sealed trait ProtocolCase {
+ def label: String
+ def assignor: Class[?]
+
+ /** Expected rebalance listener events after the second consumer joins and
takes tp1. */
+ def expectSecondConsumerJoin(
+ probe1rebalanceActor: TestProbe,
+ probe1subscription: AutoSubscription,
+ probe2rebalanceActor: TestProbe,
+ probe2subscription: AutoSubscription,
+ tp0: TopicPartition,
+ tp1: TopicPartition): Unit
+ }
+
+ case object EagerCase extends ProtocolCase {
+ override val label = "eager"
+ override val assignor: Class[?] = classOf[PekkoConnectorsAssignor]
+
+ override def expectSecondConsumerJoin(
+ probe1rebalanceActor: TestProbe,
+ probe1subscription: AutoSubscription,
+ probe2rebalanceActor: TestProbe,
+ probe2subscription: AutoSubscription,
+ tp0: TopicPartition,
+ tp1: TopicPartition): Unit = {
+
probe1rebalanceActor.expectMsg(TopicPartitionsRevoked(probe1subscription,
Set(tp0, tp1)))
+
probe1rebalanceActor.expectMsg(TopicPartitionsAssigned(probe1subscription,
Set(tp0)))
+
probe2rebalanceActor.expectMsg(TopicPartitionsAssigned(probe2subscription,
Set(tp1)))
+ }
+ }
+
+ case object CooperativeCase extends ProtocolCase {
+ override val label = "cooperative"
+ override val assignor: Class[?] =
classOf[CooperativePekkoConnectorsAssignor]
+
+ override def expectSecondConsumerJoin(
+ probe1rebalanceActor: TestProbe,
+ probe1subscription: AutoSubscription,
+ probe2rebalanceActor: TestProbe,
+ probe2subscription: AutoSubscription,
+ tp0: TopicPartition,
+ tp1: TopicPartition): Unit = {
+ // tp1 is revoked from consumer 1 and assigned to consumer 2 in a
follow-up rebalance.
+ // Intermediate empty assignments are not asserted: members that fail
the intermediate
+ // generation's sync with REBALANCE_IN_PROGRESS skip its assignment
callback entirely.
+
probe1rebalanceActor.expectMsg(TopicPartitionsRevoked(probe1subscription,
Set(tp1)))
+ probe2rebalanceActor.fishForMessage(10.seconds) {
+ case TopicPartitionsAssigned(`probe2subscription`, assigned) if
assigned == Set(tp1) => true
+ case TopicPartitionsAssigned(`probe2subscription`, assigned) if
assigned.isEmpty => false
+ }
+ }
+ }
+
"Fetched records" must {
// The `max.poll.records` controls how many records Kafka fetches
internally during a poll.
// issue explained in https://github.com/akka/alpakka-kafka/issues/872
// this test added with https://github.com/akka/alpakka-kafka/pull/865
- "be removed from the source stage buffer when a partition is revoked" in
assertAllStagesStopped {
- val count = 20L
- // de-coupling consecutive test runs with crossScalaVersions on build
- val topicSuffix = Random.nextInt()
- val topic1 = createTopic(topicSuffix, partitions = 2)
- val group1 = createGroupId(1)
- val tp0 = new TopicPartition(topic1, partition0)
- val tp1 = new TopicPartition(topic1, partition1)
- val consumerSettings = consumerDefaults
- .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500") // 500 is
the default value
- .withProperty(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
classOf[PekkoConnectorsAssignor].getName)
- .withGroupId(group1)
-
- awaitProduce(produce(topic1, 0 to count.toInt, partition1))
-
- PekkoConnectorsAssignor.clientIdToPartitionMap.set(
- Map(
- consumerClientId1 -> Set(tp0, tp1)))
-
- log.debug("Subscribe to the topic (without demand)")
- val probe1rebalanceActor = TestProbe()
- val probe1subscription =
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
- val (control1, probe1) = Consumer
- .plainSource(consumerSettings.withClientId(consumerClientId1),
probe1subscription)
- .toMat(TestSink())(Keep.both)
- .run()
-
- log.debug("Await initial partition assignment")
- probe1rebalanceActor.expectMsg(
- TopicPartitionsAssigned(probe1subscription,
- Set(new TopicPartition(topic1, partition0), new
TopicPartition(topic1, partition1))))
-
- log.debug("read one message from probe1 with partition 1")
- probe1.requestNext()
-
- PekkoConnectorsAssignor.clientIdToPartitionMap.set(
- Map(
- consumerClientId1 -> Set(tp0),
- consumerClientId2 -> Set(tp1)))
-
- log.debug("Subscribe to the topic (without demand)")
- val probe2rebalanceActor = TestProbe()
- val probe2subscription =
Subscriptions.topics(topic1).withRebalanceListener(probe2rebalanceActor.ref)
- val (control2, probe2) = Consumer
- .plainSource(consumerSettings.withClientId(consumerClientId2),
probe2subscription)
- .toMat(TestSink())(Keep.both)
- .run()
-
- log.debug("Await a revoke to consumer 1")
- probe1rebalanceActor.expectMsg(
- TopicPartitionsRevoked(probe1subscription,
- Set(new TopicPartition(topic1, partition0), new
TopicPartition(topic1, partition1))))
-
- log.debug("the rebalance finishes")
- probe1rebalanceActor.expectMsg(
- TopicPartitionsAssigned(probe1subscription, Set(new
TopicPartition(topic1, partition0))))
- probe2rebalanceActor.expectMsg(
- TopicPartitionsAssigned(probe2subscription, Set(new
TopicPartition(topic1, partition1))))
-
- log.debug("resume demand on both consumers")
- probe1.request(count)
- probe2.request(count)
-
- val probe2messages = probe2.expectNextN(count)
-
- log.debug("no further messages enqueued on probe1 as partition 1 is
balanced away")
- probe1.expectNoMessage(500.millis)
-
- probe2messages should have size count
-
- probe1.cancel()
- probe2.cancel()
-
- control1.isShutdown.futureValue shouldBe Done
- control2.isShutdown.futureValue shouldBe Done
+ List(EagerCase, CooperativeCase).foreach { mode =>
+ s"be removed from the source stage buffer when a partition is revoked
(${mode.label})" in assertAllStagesStopped {
+ val count = 20L
+ // de-coupling consecutive test runs with crossScalaVersions on build
+ val topicSuffix = Random.nextInt()
+ val topic1 = createTopic(topicSuffix, partitions = 2)
+ val group1 = createGroupId(1)
+ val tp0 = new TopicPartition(topic1, partition0)
+ val tp1 = new TopicPartition(topic1, partition1)
+ val consumerSettings = consumerDefaults
+ .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500") // 500
is the default value
+ .withProperty(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
mode.assignor.getName)
+ .withGroupId(group1)
+
+ awaitProduce(produce(topic1, 0 to count.toInt, partition1))
+
+ PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+ Map(
+ consumerClientId1 -> Set(tp0, tp1)))
+
+ log.debug("Subscribe to the topic (without demand)")
+ val probe1rebalanceActor = TestProbe()
+ val probe1subscription =
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
+ val (control1, probe1) = Consumer
+ .plainSource(consumerSettings.withClientId(consumerClientId1),
probe1subscription)
+ .toMat(TestSink())(Keep.both)
+ .run()
+
+ log.debug("Await initial partition assignment")
+ probe1rebalanceActor.expectMsg(
+ TopicPartitionsAssigned(probe1subscription,
+ Set(new TopicPartition(topic1, partition0), new
TopicPartition(topic1, partition1))))
+
+ log.debug("read one message from probe1 with partition 1")
+ probe1.requestNext()
+
+ PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+ Map(
+ consumerClientId1 -> Set(tp0),
+ consumerClientId2 -> Set(tp1)))
+
+ log.debug("Subscribe to the topic (without demand)")
+ val probe2rebalanceActor = TestProbe()
+ val probe2subscription =
Subscriptions.topics(topic1).withRebalanceListener(probe2rebalanceActor.ref)
+ val (control2, probe2) = Consumer
+ .plainSource(consumerSettings.withClientId(consumerClientId2),
probe2subscription)
+ .toMat(TestSink())(Keep.both)
+ .run()
+
+ log.debug("Await the rebalance to complete")
+ mode.expectSecondConsumerJoin(probe1rebalanceActor,
probe1subscription, probe2rebalanceActor,
+ probe2subscription, tp0, tp1)
+
+ log.debug("resume demand on both consumers")
+ probe1.request(count)
+ probe2.request(count)
+
+ val probe2messages = probe2.expectNextN(count)
+
+ log.debug("no further messages enqueued on probe1 as partition 1 is
balanced away")
+ probe1.expectNoMessage(500.millis)
+
+ probe2messages should have size count
+
+ probe1.cancel()
+ probe2.cancel()
+
+ control1.isShutdown.futureValue shouldBe Done
+ control2.isShutdown.futureValue shouldBe Done
+ }
}
- "be removed from the partitioned source stage buffer when a partition is
revoked" in assertAllStagesStopped {
- def subSourcesWithProbes(
- partitions: Int,
- probe: TestSubscriber.Probe[(TopicPartition,
Source[ConsumerRecord[String, String], NotUsed])])
- : Seq[(TopicPartition, TestSubscriber.Probe[ConsumerRecord[String,
String]])] =
- probe
- .expectNextN(partitions.toLong)
- .map {
- case (tp, subSource) =>
- (tp, subSource.toMat(TestSink())(Keep.right).run())
+ List(EagerCase, CooperativeCase).foreach { mode =>
+ s"be removed from the partitioned source stage buffer when a partition
is revoked (${mode.label})" in
+ assertAllStagesStopped {
+ def subSourcesWithProbes(
+ partitions: Int,
+ probe: TestSubscriber.Probe[(TopicPartition,
Source[ConsumerRecord[String, String], NotUsed])])
+ : Seq[(TopicPartition, TestSubscriber.Probe[ConsumerRecord[String,
String]])] =
+ probe
+ .expectNextN(partitions.toLong)
+ .map {
+ case (tp, subSource) =>
+ (tp, subSource.toMat(TestSink())(Keep.right).run())
+ }
+
+ def runForSubSource(
+ partition: Int,
+ subSourcesWithProbes: Seq[(TopicPartition,
TestSubscriber.Probe[ConsumerRecord[String, String]])])(
+ fun: TestSubscriber.Probe[ConsumerRecord[String, String]] => Unit)
=
+ subSourcesWithProbes
+ .find { case (tp, _) => tp.partition() == partition }
+ .foreach { case (_, probe) => fun(probe) }
+
+ val count = 20L
+ // de-coupling consecutive test runs with crossScalaVersions on build
+ val topicSuffix = Random.nextInt()
+ val topic1 = createTopic(topicSuffix, partitions = 2)
+ val group1 = createGroupId(1)
+ val tp0 = new TopicPartition(topic1, partition0)
+ val tp1 = new TopicPartition(topic1, partition1)
+ val consumerSettings = consumerDefaults
+ .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500") // 500
is the default value
+ .withProperty(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
mode.assignor.getName)
+ .withGroupId(group1)
+
+ awaitProduce(produce(topic1, 0 to count.toInt, partition1))
+
+ PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+ Map(
+ consumerClientId1 -> Set(tp0, tp1)))
+
+ log.debug("Subscribe to the topic (without demand)")
+ val probe1rebalanceActor = TestProbe()
+ val probe1subscription =
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
+ val (control1, probe1) = Consumer
+
.plainPartitionedSource(consumerSettings.withClientId(consumerClientId1),
probe1subscription)
+ .toMat(TestSink())(Keep.both)
+ .run()
+
+ log.debug("Await initial partition assignment")
+ probe1rebalanceActor.expectMsg(
+ TopicPartitionsAssigned(probe1subscription,
+ Set(new TopicPartition(topic1, partition0), new
TopicPartition(topic1, partition1))))
+
+ log.debug("read 2 sub sources returned by partitioned source")
+ probe1.request(2)
+ val probe1RunningSubSourceProbes = subSourcesWithProbes(partitions =
2, probe1)
+
+ log.debug("read one message from probe1 sub source for partition 1")
+ probe1RunningSubSourceProbes
+ .find { case (tp, _) => tp.partition() == partition1 }
+ .foreach { case (_, probe) => probe.requestNext() }
+
+ PekkoConnectorsAssignor.clientIdToPartitionMap.set(
+ Map(
+ consumerClientId1 -> Set(tp0),
+ consumerClientId2 -> Set(tp1)))
+
+ log.debug("Subscribe to the topic (without demand)")
+ val probe2rebalanceActor = TestProbe()
+ val probe2subscription =
Subscriptions.topics(topic1).withRebalanceListener(probe2rebalanceActor.ref)
+ val (control2, probe2) = Consumer
+
.plainPartitionedSource(consumerSettings.withClientId(consumerClientId2),
probe2subscription)
+ .toMat(TestSink())(Keep.both)
+ .run()
+
+ probe2.request(1)
+ val probe2RunningSubSourceProbes = subSourcesWithProbes(partitions =
1, probe2)
+
+ log.debug("Await the rebalance to complete")
+ mode.expectSecondConsumerJoin(probe1rebalanceActor,
probe1subscription, probe2rebalanceActor,
+ probe2subscription, tp0, tp1)
+
+ log.debug("resume demand on both consumers")
+ runForSubSource(partition = 1,
probe1RunningSubSourceProbes)(_.request(count))
+ runForSubSource(partition = 1,
probe2RunningSubSourceProbes)(_.request(count))
+
+ log.debug("no further messages enqueued on probe1 as partition 1 is
balanced away")
+ runForSubSource(partition = 1,
probe1RunningSubSourceProbes)(_.expectComplete())
+
+ val probe2messages = probe2RunningSubSourceProbes
+ .find { case (tp, _) => tp.partition() == partition1 }
+ .toList
+ .flatMap {
+ case (_, probe) =>
+ probe.expectNextN(count)
}
- def runForSubSource(
- partition: Int,
- subSourcesWithProbes: Seq[(TopicPartition,
TestSubscriber.Probe[ConsumerRecord[String, String]])])(
- fun: TestSubscriber.Probe[ConsumerRecord[String, String]] => Unit) =
- subSourcesWithProbes
- .find { case (tp, _) => tp.partition() == partition }
- .foreach { case (_, probe) => fun(probe) }
-
- val count = 20L
- // de-coupling consecutive test runs with crossScalaVersions on build
- val topicSuffix = Random.nextInt()
- val topic1 = createTopic(topicSuffix, partitions = 2)
- val group1 = createGroupId(1)
- val tp0 = new TopicPartition(topic1, partition0)
- val tp1 = new TopicPartition(topic1, partition1)
- val consumerSettings = consumerDefaults
- .withProperty(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, "500") // 500 is
the default value
- .withProperty(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
classOf[PekkoConnectorsAssignor].getName)
- .withGroupId(group1)
-
- awaitProduce(produce(topic1, 0 to count.toInt, partition1))
-
- PekkoConnectorsAssignor.clientIdToPartitionMap.set(
- Map(
- consumerClientId1 -> Set(tp0, tp1)))
-
- log.debug("Subscribe to the topic (without demand)")
- val probe1rebalanceActor = TestProbe()
- val probe1subscription =
Subscriptions.topics(topic1).withRebalanceListener(probe1rebalanceActor.ref)
- val (control1, probe1) = Consumer
-
.plainPartitionedSource(consumerSettings.withClientId(consumerClientId1),
probe1subscription)
- .toMat(TestSink())(Keep.both)
- .run()
-
- log.debug("Await initial partition assignment")
- probe1rebalanceActor.expectMsg(
- TopicPartitionsAssigned(probe1subscription,
- Set(new TopicPartition(topic1, partition0), new
TopicPartition(topic1, partition1))))
-
- log.debug("read 2 sub sources returned by partitioned source")
- probe1.request(2)
- val probe1RunningSubSourceProbes = subSourcesWithProbes(partitions = 2,
probe1)
-
- log.debug("read one message from probe1 sub source for partition 1")
- probe1RunningSubSourceProbes
- .find { case (tp, _) => tp.partition() == partition1 }
- .foreach { case (_, probe) => probe.requestNext() }
-
- PekkoConnectorsAssignor.clientIdToPartitionMap.set(
- Map(
- consumerClientId1 -> Set(tp0),
- consumerClientId2 -> Set(tp1)))
-
- log.debug("Subscribe to the topic (without demand)")
- val probe2rebalanceActor = TestProbe()
- val probe2subscription =
Subscriptions.topics(topic1).withRebalanceListener(probe2rebalanceActor.ref)
- val (control2, probe2) = Consumer
-
.plainPartitionedSource(consumerSettings.withClientId(consumerClientId2),
probe2subscription)
- .toMat(TestSink())(Keep.both)
- .run()
-
- probe2.request(1)
- val probe2RunningSubSourceProbes = subSourcesWithProbes(partitions = 1,
probe2)
-
- log.debug("Await a revoke to consumer 1")
- probe1rebalanceActor.expectMsg(
- TopicPartitionsRevoked(probe1subscription,
- Set(new TopicPartition(topic1, partition0), new
TopicPartition(topic1, partition1))))
-
- log.debug("the rebalance finishes")
- probe1rebalanceActor.expectMsg(
- TopicPartitionsAssigned(probe1subscription, Set(new
TopicPartition(topic1, partition0))))
- probe2rebalanceActor.expectMsg(
- TopicPartitionsAssigned(probe2subscription, Set(new
TopicPartition(topic1, partition1))))
-
- log.debug("resume demand on both consumers")
- runForSubSource(partition = 1,
probe1RunningSubSourceProbes)(_.request(count))
- runForSubSource(partition = 1,
probe2RunningSubSourceProbes)(_.request(count))
-
- log.debug("no further messages enqueued on probe1 as partition 1 is
balanced away")
- runForSubSource(partition = 1,
probe1RunningSubSourceProbes)(_.expectComplete())
-
- val probe2messages = probe2RunningSubSourceProbes
- .find { case (tp, _) => tp.partition() == partition1 }
- .toList
- .flatMap {
- case (_, probe) =>
- probe.expectNextN(count)
- }
-
- probe2messages should have size count
+ probe2messages should have size count
- probe1.cancel()
- probe2.cancel()
+ probe1.cancel()
+ probe2.cancel()
- control1.isShutdown.futureValue shouldBe Done
- control2.isShutdown.futureValue shouldBe Done
+ control1.isShutdown.futureValue shouldBe Done
+ control2.isShutdown.futureValue shouldBe Done
+ }
}
}
}
@@ -290,3 +334,38 @@ class PekkoConnectorsAssignor extends
AbstractPartitionAssignor {
assignments.toMap.asJava
}
}
+
+/**
+ * Variant of [[PekkoConnectorsAssignor]] that uses the cooperative rebalance
protocol.
+ *
+ * The cooperative protocol requires that a partition never moves directly
from one member to
+ * another within a single rebalance: it must be absent from all assignments
for one generation
+ * (revoking it from its previous owner, which triggers a follow-up rebalance)
before it may be
+ * assigned to its new owner.
+ */
+class CooperativePekkoConnectorsAssignor extends PekkoConnectorsAssignor {
+
+ override def name(): String = "pekko-connector-kafka-test-cooperative"
+
+ override def supportedProtocols():
util.List[ConsumerPartitionAssignor.RebalanceProtocol] =
+ util.Arrays.asList(ConsumerPartitionAssignor.RebalanceProtocol.COOPERATIVE)
+
+ override def assign(
+ partitionsPerTopic: util.Map[String, Integer],
+ subscriptions: util.Map[String, ConsumerPartitionAssignor.Subscription])
+ : util.Map[String, util.List[TopicPartition]] = {
+ val desired = super.assign(partitionsPerTopic, subscriptions).asScala
+ val currentOwner: Map[TopicPartition, String] = (for {
+ (memberId, subscription) <- subscriptions.asScala.toSeq
+ tp <- subscription.ownedPartitions().asScala
+ } yield tp -> memberId).toMap
+
+ desired.map {
+ case (memberId, tps) =>
+ val withoutMovingPartitions = tps.asScala.filter { tp =>
+ currentOwner.get(tp).forall(_ == memberId)
+ }
+ memberId -> withoutMovingPartitions.asJava
+ }.asJava
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]