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.git
The following commit(s) were added to refs/heads/main by this push:
new 05b300dafb Port scalable fan-out persistence query abstraction from
Akka (#3277)
05b300dafb is described below
commit 05b300dafb7330fad8a47247f562f061cb85d4f8
Author: PJ Fanning <[email protected]>
AuthorDate: Wed Jul 1 11:31:20 2026 +0100
Port scalable fan-out persistence query abstraction from Akka (#3277)
* Port scalable fan-out persistence query abstraction from Akka
manual changes
more
more
more
more
compile issues
Update EventsBySliceFirehoseSpec.scala
build issues
Update EventsBySliceFirehoseSpec.scala
unused imports
remove new cluster-sharding files
revert
* javadoc
* scalafmt
---------
Co-authored-by: copilot-swe-agent[bot]
<[email protected]>
---
docs/src/main/paradox/persistence-query.md | 4 +-
.../src/main/resources/reference.conf | 53 ++
.../EventsBySliceFirehoseReadJournalProvider.scala | 33 +
.../typed/internal/EventsBySliceFirehose.scala | 839 +++++++++++++++++++++
...sByPersistenceIdStartingFromSnapshotQuery.scala | 39 +
...ntEventsBySliceStartingFromSnapshotsQuery.scala | 52 ++
...sByPersistenceIdStartingFromSnapshotQuery.scala | 39 +
.../typed/javadsl/EventsBySliceFirehoseQuery.scala | 95 +++
.../query/typed/javadsl/EventsBySliceQuery.scala | 2 +
.../EventsBySliceStartingFromSnapshotsQuery.scala | 54 ++
...sByPersistenceIdStartingFromSnapshotQuery.scala | 39 +
...ntEventsBySliceStartingFromSnapshotsQuery.scala | 53 ++
...sByPersistenceIdStartingFromSnapshotQuery.scala | 39 +
.../scaladsl/EventsBySliceFirehoseQuery.scala | 119 +++
.../query/typed/scaladsl/EventsBySliceQuery.scala | 2 +
.../EventsBySliceStartingFromSnapshotsQuery.scala | 55 ++
.../apache/pekko/persistence/query/TestClock.scala | 59 ++
.../typed/internal/EventsBySliceFirehoseSpec.scala | 440 +++++++++++
18 files changed, 2015 insertions(+), 1 deletion(-)
diff --git a/docs/src/main/paradox/persistence-query.md
b/docs/src/main/paradox/persistence-query.md
index 801dfe5509..44f2de0bc1 100644
--- a/docs/src/main/paradox/persistence-query.md
+++ b/docs/src/main/paradox/persistence-query.md
@@ -168,7 +168,9 @@ If your usage does not require a live stream, you can use
the @apidoc[currentEve
Query events for given entity type and slices. A slice is deterministically
defined based on the persistence id.
The purpose is to evenly distribute all persistence ids over the slices.
-See @apidoc[persistence.query.typed.*.EventsBySliceQuery] and
@apidoc[persistence.query.typed.*.CurrentEventsBySliceQuery].
+See @apidoc[pekko.persistence.query.typed.*.EventsBySliceQuery] and
@apidoc[pekko.persistence.query.typed.*.CurrentEventsBySliceQuery].
+
+A variation of these are
@apidoc[pekko.persistence.query.typed.*.EventsBySliceStartingFromSnapshotsQuery]
and
@apidoc[pekko.persistence.query.typed.*.CurrentEventsBySliceStartingFromSnapshotsQuery].
### Materialized values of queries
diff --git a/persistence-query/src/main/resources/reference.conf
b/persistence-query/src/main/resources/reference.conf
index 2dfff044c1..c9bb5af9ee 100644
--- a/persistence-query/src/main/resources/reference.conf
+++ b/persistence-query/src/main/resources/reference.conf
@@ -30,6 +30,59 @@ pekko.persistence.query.journal.leveldb {
}
#//#query-leveldb
+pekko.persistence.query.events-by-slice-firehose {
+ class =
"org.apache.pekko.persistence.query.typed.EventsBySliceFirehoseReadJournalProvider"
+
+ # The identifier (config path) of the underlying EventsBySlice query plugin.
+ # This must be defined by the application.
+ delegate-query-plugin-id = ""
+
+ # Buffer size of the BroadcastHub that will fan out the shared firehose
stream
+ # to attached consumer streams. If too small, some consumers may slow down
other
+ # consumers before the slow consumers have been aborted. If too large, it
will
+ # use more memory by holding more events in the buffer memory.
+ # Must be a power of two and less than 4096.
+ broadcast-buffer-size = 256
+
+ # The shared firehose stream will be closed after this timeout when all
consumer
+ # streams have been closed. It will be started again when new consumers
attach,
+ # but there is some overhead of stopping and starting so it's good to keep it
+ # around for a while. For example, keep around long enough to cover
Projection
+ # restarts.
+ firehose-linger-timeout = 40s
+
+ # When the catchup stream for a new consumer has caught up to the shared
firehose
+ # stream events will be retrieved from both during this time of overlap. The
reason
+ # is to ensure that no events are missed when switching over. After that,
+ # the catchup stream will be closed. Time is based on the timestamps of the
+ # EventEnvelope.
+ catchup-overlap = 10s
+
+ # Approximately number of entries of the deduplication cache.
+ # During the overlap period events will be deduplicated by keeping track of
emitted
+ # persistenceId and seqNr.
+ deduplication-capacity = 10000
+
+ # Slow consumers are detected and aborted by a background task that is
running
+ # with this interval. Should be less than `slow-consumer-lag-threshold`.
+ slow-consumer-reaper-interval = 2s
+
+ # Slow consumer candidates are determined if the fastest consumer has a lag
greater
+ # than this duration, and the slow consumer is behind the fastest consumer
by more
+ # than half of the `broadcast-buffer-size`.
+ # Slow consumers are then confirmed to be slow if they stay as such for at
+ # least `abort-slow-consumer-after`.
+ slow-consumer-lag-threshold = 5s
+
+ # See `slow-consumer-lag-threshold`.
+ # This duration is based on wall clock time.
+ abort-slow-consumer-after = 2s
+
+ # Provide a higher level of details in the debug logs, often per event. Be
careful about enabling
+ # in production systems.
+ verbose-debug-logging = off
+}
+
pekko.actor {
serializers {
pekko-persistence-query =
"org.apache.pekko.persistence.query.internal.QuerySerializer"
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/EventsBySliceFirehoseReadJournalProvider.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/EventsBySliceFirehoseReadJournalProvider.scala
new file mode 100644
index 0000000000..19f4e7db51
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/EventsBySliceFirehoseReadJournalProvider.scala
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed
+import com.typesafe.config.Config
+
+import org.apache.pekko
+import pekko.actor.ExtendedActorSystem
+import pekko.persistence.query.ReadJournalProvider
+
+final class EventsBySliceFirehoseReadJournalProvider(system:
ExtendedActorSystem, config: Config, cfgPath: String)
+ extends ReadJournalProvider {
+
+ private lazy val scaladslReadJournalInstance:
scaladsl.EventsBySliceFirehoseQuery =
+ new scaladsl.EventsBySliceFirehoseQuery(system, config, cfgPath)
+
+ override def scaladslReadJournal(): scaladsl.EventsBySliceFirehoseQuery =
scaladslReadJournalInstance
+
+ private lazy val javadslReadJournalInstance =
+ new javadsl.EventsBySliceFirehoseQuery(new
scaladsl.EventsBySliceFirehoseQuery(system, config, cfgPath))
+
+ override def javadslReadJournal(): javadsl.EventsBySliceFirehoseQuery =
javadslReadJournalInstance
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/internal/EventsBySliceFirehose.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/internal/EventsBySliceFirehose.scala
new file mode 100644
index 0000000000..94728c1334
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/internal/EventsBySliceFirehose.scala
@@ -0,0 +1,839 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.internal
+
+import java.time.{ Duration => JDuration }
+import java.time.Instant
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+import scala.annotation.{ nowarn, tailrec }
+import scala.collection.mutable
+import scala.concurrent.duration._
+import scala.jdk.DurationConverters._
+import scala.util.control.NoStackTrace
+
+import com.typesafe.config.Config
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.actor.ActorSystem
+import pekko.actor.Cancellable
+import pekko.actor.ClassicActorSystemProvider
+import pekko.actor.ExtendedActorSystem
+import pekko.actor.Extension
+import pekko.actor.ExtensionId
+import pekko.actor.ExtensionIdProvider
+import pekko.annotation.InternalApi
+import pekko.event.Logging
+import pekko.event.LoggingAdapter
+import pekko.persistence.query.Offset
+import pekko.persistence.query.PersistenceQuery
+import pekko.persistence.query.TimestampOffset
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.persistence.query.typed.scaladsl.EventsBySliceQuery
+import
pekko.persistence.query.typed.scaladsl.EventsBySliceStartingFromSnapshotsQuery
+import pekko.stream.Attributes
+import pekko.stream.FanInShape2
+import pekko.stream.FlowShape
+import pekko.stream.Inlet
+import pekko.stream.KillSwitch
+import pekko.stream.KillSwitches
+import pekko.stream.Outlet
+import pekko.stream.scaladsl.BroadcastHub
+import pekko.stream.scaladsl.GraphDSL
+import pekko.stream.scaladsl.Keep
+import pekko.stream.scaladsl.Source
+import pekko.stream.stage.GraphStage
+import pekko.stream.stage.GraphStageLogic
+import pekko.stream.stage.InHandler
+import pekko.stream.stage.OutHandler
+import pekko.stream.stage.StageLogging
+import pekko.util.OptionVal
+
+/**
+ * INTERNAL API
+ *
+ * The purpose is to share the stream of events from the database and fan out
to connected consumer
+ * streams. Thereby less queries and loading of events from the database. The
shared stream is called
+ * the firehose stream.
+ *
+ * The fan out of the firehose stream is via a `BroadcastHub` that consumer
streams dynamically attach to.
+ *
+ * A new consumer starts a catchup stream since the start offset typically is
behind the live
+ * firehose stream. In the beginning it will emit events only from the catchup
stream. Offset progress
+ * for the firehose stream is tracked and when the catchup stream has caught
up with the firehose
+ * stream it will switch over to emitting from firehose stream and close the
catchup stream. During
+ * an overlap period of time it will use events from both catchup and firehose
streams to make sure
+ * that no events are missed. During this overlap time there is best effort
deduplication.
+ *
+ * The `BroadcastHub` has a limited buffer that holds events between the
slowest and fastest consumer.
+ * When the buffer is full the fastest consumer can't progress faster than the
slowest. Short periods of
+ * slow down can be fine, but after a while the slow consumers are detected
and aborted. They have to
+ * connect again and try catching up, but without slowing down other streams.
+ *
+ * The firehose stream is started on demand when the first consumer is
attaching. It will be stopped
+ * when the last consumer is stopped, but it stays around for a while to make
it more efficient for
+ * new or restarted consumers to attach again.
+ */
+@InternalApi private[pekko] object EventsBySliceFirehose
+ extends ExtensionId[EventsBySliceFirehose]
+ with ExtensionIdProvider {
+
+ override def get(system: ActorSystem): EventsBySliceFirehose =
super.get(system)
+
+ override def get(system: ClassicActorSystemProvider): EventsBySliceFirehose
= super.get(system)
+
+ override def lookup = EventsBySliceFirehose
+
+ override def createExtension(system: ExtendedActorSystem):
EventsBySliceFirehose =
+ new EventsBySliceFirehose(system)
+
+ object Settings {
+ def apply(system: ActorSystem, pluginId: String): Settings =
+ apply(system.settings.config.getConfig(pluginId))
+
+ def apply(config: Config): Settings =
+ Settings(
+ delegateQueryPluginId = delegateQueryPluginId(config),
+ broadcastBufferSize = config.getInt("broadcast-buffer-size"),
+ firehoseLingerTimeout =
config.getDuration("firehose-linger-timeout").toScala,
+ catchupOverlap = config.getDuration("catchup-overlap"),
+ deduplicationCapacity = config.getInt("deduplication-capacity"),
+ slowConsumerReaperInterval =
config.getDuration("slow-consumer-reaper-interval").toScala,
+ slowConsumerLagThreshold =
config.getDuration("slow-consumer-lag-threshold"),
+ abortSlowConsumerAfter =
config.getDuration("abort-slow-consumer-after"),
+ verboseLogging = config.getBoolean("verbose-debug-logging"))
+
+ def delegateQueryPluginId(config: Config): String =
+ config.getString("delegate-query-plugin-id")
+ }
+
+ final case class Settings(
+ delegateQueryPluginId: String,
+ broadcastBufferSize: Int,
+ firehoseLingerTimeout: FiniteDuration,
+ catchupOverlap: JDuration,
+ deduplicationCapacity: Int,
+ slowConsumerReaperInterval: FiniteDuration,
+ slowConsumerLagThreshold: JDuration,
+ abortSlowConsumerAfter: JDuration,
+ verboseLogging: Boolean) {
+ require(
+ delegateQueryPluginId != null && delegateQueryPluginId.nonEmpty,
+ "Configuration of delegate-query-plugin-id must defined.")
+ }
+
+ final class SlowConsumerException(message: String) extends
RuntimeException(message) with NoStackTrace
+
+ final case class FirehoseKey(pluginId: String, entityType: String,
sliceRange: Range)
+
+ final case class ConsumerTracking(
+ consumerId: String,
+ history: Vector[TimestampOffset],
+ firehoseOnly: Boolean,
+ consumerKillSwitch: KillSwitch,
+ slowConsumerCandidate: Option[Instant]) {
+ def offsetTimestamp: Instant =
+ if (history.isEmpty) Instant.EPOCH
+ else history.last.timestamp
+ }
+
+ final class Firehose(
+ val firehoseKey: FirehoseKey,
+ val settings: Settings,
+ val firehoseHub: Source[EventEnvelope[Any], NotUsed],
+ firehoseKillSwitch: KillSwitch,
+ log: LoggingAdapter) {
+
+ val consumerTracking: ConcurrentHashMap[String, ConsumerTracking] = new
ConcurrentHashMap
+ @volatile private var firehoseIsShutdown = false
+
+ private def entityType = firehoseKey.entityType
+ private val sliceRangeStr =
s"${firehoseKey.sliceRange.min}-${firehoseKey.sliceRange.max}"
+
+ private def consumerTrackingValues(): Vector[ConsumerTracking] = {
+ import scala.jdk.CollectionConverters._
+ consumerTracking.values.iterator.asScala.filter(h => h.history.nonEmpty
&& h.firehoseOnly).toVector
+ }
+
+ def consumerStarted(consumerId: String, consumerKillSwitch: KillSwitch):
Unit = {
+ log.debug("Firehose entityType [{}] sliceRange [{}] consumer [{}]
started", entityType, sliceRangeStr, consumerId)
+ consumerTracking.putIfAbsent(
+ consumerId,
+ ConsumerTracking(consumerId, history = Vector.empty, firehoseOnly =
false, consumerKillSwitch, None))
+ }
+
+ def consumerTerminated(consumerId: String): Int = {
+ log.debug(
+ "Firehose entityType [{}] sliceRange [{}] consumer [{}] terminated",
+ entityType,
+ sliceRangeStr,
+ consumerId)
+ consumerTracking.remove(consumerId)
+ consumerTracking.size
+ }
+
+ def shutdownFirehoseIfNoConsumers(): Boolean = {
+ if (consumerTracking.isEmpty) {
+ log.debug("Firehose entityType [{}] sliceRange [{}] is shutting down,
no consumers", entityType, sliceRangeStr)
+ firehoseIsShutdown = true
+ firehoseKillSwitch.shutdown()
+ true
+ } else
+ false
+ }
+
+ def isShutdown: Boolean =
+ firehoseIsShutdown
+
+ def updateConsumerTracking(
+ consumerId: String,
+ now: Instant,
+ offset: TimestampOffset,
+ consumerKillSwitch: KillSwitch): Unit = {
+
+ val newTracking = consumerTracking.compute(
+ consumerId,
+ (_, existing) => {
+ if (existing == null)
+ ConsumerTracking(consumerId, history = Vector(offset),
firehoseOnly = false, consumerKillSwitch, None)
+ else {
+ val newHistory =
+ if (existing.history.size <= settings.broadcastBufferSize)
+ existing.history :+ offset
+ else
+ existing.history.tail :+ offset // drop one, add one
+ existing.copy(history = newHistory)
+ }
+ })
+ logUpdateConsumerTracking(consumerId, now, newTracking)
+ }
+
+ private def logUpdateConsumerTracking(consumerId: String, now: Instant,
tracking: ConsumerTracking): Unit = {
+ if (settings.verboseLogging && log.isDebugEnabled) {
+ val trackingValues = consumerTrackingValues()
+ if (trackingValues.size > 1) {
+ val slowestConsumer = trackingValues.minBy(_.offsetTimestamp)
+ val fastestConsumer = trackingValues.maxBy(_.offsetTimestamp)
+ val behind = elementsBehind(fastestConsumer.history,
slowestConsumer.history)
+ if (behind > 0) {
+ val diffSlowestFastestsMillis =
fastestConsumer.offsetTimestamp.toEpochMilli -
+ slowestConsumer.offsetTimestamp.toEpochMilli
+ val fastestLagMillis = now.toEpochMilli -
fastestConsumer.offsetTimestamp.toEpochMilli
+
+ val diffFastest = fastestConsumer.offsetTimestamp.toEpochMilli -
tracking.offsetTimestamp.toEpochMilli
+ val diffFastestStr =
+ if (diffFastest > 0) s"behind fastest [$diffFastest] ms"
+ else if (diffFastest < 0) s"ahead of fastest [$diffFastest] ms"
// not possible
+ else "same as fastest"
+ val diffSlowest = slowestConsumer.offsetTimestamp.toEpochMilli -
tracking.offsetTimestamp.toEpochMilli
+ val diffSlowestStr =
+ if (diffSlowest > 0) s"behind slowest [$diffSlowest] ms" // not
possible
+ else if (diffSlowest < 0) s"ahead of slowest [${-diffSlowest}]
ms"
+ else "same as slowest"
+ val consumerBehind = elementsBehind(fastestConsumer.history,
tracking.history)
+
+ log.debug(
+ s"Firehose entityType [$entityType] sliceRange [$sliceRangeStr]
updateConsumerTracking [$consumerId], " +
+ s"behind [$consumerBehind] events from fastest, " +
+ s"$diffFastestStr, $diffSlowestStr, " +
+ s"slowest [${slowestConsumer.consumerId}] is behind [$behind] " +
+ s"events from fastest [${fastestConsumer.consumerId}],
[$diffSlowestFastestsMillis] ms. " +
+ s"Consumer lag of fastest [$fastestLagMillis] ms.")
+ }
+ }
+ }
+ }
+
+ def detectSlowConsumers(now: Instant): Unit = {
+ val trackingValues = consumerTrackingValues()
+ if (trackingValues.size > 1) {
+ val slowestConsumer = trackingValues.minBy(_.offsetTimestamp)
+ val fastestConsumer = trackingValues.maxBy(_.offsetTimestamp)
+
+ val behind = elementsBehind(fastestConsumer.history,
slowestConsumer.history)
+ val fastestLagMillis = now.toEpochMilli -
fastestConsumer.offsetTimestamp.toEpochMilli
+
+ if (behind >= settings.broadcastBufferSize / 2 &&
+ fastestLagMillis > settings.slowConsumerLagThreshold.toMillis) {
+ logDetectSlowConsumers(trackingValues, slowestConsumer,
fastestConsumer, behind, fastestLagMillis)
+
+ val changedConsumerTrackingValues = trackingValues.flatMap {
tracking =>
+ val consumerBehind = elementsBehind(fastestConsumer.history,
tracking.history)
+ if (consumerBehind >= settings.broadcastBufferSize / 2) {
+ if (tracking.slowConsumerCandidate.isDefined)
+ None // keep original
+ else
+ Some(tracking.copy(slowConsumerCandidate = Some(now)))
+ } else if (tracking.slowConsumerCandidate.isDefined) {
+ Some(tracking.copy(slowConsumerCandidate = None)) // not slow
any more
+ } else {
+ None
+ }
+ }
+
+ changedConsumerTrackingValues.foreach { tracking =>
+ consumerTracking.computeIfPresent(
+ tracking.consumerId,
+ (_, existing) => existing.copy(slowConsumerCandidate =
tracking.slowConsumerCandidate))
+ }
+
+ val newTrackingValues = consumerTrackingValues()
+
+ val confirmedSlowConsumers = newTrackingValues.filter {
+ _.slowConsumerCandidate.exists(isDurationGreaterThan(_, now,
settings.abortSlowConsumerAfter))
+ }
+
+ if (confirmedSlowConsumers.nonEmpty) {
+ if (log.isInfoEnabled) {
+ val behindMillis = fastestConsumer.offsetTimestamp.toEpochMilli -
+ confirmedSlowConsumers
+ .maxBy(_.offsetTimestamp)
+ .offsetTimestamp
+ .toEpochMilli
+ log.info(
+ s"Firehose entityType [$entityType] sliceRange
[$sliceRangeStr], [${confirmedSlowConsumers.size}] " +
+ s"slow consumers are aborted
[${confirmedSlowConsumers.map(_.consumerId).mkString(", ")}], " +
+ s"behind by [$behind] events [$behindMillis] ms.")
+ }
+
+ confirmedSlowConsumers.foreach { tracking =>
+ tracking.consumerKillSwitch.abort(
+ new SlowConsumerException(s"Consumer [${tracking.consumerId}]
is too slow."))
+ }
+ }
+
+ } else if (behind < 0) {
+ // can happen if fastest is a new consumer, but ignoring should be ok
+ log.debug(
+ s"Firehose entityType [{}] sliceRange [{}] missing history for
fastest consumer [{}] corresponding to slowest consumer [{}]",
+ entityType,
+ sliceRangeStr,
+ fastestConsumer.consumerId,
+ slowestConsumer.consumerId)
+ } else if (settings.verboseLogging) {
+ logDetectSlowConsumers(trackingValues, slowestConsumer,
fastestConsumer, behind, fastestLagMillis)
+ }
+
+ }
+ }
+
+ private def logDetectSlowConsumers(
+ consumerTrackingValues: Vector[ConsumerTracking],
+ slowestConsumer: ConsumerTracking,
+ fastestConsumer: ConsumerTracking,
+ behind: Int,
+ fastestLagMillis: Long): Unit = {
+ if (behind > 0) {
+ val behindMillis = fastestConsumer.offsetTimestamp.toEpochMilli -
slowestConsumer.offsetTimestamp.toEpochMilli
+ log.debug(
+ s"Firehose entityType [$entityType] sliceRange [$sliceRangeStr]
detectSlowConsumers, " +
+ s"slowest [${slowestConsumer.consumerId}] is behind [$behind] " +
+ s"events from fastest [${fastestConsumer.consumerId}],
[$behindMillis] ms. " +
+ s"Consumer lag of fastest [$fastestLagMillis] ms")
+
+ consumerTrackingValues.foreach { tracking =>
+ val diffFastest = fastestConsumer.offsetTimestamp.toEpochMilli -
tracking.offsetTimestamp.toEpochMilli
+ val diffFastestStr =
+ if (diffFastest > 0) s"behind fastest [$diffFastest] ms"
+ else if (diffFastest < 0) s"ahead of fastest [$diffFastest] ms" //
not possible
+ else "same as fastest"
+ val diffSlowest = slowestConsumer.offsetTimestamp.toEpochMilli -
tracking.offsetTimestamp.toEpochMilli
+ val diffSlowestStr =
+ if (diffSlowest > 0) s"behind slowest [$diffSlowest] ms" // not
possible
+ else if (diffSlowest < 0) s"ahead of slowest [${-diffSlowest}] ms"
+ else "same as slowest"
+ val consumerBehind = elementsBehind(fastestConsumer.history,
tracking.history)
+
+ val logMessage =
+ s"Firehose entityType [$entityType] sliceRange [$sliceRangeStr]
consumer [${tracking.consumerId}], " +
+ s"behind [$consumerBehind] events from fastest, " +
+ s"$diffFastestStr, $diffSlowestStr, firehoseOnly
[${tracking.firehoseOnly}]"
+
+ log.debug(logMessage)
+ }
+ }
+ }
+
+ private def elementsBehind(fastest: Vector[TimestampOffset], slowest:
Vector[TimestampOffset]): Int = {
+ if (fastest.last == slowest.last) {
+ 0
+ } else {
+ val i = fastest.indexOf(slowest.last)
+ if (i >= 0)
+ fastest.size - i - 1
+ else
+ -1
+ }
+ }
+
+ @tailrec def updateConsumerFirehoseOnly(consumerId: String): Unit = {
+ val existingTracking = consumerTracking.get(consumerId)
+ val tracking = existingTracking match {
+ case null =>
+ throw new IllegalStateException(s"Expected existing tracking for
consumer [$consumerId]")
+ case existing =>
+ existing.copy(firehoseOnly = true)
+ }
+
+ if (!consumerTracking.replace(consumerId, existingTracking, tracking))
+ // concurrent update, try again
+ updateConsumerFirehoseOnly(consumerId)
+ }
+
+ }
+
+ def timestampOffset(env: EventEnvelope[Any]): TimestampOffset =
+ env match {
+ case eventEnvelope: EventEnvelope[?] if
eventEnvelope.offset.isInstanceOf[TimestampOffset] =>
+ eventEnvelope.offset.asInstanceOf[TimestampOffset]
+ case _ =>
+ throw new IllegalArgumentException(s"Expected TimestampOffset, but was
[${env.offset.getClass.getName}]")
+ }
+
+ def isDurationGreaterThan(from: Instant, to: Instant, duration: JDuration):
Boolean =
+ JDuration.between(from, to).compareTo(duration) > 0
+}
+
+/**
+ * INTERNAL API
+ */
+@InternalApi private[pekko] class EventsBySliceFirehose(system: ActorSystem)
extends Extension {
+ import EventsBySliceFirehose._
+ private val log = Logging(system, classOf[EventsBySliceFirehose])
+ private val firehoses = new ConcurrentHashMap[FirehoseKey, Firehose]()
+
+ @tailrec final def getFirehose(firehoseKey: FirehoseKey): Firehose = {
+ val firehose = firehoses.computeIfAbsent(firehoseKey, key =>
createFirehose(key))
+ if (firehose.isShutdown) {
+ // concurrency race condition, but it should be removed
+ firehoses.remove(firehoseKey, firehose)
+ getFirehose(firehoseKey) // try again
+ } else
+ firehose
+ }
+
+ private def createFirehose(key: FirehoseKey): Firehose = {
+ implicit val sys: ActorSystem = system
+ val sliceRangeStr = s"${key.sliceRange.min}-${key.sliceRange.max}"
+
+ log.debug("Create firehose entityType [{}], sliceRange [{}]",
key.entityType, sliceRangeStr)
+
+ val settings = Settings(sys, key.pluginId)
+
+ val firehoseKillSwitch = KillSwitches.shared("firehoseKillSwitch")
+
+ val firehoseHub: Source[EventEnvelope[Any], NotUsed] =
+ underlyingEventsBySlices[Any](
+ settings.delegateQueryPluginId,
+ key.entityType,
+ key.sliceRange.min,
+ key.sliceRange.max,
+ TimestampOffset(Instant.now(), Map.empty),
+ firehose = true)
+ .via(firehoseKillSwitch.flow)
+
.runWith(BroadcastHub.sink[EventEnvelope[Any]](settings.broadcastBufferSize))
+
+ val firehose = new Firehose(key, settings, firehoseHub,
firehoseKillSwitch, log)
+
+ val reaperInterval = settings.slowConsumerReaperInterval
+ // var because it is used inside the scheduled block to cancel itself
+ var reaperTask: Cancellable = null
+ reaperTask = system.scheduler.scheduleWithFixedDelay(reaperInterval,
reaperInterval) { () =>
+ if (reaperTask == null)
+ () // theoretical possibility but would only mean that the first tick
is ignored
+ else if (firehose.isShutdown)
+ reaperTask.cancel()
+ else
+ firehose.detectSlowConsumers(Instant.now)
+ }(system.dispatcher)
+
+ firehose
+ }
+
+ def eventsBySlices[Event](
+ pluginId: String,
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset): Source[EventEnvelope[Event], NotUsed] = {
+ val sliceRange = minSlice to maxSlice
+ val firehoseKey = FirehoseKey(pluginId, entityType, sliceRange)
+ val firehose = getFirehose(firehoseKey)
+ val catchupSource = underlyingEventsBySlices[Event](
+ firehose.settings.delegateQueryPluginId,
+ entityType,
+ minSlice,
+ maxSlice,
+ offset,
+ firehose = false)
+ eventsBySlicesImpl(firehose, catchupSource)
+ }
+
+ def eventsBySlicesStartingFromSnapshots[Snapshot, Event](
+ pluginId: String,
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ transformSnapshot: Snapshot => Event): Source[EventEnvelope[Event],
NotUsed] = {
+ val sliceRange = minSlice to maxSlice
+ val firehoseKey = FirehoseKey(pluginId, entityType, sliceRange)
+ val firehose = getFirehose(firehoseKey)
+ val catchupSource =
underlyingEventsBySlicesStartingFromSnapshots[Snapshot, Event](
+ firehose.settings.delegateQueryPluginId,
+ entityType,
+ minSlice,
+ maxSlice,
+ offset,
+ transformSnapshot)
+ eventsBySlicesImpl(firehose, catchupSource)
+ }
+
+ private def eventsBySlicesImpl[Event](
+ firehose: Firehose,
+ catchupSource: Source[EventEnvelope[Event], NotUsed]):
Source[EventEnvelope[Event], NotUsed] = {
+
+ val firehoseKey = firehose.firehoseKey
+ val settings = firehose.settings
+ val consumerId = UUID.randomUUID().toString
+
+ def consumerTerminated(): Unit = {
+ if (firehose.consumerTerminated(consumerId) == 0) {
+ // Don't shutdown firehose immediately because Projection it should
survive Projection restart
+ system.scheduler.scheduleOnce(settings.firehoseLingerTimeout) {
+ if (firehose.shutdownFirehoseIfNoConsumers())
+ firehoses.remove(firehoseKey)
+ }(system.dispatcher)
+ }
+ }
+
+ val catchupKillSwitch = KillSwitches.shared("catchupKillSwitch")
+ val catchupSourceWithKillSwitch =
+ catchupSource.asInstanceOf[Source[EventEnvelope[Any],
NotUsed]].via(catchupKillSwitch.flow)
+
+ val consumerKillSwitch = KillSwitches.shared("consumerKillSwitch")
+
+ val firehoseSource = firehose.firehoseHub
+
+ import GraphDSL.Implicits._
+ val catchupOrFirehose = GraphDSL.createGraph(catchupSourceWithKillSwitch)
{ implicit b => r =>
+ val merge = b.add(new CatchupOrFirehose(consumerId, firehose,
catchupKillSwitch))
+ r ~> merge.in1
+ FlowShape(merge.in0, merge.out)
+ }
+
+ firehoseSource
+ .via(catchupOrFirehose)
+ .map { env =>
+ // don't look at pub-sub or backtracking events
+ if (env.source == "")
+ firehose.updateConsumerTracking(consumerId, Instant.now(),
timestampOffset(env), consumerKillSwitch)
+ env.asInstanceOf[EventEnvelope[Event]]
+ }
+ .via(consumerKillSwitch.flow)
+ .watchTermination(Keep.right)
+ .mapMaterializedValue { termination =>
+ firehose.consumerStarted(consumerId, consumerKillSwitch)
+ termination.onComplete { _ =>
+ consumerTerminated()
+ }(system.dispatcher)
+ NotUsed
+ }
+ }
+
+ // can be overridden in tests
+ protected def underlyingEventsBySlices[Event](
+ pluginId: String,
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ @nowarn("msg=never used") firehose: Boolean):
Source[EventEnvelope[Event], NotUsed] = {
+ PersistenceQuery(system)
+ .readJournalFor[EventsBySliceQuery](pluginId)
+ .eventsBySlices(entityType, minSlice, maxSlice, offset)
+ }
+
+ private def underlyingEventsBySlicesStartingFromSnapshots[Snapshot, Event](
+ pluginId: String,
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ transformSnapshot: Snapshot => Event): Source[EventEnvelope[Event],
NotUsed] = {
+ PersistenceQuery(system)
+ .readJournalFor[EventsBySliceStartingFromSnapshotsQuery](pluginId)
+ .eventsBySlicesStartingFromSnapshots(entityType, minSlice, maxSlice,
offset, transformSnapshot)
+ }
+
+}
+
+/**
+ * INTERNAL API
+ */
+@InternalApi private[pekko] object CatchupOrFirehose {
+ private sealed trait Mode
+ private case object CatchUpOnly extends Mode
+ private final case class Both(caughtUpTimestamp: Instant) extends Mode
+ private case object FirehoseOnly extends Mode
+
+ private case class DeduplicationCacheEntry(pid: String, seqNr: Long, source:
String)
+}
+
+/**
+ * INTERNAL API
+ */
+@InternalApi private[pekko] class CatchupOrFirehose(
+ consumerId: String,
+ firehose: EventsBySliceFirehose.Firehose,
+ catchupKillSwitch: KillSwitch)
+ extends GraphStage[FanInShape2[EventEnvelope[Any], EventEnvelope[Any],
EventEnvelope[Any]]] {
+ import CatchupOrFirehose._
+ import EventsBySliceFirehose.isDurationGreaterThan
+ import EventsBySliceFirehose.timestampOffset
+ import firehose.firehoseKey.entityType
+ import firehose.settings
+
+ override def initialAttributes = Attributes.name("CatchupOrFirehose")
+ override val shape: FanInShape2[EventEnvelope[Any], EventEnvelope[Any],
EventEnvelope[Any]] =
+ new FanInShape2[EventEnvelope[Any], EventEnvelope[Any],
EventEnvelope[Any]]("CatchupOrFirehose")
+ def out: Outlet[EventEnvelope[Any]] = shape.out
+ val firehoseInlet: Inlet[EventEnvelope[Any]] = shape.in0
+ val catchupInlet: Inlet[EventEnvelope[Any]] = shape.in1
+
+ private val sliceRangeStr =
s"${firehose.firehoseKey.sliceRange.min}-${firehose.firehoseKey.sliceRange.max}"
+
+ override def createLogic(inheritedAttributes: Attributes): GraphStageLogic =
+ new GraphStageLogic(shape) with StageLogging {
+
+ // Without this the completion signalling would take one extra pull
+ private def willShutDown: Boolean = isClosed(firehoseInlet)
+
+ private val firehoseHandler = new FirehoseHandler(firehoseInlet)
+ private val catchupHandler = new CatchupHandler(catchupInlet)
+
+ private var mode: Mode = CatchUpOnly
+
+ // cache of seen pid/seqNr
+ private var deduplicationCache =
mutable.LinkedHashSet.empty[DeduplicationCacheEntry]
+ private val deduplicationCacheEvictThreshold =
(settings.deduplicationCapacity * 1.1).toInt
+
+ override protected def logSource: Class[?] = classOf[CatchupOrFirehose]
+
+ setHandler(out,
+ new OutHandler {
+ override def onPull(): Unit = {
+ tryPushOutput()
+ tryPullAllIfNeeded()
+ }
+ })
+
+ setHandler(firehoseInlet, firehoseHandler)
+ setHandler(catchupInlet, catchupHandler)
+
+ private def tryPushOutput(): Unit = {
+ def tryPushFirehoseValue(deduplicate: Boolean): Boolean =
+ firehoseHandler.value match {
+ case OptionVal.Some(env) =>
+ firehoseHandler.value = OptionVal.None
+ if (!(deduplicate && isDuplicate(env))) {
+ if (settings.verboseLogging && log.isDebugEnabled)
+ log.debug(
+ s"Firehose entityType [$entityType] sliceRange
[$sliceRangeStr] consumer [$consumerId] push from " +
+ s" firehose [${env.persistenceId}] seqNr
[${env.sequenceNr}], source [${env.source}]")
+ push(out, env)
+ true
+ } else
+ false
+ case _ =>
+ false
+ }
+
+ def tryPushCatchupValue(deduplicate: Boolean): Boolean =
+ catchupHandler.value match {
+ case OptionVal.Some(env) =>
+ catchupHandler.value = OptionVal.None
+ if (!(deduplicate && isDuplicate(env))) {
+ if (settings.verboseLogging && log.isDebugEnabled)
+ log.debug(
+ s"Firehose entityType [$entityType] sliceRange
[$sliceRangeStr] consumer [$consumerId] push from " +
+ s"catchup [${env.persistenceId}] seqNr
[${env.sequenceNr}], source [${env.source}]")
+ push(out, env)
+ true
+ } else
+ false
+ case _ =>
+ false
+ }
+
+ if (isAvailable(out)) {
+ mode match {
+ case FirehoseOnly =>
+ // there can be one final value from catchup when switching to
FirehoseOnly
+ if (!tryPushCatchupValue(deduplicate = false))
+ tryPushFirehoseValue(deduplicate = false)
+ case Both(_) =>
+ if (!tryPushFirehoseValue(deduplicate = true))
+ tryPushCatchupValue(deduplicate = true)
+ case CatchUpOnly =>
+ tryPushCatchupValue(deduplicate = false)
+ }
+ }
+
+ if (willShutDown) completeStage()
+ }
+
+ def isDuplicate(env: EventEnvelope[Any]): Boolean = {
+ if (settings.deduplicationCapacity == 0)
+ false
+ else {
+ val entry = DeduplicationCacheEntry(env.persistenceId,
env.sequenceNr, env.source)
+ val result = {
+ if (deduplicationCache.contains(entry)) {
+ true
+ } else {
+ deduplicationCache.add(entry)
+ false
+ }
+ }
+
+ if (deduplicationCache.size >= deduplicationCacheEvictThreshold) {
+ // weird that add modifies the instance but drop returns a new
instance
+ deduplicationCache =
deduplicationCache.drop(deduplicationCache.size -
settings.deduplicationCapacity)
+ }
+
+ result
+ }
+ }
+
+ private def tryPullAllIfNeeded(): Unit = {
+ if (isClosed(firehoseInlet)) {
+ completeStage()
+ } else {
+ if (!hasBeenPulled(firehoseInlet) && firehoseHandler.value.isEmpty) {
+ tryPull(firehoseInlet)
+ }
+ if (mode != FirehoseOnly && !hasBeenPulled(catchupInlet) &&
catchupHandler.value.isEmpty) {
+ tryPull(catchupInlet)
+ }
+ }
+ }
+
+ def isCaughtUp(env: EventEnvelope[Any]): Boolean = {
+ if (env.source == "") {
+ val offset = timestampOffset(env)
+ firehoseHandler.firehoseOffset.timestamp != Instant.EPOCH &&
+ !firehoseHandler.firehoseOffset.timestamp.isAfter(offset.timestamp)
+ } else
+ false // don't look at pub-sub or backtracking events
+ }
+
+ private class FirehoseHandler(in: Inlet[EventEnvelope[Any]]) extends
InHandler {
+ var value: OptionVal[EventEnvelope[Any]] = OptionVal.None
+ var firehoseOffset: TimestampOffset = TimestampOffset(Instant.EPOCH,
Map.empty)
+
+ def updateFirehoseOffset(env: EventEnvelope[Any]): Unit = {
+ // don't look at pub-sub or backtracking events
+ if (env.source == "")
+ firehoseOffset = timestampOffset(env)
+ }
+
+ override def onPush(): Unit = {
+ if (value.isDefined)
+ throw new IllegalStateException("FirehoseInlet.onPush but has
already value. This is a bug.")
+
+ val env = grab(in)
+
+ mode match {
+ case FirehoseOnly =>
+ value = OptionVal.Some(env)
+ case Both(_) =>
+ updateFirehoseOffset(env)
+ value = OptionVal.Some(env)
+ case CatchUpOnly =>
+ updateFirehoseOffset(env)
+ }
+
+ tryPushOutput()
+ tryPullAllIfNeeded()
+ }
+ }
+
+ private class CatchupHandler(in: Inlet[EventEnvelope[Any]]) extends
InHandler {
+ var value: OptionVal[EventEnvelope[Any]] = OptionVal.None
+
+ override def onPush(): Unit = {
+ if (value.isDefined)
+ throw new IllegalStateException("CatchupInlet.onPush but has
already value. This is a bug.")
+
+ val env = grab(in)
+
+ mode match {
+ case CatchUpOnly =>
+ if (isCaughtUp(env)) {
+ val timestamp = timestampOffset(env).timestamp
+ log.debug(
+ "Firehose entityType [{}] sliceRange [{}] consumer [{}]
caught up at [{}]",
+ entityType,
+ sliceRangeStr,
+ consumerId,
+ timestamp)
+ mode = Both(timestamp)
+ }
+ value = OptionVal.Some(env)
+
+ case Both(caughtUpTimestamp) =>
+ // don't look at pub-sub or backtracking events
+ if (env.source == "") {
+ val timestamp = timestampOffset(env).timestamp
+ if (isDurationGreaterThan(caughtUpTimestamp, timestamp,
settings.catchupOverlap)) {
+ firehose.updateConsumerFirehoseOnly(consumerId)
+ log.debug(
+ "Firehose entityType [{}] sliceRange [{}] consumer [{}]
switching to firehose only [{}]",
+ entityType,
+ sliceRangeStr,
+ consumerId,
+ timestamp)
+ catchupKillSwitch.shutdown()
+ mode = FirehoseOnly
+ deduplicationCache =
mutable.LinkedHashSet.empty[DeduplicationCacheEntry]
+ }
+ }
+
+ value = OptionVal.Some(env)
+
+ case FirehoseOnly =>
+ // skip
+ }
+
+ tryPushOutput()
+ tryPullAllIfNeeded()
+ }
+
+ override def onUpstreamFinish(): Unit = {
+ // important to override onUpstreamFinish, otherwise it will close
everything
+ log.debug(
+ "Firehose entityType [{}] sliceRange [{}] consumer [{}] catchup
closed",
+ entityType,
+ sliceRangeStr,
+ consumerId)
+ }
+ }
+
+ }
+
+ override def toString = "CatchupOrFirehose"
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/CurrentEventsByPersistenceIdStartingFromSnapshotQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/CurrentEventsByPersistenceIdStartingFromSnapshotQuery.scala
new file mode 100644
index 0000000000..88d8f5c262
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/CurrentEventsByPersistenceIdStartingFromSnapshotQuery.scala
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.javadsl
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.persistence.query.javadsl.ReadJournal
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.stream.javadsl.Source
+
+/**
+ * A plugin may optionally support this query by implementing this trait.
+ */
+@ApiMayChange
+trait CurrentEventsByPersistenceIdStartingFromSnapshotQuery extends
ReadJournal {
+
+ /**
+ * Same as [[CurrentEventsByPersistenceIdTypedQuery]] but with the purpose
to use snapshot as starting point
+ * and thereby reducing number of events that have to be loaded.
+ */
+ def currentEventsByPersistenceIdStartingFromSnapshot[Snapshot, Event](
+ persistenceId: String,
+ fromSequenceNr: Long,
+ toSequenceNr: Long,
+ transformSnapshot: java.util.function.Function[Snapshot, Event]):
Source[EventEnvelope[Event], NotUsed]
+
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/CurrentEventsBySliceStartingFromSnapshotsQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/CurrentEventsBySliceStartingFromSnapshotsQuery.scala
new file mode 100644
index 0000000000..c7e57636be
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/CurrentEventsBySliceStartingFromSnapshotsQuery.scala
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.javadsl
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.japi.Pair
+import pekko.persistence.query.Offset
+import pekko.persistence.query.javadsl.ReadJournal
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.stream.javadsl.Source
+
+/**
+ * A plugin may optionally support this query by implementing this trait.
+ *
+ * API May Change
+ */
+@ApiMayChange
+trait CurrentEventsBySliceStartingFromSnapshotsQuery extends ReadJournal {
+
+ /**
+ * Same as [[EventsBySliceStartingFromSnapshotsQuery]] but with the purpose
to use snapshots as starting points
+ * and thereby reducing number of events that have to be loaded. This can be
useful if the consumer start
+ * from zero without any previously processed offset or if it has been
disconnected for a long while and
+ * its offset is far behind.
+ *
+ * Same type of query as
[[EventsBySliceStartingFromSnapshotsQuery.eventsBySlicesStartingFromSnapshots]]
but
+ * the event stream is completed immediately when it reaches the end of the
"result set".
+ */
+ def currentEventsBySlicesStartingFromSnapshots[Snapshot, Event](
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ transformSnapshot: java.util.function.Function[Snapshot, Event]):
Source[EventEnvelope[Event], NotUsed]
+
+ def sliceForPersistenceId(persistenceId: String): Int
+
+ def sliceRanges(numberOfRanges: Int): java.util.List[Pair[Integer, Integer]]
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsByPersistenceIdStartingFromSnapshotQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsByPersistenceIdStartingFromSnapshotQuery.scala
new file mode 100644
index 0000000000..ebbe98a6fc
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsByPersistenceIdStartingFromSnapshotQuery.scala
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.javadsl
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.persistence.query.javadsl.ReadJournal
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.stream.javadsl.Source
+
+/**
+ * A plugin may optionally support this query by implementing this trait.
+ */
+@ApiMayChange
+trait EventsByPersistenceIdStartingFromSnapshotQuery extends ReadJournal {
+
+ /**
+ * Same as [[EventsByPersistenceIdTypedQuery]] but with the purpose to use
snapshot as starting point
+ * and thereby reducing number of events that have to be loaded.
+ */
+ def eventsByPersistenceIdStartingFromSnapshot[Snapshot, Event](
+ persistenceId: String,
+ fromSequenceNr: Long,
+ toSequenceNr: Long,
+ transformSnapshot: java.util.function.Function[Snapshot, Event]):
Source[EventEnvelope[Event], NotUsed]
+
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceFirehoseQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceFirehoseQuery.scala
new file mode 100644
index 0000000000..f08a5426bc
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceFirehoseQuery.scala
@@ -0,0 +1,95 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.javadsl
+
+import java.time.Instant
+import java.util
+import java.util.Optional
+import java.util.concurrent.CompletionStage
+
+import scala.concurrent.ExecutionContext
+import scala.jdk.FutureConverters._
+import scala.jdk.OptionConverters._
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.japi.Pair
+import pekko.persistence.query.Offset
+import pekko.persistence.query.javadsl.ReadJournal
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.persistence.query.typed.scaladsl
+import pekko.stream.javadsl.Source
+
+object EventsBySliceFirehoseQuery {
+ val Identifier: String = scaladsl.EventsBySliceFirehoseQuery.Identifier
+}
+
+/**
+ * This wrapper of [[EventsBySliceQuery]] gives better scalability when many
consumers retrieve the
+ * same events, for example many Projections of the same entity type. The
purpose is to share
+ * the stream of events from the database and fan out to connected consumer
streams. Thereby fewer
+ * queries and loading of events from the database.
+ *
+ * It is retrieved with:
+ * {{{
+ * EventsBySliceQuery queries =
+ * PersistenceQuery.get(system).getReadJournalFor(EventsBySliceQuery.class,
EventsBySliceFirehoseQuery.Identifier());
+ * }}}
+ *
+ * Corresponding Scala API is in
[[pekko.persistence.query.typed.scaladsl.EventsBySliceFirehoseQuery]].
+ *
+ * Configuration settings can be defined in the configuration section with the
+ * absolute path corresponding to the identifier, which is
`"pekko.persistence.query.events-by-slice-firehose"`
+ * for the default [[EventsBySliceFirehoseQuery#Identifier]]. See
`reference.conf`.
+ */
+final class EventsBySliceFirehoseQuery(delegate:
scaladsl.EventsBySliceFirehoseQuery)
+ extends ReadJournal
+ with EventsBySliceQuery
+ with EventsBySliceStartingFromSnapshotsQuery
+ with EventTimestampQuery
+ with LoadEventQuery {
+
+ override def sliceForPersistenceId(persistenceId: String): Int =
+ delegate.sliceForPersistenceId(persistenceId)
+
+ override def eventsBySlices[Event](
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset): Source[EventEnvelope[Event], NotUsed] =
+ delegate.eventsBySlices(entityType, minSlice, maxSlice, offset).asJava
+
+ override def eventsBySlicesStartingFromSnapshots[Snapshot, Event](
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ transformSnapshot: java.util.function.Function[Snapshot, Event]):
Source[EventEnvelope[Event], NotUsed] =
+ delegate.eventsBySlicesStartingFromSnapshots(entityType, minSlice,
maxSlice, offset, transformSnapshot(_)).asJava
+
+ override def sliceRanges(numberOfRanges: Int): util.List[Pair[Integer,
Integer]] = {
+ import scala.jdk.CollectionConverters._
+ delegate
+ .sliceRanges(numberOfRanges)
+ .map(range => Pair(Integer.valueOf(range.min),
Integer.valueOf(range.max)))
+ .asJava
+ }
+
+ override def timestampOf(persistenceId: String, sequenceNr: Long):
CompletionStage[Optional[Instant]] =
+ delegate.timestampOf(persistenceId,
sequenceNr).map(_.toJava)(ExecutionContext.parasitic).asJava
+
+ override def loadEnvelope[Event](persistenceId: String, sequenceNr: Long):
CompletionStage[EventEnvelope[Event]] =
+ delegate.loadEnvelope[Event](persistenceId, sequenceNr).asJava
+
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceQuery.scala
index b0a4afbfdb..190851788a 100644
---
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceQuery.scala
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceQuery.scala
@@ -28,6 +28,8 @@ import pekko.stream.javadsl.Source
* `EventsBySliceQuery` that is using a timestamp based offset should also
implement [[EventTimestampQuery]] and
* [[LoadEventQuery]].
*
+ * See also [[EventsBySliceFirehoseQuery]].
+ *
* API May Change
*/
@ApiMayChange
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceStartingFromSnapshotsQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceStartingFromSnapshotsQuery.scala
new file mode 100644
index 0000000000..84e36ccb1d
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/javadsl/EventsBySliceStartingFromSnapshotsQuery.scala
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.javadsl
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.japi.Pair
+import pekko.persistence.query.Offset
+import pekko.persistence.query.javadsl.ReadJournal
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.stream.javadsl.Source
+
+/**
+ * A plugin may optionally support this query by implementing this trait.
+ *
+ * `EventsBySliceQuery` that is using a timestamp based offset should also
implement [[EventTimestampQuery]] and
+ * [[LoadEventQuery]].
+ *
+ * See also [[EventsBySliceFirehoseQuery]].
+ *
+ * API May Change
+ */
+@ApiMayChange
+trait EventsBySliceStartingFromSnapshotsQuery extends ReadJournal {
+
+ /**
+ * Same as [[EventsBySliceQuery]] but with the purpose to use snapshots as
starting points and thereby reducing number of
+ * events that have to be loaded. This can be useful if the consumer start
from zero without any previously processed
+ * offset or if it has been disconnected for a long while and its offset is
far behind.
+ */
+ def eventsBySlicesStartingFromSnapshots[Snapshot, Event](
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ transformSnapshot: java.util.function.Function[Snapshot, Event]):
Source[EventEnvelope[Event], NotUsed]
+
+ def sliceForPersistenceId(persistenceId: String): Int
+
+ def sliceRanges(numberOfRanges: Int): java.util.List[Pair[Integer, Integer]]
+
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/CurrentEventsByPersistenceIdStartingFromSnapshotQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/CurrentEventsByPersistenceIdStartingFromSnapshotQuery.scala
new file mode 100644
index 0000000000..d2075bf2ea
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/CurrentEventsByPersistenceIdStartingFromSnapshotQuery.scala
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.scaladsl
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.persistence.query.scaladsl.ReadJournal
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.stream.scaladsl.Source
+
+/**
+ * A plugin may optionally support this query by implementing this trait.
+ */
+@ApiMayChange
+trait CurrentEventsByPersistenceIdStartingFromSnapshotQuery extends
ReadJournal {
+
+ /**
+ * Same as [[CurrentEventsByPersistenceIdTypedQuery]] but with the purpose
to use snapshot as starting point
+ * and thereby reducing number of events that have to be loaded.
+ */
+ def currentEventsByPersistenceIdStartingFromSnapshot[Snapshot, Event](
+ persistenceId: String,
+ fromSequenceNr: Long,
+ toSequenceNr: Long,
+ transformSnapshot: Snapshot => Event): Source[EventEnvelope[Event],
NotUsed]
+
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/CurrentEventsBySliceStartingFromSnapshotsQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/CurrentEventsBySliceStartingFromSnapshotsQuery.scala
new file mode 100644
index 0000000000..d79f01d830
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/CurrentEventsBySliceStartingFromSnapshotsQuery.scala
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.scaladsl
+
+import scala.collection.immutable
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.persistence.query.Offset
+import pekko.persistence.query.scaladsl.ReadJournal
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.stream.scaladsl.Source
+
+/**
+ * A plugin may optionally support this query by implementing this trait.
+ *
+ * API May Change
+ */
+@ApiMayChange
+trait CurrentEventsBySliceStartingFromSnapshotsQuery extends ReadJournal {
+
+ /**
+ * Same as [[EventsBySliceStartingFromSnapshotsQuery]] but with the purpose
to use snapshots as starting points
+ * and thereby reducing number of events that have to be loaded. This can be
useful if the consumer start
+ * from zero without any previously processed offset or if it has been
disconnected for a long while and
+ * its offset is far behind.
+ *
+ * Same type of query as
[[EventsBySliceStartingFromSnapshotsQuery.eventsBySlicesStartingFromSnapshots]]
but
+ * the event stream is completed immediately when it reaches the end of the
"result set".
+ */
+ def currentEventsBySlicesStartingFromSnapshots[Snapshot, Event](
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ transformSnapshot: Snapshot => Event): Source[EventEnvelope[Event],
NotUsed]
+
+ def sliceForPersistenceId(persistenceId: String): Int
+
+ def sliceRanges(numberOfRanges: Int): immutable.Seq[Range]
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsByPersistenceIdStartingFromSnapshotQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsByPersistenceIdStartingFromSnapshotQuery.scala
new file mode 100644
index 0000000000..e33fe28abf
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsByPersistenceIdStartingFromSnapshotQuery.scala
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.scaladsl
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.persistence.query.scaladsl.ReadJournal
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.stream.scaladsl.Source
+
+/**
+ * A plugin may optionally support this query by implementing this trait.
+ */
+@ApiMayChange
+trait EventsByPersistenceIdStartingFromSnapshotQuery extends ReadJournal {
+
+ /**
+ * Same as [[EventsByPersistenceIdTypedQuery]] but with the purpose to use
snapshot as starting point
+ * and thereby reducing number of events that have to be loaded.
+ */
+ def eventsByPersistenceIdStartingFromSnapshot[Snapshot, Event](
+ persistenceId: String,
+ fromSequenceNr: Long,
+ toSequenceNr: Long,
+ transformSnapshot: Snapshot => Event): Source[EventEnvelope[Event],
NotUsed]
+
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceFirehoseQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceFirehoseQuery.scala
new file mode 100644
index 0000000000..5eb68e98f7
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceFirehoseQuery.scala
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.scaladsl
+
+import java.time.Instant
+
+import scala.annotation.nowarn
+import scala.collection.immutable
+import scala.concurrent.Future
+
+import com.typesafe.config.Config
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.actor.ExtendedActorSystem
+import pekko.persistence.Persistence
+import pekko.persistence.query.Offset
+import pekko.persistence.query.PersistenceQuery
+import pekko.persistence.query.scaladsl._
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.persistence.query.typed.internal.EventsBySliceFirehose
+import pekko.stream.scaladsl.Source
+
+object EventsBySliceFirehoseQuery {
+ val Identifier = "pekko.persistence.query.events-by-slice-firehose"
+
+}
+
+/**
+ * This wrapper of [[EventsBySliceQuery]] gives better scalability when many
consumers retrieve the
+ * same events, for example many Projections of the same entity type. The
purpose is to share
+ * the stream of events from the database and fan out to connected consumer
streams. Thereby fewer
+ * queries and loading of events from the database.
+ *
+ * It is retrieved with:
+ * {{{
+ * val queries =
PersistenceQuery(system).readJournalFor[EventsBySliceQuery](EventsBySliceFirehoseQuery.Identifier)
+ * }}}
+ *
+ * Corresponding Java API is in
[[org.apache.pekko.persistence.query.typed.javadsl.EventsBySliceFirehoseQuery]].
+ *
+ * Configuration settings can be defined in the configuration section with the
+ * absolute path corresponding to the identifier, which is
`"pekko.persistence.query.events-by-slice-firehose"`
+ * for the default [[EventsBySliceFirehoseQuery#Identifier]]. See
`reference.conf`.
+ */
+@nowarn("msg=never used")
+final class EventsBySliceFirehoseQuery(system: ExtendedActorSystem, config:
Config, cfgPath: String)
+ extends ReadJournal
+ with EventsBySliceQuery
+ with EventsBySliceStartingFromSnapshotsQuery
+ with EventTimestampQuery
+ with LoadEventQuery {
+
+ private lazy val persistenceExt = Persistence(system)
+ private lazy val settings = EventsBySliceFirehose.Settings(system, cfgPath)
+
+ override def eventsBySlices[Event](
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset): Source[EventEnvelope[Event], NotUsed] =
+ EventsBySliceFirehose(system).eventsBySlices(cfgPath, entityType,
minSlice, maxSlice, offset)
+
+ override def eventsBySlicesStartingFromSnapshots[Snapshot, Event](
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ transformSnapshot: Snapshot => Event): Source[EventEnvelope[Event],
NotUsed] =
+ EventsBySliceFirehose(system).eventsBySlicesStartingFromSnapshots(
+ cfgPath,
+ entityType,
+ minSlice,
+ maxSlice,
+ offset,
+ transformSnapshot)
+
+ override def sliceForPersistenceId(persistenceId: String): Int =
+ persistenceExt.sliceForPersistenceId(persistenceId)
+
+ override def sliceRanges(numberOfRanges: Int): immutable.Seq[Range] =
+ persistenceExt.sliceRanges(numberOfRanges)
+
+ override def timestampOf(persistenceId: String, sequenceNr: Long):
Future[Option[Instant]] = {
+ eventsBySliceQuery match {
+ case q: EventTimestampQuery => q.timestampOf(persistenceId, sequenceNr)
+ case _ =>
+ throw new IllegalArgumentException(
+ s"Underlying ReadJournal [${settings.delegateQueryPluginId}] doesn't
implement EventTimestampQuery")
+ }
+ }
+
+ override def loadEnvelope[Event](persistenceId: String, sequenceNr: Long):
Future[EventEnvelope[Event]] =
+ eventsBySliceQuery match {
+ case q: LoadEventQuery => q.loadEnvelope(persistenceId, sequenceNr)
+ case _ =>
+ throw new IllegalArgumentException(
+ s"Underlying ReadJournal [${settings.delegateQueryPluginId}] " +
+ "doesn't implement LoadEventQuery")
+ }
+
+ private def eventsBySliceQuery: EventsBySliceQuery = {
+ val delegateQueryPluginId =
+
EventsBySliceFirehose.Settings.delegateQueryPluginId(system.settings.config.getConfig(cfgPath))
+
PersistenceQuery(system).readJournalFor[EventsBySliceQuery](delegateQueryPluginId)
+ }
+
+}
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceQuery.scala
index 3591a17642..7d76b020d7 100644
---
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceQuery.scala
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceQuery.scala
@@ -29,6 +29,8 @@ import pekko.stream.scaladsl.Source
* `EventsBySliceQuery` that is using a timestamp based offset should also
implement [[EventTimestampQuery]] and
* [[LoadEventQuery]].
*
+ * See also [[EventsBySliceFirehoseQuery]].
+ *
* API May Change
*/
@ApiMayChange
diff --git
a/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceStartingFromSnapshotsQuery.scala
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceStartingFromSnapshotsQuery.scala
new file mode 100644
index 0000000000..cb53fbd02a
--- /dev/null
+++
b/persistence-query/src/main/scala/org/apache/pekko/persistence/query/typed/scaladsl/EventsBySliceStartingFromSnapshotsQuery.scala
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.scaladsl
+
+import scala.collection.immutable
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.annotation.ApiMayChange
+import pekko.persistence.query.Offset
+import pekko.persistence.query.scaladsl.ReadJournal
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.stream.scaladsl.Source
+
+/**
+ * A plugin may optionally support this query by implementing this trait.
+ *
+ * `EventsBySliceStartingFromSnapshotsQuery` that is using a timestamp based
offset should also implement [[EventTimestampQuery]] and
+ * [[LoadEventQuery]].
+ *
+ * See also [[EventsBySliceFirehoseQuery]].
+ *
+ * API May Change
+ */
+@ApiMayChange
+trait EventsBySliceStartingFromSnapshotsQuery extends ReadJournal {
+
+ /**
+ * Same as [[EventsBySliceQuery]] but with the purpose to use snapshots as
starting points and thereby reducing number of
+ * events that have to be loaded. This can be useful if the consumer start
from zero without any previously processed
+ * offset or if it has been disconnected for a long while and its offset is
far behind.
+ */
+ def eventsBySlicesStartingFromSnapshots[Snapshot, Event](
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ transformSnapshot: Snapshot => Event): Source[EventEnvelope[Event],
NotUsed]
+
+ def sliceForPersistenceId(persistenceId: String): Int
+
+ def sliceRanges(numberOfRanges: Int): immutable.Seq[Range]
+
+}
diff --git
a/persistence-query/src/test/scala/org/apache/pekko/persistence/query/TestClock.scala
b/persistence-query/src/test/scala/org/apache/pekko/persistence/query/TestClock.scala
new file mode 100644
index 0000000000..55fcd4fa3c
--- /dev/null
+++
b/persistence-query/src/test/scala/org/apache/pekko/persistence/query/TestClock.scala
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query
+
+import java.time.{ Duration => JDuration }
+import java.time.Clock
+import java.time.Instant
+import java.time.ZoneId
+import java.time.ZoneOffset
+
+import scala.concurrent.duration.FiniteDuration
+
+import org.apache.pekko.annotation.InternalApi
+
+/**
+ * INTERNAL API
+ */
+@InternalApi private[pekko] class TestClock extends Clock {
+
+ @volatile private var _instant = roundToMillis(Instant.now())
+
+ override def getZone: ZoneId = ZoneOffset.UTC
+
+ override def withZone(zone: ZoneId): Clock =
+ throw new UnsupportedOperationException("withZone not supported")
+
+ override def instant(): Instant =
+ _instant
+
+ def setInstant(newInstant: Instant): Unit =
+ _instant = roundToMillis(newInstant)
+
+ def tick(duration: JDuration): Instant = {
+ val newInstant = roundToMillis(_instant.plus(duration))
+ _instant = newInstant
+ newInstant
+ }
+
+ def tick(duration: FiniteDuration): Instant =
+ tick(JDuration.ofMillis(duration.toMillis))
+
+ private def roundToMillis(i: Instant): Instant = {
+ // algo taken from java.time.Clock.tick
+ val epochMilli = i.toEpochMilli
+ Instant.ofEpochMilli(epochMilli - Math.floorMod(epochMilli, 1L))
+ }
+
+}
diff --git
a/persistence-query/src/test/scala/org/apache/pekko/persistence/query/typed/internal/EventsBySliceFirehoseSpec.scala
b/persistence-query/src/test/scala/org/apache/pekko/persistence/query/typed/internal/EventsBySliceFirehoseSpec.scala
new file mode 100644
index 0000000000..fc96abab8d
--- /dev/null
+++
b/persistence-query/src/test/scala/org/apache/pekko/persistence/query/typed/internal/EventsBySliceFirehoseSpec.scala
@@ -0,0 +1,440 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.query.typed.internal
+
+import java.time.{ Duration => JDuration }
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.atomic.AtomicInteger
+
+import scala.concurrent.{ ExecutionContext, Promise }
+import scala.concurrent.duration._
+
+import com.typesafe.config.ConfigFactory
+import org.scalatest.concurrent.Eventually
+
+import org.apache.pekko
+import pekko.NotUsed
+import pekko.persistence.Persistence
+import pekko.persistence.query.NoOffset
+import pekko.persistence.query.Offset
+import pekko.persistence.query.TestClock
+import pekko.persistence.query.TimestampOffset
+import pekko.persistence.query.typed.EventEnvelope
+import pekko.persistence.query.typed.internal.EventsBySliceFirehose.FirehoseKey
+import
pekko.persistence.query.typed.internal.EventsBySliceFirehose.SlowConsumerException
+import pekko.persistence.query.typed.scaladsl.EventsBySliceFirehoseQuery
+import pekko.stream.scaladsl.Keep
+import pekko.stream.scaladsl.Source
+import pekko.stream.testkit.TestPublisher
+import pekko.stream.testkit.TestSubscriber
+import pekko.stream.testkit.TestSubscriber.OnNext
+import pekko.stream.testkit.scaladsl.TestSink
+import pekko.stream.testkit.scaladsl.TestSource
+import pekko.testkit.PekkoSpec
+import pekko.testkit.WithLogCapturing
+
+object EventsBySliceFirehoseSpec {
+ // real PersistenceId is in pekko-persistence-typed, and no dependency to
that from here
+ final case class PersistenceId(entityTypeHint: String, entityId: String) {
+ def id: String = s"$entityTypeHint|$entityId"
+ }
+
+ private val config = ConfigFactory.parseString(s"""
+ pekko.loglevel = DEBUG
+ pekko.loggers = ["org.apache.pekko.testkit.SilenceAllTestEventListener"]
+
+ pekko.persistence.query.events-by-slice-firehose {
+ # test-query-plugin is not used by this test, but must be defined
+ delegate-query-plugin-id = test-query-plugin
+
+ broadcast-buffer-size = 64
+
+ firehose-linger-timeout = 2s
+
+ # don't deplicate in this test
+ deduplication-capacity = 0
+
+ # disable reaper in this test, will be triggered by code
+ slow-consumer-reaper-interval = 1 day
+
+ verbose-debug-logging = on
+ }
+
+ events-by-slice-firehose-with-deduplication =
$${pekko.persistence.query.events-by-slice-firehose}
+ events-by-slice-firehose-with-deduplication {
+ deduplication-capacity = 10000
+ }
+ """)
+}
+
+class EventsBySliceFirehoseSpec
+ extends PekkoSpec(EventsBySliceFirehoseSpec.config)
+ with WithLogCapturing
+ with Eventually {
+ import EventsBySliceFirehoseSpec._
+
+ private val entityType = "EntityA"
+
+ private val persistence = Persistence(system)
+
+ private val clock = new TestClock
+
+ private def createEnvelope(
+ pid: PersistenceId,
+ seqNr: Long,
+ evt: String,
+ tags: Set[String] = Set.empty): EventEnvelope[Any] = {
+ clock.tick(JDuration.ofMillis(1))
+ val now = clock.instant()
+ EventEnvelope(
+ TimestampOffset(now, Map(pid.id -> seqNr)),
+ pid.id,
+ seqNr,
+ evt,
+ now.toEpochMilli,
+ pid.entityTypeHint,
+ persistence.sliceForPersistenceId(pid.id),
+ filtered = false,
+ source = "",
+ tags = tags)
+ }
+
+ private val envelopes = Vector(
+ createEnvelope(PersistenceId(entityType, "a"), 1, "a1"),
+ createEnvelope(PersistenceId(entityType, "b"), 1, "b1"),
+ createEnvelope(PersistenceId(entityType, "c"), 1, "c1"),
+ createEnvelope(PersistenceId(entityType, "d"), 1, "d1"))
+
+ private class Setup {
+ def allEnvelopes: Vector[EventEnvelope[Any]] = envelopes
+
+ def sliceRange = 0 to 1023
+
+ def pluginId: String = EventsBySliceFirehoseQuery.Identifier
+
+ class ConsumerSetup {
+ private val catchupPublisherPromise =
Promise[TestPublisher.Probe[EventEnvelope[Any]]]()
+ val catchupSource: Source[EventEnvelope[Any], NotUsed] =
+ TestSource[EventEnvelope[Any]]().mapMaterializedValue { probe =>
+ catchupPublisherPromise.success(probe)
+ NotUsed
+ }
+
+ lazy val outProbe =
+ eventsBySliceFirehose
+ .eventsBySlices[Any](pluginId, entityType, sliceRange.min,
sliceRange.max, NoOffset)
+ .runWith(TestSink())
+
+ lazy val catchupPublisher = {
+ outProbe // materialize
+ catchupPublisherPromise.future.futureValue
+ }
+ }
+
+ private val consumerCount = new AtomicInteger
+ private val consumers = Vector.fill(100)(new ConsumerSetup)
+
+ def catchupPublisher(consumerIndex: Int):
TestPublisher.Probe[EventEnvelope[Any]] =
+ consumers(consumerIndex).catchupPublisher
+ def catchupPublisher: TestPublisher.Probe[EventEnvelope[Any]] =
catchupPublisher(0)
+
+ def outProbe(consumerIndex: Int): TestSubscriber.Probe[EventEnvelope[Any]]
= consumers(consumerIndex).outProbe
+ def outProbe: TestSubscriber.Probe[EventEnvelope[Any]] = outProbe(0)
+
+ val firehoseRunning = new AtomicBoolean
+ private val firehosePublisherPromise =
Promise[TestPublisher.Probe[EventEnvelope[Any]]]()
+ private val firehoseSource: Source[EventEnvelope[Any], NotUsed] =
+
TestSource[EventEnvelope[Any]]().watchTermination(Keep.both).mapMaterializedValue
{
+ case (probe, termination) =>
+ firehoseRunning.set(true)
+ termination.onComplete(_ =>
firehoseRunning.set(false))(ExecutionContext.parasitic)
+ firehosePublisherPromise.success(probe)
+ NotUsed
+ }
+
+ val eventsBySliceFirehose = new
EventsBySliceFirehose(system.classicSystem) {
+ override protected def underlyingEventsBySlices[Event](
+ pluginId: String,
+ entityType: String,
+ minSlice: Int,
+ maxSlice: Int,
+ offset: Offset,
+ firehose: Boolean): Source[EventEnvelope[Event], NotUsed] = {
+ if (firehose)
+ firehoseSource.map(_.asInstanceOf[EventEnvelope[Event]])
+ else {
+ val i = consumerCount.getAndIncrement()
+ consumers(i).catchupSource.map(_.asInstanceOf[EventEnvelope[Event]])
+ }
+ }
+ }
+
+ lazy val firehosePublisher = {
+ outProbe // materialize at least one
+ firehosePublisherPromise.future.futureValue
+ }
+ }
+
+ "EventsBySliceFirehose" must {
+ "emit from catchup" in new Setup {
+ allEnvelopes.foreach(catchupPublisher.sendNext)
+ outProbe.request(10)
+ outProbe.expectNextN(envelopes.size) shouldBe envelopes
+ }
+
+ "emit from catchup and then switch over to firehose" in new Setup {
+ catchupPublisher.sendNext(allEnvelopes(0))
+ catchupPublisher.sendNext(allEnvelopes(1))
+ outProbe.request(10)
+ outProbe.expectNext(allEnvelopes(0))
+ outProbe.expectNext(allEnvelopes(1))
+
+ firehosePublisher.sendNext(allEnvelopes(2))
+ outProbe.expectNoMessage()
+
+ catchupPublisher.sendNext(allEnvelopes(2))
+ outProbe.expectNext(allEnvelopes(2)) // still from catchup
+
+ firehosePublisher.sendNext(allEnvelopes(3))
+ outProbe.expectNext(allEnvelopes(3)) // from firehose
+
+ catchupPublisher.sendNext(allEnvelopes(3))
+ outProbe.expectNext(allEnvelopes(3)) // from catchup, emitting from both
+
+ clock.tick(JDuration.ofSeconds(60))
+ val env5 = createEnvelope(PersistenceId(entityType, "a"), 2, "a2")
+ catchupPublisher.sendNext(env5)
+ outProbe.expectNext(env5)
+
+ val env6 = createEnvelope(PersistenceId(entityType, "a"), 3, "a3")
+ catchupPublisher.sendNext(env6)
+ outProbe.expectNoMessage() // catchup closed
+ firehosePublisher.sendNext(env6)
+ outProbe.expectNext(env6)
+ }
+
+ "track consumer progress" in new Setup {
+ // using two consumers
+ outProbe(0).request(2)
+ outProbe(1).request(2)
+
+ // FIXME is there a way to know that it has been added to the hub?
+ Thread.sleep(1000)
+
+ catchupPublisher(0).sendNext(allEnvelopes(0))
+ catchupPublisher(1).sendNext(allEnvelopes(0))
+ outProbe(0).expectNext(allEnvelopes(0))
+ outProbe(1).expectNext(allEnvelopes(0))
+
+ firehosePublisher.sendNext(allEnvelopes(0))
+ // this "sleep" is needed so that the firehose envelope is received first
+ outProbe.expectNoMessage()
+
+ catchupPublisher(0).sendNext(allEnvelopes(1))
+ catchupPublisher(1).sendNext(allEnvelopes(1))
+ outProbe(0).expectNext(allEnvelopes(1))
+ outProbe(1).expectNext(allEnvelopes(1))
+
+ val firehose =
+
eventsBySliceFirehose.getFirehose(FirehoseKey(EventsBySliceFirehoseQuery.Identifier,
entityType, sliceRange))
+ firehose.consumerTracking.size shouldBe 2
+ import scala.jdk.CollectionConverters._
+ firehose.consumerTracking.values.asScala.foreach { tracking =>
+ tracking.offsetTimestamp shouldBe
allEnvelopes(1).offset.asInstanceOf[TimestampOffset].timestamp
+ }
+
+ // only requesting for outProbe(0)
+ outProbe(0).request(100)
+ // less than BroadcastHub buffer size
+ val moreEnvelopes = (1 to 20).map(n =>
createEnvelope(PersistenceId(entityType, "x"), n, s"x$n"))
+ moreEnvelopes.foreach(firehosePublisher.sendNext)
+ outProbe(0).expectNextN(moreEnvelopes.size) shouldBe moreEnvelopes
+ firehose.consumerTracking.size shouldBe 2
+ firehose.consumerTracking.values.asScala
+ .count(_.offsetTimestamp ==
moreEnvelopes.last.offset.asInstanceOf[TimestampOffset].timestamp) shouldBe 1
+ }
+
+ "abort slow consumers" in new Setup {
+ // using two consumers
+ outProbe(0).request(3)
+ outProbe(1).request(3)
+
+ // FIXME is there a way to know that it has been added to the hub?
+ Thread.sleep(1000)
+
+ catchupPublisher(0).sendNext(allEnvelopes(0))
+ catchupPublisher(1).sendNext(allEnvelopes(0))
+ outProbe(0).expectNext(allEnvelopes(0))
+ outProbe(1).expectNext(allEnvelopes(0))
+
+ firehosePublisher.sendNext(allEnvelopes(0))
+ // this "sleep" is needed so that the firehose envelope is received first
+ outProbe.expectNoMessage()
+
+ catchupPublisher(0).sendNext(allEnvelopes(1))
+ catchupPublisher(1).sendNext(allEnvelopes(1))
+ outProbe(0).expectNext(allEnvelopes(1))
+ outProbe(1).expectNext(allEnvelopes(1))
+
+ // switch to firehose only
+ clock.tick(JDuration.ofSeconds(60))
+ // more than half BroadcastHub buffer size
+ val moreEnvelopes = (1 to 40).map { n =>
+ clock.tick(JDuration.ofSeconds(1))
+ createEnvelope(PersistenceId(entityType, "x"), n, s"x$n")
+ }
+ // both consumers will now switch to firehose only
+ catchupPublisher(0).sendNext(moreEnvelopes.head)
+ catchupPublisher(1).sendNext(moreEnvelopes.head)
+ outProbe(0).expectNext(moreEnvelopes.head)
+ outProbe(1).expectNext(moreEnvelopes.head)
+ // only requesting for outProbe(0)
+ outProbe(0).request(100)
+ moreEnvelopes.foreach(firehosePublisher.sendNext)
+ outProbe(0).expectNextN(moreEnvelopes.size) shouldBe moreEnvelopes
+ val firehose =
+
eventsBySliceFirehose.getFirehose(FirehoseKey(EventsBySliceFirehoseQuery.Identifier,
entityType, sliceRange))
+ clock.tick(JDuration.ofSeconds(6)) // simulate consumer lag
+ firehose.detectSlowConsumers(clock.instant())
+ clock.tick(JDuration.ofSeconds(2))
+ firehose.detectSlowConsumers(clock.instant())
+ clock.tick(JDuration.ofSeconds(2))
+ firehose.detectSlowConsumers(clock.instant())
+ outProbe(1).expectError().getClass shouldBe
classOf[SlowConsumerException]
+ }
+
+ "not abort consumers when fast" in new Setup {
+ val numberOfConsumers = 10
+ (0 until numberOfConsumers).foreach { i =>
+ outProbe(i).request(10000)
+ }
+
+ // FIXME is there a way to know that it has been added to the hub?
+ Thread.sleep(1000)
+
+ val moreEnvelopes = (1 to 100).map { n =>
+ createEnvelope(PersistenceId(entityType, "x"), n, s"x$n")
+ }
+
+ moreEnvelopes.foreach { env =>
+ firehosePublisher.sendNext(env)
+ (0 until numberOfConsumers).foreach { i =>
+ catchupPublisher(i).sendNext(env)
+ }
+ }
+
+ (0 until numberOfConsumers).foreach { i =>
+ // there may be duplicates
+ val received = outProbe(i).receiveWhile(max = 10.seconds, idle =
100.millis) { case OnNext(env) => env }
+ received.toSet shouldBe moreEnvelopes.toSet
+ }
+ }
+
+ "not close when catchup is closed" in new Setup {
+ allEnvelopes.foreach(catchupPublisher.sendNext)
+ outProbe.request(10)
+ catchupPublisher.sendComplete()
+ outProbe.expectNextN(envelopes.size) shouldBe envelopes
+ outProbe.expectNoMessage() // not OnComplete
+ }
+
+ "close when firehose is closed" in new Setup {
+ allEnvelopes.foreach(catchupPublisher.sendNext)
+ outProbe.request(10)
+ outProbe.expectNextN(envelopes.size) shouldBe envelopes
+ firehosePublisher.sendComplete()
+ outProbe.expectComplete()
+ }
+
+ "close when last consumer is removed, but more consumers can be added
later" in new Setup {
+ // using two consumers first
+ outProbe(0).request(10)
+ outProbe(1).request(10)
+
+ // FIXME is there a way to know that it has been added to the hub?
+ Thread.sleep(1000)
+
+ catchupPublisher(0).sendNext(allEnvelopes(0))
+ catchupPublisher(1).sendNext(allEnvelopes(0))
+ outProbe(0).expectNext(allEnvelopes(0))
+ outProbe(1).expectNext(allEnvelopes(0))
+ firehoseRunning.get shouldBe true
+
+ outProbe(0).cancel()
+ catchupPublisher(1).sendNext(allEnvelopes(1))
+ outProbe(1).expectNext(allEnvelopes(1))
+ firehoseRunning.get shouldBe true
+
+ outProbe(1).cancel()
+
+ // another consumer
+ outProbe(2).request(10)
+ // FIXME is there a way to know that it has been added to the hub?
+ Thread.sleep(1000)
+ catchupPublisher(2).sendNext(allEnvelopes(2))
+ outProbe(2).expectNext(allEnvelopes(2))
+ firehoseRunning.get shouldBe true
+
+ outProbe(2).cancel()
+ // after a while the firehose will be shutdown
+ eventually {
+ firehoseRunning.get shouldBe false
+ }
+
+ // FIXME add another (more integration test) that verifies similar that
consumers can be added to the
+ // EventsBySliceFirehose extension after the firehose has been shutdown
+ }
+
+ "deduplicate when emitting from both" in new Setup {
+ override def pluginId = "events-by-slice-firehose-with-deduplication"
+
+ catchupPublisher.sendNext(allEnvelopes(0))
+ catchupPublisher.sendNext(allEnvelopes(1))
+ outProbe.request(10)
+ outProbe.expectNext(allEnvelopes(0))
+ outProbe.expectNext(allEnvelopes(1))
+
+ firehosePublisher.sendNext(allEnvelopes(2))
+ outProbe.expectNoMessage()
+
+ catchupPublisher.sendNext(allEnvelopes(2))
+ outProbe.expectNext(allEnvelopes(2)) // still from catchup
+
+ firehosePublisher.sendNext(allEnvelopes(3))
+ outProbe.expectNext(allEnvelopes(3)) // from firehose
+
+ catchupPublisher.sendNext(allEnvelopes(3))
+ outProbe.expectNoMessage() // duplicate from catchup
+
+
clock.setInstant(allEnvelopes.last.offset.asInstanceOf[TimestampOffset].timestamp)
+ val env4 = createEnvelope(PersistenceId(entityType, "a"), 2, "a2")
+ catchupPublisher.sendNext(env4)
+ firehosePublisher.sendNext(env4)
+ outProbe.expectNext(env4)
+ outProbe.expectNoMessage() // deduplicate
+
+ clock.tick(JDuration.ofSeconds(60))
+ val env5 = createEnvelope(PersistenceId(entityType, "b"), 2, "b2")
+ catchupPublisher.sendNext(env5)
+ outProbe.expectNext(env5)
+
+ val env6 = createEnvelope(PersistenceId(entityType, "b"), 3, "b3")
+ catchupPublisher.sendNext(env6)
+ outProbe.expectNoMessage() // catchup closed
+ firehosePublisher.sendNext(env6)
+ outProbe.expectNext(env6)
+ }
+ }
+
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]