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-persistence-cassandra.git


The following commit(s) were added to refs/heads/main by this push:
     new 0e96d77  add max-buffer-size for tag writing (#478)
0e96d77 is described below

commit 0e96d77b78487ea4788d847ee75a0f6461eff6e0
Author: PJ Fanning <[email protected]>
AuthorDate: Sun Sep 6 12:43:31 2026 +0100

    add max-buffer-size for tag writing (#478)
    
    * add max-buffer-size for tag writing
    
    * add tests
    
    * fail tag writes explicitly when the buffer limit is reached
    
    Motivation:
    The buffer limit added in this branch dropped the TagWrite message and
    relied on the comment "the sender's ask will timeout and the journal will
    retry". There is no retry. CassandraJournal.asyncWriteMessages does
    `t.ask(extractTagWrites(...))` with tag-write-timeout, and TagWriters
    forwards with askTagActor, so a dropped message means the journal stalls
    for the full timeout (4s by default) and then fails the write, stopping
    the persistent actor. Repeating that for every write while the buffer is
    full turns memory pressure into a slow storm of actor failures.
    
    The limit was also measured inconsistently: the idle branch used
    buffer.size while the write-in-progress branch used a new Buffer.pendingSize
    that folded over the pending vector on every message, which is O(n) in
    exactly the situation the limit exists for.
    
    Buffer.size itself could not be used as it stood because it has two
    accounting bugs. rebuild carried leftover writes into pending without
    counting their events, so size undercounted after a write completed with
    events still pending. remove dropped a pid's pending writes without
    subtracting them, so size overcounted afterwards. Both also affect the
    existing "buffer for tagged events is getting too large" warning.
    
    Modification:
    - Fix Buffer.size accounting. rebuild now takes the total from the caller,
      which is arithmetic rather than a fold, so completing a write stays cheap
      when pending is large. remove partitions pending and subtracts what it
      removed. Buffer.pendingSize is gone.
    - Reject an over-limit write by replying Status.Failure with a
      TagWriterBufferFullException instead of dropping the message. The journal
      sees the failure immediately rather than after tag-write-timeout, and the
      cause names the tag and the sizes.
    - Check the limit before assigning tag pid sequence numbers, in both
      branches, so a rejected write consumes none. A consumed sequence nr would
      leave a gap in tag_views that stalls eventsByTag until repaired.
    - Measure with buffer.size in both branches. It includes the batch being
      written, which is held in memory and so counts towards the bound.
    - Default max-buffer-size to 0, meaning no limit, so behaviour is unchanged
      unless the limit is opted into. Rejection stops persistent actors, which
      is not something to turn on for everyone by default.
    - Document the setting, including that recovery, not the normal write path,
      is what makes the buffer grow.
    
    Result:
    The buffer can be bounded, and reaching the bound produces an immediate,
    attributable failure rather than a silent drop followed by a timeout. No
    events are lost: rejected writes leave no gap and are written by tag
    scanning when the persistent actor recovers. Existing deployments are
    unaffected until they set max-buffer-size.
    
    Tests:
    - Added TagWriterSpec cases: rejection fails the sender with
      TagWriterBufferFullException when idle and when a write is in progress, a
      rejected write consumes no tag pid sequence nr, writes are accepted again
      once the buffer drains, and nothing is rejected when the limit is 0.
    - Added BufferSpec cases for both size accounting bugs.
    - sbt "core/testOnly ...BufferSpec ...TagWriterSpec ...TagWritersSpec" - 48
      passed. The existing "handle being overloaded" case also guards the cost
      of the accounting fix; a fold over pending there made it quadratic and it
      did not complete.
    - sbt +mimaReportBinaryIssues - no issues
    - sbt "+core/Test/compile" - Scala 2.13.18 and 3.3.8 compile
    - sbt docs/paradox - builds
    - sbt scalafmtAll headerCreateAll - clean
    - Cassandra integration tests - Not run, no Cassandra or Docker available
      locally
    
    References:
    Refs #478
    
    * accept unlimited as max-buffer-size value
    
    Support `max-buffer-size = unlimited` (and `off`/`false`) alongside `0`
    for no limit, following the spelling already used by other events-by-tag
    settings, and make `unlimited` the default so the reference.conf value is
    self-describing.
    
    ---------
    
    Co-authored-by: PJ Fanning <[email protected]>
---
 core/src/main/resources/reference.conf             |  18 +++
 .../cassandra/EventsByTagSettings.scala            |  13 ++
 .../persistence/cassandra/journal/Buffer.scala     |  22 +++-
 .../persistence/cassandra/journal/TagWriter.scala  |  99 +++++++++++----
 .../cassandra/CassandraPluginSettingsSpec.scala    |  22 ++++
 .../persistence/cassandra/journal/BufferSpec.scala |  32 +++++
 .../cassandra/journal/TagWriterSpec.scala          | 133 ++++++++++++++++++++-
 .../cassandra/journal/TagWritersSpec.scala         |   1 +
 docs/src/main/paradox/events-by-tag.md             |  24 ++++
 9 files changed, 335 insertions(+), 29 deletions(-)

diff --git a/core/src/main/resources/reference.conf 
b/core/src/main/resources/reference.conf
index 0aaf24f..b1483b6 100644
--- a/core/src/main/resources/reference.conf
+++ b/core/src/main/resources/reference.conf
@@ -250,6 +250,24 @@ pekko.persistence.cassandra {
     # size and this should be reduced if that warning is seen.
     max-message-batch-size = 150
 
+    # Hard upper bound on the number of events buffered by a single tag writer.
+    # Set to "unlimited" (the default), "off" or 0 for no limit, which is the
+    # behaviour of earlier versions.
+    #
+    # Normal writes are already bounded: the journal waits for the tag write 
to be
+    # acknowledged, so a persistent actor has at most one write outstanding. 
Recovery
+    # sends tag writes without waiting for acknowledgement, so a persistence 
id with a
+    # lot of tagged events to replay is what can make this buffer grow.
+    #
+    # When the limit is reached the tag write is rejected and the failure is 
returned to
+    # the journal, which fails the write for that persistent actor. Nothing is 
silently
+    # dropped: rejected events consume no tag pid sequence nrs and are written 
by tag
+    # scanning when the persistent actor next recovers. Enable this only if an 
unbounded
+    # buffer is an actual risk for you, as the rejection stops persistent 
actors.
+    #
+    # Should be comfortably larger than max-message-batch-size.
+    max-buffer-size = unlimited
+
     # Max time to buffer events for before writing.
     # Larger values will increase cassandra write efficiency but increase the 
delay before
     # seeing events in EventsByTag queries.
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/EventsByTagSettings.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/EventsByTagSettings.scala
index f02fd06..2f82cfb 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/EventsByTagSettings.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/EventsByTagSettings.scala
@@ -154,8 +154,11 @@ import com.typesafe.config.Config
 
   val tagWriteTimeout = eventsByTagConfig.getDuration("tag-write-timeout", 
TimeUnit.MILLISECONDS).millis
 
+  val maxBufferSize: Int = unlimitedInt(eventsByTagConfig, "max-buffer-size")
+
   val tagWriterSettings = TagWriterSettings(
     eventsByTagConfig.getInt("max-message-batch-size"),
+    maxBufferSize,
     eventsByTagConfig.getDuration("flush-interval", 
TimeUnit.MILLISECONDS).millis,
     eventsByTagConfig.getDuration("scanning-flush-interval", 
TimeUnit.MILLISECONDS).millis,
     eventsByTagConfig.getDuration("stop-tag-writer-when-idle", 
TimeUnit.MILLISECONDS).millis,
@@ -218,6 +221,16 @@ import com.typesafe.config.Config
 
   val maxMissingToSearch: Long = 
eventsByTagConfig.getLong("max-missing-to-search")
 
+  /**
+   * An int setting where no limit can be spelled either as `0` or as 
`unlimited`/`off`. Returns `0` for no limit.
+   */
+  private def unlimitedInt(cfg: Config, path: String): Int = {
+    cfg.getString(path).toLowerCase match {
+      case "unlimited" | "off" | "false" => 0
+      case _                             => cfg.getInt(path)
+    }
+  }
+
   private def optionalDuration(cfg: Config, path: String): 
Option[FiniteDuration] = {
     cfg.getString(path).toLowerCase match {
       case "off" | "false" => None
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/Buffer.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/Buffer.scala
index 7ac26e0..95b9f48 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/Buffer.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/Buffer.scala
@@ -44,8 +44,8 @@ private[pekko] case class Buffer(
 
   def remove(pid: String): Buffer = {
     val (toFilter, without) = 
nextBatch.partition(_.events.head._1.persistenceId == pid)
-    val filteredPending = pending.filterNot(_.events.head._1.persistenceId == 
pid)
-    val removed = toFilter.foldLeft(0)((acc, next) => acc + next.events.size)
+    val (removedPending, filteredPending) = 
pending.partition(_.events.head._1.persistenceId == pid)
+    val removed = Buffer.eventCount(toFilter) + 
Buffer.eventCount(removedPending)
     copy(size = size - removed, nextBatch = without, pending = filteredPending)
   }
 
@@ -80,7 +80,7 @@ private[pekko] case class Buffer(
         // rare case where events have been received out of order, just 
re-build the buffer
         require(pending.isEmpty)
         val allWrites = (nextBatch :+ 
write).sortBy(_.events.head._1.timeUuid)(timeUuidOrdering)
-        rebuild(allWrites)
+        rebuild(allWrites, newSize)
       } else if (nextBatch.headOption.exists(_.events.head._1.timeBucket != 
write.events.head._1.timeBucket)) {
         // time bucket has changed
         copy(size = newSize, pending = pending :+ write, writeRequired = true)
@@ -104,7 +104,12 @@ private[pekko] case class Buffer(
     }
   }
 
-  private def rebuild(writes: Vector[AwaitingWrite]): Buffer = {
+  /**
+   * `totalSize` is the number of events in `writes`. It is passed in rather 
than counted here because
+   * `writes` can be very large when the database is falling behind, and this 
runs on every completed
+   * write.
+   */
+  private def rebuild(writes: Vector[AwaitingWrite], totalSize: Int): Buffer = 
{
     var buffer = Buffer.empty(batchSize)
     var i = 0
     while (!buffer.shouldWrite() && i < writes.size) {
@@ -112,7 +117,7 @@ private[pekko] case class Buffer(
       i += 1
     }
     //       pending may have one in it as the last one may have been a time 
bucket change rather than bach full
-    val done = buffer.copy(pending = buffer.pending ++ writes.drop(i))
+    val done = buffer.copy(size = totalSize, pending = buffer.pending ++ 
writes.drop(i))
     done
   }
 
@@ -123,7 +128,8 @@ private[pekko] case class Buffer(
   def writeComplete(): Buffer = {
     // this could be more efficient by adding until a write is required but 
this is simpler and
     // pending is expected to be small unless the database is falling behind
-    rebuild(pending)
+    // nextBatch has just been written and is bounded by batchSize, so what is 
left is the rest of size
+    rebuild(pending, size - Buffer.eventCount(nextBatch))
   }
 }
 
@@ -132,6 +138,10 @@ private[pekko] case class Buffer(
  */
 @InternalApi
 private[pekko] object Buffer {
+
+  private def eventCount(writes: Vector[AwaitingWrite]): Int =
+    writes.foldLeft(0)((acc, next) => acc + next.events.size)
+
   def empty(batchSize: Int): Buffer = {
     require(batchSize > 0)
     Buffer(batchSize, 0, Vector.empty, Vector.empty, writeRequired = false)
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/TagWriter.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/TagWriter.scala
index 33a7ab3..8bc083a 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/TagWriter.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/cassandra/journal/TagWriter.scala
@@ -17,7 +17,16 @@ import java.util.UUID
 
 import org.apache.pekko
 import pekko.Done
-import pekko.actor.{ Actor, ActorLogging, ActorRef, 
NoSerializationVerificationNeeded, Props, ReceiveTimeout, Timers }
+import pekko.actor.{
+  Actor,
+  ActorLogging,
+  ActorRef,
+  NoSerializationVerificationNeeded,
+  Props,
+  ReceiveTimeout,
+  Status,
+  Timers
+}
 import pekko.annotation.InternalApi
 import pekko.cluster.pubsub.{ DistributedPubSub, DistributedPubSubMediator }
 import pekko.event.LoggingAdapter
@@ -29,6 +38,7 @@ import 
pekko.persistence.cassandra.journal.TagWriters.TagWritersSession
 import pekko.util.{ OptionVal, UUIDComparator }
 
 import scala.concurrent.duration.{ Duration, FiniteDuration, _ }
+import scala.util.control.NoStackTrace
 import scala.util.control.NonFatal
 import scala.util.{ Failure, Success, Try }
 
@@ -43,7 +53,6 @@ import scala.util.{ Failure, Success, Try }
  * Prevents any concurrent writes.
  *
  * Possible improvements:
- * - Max buffer size
  * - Optimize sorting given they are nearly sorted
  */
 @InternalApi private[pekko] object TagWriter {
@@ -51,8 +60,20 @@ import scala.util.{ Failure, Success, Try }
   private[pekko] def props(settings: TagWriterSettings, session: 
TagWritersSession, tag: Tag, parent: ActorRef): Props =
     Props(new TagWriter(settings, session, tag, parent))
 
+  /**
+   * Returned to the sender when a tag write can not be buffered because 
`max-buffer-size` has been
+   * reached. The write is not buffered and no tag pid sequence nrs are 
consumed by it, so the events
+   * are picked up again by tag scanning when the persistent actor next 
recovers.
+   */
+  private[pekko] final class TagWriterBufferFullException(val tag: Tag, val 
bufferSize: Int, val maxBufferSize: Int)
+      extends RuntimeException(
+        s"Tag write for tag [$tag] rejected, buffer is full [$bufferSize >= 
$maxBufferSize]. " +
+        "Cassandra is not keeping up with tagged writes.")
+      with NoStackTrace
+
   private[pekko] case class TagWriterSettings(
       maxBatchSize: Int,
+      maxBufferSize: Int,
       flushInterval: FiniteDuration,
       scanningFlushInterval: FiniteDuration,
       stopTagWriterWhenIdle: FiniteDuration,
@@ -143,6 +164,30 @@ import scala.util.{ Failure, Success, Try }
 
   var lastLoggedBufferNs: Long = -1
   val bufferWarningMinDurationNs: Long = 5.seconds.toNanos
+  var lastLoggedBufferFullNs: Long = -1
+
+  private def bufferFull(buffer: Buffer): Boolean =
+    settings.maxBufferSize > 0 && buffer.size >= settings.maxBufferSize
+
+  /**
+   * Fails the sender's ask rather than dropping the write silently, so that 
the journal sees the
+   * rejection straight away instead of waiting out `tag-write-timeout`. 
Nothing is buffered and no tag
+   * pid sequence nrs are consumed, so the events are not lost: tag scanning 
picks them up when the
+   * persistent actor next recovers.
+   */
+  private def rejectWrite(buffer: Buffer, replyTo: ActorRef): Unit = {
+    val now = System.nanoTime()
+    if (now > (lastLoggedBufferFullNs + bufferWarningMinDurationNs)) {
+      lastLoggedBufferFullNs = now
+      log.error(
+        "Buffer for tagged events is full ({} >= {}) for tag [{}], rejecting 
writes. Is Cassandra responsive? " +
+        "Are writes failing? Rejected events will be written by tag scanning 
when the persistent actor recovers.",
+        buffer.size,
+        settings.maxBufferSize,
+        tag)
+    }
+    replyTo ! Status.Failure(new TagWriter.TagWriterBufferFullException(tag, 
buffer.size, settings.maxBufferSize))
+  }
 
   override def preStart(): Unit = {
     log.debug("Running TagWriter for [{}] with settings {}", tag, settings)
@@ -171,12 +216,17 @@ import scala.util.{ Failure, Success, Try }
         sender() ! FlushComplete
       }
     case TagWrite(_, payload, _) =>
-      val (newTagPidSequenceNrs, events: Seq[(Serialized, TagPidSequenceNr)]) 
= {
-        assignTagPidSequenceNumbers(payload.toVector, tagPidSequenceNrs)
+      // checked before any tag pid sequence nrs are assigned so that a 
rejected write consumes none
+      if (bufferFull(buffer)) {
+        rejectWrite(buffer, sender())
+      } else {
+        val (newTagPidSequenceNrs, events: Seq[(Serialized, 
TagPidSequenceNr)]) = {
+          assignTagPidSequenceNumbers(payload.toVector, tagPidSequenceNrs)
+        }
+        val newWrite = AwaitingWrite(events, OptionVal(sender()))
+        val newBuffer = buffer.add(newWrite)
+        flushIfRequired(newBuffer, newTagPidSequenceNrs)
       }
-      val newWrite = AwaitingWrite(events, OptionVal(sender()))
-      val newBuffer = buffer.add(newWrite)
-      flushIfRequired(newBuffer, newTagPidSequenceNrs)
     case twd: TagWriteDone =>
       log.error("Received Done when in idle state. This is a bug. Please 
report with DEBUG logs: {}", twd)
     case ResetPersistenceId(_, tp @ TagProgress(pid, _, tagPidSequenceNr)) =>
@@ -208,22 +258,27 @@ import scala.util.{ Failure, Success, Try }
       log.debug("External flush while write in progress. Will flush after 
write complete")
       become(writeInProgress(buffer, tagPidSequenceNrs, Some(sender())))
     case TagWrite(_, payload, _) =>
-      val (updatedTagPidSequenceNrs, events) =
-        assignTagPidSequenceNumbers(payload.toVector, tagPidSequenceNrs)
-      val awaitingWrite = AwaitingWrite(events, OptionVal(sender()))
-      val now = System.nanoTime()
-      if (buffer.size > (4 * settings.maxBatchSize) && now > 
(lastLoggedBufferNs + bufferWarningMinDurationNs)) {
-        lastLoggedBufferNs = now
-        log.warning(
-          "Buffer for tagged events is getting too large ({}), is Cassandra 
responsive? Are writes failing? " +
-          "If events are buffered for longer than the 
eventual-consistency-delay they won't be picked up by live queries. The oldest 
event in the buffer is offset: {}",
-          buffer.size,
-          formatOffset(buffer.nextBatch.head.events.head._1.timeUuid))
+      // checked before any tag pid sequence nrs are assigned so that a 
rejected write consumes none
+      if (bufferFull(buffer)) {
+        rejectWrite(buffer, sender())
+      } else {
+        val (updatedTagPidSequenceNrs, events) =
+          assignTagPidSequenceNumbers(payload.toVector, tagPidSequenceNrs)
+        val now = System.nanoTime()
+        val awaitingWrite = AwaitingWrite(events, OptionVal(sender()))
+        if (buffer.size > (4 * settings.maxBatchSize) && now > 
(lastLoggedBufferNs + bufferWarningMinDurationNs)) {
+          lastLoggedBufferNs = now
+          log.warning(
+            "Buffer for tagged events is getting too large ({}), is Cassandra 
responsive? Are writes failing? " +
+            "If events are buffered for longer than the 
eventual-consistency-delay they won't be picked up by live queries. The oldest 
event in the buffer is offset: {}",
+            buffer.size,
+            formatOffset(buffer.nextBatch.head.events.head._1.timeUuid))
+        }
+        // buffer until current query is finished
+        // Don't sort until the write has finished
+        val newBuffer = buffer.addPending(awaitingWrite)
+        become(writeInProgress(newBuffer, updatedTagPidSequenceNrs, 
awaitingFlush))
       }
-      // buffer until current query is finished
-      // Don't sort until the write has finished
-      val newBuffer = buffer.addPending(awaitingWrite)
-      become(writeInProgress(newBuffer, updatedTagPidSequenceNrs, 
awaitingFlush))
     case TagWriteDone(summary, doneNotify) =>
       log.debug("Tag write done: {}", summary)
       val nextBuffer = buffer.writeComplete()
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/CassandraPluginSettingsSpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/CassandraPluginSettingsSpec.scala
index ce97bcb..7b9acab 100644
--- 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/CassandraPluginSettingsSpec.scala
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/CassandraPluginSettingsSpec.scala
@@ -256,4 +256,26 @@ class CassandraPluginSettingsSpec
     }
   }
 
+  "An EventsByTagSettings" must {
+
+    def settingsWithMaxBufferSize(value: String): EventsByTagSettings =
+      new EventsByTagSettings(
+        system,
+        ConfigFactory.parseString(s"events-by-tag.max-buffer-size = 
$value").withFallback(defaultConfig))
+
+    "default max-buffer-size to no limit" in {
+      new EventsByTagSettings(system, defaultConfig).maxBufferSize must be(0)
+    }
+
+    "parse max-buffer-size as a number" in {
+      settingsWithMaxBufferSize("100000").maxBufferSize must be(100000)
+    }
+
+    "parse max-buffer-size no limit aliases" in {
+      forAll(Table("value", "unlimited", "UNLIMITED", "off", "false", "0")) { 
value =>
+        settingsWithMaxBufferSize(value).maxBufferSize must be(0)
+      }
+    }
+  }
+
 }
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/BufferSpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/BufferSpec.scala
index 62e08e8..41d9aef 100644
--- 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/BufferSpec.scala
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/BufferSpec.scala
@@ -183,6 +183,38 @@ class BufferSpec extends AnyWordSpec with Matchers with 
BeforeAndAfterAll {
       }
       writes shouldEqual totalWrites / 2
     }
+
+    // size is what the tag writer reports and limits on, so it has to stay 
equal to the number of
+    // events actually held in nextBatch plus pending
+    "keep size accurate when a write completes with events still pending" in {
+      val bucket = nowBucket()
+      var buffer = Buffer.empty(2)
+      for (i <- 1 to 5) {
+        buffer = buffer.add(awNoSender((event("p1", seqNr = i, payload = 
"cats", bucket), i)))
+      }
+      buffer.size shouldEqual 5
+
+      // two events are written, leaving three, of which one does not fit in 
the rebuilt batch
+      val afterWrite = buffer.writeComplete()
+      afterWrite.size shouldEqual 3
+      afterWrite.nextBatch.map(_.events.size).sum + 
afterWrite.pending.map(_.events.size).sum shouldEqual 3
+    }
+
+    "keep size accurate when a persistence id with pending events is removed" 
in {
+      val bucket = nowBucket()
+      val buffer = Buffer
+        .empty(2)
+        .add(awNoSender((event("p1", seqNr = 1, payload = "cats", bucket), 1)))
+        .add(awNoSender((event("p1", seqNr = 2, payload = "cats", bucket), 2)))
+        .add(awNoSender((event("p2", seqNr = 1, payload = "dogs", bucket), 1)))
+        .add(awNoSender((event("p1", seqNr = 3, payload = "cats", bucket), 3)))
+      buffer.size shouldEqual 4
+
+      // removes two from nextBatch and one from pending, leaving only p2
+      val afterRemove = buffer.remove("p1")
+      afterRemove.size shouldEqual 1
+      afterRemove.nextBatch.map(_.events.size).sum + 
afterRemove.pending.map(_.events.size).sum shouldEqual 1
+    }
   }
 
   override protected def afterAll(): Unit = {
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/TagWriterSpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/TagWriterSpec.scala
index 9ee9a98..5ab6e0b 100644
--- 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/TagWriterSpec.scala
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/TagWriterSpec.scala
@@ -17,7 +17,8 @@ import java.nio.ByteBuffer
 import java.util.UUID
 import org.apache.pekko
 import pekko.Done
-import pekko.actor.{ ActorRef, ActorSystem }
+import pekko.actor.{ ActorRef, ActorSystem, Status }
+import pekko.event.Logging
 import pekko.event.Logging.Warning
 import pekko.persistence.cassandra.Day
 import pekko.persistence.cassandra.journal.CassandraJournal._
@@ -78,6 +79,7 @@ class TagWriterSpec
   val successfulWrite: Statement[?] => Future[Done] = _ => 
Future.successful(Done)
   val defaultSettings = TagWriterSettings(
     maxBatchSize = 10,
+    maxBufferSize = 0,
     flushInterval = 10.seconds,
     scanningFlushInterval = 20.seconds,
     stopTagWriterWhenIdle = 5.seconds,
@@ -102,6 +104,11 @@ class TagWriterSpec
     implicit val senderRef: ActorRef = sender.ref
   }
 
+  private def expectBufferFullLogged(): Unit =
+    logProbe.expectMsgPF(waitDuration) {
+      case Logging.Error(_, _, _, msg) if msg.toString.contains("Buffer for 
tagged events is full") => ()
+    }
+
   "Tag writer batching" must {
 
     "external flush when idle" in new Setup {
@@ -672,6 +679,130 @@ class TagWriterSpec
 
   }
 
+  "Tag writer buffer limit" must {
+
+    "reject a write and fail the sender when the buffer is full" in new Setup {
+      val (probe, ref) =
+        setup(settings = defaultSettings.copy(maxBatchSize = 100, 
maxBufferSize = 1, flushInterval = 1.hour))
+      val bucket = nowBucket()
+      val e1 = event("p1", 1L, "e-1", bucket)
+      val e2 = event("p2", 1L, "e-2", bucket)
+
+      // fills the buffer, nothing is written yet as the batch is not full
+      ref ! TagWrite(tagName, Vector(e1))
+      probe.expectNoMessage(waitDuration)
+
+      ref ! TagWrite(tagName, Vector(e2))
+      val failure = sender.expectMsgType[Status.Failure]
+      
assert(failure.cause.isInstanceOf[TagWriter.TagWriterBufferFullException])
+      probe.expectNoMessage(waitDuration)
+      expectBufferFullLogged()
+    }
+
+    "reject a write and fail the sender when the buffer is full during a 
write" in new Setup {
+      val writePromise = Promise[Done]()
+      val (probe, ref) = setup(
+        settings = defaultSettings.copy(maxBatchSize = 1, maxBufferSize = 2, 
flushInterval = 1.hour),
+        writeResponse = LazyList(writePromise.future) ++ 
LazyList.continually(Future.successful(Done)))
+      val bucket = nowBucket()
+      val e1 = event("p1", 1L, "e-1", bucket)
+      val e2 = event("p2", 1L, "e-2", bucket)
+      val e3 = event("p3", 1L, "e-3", bucket)
+
+      // maxBatchSize is 1 so this write starts immediately and stays in the 
buffer while in flight
+      ref ! TagWrite(tagName, Vector(e1))
+      probe.expectMsg(Vector(toEw(e1, 1)))
+
+      // buffer holds the in flight batch, one more still fits
+      ref ! TagWrite(tagName, Vector(e2))
+      probe.expectNoMessage(waitDuration)
+
+      ref ! TagWrite(tagName, Vector(e3))
+      val failure = sender.expectMsgType[Status.Failure]
+      
assert(failure.cause.isInstanceOf[TagWriter.TagWriterBufferFullException])
+      expectBufferFullLogged()
+
+      // the accepted write is unaffected by the rejection
+      writePromise.success(Done)
+      sender.expectMsg(Done)
+      probe.expectMsg(ProgressWrite("p1", 1, 1, e1.timeUuid))
+      probe.expectMsg(Vector(toEw(e2, 1)))
+      probe.expectMsg(ProgressWrite("p2", 1, 1, e2.timeUuid))
+      sender.expectMsg(Done)
+    }
+
+    "not consume a tag pid sequence nr for a rejected write" in new Setup {
+      val (probe, ref) =
+        setup(settings = defaultSettings.copy(maxBatchSize = 100, 
maxBufferSize = 1, flushInterval = 1.hour))
+      val bucket = nowBucket()
+      val e1 = event("p1", 1L, "e-1", bucket)
+      val e2 = event("p1", 2L, "e-2", bucket)
+
+      ref ! TagWrite(tagName, Vector(e1))
+      probe.expectNoMessage(waitDuration)
+
+      // rejected, so it must not take tag pid sequence nr 2
+      ref ! TagWrite(tagName, Vector(e2))
+      sender.expectMsgType[Status.Failure]
+      expectBufferFullLogged()
+
+      ref ! Flush
+      probe.expectMsg(Vector(toEw(e1, 1)))
+      probe.expectMsg(ProgressWrite("p1", 1, 1, e1.timeUuid))
+      sender.expectMsg(Done)
+      sender.expectMsg(FlushComplete)
+
+      // buffer has drained, the retry gets the sequence nr the rejected write 
did not take.
+      // a gap here would stall eventsByTag until tag_views was repaired
+      ref ! TagWrite(tagName, Vector(e2))
+      ref ! Flush
+      probe.expectMsg(Vector(toEw(e2, 2)))
+      probe.expectMsg(ProgressWrite("p1", 2, 2, e2.timeUuid))
+      sender.expectMsg(Done)
+      sender.expectMsg(FlushComplete)
+    }
+
+    "accept writes again once the buffer has drained" in new Setup {
+      val (probe, ref) =
+        setup(settings = defaultSettings.copy(maxBatchSize = 100, 
maxBufferSize = 1, flushInterval = 1.hour))
+      val bucket = nowBucket()
+      val e1 = event("p1", 1L, "e-1", bucket)
+      val e2 = event("p2", 1L, "e-2", bucket)
+      val e3 = event("p3", 1L, "e-3", bucket)
+
+      ref ! TagWrite(tagName, Vector(e1))
+      ref ! TagWrite(tagName, Vector(e2))
+      sender.expectMsgType[Status.Failure]
+      expectBufferFullLogged()
+
+      ref ! Flush
+      probe.expectMsg(Vector(toEw(e1, 1)))
+      probe.expectMsg(ProgressWrite("p1", 1, 1, e1.timeUuid))
+      sender.expectMsg(Done)
+      sender.expectMsg(FlushComplete)
+
+      ref ! TagWrite(tagName, Vector(e3))
+      ref ! Flush
+      probe.expectMsg(Vector(toEw(e3, 1)))
+      probe.expectMsg(ProgressWrite("p3", 1, 1, e3.timeUuid))
+      sender.expectMsg(Done)
+      sender.expectMsg(FlushComplete)
+    }
+
+    "not reject anything when max-buffer-size is 0" in new Setup {
+      val (probe, ref) =
+        setup(settings = defaultSettings.copy(maxBatchSize = 100, 
maxBufferSize = 0, flushInterval = 1.hour))
+      val bucket = nowBucket()
+
+      (1 to 10).foreach { i =>
+        ref ! TagWrite(tagName, Vector(event(s"p$i", 1L, s"e-$i", bucket)))
+      }
+
+      sender.expectNoMessage(waitDuration)
+      probe.expectNoMessage(waitDuration)
+    }
+  }
+
   "Tag writer error scenarios" must {
 
     "handle tag writes view failing" in new Setup {
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/TagWritersSpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/TagWritersSpec.scala
index 7633221..053d1a9 100644
--- 
a/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/TagWritersSpec.scala
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/cassandra/journal/TagWritersSpec.scala
@@ -54,6 +54,7 @@ class TagWritersSpec
 
   private val defaultSettings = TagWriterSettings(
     maxBatchSize = 10,
+    maxBufferSize = 0,
     flushInterval = 10.seconds,
     scanningFlushInterval = 20.seconds,
     stopTagWriterWhenIdle = 5.seconds,
diff --git a/docs/src/main/paradox/events-by-tag.md 
b/docs/src/main/paradox/events-by-tag.md
index acc230f..e3df245 100644
--- a/docs/src/main/paradox/events-by-tag.md
+++ b/docs/src/main/paradox/events-by-tag.md
@@ -192,6 +192,30 @@ be taken not to have batches that will be rejected by 
Cassandra. Two other cases
 * Periodically: By default 250ms. To prevent eventsByTag queries being too out 
of date.
 * When a starting a new timebucket, which translates to a new partition in 
Cassandra, the events for the old timebucket are written.
 
+### Limiting the tag write buffer
+
+Each tag has a writer actor that buffers events until they are batched into a 
write. Normal writes are already
+bounded: the journal waits for the tag write to be acknowledged before 
completing the persist, so a persistent
+actor has at most one write outstanding. Recovery is different — it sends tag 
writes without waiting for
+acknowledgement, so a persistence id replaying a lot of tagged events is what 
can make the buffer grow.
+
+`max-buffer-size` puts a hard upper bound on the number of events one tag 
writer will hold. It is `unlimited` by
+default, which means no limit; `off` and `0` mean the same thing.
+
+```
+pekko.persistence.cassandra.events-by-tag.max-buffer-size = 100000
+```
+
+When the limit is reached the tag write is rejected and the failure is 
returned to the journal, which fails the
+write for that persistent actor and stops it. Nothing is dropped silently: a 
rejected write consumes no tag pid
+sequence nrs, so it leaves no gap in `tag_views`, and the events are written 
by tag scanning when the persistent
+actor next recovers.
+
+That is a disruptive outcome, so only set this if an unbounded buffer is a 
real risk for you — it trades stopped
+persistent actors for bounded memory. Set it comfortably above 
`max-message-batch-size`, as the batch currently
+being written counts towards the buffer. The existing warning about the buffer 
getting too large is logged well
+before the limit is reached and is the signal to investigate first.
+
 ## Cleanup of tag_views table
 
 By default the tag_views table keeps tagged events indefinitely, even when the 
original events have been removed. 


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

Reply via email to