He-Pin commented on code in PR #3653:
URL: https://github.com/apache/pekko/pull/3653#discussion_r4355898953


##########
persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/EventWriter.scala:
##########
@@ -269,8 +417,124 @@ private[pekko] object EventWriter {
               Behaviors.same
           }
 
+        def handleWrite(
+            repr: PersistentRepr,
+            replyTo: ActorRef[StatusReply[WriteAck]],
+            isSnapshotEvent: Boolean,
+            calledAfterMaxSeqNr: Boolean): Unit = {
+          val persistenceId = repr.persistenceId
+          val sequenceNumber = repr.sequenceNr
+          val newStateForPid =
+            perPidWriteState.get(persistenceId) match {
+              case None =>
+                if ((fillSequenceNumberGaps || isSnapshotEvent) && 
sequenceNumber != 1L) {
+                  val reason = if (isSnapshotEvent) 
AskMaxSeqNrReason.SnapshotEvent else AskMaxSeqNrReason.FillGaps
+                  askMaxSeqNr(persistenceId, reason)
+                  StateForPid(waitingForReply = Map.empty, 
waitingForSeqNrLookup = Vector((repr, replyTo)))

Review Comment:
   Gap fillers can be rejected before storage, for example by a broadly bound 
EventAdapter that does not accept FilteredPayload. AsyncWriteJournal reports 
these as WriteMessageRejected, but handleJournalResponse currently handles only 
WriteMessageSuccess and WriteMessageFailure and ignores the rejection. The 
pending callers are then left unresolved. Please handle the rejection outcome 
and cover a rejected atomic gap-fill batch followed by a later successful write.



##########
persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/EventWriter.scala:
##########
@@ -269,8 +417,124 @@ private[pekko] object EventWriter {
               Behaviors.same
           }
 
+        def handleWrite(
+            repr: PersistentRepr,
+            replyTo: ActorRef[StatusReply[WriteAck]],
+            isSnapshotEvent: Boolean,
+            calledAfterMaxSeqNr: Boolean): Unit = {
+          val persistenceId = repr.persistenceId
+          val sequenceNumber = repr.sequenceNr
+          val newStateForPid =
+            perPidWriteState.get(persistenceId) match {
+              case None =>
+                if ((fillSequenceNumberGaps || isSnapshotEvent) && 
sequenceNumber != 1L) {
+                  val reason = if (isSnapshotEvent) 
AskMaxSeqNrReason.SnapshotEvent else AskMaxSeqNrReason.FillGaps
+                  askMaxSeqNr(persistenceId, reason)
+                  StateForPid(waitingForReply = Map.empty, 
waitingForSeqNrLookup = Vector((repr, replyTo)))
+                } else {
+                  sendToJournal(1, Vector(repr))
+                  StateForPid(Map((repr.sequenceNr, (repr, replyTo))), 
currentTransactionId = 1)
+                }
+
+              case Some(state) =>
+                val expectedSeqNr =
+                  if (fillSequenceNumberGaps || isSnapshotEvent) 
state.nextExpectedSeqNr
+                  else repr.sequenceNr
+
+                if (state.seqNrlookupInProgress) {
+                  context.log.trace2(
+                    "Seq nr lookup in progress for persistence id [{}], adding 
sequence nr [{}] to pending",
+                    persistenceId,
+                    sequenceNumber)
+                  state.copy(waitingForSeqNrLookup = 
state.waitingForSeqNrLookup :+ ((repr, replyTo)))
+                } else if (sequenceNumber == expectedSeqNr) {
+                  if (state.idle) {
+                    sendToJournal(state.currentTransactionId + 1, Vector(repr))
+                    state.copy(
+                      waitingForReply = Map((repr.sequenceNr, (repr, 
replyTo))),
+                      currentTransactionId = state.currentTransactionId + 1)
+                  } else {
+                    // write in progress for pid, add write to batch and 
perform once current write completes
+                    if 
(state.waitingForWriteExceedingMaxBatchSize(settings.maxBatchSize)) {
+                      replyTo ! StatusReply.error(
+                        s"Max batch reached for pid $persistenceId, at most 
${settings.maxBatchSize} writes for " +
+                        "the same pid may be in flight at the same time")
+                      state
+                    } else {
+                      context.log.trace2(
+                        "Writing event in progress for persistence id [{}], 
adding sequence nr [{}] to batch",
+                        persistenceId,
+                        sequenceNumber)
+                      state.copy(waitingForWrite = state.waitingForWrite :+ 
((repr, replyTo)))
+                    }
+                  }
+                } else if (sequenceNumber < expectedSeqNr) {
+                  context.log.trace2("Duplicate seq nr [{}] for persistence id 
[{}]", sequenceNumber, persistenceId)
+                  replyTo ! StatusReply.success(WriteAck(persistenceId, 
sequenceNumber)) // duplicate

Review Comment:
   `nextExpectedSeqNr` includes events in waitingForReply and waitingForWrite, 
so a lower sequence number is not necessarily durable yet. With gap filling 
enabled, send sequence 1 twice before the journal acknowledges the first write: 
the second caller receives success here even if the pending write subsequently 
fails. Please retain duplicate callers until the corresponding journal outcome 
(or a highest-sequence-number lookup proving persistence), and assert that no 
caller is acknowledged while that write is unresolved.



##########
persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/EventWriter.scala:
##########
@@ -378,6 +612,58 @@ private[pekko] object EventWriter {
             }
             Behaviors.same
 
+          case MaxSeqNrForPid(pid, maxSeqNr, reason @ 
(AskMaxSeqNrReason.FillGaps | AskMaxSeqNrReason.SnapshotEvent)) =>
+            bypassCircuitBreaker = true
+            perPidWriteState.get(pid) match {
+              case None =>
+                context.log.debug("Got max seq nr with no waiting previous 
state for pid, ignoring (pid [{}])", pid)
+              case Some(state) =>
+                context.log.debug("Retrieved max seq nr [{}] for pid [{}]", 
maxSeqNr, pid)
+                val waiting = state.waitingForSeqNrLookup
+                perPidWriteState = perPidWriteState.updated(
+                  pid,
+                  state.copy(latestSeqNr = maxSeqNr, waitingForSeqNrLookup = 
Vector.empty))
+                waiting.foreach {
+                  case (repr, replyTo) =>
+                    handleWrite(
+                      repr,
+                      replyTo,
+                      isSnapshotEvent = reason == 
AskMaxSeqNrReason.SnapshotEvent,

Review Comment:
   The lookup reason belongs to its initiating request, but this applies it to 
every queued request. With general gap filling disabled, queue a normal event 
behind a snapshot event while the snapshot's lookup is outstanding: the normal 
event is replayed with `isSnapshotEvent = true` and its gap is filled too. 
Please retain the flag per queued request and add a mixed 
snapshot/ordinary-event regression.



##########
persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/EventWriter.scala:
##########
@@ -378,6 +612,58 @@ private[pekko] object EventWriter {
             }
             Behaviors.same
 
+          case MaxSeqNrForPid(pid, maxSeqNr, reason @ 
(AskMaxSeqNrReason.FillGaps | AskMaxSeqNrReason.SnapshotEvent)) =>
+            bypassCircuitBreaker = true
+            perPidWriteState.get(pid) match {
+              case None =>
+                context.log.debug("Got max seq nr with no waiting previous 
state for pid, ignoring (pid [{}])", pid)
+              case Some(state) =>
+                context.log.debug("Retrieved max seq nr [{}] for pid [{}]", 
maxSeqNr, pid)
+                val waiting = state.waitingForSeqNrLookup
+                perPidWriteState = perPidWriteState.updated(
+                  pid,
+                  state.copy(latestSeqNr = maxSeqNr, waitingForSeqNrLookup = 
Vector.empty))
+                waiting.foreach {
+                  case (repr, replyTo) =>
+                    handleWrite(
+                      repr,
+                      replyTo,
+                      isSnapshotEvent = reason == 
AskMaxSeqNrReason.SnapshotEvent,
+                      calledAfterMaxSeqNr = true)
+                }
+            }
+            Behaviors.same
+
+          case MaxSeqNrForPidFailed(pid, errorDescOpt) =>
+            // journal seems to have real problems, let the circuit breaker 
protect it until next successful write
+            bypassCircuitBreaker = false
+            def errorDescInLog = errorDescOpt match {
+              case AskMaxSeqNrReason.WriteFailure(errorDesc) => s", original 
error desc: $errorDesc"
+              case _                                         => ""
+            }
+            perPidWriteState.get(pid) match {
+              case None =>
+                context.log.debug2(
+                  "Got max seq nr (failed) with no waiting previous state for 
persistence id [{}]{}",
+                  pid,
+                  errorDescInLog)
+              case Some(state) =>
+                state.waitingForReply.values.foreach {
+                  case (_, replyTo) =>
+                    replyTo ! StatusReply.error("Journal write failed")
+                }
+                val sortedSeqs = state.waitingForReply.keys.toSeq.sorted

Review Comment:
   For the initial FillGaps/SnapshotEvent lookup, line 433 puts the request in 
`waitingForSeqNrLookup` and leaves `waitingForReply` empty. A 
ReplayMessagesFailure or ask timeout therefore reaches `sortedSeqs.head/last` 
on an empty collection and restarts the writer. Even guarding the log would 
leave the lookup queue unanswered and uncleared. Please fail and clear every 
affected waiter, preserve the failure cause, and test both initial lookup 
failure and timeout followed by a successful later write.



##########
persistence/src/main/scala/org/apache/pekko/persistence/journal/AsyncWriteJournal.scala:
##########
@@ -229,8 +232,10 @@ trait AsyncWriteJournal extends Actor with 
WriteJournalBase with AsyncRecovery {
             Future.successful(Nil)
           case Success(prep) =>
             // try in case the asyncWriteMessages throws
-            try breaker.withCircuitBreaker(asyncWriteMessages(prep))
-            catch { case NonFatal(e) => Future.failed(e) }
+            try {
+              if (w.bypassCircuitBreaker) asyncWriteMessages(prep)

Review Comment:
   This also removes the journal circuit-breaker call timeout. If a bypassed 
write's Future never completes, its resequencer slot is never released; with 
global response ordering enabled, later ordinary writes to the same journal can 
lose their responses too. Please preserve a bounded call timeout independently 
of breaker failure accounting, and test a hanging bypassed write followed by an 
ordinary write whose response must eventually be released.



##########
persistence/src/test/scala/org/apache/pekko/persistence/WriteMessagesSpec.scala:
##########
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.persistence
+
+import org.apache.pekko
+import pekko.actor.ActorRef
+import pekko.persistence.JournalProtocol.WriteMessages
+
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+
+class WriteMessagesSpec extends AnyWordSpec with Matchers {
+
+  private val writes = AtomicWrite(PersistentRepr("a", 1, "p1")) :: Nil
+
+  "JournalProtocol.WriteMessages" must {
+    "not bypass the circuit breaker by default" in {
+      WriteMessages(writes, ActorRef.noSender, 1).bypassCircuitBreaker should 
===(false)
+      new WriteMessages(writes, ActorRef.noSender, 1).bypassCircuitBreaker 
should ===(false)
+      WriteMessages(writes, ActorRef.noSender, 1, bypassCircuitBreaker = 
true).bypassCircuitBreaker should ===(true)
+    }
+
+    "keep bypassCircuitBreaker in copy" in {
+      val w = WriteMessages(writes, ActorRef.noSender, 1, bypassCircuitBreaker 
= true)
+      val copied = w.copy(actorInstanceId = 2)
+      copied.actorInstanceId should ===(2)
+      copied.bypassCircuitBreaker should ===(true)
+      w.withBypassCircuitBreaker(false).bypassCircuitBreaker should ===(false)
+    }
+
+    "extract the first 3 fields in pattern matching" in {
+      val w: Any = WriteMessages(writes, ActorRef.noSender, 3, 
bypassCircuitBreaker = true)
+      w match {
+        case WriteMessages(messages, persistentActor, actorInstanceId) =>
+          messages should ===(writes)
+          persistentActor should ===(ActorRef.noSender)
+          actorInstanceId should ===(3)
+        case other =>
+          fail(s"unexpected $other")
+      }
+    }
+
+    "include bypassCircuitBreaker in equality" in {
+      WriteMessages(writes, ActorRef.noSender, 1) should 
===(WriteMessages(writes, ActorRef.noSender, 1))
+      WriteMessages(writes, ActorRef.noSender, 1).hashCode should ===(

Review Comment:
   The current next CI failure is here: [job 
log](https://github.com/apache/pekko/actions/runs/39882269597/job/120182587311).
 Hashing `writes` reaches `PersistentImpl.hashCode(Persistent.scala:234)`, 
which passes the default null sender to `HashCode.hash` and throws 
`IllegalArgumentException: Unexpected hash parameter: null`. The outer 
WriteMessages hash already handles a null persistentActor, so that is not the 
failing value. Please fix the fixture or the underlying PersistentRepr 
null-sender contract and rerun the focused spec plus the cancelled matrix jobs.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to