ptrdom commented on code in PR #515:
URL: 
https://github.com/apache/pekko-persistence-r2dbc/pull/515#discussion_r4375627011


##########
core/src/main/scala/org/apache/pekko/persistence/r2dbc/internal/BySliceQuery.scala:
##########
@@ -463,6 +467,7 @@ import org.slf4j.Logger
             toTimestamp,
             behindCurrentTime,
             backtracking = newState.backtracking)
+          .filter(row => filterEventsBeforeSnapshots(row.persistenceId, 
row.seqNr, row.source))

Review Comment:
   Same issue as above for the live query: a fully filtered page means `latest` 
doesn't advance, and the query never gets past that page.



##########
core/src/main/scala/org/apache/pekko/persistence/r2dbc/internal/BySliceQuery.scala:
##########
@@ -257,6 +259,7 @@ import org.slf4j.Logger
               toTimestamp = Some(toTimestamp),
               behindCurrentTime = Duration.Zero,
               backtracking = false)
+            .filter(row => filterEventsBeforeSnapshots(row.persistenceId, 
row.seqNr, row.source))

Review Comment:
   The filter runs before `deserializeAndAddOffset`, which is what moves 
`state.latest` and `rowCount` forward (via `nextOffset`). If every row of a 
page is dropped, the page looks empty:
   
   - here in `currentBySlices`, `nextQuery` stops on `rowCount == 0`, so the 
query completes without the events after the snapshots;
   - in `liveBySlices` (line 470) the offset doesn't move, so it keeps 
re-reading the same page and never gets past it.
   
   Repro with `pekko.persistence.r2dbc.query.buffer-size = 10`: entity A 
persists `a-1-snap` (snapshot at seq 1, which becomes the init offset), then 
entity B persists 33 events with a snapshot at seq 30. 
`currentEventsBySlicesStartingFromSnapshots(entityType, 0, 1023, NoOffset, s => 
s)` emits the 2 snapshots and then completes without `b-31..b-33`; the live 
variant emits the 2 snapshots and nothing more within 10 seconds. With 
`buffer-size = 100` the same test passes.
   
   With the default buffer size of 1000 it's enough that 1000 consecutive rows 
are before their entities' snapshots, which is easy to hit with many entities. 
The dropped rows still need to update the offset and row count, e.g. by 
applying the filter after the state update (on the envelope), or by letting 
`deserializeAndAddOffset` skip emitting them while still updating the state.



##########
core/src/main/resources/reference.conf:
##########
@@ -131,6 +131,16 @@ pekko.persistence.r2dbc {
     use-connection-factory = ${pekko.persistence.r2dbc.use-connection-factory}
 
     log-db-calls-exceeding = ${pekko.persistence.r2dbc.log-db-calls-exceeding}
+
+    # When enabled, the timestamp and tags of the corresponding event are 
stored with the snapshot.
+    # Required by the queries that use snapshots as starting points, see 
query.start-from-snapshot.
+    start-from-snapshot.enabled = 
${pekko.persistence.r2dbc.query.start-from-snapshot.enabled}
+
+    # the journal table that the timestamp and tags of the corresponding event 
are read from
+    journal-table = ${pekko.persistence.r2dbc.journal.table}

Review Comment:
   Minor: these defaults (and `start-from-snapshot.enabled` above) refer to the 
default plugin paths, and `journalTableWithSchema` uses the snapshot plugin's 
`schema`. With custom plugin ids or a journal in another schema, the snapshot 
plugin silently stores `write_timestamp` and empty tags, because 
`eventTimestampAndTags` just returns `None`. A note in the docs that these must 
be set for custom plugin setups, and maybe a warning log in `saveAsync` when 
enabled and the event isn't found, would make that easier to notice.



##########
ddl-scripts/migration/snapshot_start_from_snapshot_postgres.sql:
##########
@@ -0,0 +1,13 @@
+-- Adds the snapshot columns and index that are needed by the queries that use 
snapshots as starting points
+-- (eventsBySlicesStartingFromSnapshots, 
eventsByPersistenceIdStartingFromSnapshot and their current variants).
+-- Use this for both the BYTEA and the JSONB schema. See the migration guide 
for a rolling update procedure.
+
+ALTER TABLE snapshot ADD COLUMN IF NOT EXISTS db_timestamp timestamp with time 
zone;
+ALTER TABLE snapshot ADD COLUMN IF NOT EXISTS tags TEXT ARRAY;
+
+-- populate the new columns from the corresponding events
+UPDATE snapshot s SET db_timestamp = e.db_timestamp, tags = e.tags
+  FROM event_journal e
+  WHERE s.persistence_id = e.persistence_id AND s.seq_nr = e.seq_nr;

Review Comment:
   Minor: `eventTimestampAndTags` filters `deleted = false`, but the migration 
`UPDATE`s (here, Yugabyte and MySQL) don't. Harmless in practice, but better to 
be consistent. For large snapshot tables, a sentence in the migration guide 
about running the update in batches could also help.



##########
core/src/main/scala/org/apache/pekko/persistence/r2dbc/query/scaladsl/R2dbcReadJournal.scala:
##########
@@ -169,37 +216,203 @@ final class R2dbcReadJournal(system: 
ExtendedActorSystem, config: Config, cfgPat
       offset: Offset): Source[EventEnvelope[Event], NotUsed] = {
     val dbSource = bySlice[Event].liveBySlices("eventsBySlices", entityType, 
minSlice, maxSlice, offset)
     if (settings.journalPublishEvents) {
-      val pubSub = PubSub(typedSystem)
-      val pubSubSource =
-        Source
-          .actorRef[EventEnvelope[Event]](
-            completionMatcher = PartialFunction.empty,
-            failureMatcher = PartialFunction.empty,
-            bufferSize = settings.bufferSize,
-            // dropHead drops the oldest buffered event when full; any dropped 
pub/sub events are
-            // recovered from the database source which is merged below, and 
deduplication handles
-            // any events received via both paths.
-            // OverflowStrategy.dropNew is long deprecated and removed in 
Pekko 2.0.0.
-            overflowStrategy = OverflowStrategy.dropHead)
-          .mapMaterializedValue { ref =>
-            pubSub.eventTopics[Event](entityType, minSlice, maxSlice).foreach 
{ topic =>
-              import pekko.actor.typed.scaladsl.adapter._
-              topic ! Topic.Subscribe(ref.toTyped[EventEnvelope[Event]])
-            }
-          }
-          .filter { env =>
-            val slice = sliceForPersistenceId(env.persistenceId)
-            minSlice <= slice && slice <= maxSlice
-          }
-      dbSource
-        .mergePrioritized(pubSubSource, leftPriority = 1, rightPriority = 10)
-        .via(skipPubSubTooFarAhead(settings.backtrackingEnabled,
-          settings.backtrackingWindow.toJava))
-        .via(deduplicate(settings.deduplicateCapacity))
+      val pubSubSource = eventsBySlicesPubSubSource[Event](entityType, 
minSlice, maxSlice)
+      mergeDbAndPubSubSources(dbSource, pubSubSource)
     } else
       dbSource
   }
 
+  /**
+   * Same as `currentEventsBySlices` 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.
+   *
+   * First it loads all snapshots with timestamps greater than or equal to the 
offset timestamp. There is at most one
+   * snapshot per persistenceId. The snapshots are transformed to events with 
the given `transformSnapshot` function.
+   *
+   * After emitting the snapshot events the ordinary events with sequence 
numbers after the snapshots are emitted.
+   *
+   * To use `currentEventsBySlicesStartingFromSnapshots` you must enable 
configuration
+   * `pekko.persistence.r2dbc.query.start-from-snapshot.enabled` and follow 
the instructions in the migration guide.
+   */
+  override def currentEventsBySlicesStartingFromSnapshots[Snapshot, Event](
+      entityType: String,
+      minSlice: Int,
+      maxSlice: Int,
+      offset: Offset,
+      transformSnapshot: Snapshot => Event): Source[EventEnvelope[Event], 
NotUsed] = {
+    checkStartFromSnapshotEnabled("currentEventsBySlicesStartingFromSnapshots")
+    val timestampOffset = toTimestampOffset(offset)
+
+    val snapshotSource =
+      snapshotsBySlice[Snapshot, Event](transformSnapshot)
+        .currentBySlices("currentSnapshotsBySlices", entityType, minSlice, 
maxSlice, offset)
+
+    Source.fromGraph(
+      new StartingFromSnapshotStage[Event](
+        snapshotSource,
+        { snapshotOffsets =>
+          val initOffset = initOffsetAfterSnapshots(timestampOffset, offset, 
snapshotOffsets)
+
+          log.debug(
+            "currentEventsBySlicesStartingFromSnapshots initOffset [{}] with 
[{}] snapshots",
+            initOffset,
+            snapshotOffsets.size)
+
+          bySlice.currentBySlices(
+            "currentEventsBySlices",
+            entityType,
+            minSlice,
+            maxSlice,
+            initOffset,
+            filterEventsBeforeSnapshots(snapshotOffsets, backtrackingEnabled = 
false))
+        }))
+  }
+
+  /**
+   * Same as `eventsBySlices` 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.
+   *
+   * First it loads all snapshots with timestamps greater than or equal to the 
offset timestamp. There is at most one
+   * snapshot per persistenceId. The snapshots are transformed to events with 
the given `transformSnapshot` function.
+   *
+   * After emitting the snapshot events the ordinary events with sequence 
numbers after the snapshots are emitted.
+   *
+   * To use `eventsBySlicesStartingFromSnapshots` you must enable configuration
+   * `pekko.persistence.r2dbc.query.start-from-snapshot.enabled` and follow 
the instructions in the migration guide.
+   */
+  override def eventsBySlicesStartingFromSnapshots[Snapshot, Event](
+      entityType: String,
+      minSlice: Int,
+      maxSlice: Int,
+      offset: Offset,
+      transformSnapshot: Snapshot => Event): Source[EventEnvelope[Event], 
NotUsed] = {
+    checkStartFromSnapshotEnabled("eventsBySlicesStartingFromSnapshots")
+    val timestampOffset = toTimestampOffset(offset)
+
+    val snapshotSource =
+      snapshotsBySlice[Snapshot, Event](transformSnapshot)
+        .currentBySlices("snapshotsBySlices", entityType, minSlice, maxSlice, 
offset)
+
+    Source.fromGraph(
+      new StartingFromSnapshotStage[Event](
+        snapshotSource,
+        { snapshotOffsets =>
+          val initOffset = initOffsetAfterSnapshots(timestampOffset, offset, 
snapshotOffsets)
+
+          log.debug(
+            "eventsBySlicesStartingFromSnapshots initOffset [{}] with [{}] 
snapshots",
+            initOffset,
+            snapshotOffsets.size)
+
+          val dbSource =
+            bySlice[Event].liveBySlices(
+              "eventsBySlices",
+              entityType,
+              minSlice,
+              maxSlice,
+              initOffset,
+              filterEventsBeforeSnapshots(snapshotOffsets, 
settings.backtrackingEnabled))
+
+          if (settings.journalPublishEvents) {
+            // Note that events via PubSub are not filtered by 
snapshotOffsets. It's unlikely that
+            // those will be earlier than the snapshots and duplicates must be 
handled downstream in that case.
+            // If we would use the filterEventsBeforeSnapshots function for 
PubSub it would be difficult to
+            // know when memory of that Map can be released or the filter 
function would have to be shared
+            // and thread safe, which is not worth it.
+            val pubSubSource = eventsBySlicesPubSubSource[Event](entityType, 
minSlice, maxSlice)
+            mergeDbAndPubSubSources(dbSource, pubSubSource)
+          } else
+            dbSource
+        }))
+  }
+
+  private def initOffsetAfterSnapshots(
+      timestampOffset: TimestampOffset,
+      offset: Offset,
+      snapshotOffsets: Map[String, (Long, Instant)]): Offset =
+    if (timestampOffset == TimestampOffset.Zero && snapshotOffsets.nonEmpty) {

Review Comment:
   With a Zero offset the event query starts at the min snapshot timestamp, so 
events of entities that have no snapshot, and that are older than that 
timestamp, are skipped. The same risk the `else` comment describes for a 
non-zero offset applies here too.
   
   Repro: entity C persists `c-1` (no snapshot), then entity A persists 
`a-1-snap`. `currentEventsBySlicesStartingFromSnapshots(entityType, 0, 1023, 
NoOffset, s => s)` emits `a-1-snap` and completes without `c-1`.
   
   The same gap applies to an entity whose snapshot row is moved past 
`toTimestamp` by a newer snapshot while the snapshot query runs: it's then not 
in `snapshotOffsets`, and its events before the min timestamp are skipped.
   
   Either start from the given offset (correct once the filter issue above is 
fixed, at the cost of scanning more events), or document that all entities of 
the entity type must have a snapshot, and add a test either way. The docs 
currently don't mention this.



-- 
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