Skip to content

Commit 63478cc

Browse files
wookievxvlovgr
andauthored
feat: Implemented RebalanceRevokeMode (#1399)
Co-authored-by: Viktor Rudebeck <viktor@rudebeck.nu>
1 parent b6cfde5 commit 63478cc

8 files changed

Lines changed: 382 additions & 31 deletions

File tree

build.sbt

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ val scala213 = "2.13.16"
2424

2525
val scala3 = "3.3.5"
2626

27-
ThisBuild / tlBaseVersion := "3.8"
27+
ThisBuild / tlBaseVersion := "3.9"
2828

2929
ThisBuild / tlCiReleaseBranches := Seq("series/3.x")
3030

@@ -299,7 +299,25 @@ ThisBuild / mimaBinaryIssueFilters ++= {
299299
ProblemFilters.exclude[DirectMissingMethodProblem](
300300
"fs2.kafka.ProducerSettings#ProducerSettingsImpl.apply"
301301
),
302-
ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.kafka.KafkaProducer.produceRecord")
302+
ProblemFilters.exclude[DirectMissingMethodProblem]("fs2.kafka.KafkaProducer.produceRecord"),
303+
ProblemFilters.exclude[ReversedMissingMethodProblem](
304+
"fs2.kafka.ConsumerSettings.sessionTimeout"
305+
),
306+
ProblemFilters.exclude[ReversedMissingMethodProblem](
307+
"fs2.kafka.ConsumerSettings.rebalanceRevokeMode"
308+
),
309+
ProblemFilters.exclude[ReversedMissingMethodProblem](
310+
"fs2.kafka.ConsumerSettings.withRebalanceRevokeMode"
311+
),
312+
ProblemFilters.exclude[DirectMissingMethodProblem](
313+
"fs2.kafka.ConsumerSettings#ConsumerSettingsImpl.copy"
314+
),
315+
ProblemFilters.exclude[DirectMissingMethodProblem](
316+
"fs2.kafka.ConsumerSettings#ConsumerSettingsImpl.this"
317+
),
318+
ProblemFilters.exclude[DirectMissingMethodProblem](
319+
"fs2.kafka.ConsumerSettings#ConsumerSettingsImpl.apply"
320+
)
303321
)
304322
}
305323

docs/src/main/mdoc/consumers.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,48 @@ You may notice, that actual graceful shutdown implementation requires a decent a
547547

548548
Also note, that even if you implement a graceful shutdown your application may fall with an error. And in this case, a graceful shutdown will not be invoked. It means that your application should be ready to an _at least once_ semantic even when a graceful shutdown is implemented. Or, if you need an _exactly once_ semantic, consider using [transactions](transactions.md).
549549

550+
### Graceful partition revoke
551+
552+
In addition to graceful shutdown of the whole consumer there is an option to configure your consumer to wait for the streams
553+
to finish processing partition before "releasing" it. Behavior can be enabled via the following settings:
554+
555+
```scala mdoc:silent
556+
object WithGracefulPartitionRevoke extends IOApp.Simple {
557+
val run: IO[Unit] = {
558+
def processRecord(record: CommittableConsumerRecord[IO, String, String]): IO[Unit] =
559+
IO(println(s"Processing record: $record"))
560+
561+
def run(consumer: KafkaConsumer[IO, String, String]): IO[Unit] = {
562+
consumer.subscribeTo("topic") >> consumer
563+
.stream
564+
.evalMap { msg =>
565+
processRecord(msg).as(msg.offset)
566+
}
567+
.through(commitBatchWithin(100, 15.seconds))
568+
.compile
569+
.drain
570+
}
571+
572+
val consumerSettings =
573+
ConsumerSettings[IO, String, String]
574+
.withRebalanceRevokeMode(RebalanceRevokeMode.Graceful)
575+
.withSessionTimeout(2.seconds)
576+
577+
KafkaConsumer
578+
.resource(consumerSettings)
579+
.use { consumer =>
580+
run(consumer)
581+
}
582+
}
583+
}
584+
```
585+
586+
Please note that this setting does not guarantee that all the commits will be performed before partition is revoked and
587+
that `session.timeout.ms` setting is set to lower value. Be aware that awaiting too long for partition processor
588+
to finish will cause processing of the whole topic to be suspended.
589+
590+
Awaiting for commits to complete might be implemented in the future.
591+
550592
[commitrecovery-default]: @API_BASE_URL@/CommitRecovery$.html#Default:fs2.kafka.CommitRecovery
551593
[committableconsumerrecord]: @API_BASE_URL@/CommittableConsumerRecord.html
552594
[committableoffset]: @API_BASE_URL@/CommittableOffset.html

modules/core/src/main/scala/fs2/kafka/ConsumerSettings.scala

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package fs2.kafka
88

99
import scala.concurrent.duration.*
1010
import scala.concurrent.ExecutionContext
11+
import scala.util.Try
1112

1213
import cats.effect.Resource
1314
import cats.Show
@@ -151,6 +152,17 @@ sealed abstract class ConsumerSettings[F[_], K, V] {
151152
*/
152153
def withMaxPollInterval(maxPollInterval: FiniteDuration): ConsumerSettings[F, K, V]
153154

155+
/**
156+
* Returns value for property:
157+
*
158+
* {{{
159+
* ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG
160+
* }}}
161+
*
162+
* Returns a value as a [[FiniteDuration]] for convenience
163+
*/
164+
def sessionTimeout: FiniteDuration
165+
154166
/**
155167
* Returns a new [[ConsumerSettings]] instance with the specified session timeout. This is
156168
* equivalent to setting the following property using the [[withProperty]] function, except you
@@ -373,6 +385,17 @@ sealed abstract class ConsumerSettings[F[_], K, V] {
373385
*/
374386
def withCredentials(credentialsStore: KafkaCredentialStore): ConsumerSettings[F, K, V]
375387

388+
/**
389+
* One of two possible modes of operation for [[KafkaConsumer.partitionsMapStream]]. See
390+
* [[RebalanceRevokeMode]] for detailed explanation of differences between them.
391+
*/
392+
def rebalanceRevokeMode: RebalanceRevokeMode
393+
394+
/**
395+
* Creates a new [[ConsumerSettings]] with the specified [[rebalanceRevokeMode]].
396+
*/
397+
def withRebalanceRevokeMode(rebalanceRevokeMode: RebalanceRevokeMode): ConsumerSettings[F, K, V]
398+
376399
}
377400

378401
object ConsumerSettings {
@@ -388,7 +411,8 @@ object ConsumerSettings {
388411
override val pollTimeout: FiniteDuration,
389412
override val commitRecovery: CommitRecovery,
390413
override val recordMetadata: ConsumerRecord[K, V] => String,
391-
override val maxPrefetchBatches: Int
414+
override val maxPrefetchBatches: Int,
415+
override val rebalanceRevokeMode: RebalanceRevokeMode
392416
) extends ConsumerSettings[F, K, V] {
393417

394418
override def withCustomBlockingContext(ec: ExecutionContext): ConsumerSettings[F, K, V] =
@@ -422,6 +446,14 @@ object ConsumerSettings {
422446
override def withMaxPollInterval(maxPollInterval: FiniteDuration): ConsumerSettings[F, K, V] =
423447
withProperty(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, maxPollInterval.toMillis.toString)
424448

449+
// need to use Try, to avoid separate implementation for scala 2.12
450+
override def sessionTimeout: FiniteDuration =
451+
properties
452+
.get(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG)
453+
.flatMap(str => Try(str.toLong).toOption)
454+
.map(_.millis)
455+
.getOrElse(45000.millis)
456+
425457
override def withSessionTimeout(sessionTimeout: FiniteDuration): ConsumerSettings[F, K, V] =
426458
withProperty(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, sessionTimeout.toMillis.toString)
427459

@@ -509,6 +541,11 @@ object ConsumerSettings {
509541
): ConsumerSettings[F, K, V] =
510542
withProperties(credentialsStore.properties)
511543

544+
override def withRebalanceRevokeMode(
545+
rebalanceRevokeMode: RebalanceRevokeMode
546+
): ConsumerSettings[F, K, V] =
547+
copy(rebalanceRevokeMode = rebalanceRevokeMode)
548+
512549
override def toString: String =
513550
s"ConsumerSettings(closeTimeout = $closeTimeout, commitTimeout = $commitTimeout, pollInterval = $pollInterval, pollTimeout = $pollTimeout, commitRecovery = $commitRecovery)"
514551

@@ -542,7 +579,8 @@ object ConsumerSettings {
542579
pollTimeout = 50.millis,
543580
commitRecovery = CommitRecovery.Default,
544581
recordMetadata = _ => OffsetFetchResponse.NO_METADATA,
545-
maxPrefetchBatches = 2
582+
maxPrefetchBatches = 2,
583+
rebalanceRevokeMode = RebalanceRevokeMode.Eager
546584
)
547585

548586
def apply[F[_], K, V](

modules/core/src/main/scala/fs2/kafka/KafkaConsumer.scala

Lines changed: 103 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import scala.collection.immutable.SortedSet
1313
import scala.concurrent.duration.FiniteDuration
1414
import scala.util.matching.Regex
1515

16-
import cats.{Foldable, Functor, Reducible}
16+
import cats.{Applicative, Foldable, Functor, Reducible}
1717
import cats.data.{NonEmptySet, OptionT}
1818
import cats.effect.*
1919
import cats.effect.implicits.*
@@ -126,6 +126,7 @@ object KafkaConsumer {
126126

127127
private def createKafkaConsumer[F[_], K, V](
128128
requests: QueueSink[F, Request[F, K, V]],
129+
settings: ConsumerSettings[F, K, V],
129130
actor: KafkaConsumerActor[F, K, V],
130131
fiber: Fiber[F, Throwable, Unit],
131132
id: Int,
@@ -141,7 +142,8 @@ object KafkaConsumer {
141142
type PartitionsMapQueue = Queue[F, Option[PartitionsMap]]
142143

143144
def partitionStream(
144-
partition: TopicPartition
145+
partition: TopicPartition,
146+
signalCompletion: F[Unit]
145147
): Stream[F, CommittableConsumerRecord[F, K, V]] =
146148
Stream.force {
147149
actor
@@ -155,54 +157,91 @@ object KafkaConsumer {
155157
.void
156158
.attempt
157159

158-
Stream.fromQueueUnterminated(chunksQueue, 1).unchunks.interruptWhen(stopStream)
160+
Stream
161+
.fromQueueUnterminated(chunksQueue, 1)
162+
.unchunks
163+
.interruptWhen(stopStream)
164+
.onFinalize(signalCompletion)
159165
}
160166
}
161167

162168
def enqueueAssignment(
163-
assigned: Set[TopicPartition],
169+
assigned: Map[TopicPartition, AssignmentSignals[F]],
164170
partitionsMapQueue: PartitionsMapQueue
165171
): F[Unit] =
166172
stopConsumingDeferred
167173
.tryGet
168174
.flatMap {
169175
case None =>
170-
val assignment: PartitionsMap = assigned
171-
.view
172-
.map { partition =>
173-
partition -> partitionStream(partition)
174-
}
175-
.toMap
176+
val assignment: PartitionsMap = assigned.map { case (partition, signals) =>
177+
partition -> partitionStream(partition, signals.signalStreamFinished.void)
178+
}
176179

177180
partitionsMapQueue.offer(Some(assignment))
178181

179182
case Some(()) =>
180183
F.unit
181184
}
182185

183-
def onRebalance(partitionsMapQueue: PartitionsMapQueue): OnRebalance[F] =
186+
def onRebalance(
187+
assignmentRef: Ref[F, Map[TopicPartition, AssignmentSignals[F]]],
188+
partitionsMapQueue: PartitionsMapQueue
189+
): OnRebalance[F] =
184190
OnRebalance(
185-
onRevoked = _ => F.unit,
186-
onAssigned = assigned => enqueueAssignment(assigned, partitionsMapQueue)
191+
onRevoked = revoked =>
192+
for {
193+
assignment <- assignmentRef.get
194+
_ <- revoked.toVector.flatMap(assignment.get).traverse_(_.awaitStreamFinishedSignal)
195+
} yield (),
196+
onAssigned = assigned =>
197+
for {
198+
assignment <- buildAssignment(assigned)
199+
_ <- assignmentRef.update(_ ++ assignment)
200+
_ <- enqueueAssignment(assignment, partitionsMapQueue)
201+
} yield ()
187202
)
188203

189-
def requestAssignment(partitionsMapQueue: PartitionsMapQueue): F[Set[TopicPartition]] = {
204+
def buildAssignment(
205+
assignedPartitions: SortedSet[TopicPartition]
206+
): F[Map[TopicPartition, AssignmentSignals[F]]] = {
207+
assignedPartitions
208+
.toVector
209+
.traverse { partition =>
210+
settings.rebalanceRevokeMode match {
211+
case RebalanceRevokeMode.EagerMode =>
212+
(partition -> AssignmentSignals.eager[F]).pure[F]
213+
case RebalanceRevokeMode.GracefulMode =>
214+
Deferred[F, Unit].map(revokeFinisher =>
215+
partition -> AssignmentSignals.graceful(revokeFinisher)
216+
)
217+
}
218+
}
219+
.map(_.toMap)
220+
}
221+
222+
def requestAssignment(
223+
assignmentRef: Ref[F, Map[TopicPartition, AssignmentSignals[F]]],
224+
partitionsMapQueue: PartitionsMapQueue
225+
): F[Map[TopicPartition, AssignmentSignals[F]]] = {
190226
val assignment = this.assignment(
191227
Some(
192-
onRebalance(partitionsMapQueue)
228+
onRebalance(assignmentRef, partitionsMapQueue)
193229
)
194230
)
195231

196232
F.race(awaitTermination.attempt, assignment)
197233
.flatMap {
198-
case Left(_) => F.pure(Set.empty)
199-
case Right(assigned) => F.pure(assigned)
234+
case Left(_) => F.pure(Map.empty)
235+
case Right(assigned) => buildAssignment(assigned).flatTap(assignmentRef.set)
200236
}
201237
}
202238

203-
def initialEnqueue(partitionsMapQueue: PartitionsMapQueue): F[Unit] =
239+
def initialEnqueue(
240+
assignmentRef: Ref[F, Map[TopicPartition, AssignmentSignals[F]]],
241+
partitionsMapQueue: PartitionsMapQueue
242+
): F[Unit] =
204243
for {
205-
assigned <- requestAssignment(partitionsMapQueue)
244+
assigned <- requestAssignment(assignmentRef, partitionsMapQueue)
206245
_ <- enqueueAssignment(assigned, partitionsMapQueue)
207246
} yield ()
208247

@@ -212,7 +251,9 @@ object KafkaConsumer {
212251
case None =>
213252
for {
214253
partitionsMapQueue <- Stream.eval(Queue.unbounded[F, Option[PartitionsMap]])
215-
_ <- Stream.eval(initialEnqueue(partitionsMapQueue))
254+
assignmentRef <-
255+
Stream.eval(Ref[F].of(Map.empty[TopicPartition, AssignmentSignals[F]]))
256+
_ <- Stream.eval(initialEnqueue(assignmentRef, partitionsMapQueue))
216257
out <- Stream
217258
.fromQueueNoneTerminated(partitionsMapQueue)
218259
.interruptWhen(awaitTermination.attempt)
@@ -574,6 +615,7 @@ object KafkaConsumer {
574615
fiber <- startBackgroundConsumer(requests, polls, actor, settings.pollInterval)
575616
} yield createKafkaConsumer(
576617
requests,
618+
settings,
577619
actor,
578620
fiber,
579621
id,
@@ -695,6 +737,47 @@ object KafkaConsumer {
695737

696738
}
697739

740+
/**
741+
* Utility class to provide clarity for internals. Goal is to make [[RebalanceRevokeMode]]
742+
* transparent to the rest of implementation internals.
743+
* @tparam F
744+
* effect used
745+
*/
746+
sealed abstract private class AssignmentSignals[F[_]] {
747+
748+
def signalStreamFinished: F[Boolean]
749+
def awaitStreamFinishedSignal: F[Unit]
750+
751+
}
752+
753+
private object AssignmentSignals {
754+
755+
def eager[F[_]: Applicative]: AssignmentSignals[F] =
756+
EagerSignals()
757+
758+
def graceful[F[_]](
759+
revokeFinisher: Deferred[F, Unit]
760+
): AssignmentSignals[F] =
761+
GracefulSignals[F](revokeFinisher)
762+
763+
final private case class EagerSignals[F[_]: Applicative]() extends AssignmentSignals[F] {
764+
765+
override def signalStreamFinished: F[Boolean] = true.pure[F]
766+
override def awaitStreamFinishedSignal: F[Unit] = ().pure[F]
767+
768+
}
769+
770+
final private case class GracefulSignals[F[_]](
771+
revokeFinisher: Deferred[F, Unit]
772+
) extends AssignmentSignals[F] {
773+
774+
override def signalStreamFinished: F[Boolean] = revokeFinisher.complete(())
775+
override def awaitStreamFinishedSignal: F[Unit] = revokeFinisher.get
776+
777+
}
778+
779+
}
780+
698781
/*
699782
* Prevents the default `MkConsumer` instance from being implicitly available
700783
* to code defined in this object, ensuring factory methods require an instance

0 commit comments

Comments
 (0)