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-jdbc.git


The following commit(s) were added to refs/heads/main by this push:
     new 891b1fc0 Honor snapshot selection lower bounds #591 (#618)
891b1fc0 is described below

commit 891b1fc0e67b41c58072f359adca4a25fef8ab0d
Author: Goutam Adwant <[email protected]>
AuthorDate: Wed Sep 23 12:12:50 2026 -0700

    Honor snapshot selection lower bounds #591 (#618)
    
    * Honor snapshot selection lower bounds #591
    
    * Apply inclusive sequence and timestamp bounds in current and legacy DAO 
queries.
    
    * Preserve existing custom DAO methods through concrete criteria defaults; 
fail unsupported bounded calls explicitly rather than widening deletion.
    
    * Add snapshot load/delete regressions and document the custom DAO 
selection contract.
    
    * Preserve custom snapshot DAO fallback behavior #591
    
    Motivation:
    Failing new criteria defaults on minimum bounds breaks existing custom
    DAO recovery and bounded retention requests.
    
    Modification:
    Delegate default criteria calls to the existing upper-bound methods.
    Document the legacy approximation and add custom DAO routing, deletion,
    typed retention and bounded recovery regressions.
    
    Result:
    Existing custom DAOs retain their behavior while built-in DAOs continue
    to enforce all four inclusive bounds.
    
    Tests:
    Custom DAO regressions and current/legacy H2 snapshot tests passed.
    Custom DAO suites: 33 tests passed on Scala 2.13, 3.3 and 3.9 each.
    Core: 387 passed, two pending, one previously observed events-by-tag
    timeout, which also occurred on the isolated rerun.
    Style, documentation and normal Scala 2.13 MiMa checks passed.
    
    References:
    Refs #591
---
 .../jdbc/snapshot/JdbcSnapshotStore.scala          |  29 +-----
 .../jdbc/snapshot/dao/DefaultSnapshotDao.scala     |  16 ++-
 .../jdbc/snapshot/dao/SnapshotDao.scala            |  69 ++++++++++++-
 .../jdbc/snapshot/dao/SnapshotQueries.scala        |  20 ++++
 .../snapshot/dao/legacy/ByteArraySnapshotDao.scala |  22 ++++-
 .../jdbc/snapshot/dao/legacy/SnapshotQueries.scala |  20 ++++
 .../snapshot/CustomSnapshotDaoRecoverySpec.scala   | 107 +++++++++++++++++++++
 .../jdbc/snapshot/JdbcSnapshotStoreSpec.scala      |  76 ++++++++++++++-
 .../jdbc/snapshot/dao/SnapshotDaoSpec.scala        |  93 ++++++++++++++++++
 docs/src/main/paradox/custom-dao.md                |  25 ++++-
 10 files changed, 444 insertions(+), 33 deletions(-)

diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/JdbcSnapshotStore.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/JdbcSnapshotStore.scala
index fbe40cf3..0487da3d 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/JdbcSnapshotStore.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/JdbcSnapshotStore.scala
@@ -63,21 +63,8 @@ class JdbcSnapshotStore(config: Config) extends 
SnapshotStore {
 
   override def loadAsync(
       persistenceId: String,
-      criteria: SnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] = 
{
-    val result = criteria match {
-      case SnapshotSelectionCriteria(Long.MaxValue, Long.MaxValue, _, _) =>
-        snapshotDao.latestSnapshot(persistenceId)
-      case SnapshotSelectionCriteria(Long.MaxValue, maxTimestamp, _, _) =>
-        snapshotDao.snapshotForMaxTimestamp(persistenceId, maxTimestamp)
-      case SnapshotSelectionCriteria(maxSequenceNr, Long.MaxValue, _, _) =>
-        snapshotDao.snapshotForMaxSequenceNr(persistenceId, maxSequenceNr)
-      case SnapshotSelectionCriteria(maxSequenceNr, maxTimestamp, _, _) =>
-        snapshotDao.snapshotForMaxSequenceNrAndMaxTimestamp(persistenceId, 
maxSequenceNr, maxTimestamp)
-      case _ => Future.successful(None)
-    }
-
-    result.map(_.map(toSelectedSnapshot))
-  }
+      criteria: SnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] =
+    snapshotDao.snapshotForCriteria(persistenceId, 
criteria).map(_.map(toSelectedSnapshot))
 
   override def saveAsync(metadata: SnapshotMetadata, snapshot: Any): 
Future[Unit] =
     snapshotDao.save(metadata, snapshot)
@@ -88,17 +75,7 @@ class JdbcSnapshotStore(config: Config) extends 
SnapshotStore {
     } yield ()
 
   override def deleteAsync(persistenceId: String, criteria: 
SnapshotSelectionCriteria): Future[Unit] =
-    criteria match {
-      case SnapshotSelectionCriteria(Long.MaxValue, Long.MaxValue, _, _) =>
-        snapshotDao.deleteAllSnapshots(persistenceId)
-      case SnapshotSelectionCriteria(Long.MaxValue, maxTimestamp, _, _) =>
-        snapshotDao.deleteUpToMaxTimestamp(persistenceId, maxTimestamp)
-      case SnapshotSelectionCriteria(maxSequenceNr, Long.MaxValue, _, _) =>
-        snapshotDao.deleteUpToMaxSequenceNr(persistenceId, maxSequenceNr)
-      case SnapshotSelectionCriteria(maxSequenceNr, maxTimestamp, _, _) =>
-        snapshotDao.deleteUpToMaxSequenceNrAndMaxTimestamp(persistenceId, 
maxSequenceNr, maxTimestamp)
-      case _ => Future.successful(())
-    }
+    snapshotDao.deleteByCriteria(persistenceId, criteria)
 
   override def postStop(): Unit = {
     if (slickDb.allowShutdown) {
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/DefaultSnapshotDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/DefaultSnapshotDao.scala
index 41f14272..bdc4ee24 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/DefaultSnapshotDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/DefaultSnapshotDao.scala
@@ -16,7 +16,7 @@ package org.apache.pekko.persistence.jdbc.snapshot.dao
 
 import slick.jdbc.{ JdbcBackend, JdbcProfile }
 import org.apache.pekko
-import pekko.persistence.SnapshotMetadata
+import pekko.persistence.{ SnapshotMetadata, SnapshotSelectionCriteria }
 import pekko.persistence.jdbc.config.SnapshotConfig
 import pekko.serialization.Serialization
 import pekko.stream.Materializer
@@ -75,6 +75,20 @@ class DefaultSnapshotDao(
   private def zeroOrOneSnapshot(rows: Seq[SnapshotRow]): 
Option[(SnapshotMetadata, Any)] =
     rows.headOption.map(row => toSnapshotData(row).get) // throw is from a 
future map
 
+  override def snapshotForCriteria(
+      persistenceId: String,
+      criteria: SnapshotSelectionCriteria): Future[Option[(SnapshotMetadata, 
Any)]] =
+    db.run(queries.selectOneByCriteria(
+      (persistenceId, criteria.maxSequenceNr, criteria.maxTimestamp, 
criteria.minSequenceNr,
+        criteria.minTimestamp)).result)
+      .map(zeroOrOneSnapshot)
+
+  override def deleteByCriteria(persistenceId: String, criteria: 
SnapshotSelectionCriteria): Future[Unit] =
+    db.run(queries.selectByCriteria(
+      (persistenceId, criteria.maxSequenceNr, criteria.maxTimestamp, 
criteria.minSequenceNr,
+        criteria.minTimestamp)).delete)
+      .map(_ => ())(ExecutionContext.parasitic)
+
   override def latestSnapshot(persistenceId: String): 
Future[Option[(SnapshotMetadata, Any)]] =
     db.run(queries.selectLatestByPersistenceId(persistenceId).result).flatMap 
{ rows =>
       rows.headOption match {
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotDao.scala
index ab57813c..9ce5f764 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotDao.scala
@@ -14,11 +14,76 @@
 
 package org.apache.pekko.persistence.jdbc.snapshot.dao
 
-import org.apache.pekko.persistence.SnapshotMetadata
+import java.util.concurrent.ConcurrentHashMap
 
-import scala.concurrent.Future
+import org.apache.pekko.persistence.{ SnapshotMetadata, 
SnapshotSelectionCriteria }
+import org.slf4j.LoggerFactory
+
+import scala.concurrent.{ ExecutionContext, Future }
+
+private object SnapshotDaoWarnings {
+  private val warnedDaoClasses = ConcurrentHashMap.newKeySet[String]()
+
+  def ignoredMinimumBounds(dao: SnapshotDao, criteria: 
SnapshotSelectionCriteria): Unit = {
+    val daoClass = dao.getClass.getName
+    if ((criteria.minSequenceNr != 0L || criteria.minTimestamp != 0L) && 
warnedDaoClasses.add(daoClass))
+      LoggerFactory.getLogger(dao.getClass).warn(
+        "Snapshot DAO [{}] uses the compatibility deleteByCriteria 
implementation; minimum bounds are ignored. " +
+        "Override deleteByCriteria to apply all criteria bounds.",
+        daoClass)
+  }
+}
 
 trait SnapshotDao {
+
+  /**
+   * Load the snapshot with the highest sequence number matching all inclusive 
criteria bounds.
+   * The default delegates to the existing upper-bound methods for custom DAO 
compatibility,
+   * then filters the returned snapshot against all bounds. Custom DAOs should 
override this
+   * method to query all four bounds directly, as the built-in DAOs do; 
otherwise a snapshot
+   * matching the criteria may be missed when timestamps are not ordered by 
sequence number.
+   * Subclasses of a built-in DAO that override upper-bound loading methods 
are bypassed by
+   * the built-in implementation and must also override this method to apply 
their behavior.
+   */
+  def snapshotForCriteria(
+      persistenceId: String,
+      criteria: SnapshotSelectionCriteria): Future[Option[(SnapshotMetadata, 
Any)]] = {
+    val snapshot = criteria match {
+      case SnapshotSelectionCriteria(Long.MaxValue, Long.MaxValue, _, _) =>
+        latestSnapshot(persistenceId)
+      case SnapshotSelectionCriteria(Long.MaxValue, maxTimestamp, _, _) =>
+        snapshotForMaxTimestamp(persistenceId, maxTimestamp)
+      case SnapshotSelectionCriteria(maxSequenceNr, Long.MaxValue, _, _) =>
+        snapshotForMaxSequenceNr(persistenceId, maxSequenceNr)
+      case SnapshotSelectionCriteria(maxSequenceNr, maxTimestamp, _, _) =>
+        snapshotForMaxSequenceNrAndMaxTimestamp(persistenceId, maxSequenceNr, 
maxTimestamp)
+    }
+    snapshot.map(_.filter { case (metadata, _) => criteria.matches(metadata) 
})(ExecutionContext.parasitic)
+  }
+
+  /**
+   * Delete only snapshots matching all inclusive criteria bounds for this 
persistence ID.
+   * The default delegates to the existing upper-bound methods for custom DAO 
compatibility,
+   * ignoring minimum bounds. Custom DAOs must override this method to apply 
all four bounds,
+   * as the built-in DAOs do; otherwise snapshots below a minimum bound may 
also be deleted.
+   * Subclasses of a built-in DAO that override upper-bound deletion methods 
are bypassed by
+   * the built-in implementation and must also override this method to apply 
their behavior.
+   */
+  def deleteByCriteria(persistenceId: String, criteria: 
SnapshotSelectionCriteria): Future[Unit] = {
+    SnapshotDaoWarnings.ignoredMinimumBounds(this, criteria)
+
+    criteria match {
+      case SnapshotSelectionCriteria(Long.MaxValue, Long.MaxValue, _, _) =>
+        deleteAllSnapshots(persistenceId)
+      case SnapshotSelectionCriteria(Long.MaxValue, maxTimestamp, _, _) =>
+        deleteUpToMaxTimestamp(persistenceId, maxTimestamp)
+      case SnapshotSelectionCriteria(maxSequenceNr, Long.MaxValue, _, _) =>
+        deleteUpToMaxSequenceNr(persistenceId, maxSequenceNr)
+      case SnapshotSelectionCriteria(maxSequenceNr, maxTimestamp, _, _) =>
+        deleteUpToMaxSequenceNrAndMaxTimestamp(persistenceId, maxSequenceNr, 
maxTimestamp)
+    }
+  }
+
   def deleteAllSnapshots(persistenceId: String): Future[Unit]
 
   def deleteUpToMaxSequenceNr(persistenceId: String, maxSequenceNr: Long): 
Future[Unit]
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotQueries.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotQueries.scala
index f5399e1d..b43ed058 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotQueries.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotQueries.scala
@@ -36,6 +36,26 @@ class SnapshotQueries(val profile: JdbcProfile, override val 
snapshotTableCfg: S
     _selectAll(persistenceId).take(1)
   val selectLatestByPersistenceId = Compiled(_selectLatestByPersistenceId _)
 
+  private def _selectByCriteria(
+      persistenceId: Rep[String],
+      maxSequenceNr: Rep[Long],
+      maxTimestamp: Rep[Long],
+      minSequenceNr: Rep[Long],
+      minTimestamp: Rep[Long]) =
+    _selectAll(persistenceId).filter(row =>
+      row.sequenceNumber <= maxSequenceNr && row.sequenceNumber >= 
minSequenceNr &&
+      row.created <= maxTimestamp && row.created >= minTimestamp)
+  val selectByCriteria = Compiled(_selectByCriteria _)
+
+  private def _selectOneByCriteria(
+      persistenceId: Rep[String],
+      maxSequenceNr: Rep[Long],
+      maxTimestamp: Rep[Long],
+      minSequenceNr: Rep[Long],
+      minTimestamp: Rep[Long]) =
+    _selectByCriteria(persistenceId, maxSequenceNr, maxTimestamp, 
minSequenceNr, minTimestamp).take(1)
+  val selectOneByCriteria = Compiled(_selectOneByCriteria _)
+
   private def _selectByPersistenceIdAndSequenceNr(persistenceId: Rep[String], 
sequenceNr: Rep[Long]) =
     _selectAll(persistenceId).filter(_.sequenceNumber === sequenceNr)
   val selectByPersistenceIdAndSequenceNr = 
Compiled(_selectByPersistenceIdAndSequenceNr _)
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/legacy/ByteArraySnapshotDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/legacy/ByteArraySnapshotDao.scala
index ba37bf6a..f570c4ac 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/legacy/ByteArraySnapshotDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/legacy/ByteArraySnapshotDao.scala
@@ -15,7 +15,7 @@
 package org.apache.pekko.persistence.jdbc.snapshot.dao.legacy
 
 import org.apache.pekko
-import pekko.persistence.SnapshotMetadata
+import pekko.persistence.{ SnapshotMetadata, SnapshotSelectionCriteria }
 import pekko.persistence.jdbc.config.SnapshotConfig
 import pekko.persistence.jdbc.snapshot.dao.legacy.SnapshotTables.SnapshotRow
 import pekko.persistence.jdbc.snapshot.dao.SnapshotDao
@@ -44,6 +44,26 @@ class ByteArraySnapshotDao(
       case Failure(cause)        => throw cause
     }
 
+  override def snapshotForCriteria(
+      persistenceId: String,
+      criteria: SnapshotSelectionCriteria): Future[Option[(SnapshotMetadata, 
Any)]] =
+    if (criteria.minSequenceNr == 0L && criteria.minTimestamp == 0L)
+      super.snapshotForCriteria(persistenceId, criteria)
+    else
+      db.run(queries.selectOneByCriteria(
+        (persistenceId, criteria.maxSequenceNr, criteria.maxTimestamp, 
criteria.minSequenceNr,
+          criteria.minTimestamp)).result)
+        .map(_.headOption.map(toSnapshotData))
+
+  override def deleteByCriteria(persistenceId: String, criteria: 
SnapshotSelectionCriteria): Future[Unit] =
+    if (criteria.minSequenceNr == 0L && criteria.minTimestamp == 0L)
+      super.deleteByCriteria(persistenceId, criteria)
+    else
+      db.run(queries.selectByCriteria(
+        (persistenceId, criteria.maxSequenceNr, criteria.maxTimestamp, 
criteria.minSequenceNr,
+          criteria.minTimestamp)).delete)
+        .map(_ => ())(ExecutionContext.parasitic)
+
   override def latestSnapshot(persistenceId: String): 
Future[Option[(SnapshotMetadata, Any)]] =
     for {
       rows <- db.run(queries.selectLatestByPersistenceId(persistenceId).result)
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/legacy/SnapshotQueries.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/legacy/SnapshotQueries.scala
index d6c0ed2e..a8c189ce 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/legacy/SnapshotQueries.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/legacy/SnapshotQueries.scala
@@ -36,6 +36,26 @@ class SnapshotQueries(val profile: JdbcProfile, override val 
snapshotTableCfg: L
     _selectAll(persistenceId).take(1)
   val selectLatestByPersistenceId = Compiled(_selectLatestByPersistenceId _)
 
+  private def _selectByCriteria(
+      persistenceId: Rep[String],
+      maxSequenceNr: Rep[Long],
+      maxTimestamp: Rep[Long],
+      minSequenceNr: Rep[Long],
+      minTimestamp: Rep[Long]) =
+    _selectAll(persistenceId).filter(row =>
+      row.sequenceNumber <= maxSequenceNr && row.sequenceNumber >= 
minSequenceNr &&
+      row.created <= maxTimestamp && row.created >= minTimestamp)
+  val selectByCriteria = Compiled(_selectByCriteria _)
+
+  private def _selectOneByCriteria(
+      persistenceId: Rep[String],
+      maxSequenceNr: Rep[Long],
+      maxTimestamp: Rep[Long],
+      minSequenceNr: Rep[Long],
+      minTimestamp: Rep[Long]) =
+    _selectByCriteria(persistenceId, maxSequenceNr, maxTimestamp, 
minSequenceNr, minTimestamp).take(1)
+  val selectOneByCriteria = Compiled(_selectOneByCriteria _)
+
   private def _selectByPersistenceIdAndSequenceNr(persistenceId: Rep[String], 
sequenceNr: Rep[Long]) =
     _selectAll(persistenceId).filter(_.sequenceNumber === sequenceNr)
   val selectByPersistenceIdAndSequenceNr = 
Compiled(_selectByPersistenceIdAndSequenceNr _)
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/CustomSnapshotDaoRecoverySpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/CustomSnapshotDaoRecoverySpec.scala
new file mode 100644
index 00000000..8a7701d1
--- /dev/null
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/CustomSnapshotDaoRecoverySpec.scala
@@ -0,0 +1,107 @@
+/*
+ * 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.jdbc.snapshot
+
+import scala.concurrent.{ ExecutionContext, Future }
+
+import org.apache.pekko
+import pekko.persistence.{
+  DeleteSnapshotsSuccess,
+  Persistence,
+  SelectedSnapshot,
+  SnapshotMetadata,
+  SnapshotSelectionCriteria
+}
+import pekko.persistence.SnapshotProtocol.{ DeleteSnapshots, LoadSnapshot, 
LoadSnapshotResult }
+import pekko.persistence.jdbc.SharedActorSystemTestSpec
+import pekko.persistence.jdbc.config.SnapshotConfig
+import pekko.persistence.jdbc.db.SlickDatabase
+import pekko.persistence.jdbc.snapshot.dao.{ DefaultSnapshotDao, SnapshotDao }
+import pekko.persistence.jdbc.testkit.internal.H2
+import pekko.serialization.Serialization
+import pekko.stream.Materializer
+import pekko.testkit.TestProbe
+import com.typesafe.config.ConfigValueFactory
+import slick.jdbc.{ JdbcBackend, JdbcProfile }
+
+// Models an existing custom DAO that implements only the pre-criteria API.
+class UpperBoundSnapshotDao(
+    db: JdbcBackend#Database,
+    profile: JdbcProfile,
+    config: SnapshotConfig,
+    serialization: Serialization)(implicit ec: ExecutionContext, mat: 
Materializer)
+    extends DefaultSnapshotDao(db, profile, config, serialization)
+    with SnapshotDao {
+  override def snapshotForCriteria(
+      persistenceId: String,
+      criteria: SnapshotSelectionCriteria): Future[Option[(SnapshotMetadata, 
Any)]] =
+    super[SnapshotDao].snapshotForCriteria(persistenceId, criteria)
+
+  override def deleteByCriteria(persistenceId: String, criteria: 
SnapshotSelectionCriteria): Future[Unit] =
+    super[SnapshotDao].deleteByCriteria(persistenceId, criteria)
+}
+
+class CustomSnapshotDaoRecoverySpec
+    extends SharedActorSystemTestSpec(
+      "h2-application.conf",
+      Map("jdbc-snapshot-store.dao" -> 
ConfigValueFactory.fromAnyRef(classOf[UpperBoundSnapshotDao].getName))) {
+
+  private lazy val snapshots = new DefaultSnapshotDao(
+    db,
+    SlickDatabase.profile(config, "slick"),
+    new 
SnapshotConfig(system.settings.config.getConfig("jdbc-snapshot-store")),
+    serialization)
+  private lazy val snapshotStore = 
Persistence(system).snapshotStoreFor("jdbc-snapshot-store")
+
+  override def beforeAll(): Unit = {
+    dropAndCreate(H2)
+    super.beforeAll()
+  }
+
+  it should "filter an upper-bound custom DAO result against lower bounds" in {
+    val persistenceId = "custom-bounded-load"
+    val older = SnapshotMetadata(persistenceId, 2L, 200L)
+    snapshots.save(older, "older").futureValue
+
+    val criteria = SnapshotSelectionCriteria(minSequenceNr = 3L, minTimestamp 
= 201L)
+    val probe = TestProbe()
+    snapshotStore.tell(LoadSnapshot(persistenceId, criteria, Long.MaxValue), 
probe.ref)
+    probe.expectMsg(LoadSnapshotResult(None, Long.MaxValue))
+
+    val matching = SnapshotMetadata(persistenceId, 4L, 400L)
+    snapshots.save(matching, "matching").futureValue
+    snapshotStore.tell(LoadSnapshot(persistenceId, criteria, Long.MaxValue), 
probe.ref)
+    probe.expectMsg(LoadSnapshotResult(Some(SelectedSnapshot(matching, 
"matching")), Long.MaxValue))
+  }
+
+  it should "preserve upper-bound deletion for a custom DAO" in {
+    val persistenceId = "custom-bounded-delete"
+    Seq(1L, 2L, 4L, 6L).foldLeft(Future.successful(())) { (saved, sequenceNr) 
=>
+      saved.flatMap(_ =>
+        snapshots.save(SnapshotMetadata(persistenceId, sequenceNr, sequenceNr 
* 100), sequenceNr.toInt))
+    }.futureValue
+
+    val criteria = SnapshotSelectionCriteria(maxSequenceNr = 4L, minSequenceNr 
= 2L)
+    val probe = TestProbe()
+    snapshotStore.tell(DeleteSnapshots(persistenceId, criteria), probe.ref)
+    probe.expectMsg(DeleteSnapshotsSuccess(criteria))
+
+    snapshots.snapshotForMaxSequenceNr(persistenceId, 4L).futureValue shouldBe 
None
+    snapshots.latestSnapshot(persistenceId).futureValue.map(_._1.sequenceNr) 
shouldBe Some(6L)
+  }
+}
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/JdbcSnapshotStoreSpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/JdbcSnapshotStoreSpec.scala
index 56b91bf4..80fe2f26 100644
--- 
a/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/JdbcSnapshotStoreSpec.scala
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/JdbcSnapshotStoreSpec.scala
@@ -14,14 +14,22 @@
 
 package org.apache.pekko.persistence.jdbc.snapshot
 
+import scala.concurrent.Future
+
 import org.apache.pekko
-import pekko.persistence.CapabilityFlag
+import pekko.persistence.{ CapabilityFlag, SelectedSnapshot, SnapshotMetadata, 
SnapshotSelectionCriteria }
+import pekko.persistence.SnapshotProtocol.{ DeleteSnapshots, LoadSnapshot, 
LoadSnapshotResult }
+import pekko.persistence.DeleteSnapshotsSuccess
 import pekko.persistence.jdbc.config._
 import pekko.persistence.jdbc.util.{ ClasspathResources, DropCreate }
 import pekko.persistence.jdbc.db.SlickDatabase
 import pekko.persistence.jdbc.testkit.internal.{ H2, SchemaType, 
SchemaUtilsImpl }
 import pekko.persistence.snapshot.SnapshotStoreSpec
-import pekko.testkit.TestKit
+import pekko.persistence.jdbc.snapshot.dao.{ DefaultSnapshotDao, SnapshotDao }
+import pekko.persistence.jdbc.snapshot.dao.legacy.ByteArraySnapshotDao
+import pekko.serialization.SerializationExtension
+import pekko.stream.SystemMaterializer
+import pekko.testkit.{ TestKit, TestProbe }
 
 import com.typesafe.config.{ Config, ConfigFactory }
 import org.scalatest.BeforeAndAfterAll
@@ -46,6 +54,70 @@ abstract class JdbcSnapshotStoreSpec(config: Config, 
schemaType: SchemaType)
   protected override def supportsSerialization: CapabilityFlag = newDao
   protected override def supportsMetadata: CapabilityFlag = newDao
 
+  private lazy val boundsDao: SnapshotDao = {
+    val profile = SlickDatabase.profile(config, "slick")
+    val snapshotConfig = new 
SnapshotConfig(system.settings.config.getConfig("jdbc-snapshot-store"))
+    val serialization = SerializationExtension(system)
+    implicit val mat = SystemMaterializer(system).materializer
+    if (newDao) new DefaultSnapshotDao(db, profile, snapshotConfig, 
serialization)
+    else new ByteArraySnapshotDao(db, profile, snapshotConfig, serialization)
+  }
+
+  private def seedBoundsSnapshots(persistenceId: String): 
Seq[SnapshotMetadata] = {
+    // Deliberately do not order timestamps by sequence number.
+    val metadata = Seq(100L, 400L, 200L, 300L, 500L).zipWithIndex.map { case 
(timestamp, index) =>
+      SnapshotMetadata(persistenceId, index + 1L, timestamp)
+    }
+    Future.traverse(metadata)(md => boundsDao.save(md, 
s"bounds-${md.sequenceNr}")).futureValue
+    metadata
+  }
+
+  private def allSequenceNumbers(persistenceId: String): Future[Seq[Long]] = 
boundsDao match {
+    case dao: DefaultSnapshotDao =>
+      import dao.queries.profile.api._
+      
db.run(dao.queries.selectAll(persistenceId).result).map(_.map(_.sequenceNumber))
+    case dao: ByteArraySnapshotDao =>
+      import dao.queries.profile.api._
+      
db.run(dao.queries.selectAll(persistenceId).result).map(_.map(_.sequenceNumber))
+    case dao => Future.failed(new IllegalStateException(s"Unexpected snapshot 
DAO: ${dao.getClass.getName}"))
+  }
+
+  private val boundsCases = Seq(
+    ("default bounds", SnapshotSelectionCriteria.Latest, Seq(1L, 2L, 3L, 4L, 
5L)),
+    ("minimum sequence number", SnapshotSelectionCriteria(minSequenceNr = 3), 
Seq(3L, 4L, 5L)),
+    ("unordered timestamps", SnapshotSelectionCriteria(maxSequenceNr = 4, 
minTimestamp = 350), Seq(2L)),
+    ("all bounds", SnapshotSelectionCriteria(4, 400, 2, 300), Seq(2L, 4L)),
+    ("equal bounds", SnapshotSelectionCriteria(3, 200, 3, 200), Seq(3L)),
+    ("empty interval", SnapshotSelectionCriteria(maxSequenceNr = 2, 
minSequenceNr = 4), Seq.empty[Long]))
+
+  "Snapshot selection bounds" must {
+    boundsCases.foreach { case (label, criteria, matching) =>
+      s"load with $label" in {
+        val persistenceId = s"$pid-bounds"
+        val metadata = seedBoundsSnapshots(persistenceId)
+        val probe = TestProbe()
+        snapshotStore.tell(LoadSnapshot(persistenceId, criteria, 
Long.MaxValue), probe.ref)
+        val expected = matching.lastOption.map { sequenceNr =>
+          SelectedSnapshot(metadata((sequenceNr - 1).toInt), 
s"bounds-$sequenceNr")
+        }
+        probe.expectMsg(LoadSnapshotResult(expected, Long.MaxValue))
+      }
+
+      s"delete with $label without deleting other snapshots" in {
+        val persistenceId = s"$pid-bounds"
+        val metadata = seedBoundsSnapshots(persistenceId)
+        val otherMetadata = seedBoundsSnapshots(s"$persistenceId-other")
+        val probe = TestProbe()
+        snapshotStore.tell(DeleteSnapshots(persistenceId, criteria), probe.ref)
+        probe.expectMsg(DeleteSnapshotsSuccess(criteria))
+        val survivors = metadata.filterNot(md => 
matching.contains(md.sequenceNr))
+        allSequenceNumbers(persistenceId).futureValue shouldBe 
survivors.map(_.sequenceNr).reverse
+        allSequenceNumbers(otherMetadata.head.persistenceId).futureValue 
shouldBe
+        otherMetadata.map(_.sequenceNr).reverse
+      }
+    }
+  }
+
   override def beforeAll(): Unit = {
     dropAndCreate(schemaType)
     super.beforeAll()
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotDaoSpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotDaoSpec.scala
new file mode 100644
index 00000000..e5a3b6d0
--- /dev/null
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/jdbc/snapshot/dao/SnapshotDaoSpec.scala
@@ -0,0 +1,93 @@
+/*
+ * 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.jdbc.snapshot.dao
+
+import org.apache.pekko.persistence.{ SnapshotMetadata, 
SnapshotSelectionCriteria }
+import org.scalatest.concurrent.ScalaFutures
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+
+import scala.concurrent.Future
+
+class SnapshotDaoSpec extends AnyWordSpec with Matchers with ScalaFutures {
+  private class CustomSnapshotDao extends SnapshotDao {
+    var calls = Vector.empty[(String, String, Seq[Long])]
+    val snapshot = Some((SnapshotMetadata("custom", 3, 200), "snapshot"))
+
+    private def loaded(method: String, persistenceId: String, bounds: Long*) = 
{
+      calls :+= ((method, persistenceId, bounds.toList))
+      Future.successful(snapshot)
+    }
+
+    private def deleted(method: String, persistenceId: String, bounds: Long*) 
= {
+      calls :+= ((method, persistenceId, bounds.toList))
+      Future.successful(())
+    }
+
+    override def latestSnapshot(persistenceId: String) = loaded("latest", 
persistenceId)
+    override def snapshotForMaxTimestamp(persistenceId: String, timestamp: 
Long) =
+      loaded("timestamp", persistenceId, timestamp)
+    override def snapshotForMaxSequenceNr(persistenceId: String, sequenceNr: 
Long) =
+      loaded("sequence", persistenceId, sequenceNr)
+    override def snapshotForMaxSequenceNrAndMaxTimestamp(persistenceId: 
String, sequenceNr: Long, timestamp: Long) =
+      loaded("both", persistenceId, sequenceNr, timestamp)
+
+    override def deleteAllSnapshots(persistenceId: String) = deleted("latest", 
persistenceId)
+    override def deleteUpToMaxTimestamp(persistenceId: String, timestamp: 
Long) =
+      deleted("timestamp", persistenceId, timestamp)
+    override def deleteUpToMaxSequenceNr(persistenceId: String, sequenceNr: 
Long) =
+      deleted("sequence", persistenceId, sequenceNr)
+    override def deleteUpToMaxSequenceNrAndMaxTimestamp(persistenceId: String, 
sequenceNr: Long, timestamp: Long) =
+      deleted("both", persistenceId, sequenceNr, timestamp)
+
+    override def delete(persistenceId: String, sequenceNr: Long) = 
deleted("single", persistenceId, sequenceNr)
+    override def save(metadata: SnapshotMetadata, snapshot: Any) =
+      Future.failed(new UnsupportedOperationException("save"))
+  }
+
+  "SnapshotDao criteria defaults" should {
+    val upperBounds = Seq(
+      (SnapshotSelectionCriteria.Latest, "latest", Seq.empty[Long]),
+      (SnapshotSelectionCriteria(maxTimestamp = 200), "timestamp", Seq(200L)),
+      (SnapshotSelectionCriteria(maxSequenceNr = 3), "sequence", Seq(3L)),
+      (SnapshotSelectionCriteria(3, 200), "both", Seq(3L, 200L)))
+
+    upperBounds.foreach { case (upper, method, arguments) =>
+      s"preserve the existing $method load and delete methods" in {
+        val dao = new CustomSnapshotDao
+        Seq(upper, upper.copy(minSequenceNr = 1L, minTimestamp = 1L)).foreach 
{ criteria =>
+          dao.snapshotForCriteria("custom", criteria).futureValue shouldBe 
dao.snapshot
+          dao.deleteByCriteria("custom", criteria).futureValue shouldBe (())
+        }
+        dao.calls shouldBe Vector.fill(4)((method, "custom", arguments))
+      }
+    }
+
+    "filter an upper-bound result that does not satisfy a lower bound" in {
+      val dao = new CustomSnapshotDao
+      val criteria = SnapshotSelectionCriteria(minSequenceNr = 4L, 
minTimestamp = 201L)
+      dao.snapshotForCriteria("custom", criteria).futureValue shouldBe None
+      dao.calls shouldBe Vector(("latest", "custom", Seq.empty))
+
+      dao.deleteByCriteria("custom", criteria).futureValue shouldBe (())
+      dao.calls shouldBe Vector(
+        ("latest", "custom", Seq.empty),
+        ("latest", "custom", Seq.empty))
+    }
+  }
+}
diff --git a/docs/src/main/paradox/custom-dao.md 
b/docs/src/main/paradox/custom-dao.md
index f9861233..c388aadc 100644
--- a/docs/src/main/paradox/custom-dao.md
+++ b/docs/src/main/paradox/custom-dao.md
@@ -40,6 +40,30 @@ class MyCustomSnapshotDao(db: JdbcBackend#Database, val 
profile: JdbcProfile, sn
 As you can see, the custom DAOs get a _Slick database_, a _Slick profile_, the 
journal or snapshot _configuration_, an 
_org.apache.pekko.serialization.Serialization_, an _ExecutionContext_ and 
_Materializer_ injected after constructed.
 You should register the Fully Qualified Class Name in `application.conf` so 
that the custom DAOs will be used.
 
+## Snapshot selection criteria
+
+Snapshot loading and criteria-based deletion use 
`SnapshotDao.snapshotForCriteria` and
+`SnapshotDao.deleteByCriteria`. Both built-in snapshot DAOs apply all four 
bounds in
+`SnapshotSelectionCriteria`: `minSequenceNr`, `maxSequenceNr`, `minTimestamp`, 
and
+`maxTimestamp`. Bounds are inclusive, and both the sequence number and 
timestamp must
+match. Loading returns the matching snapshot with the highest sequence number; 
deletion
+removes only matching snapshots for the requested persistence ID. An empty 
interval
+matches no snapshots.
+
+The existing upper-bound methods remain available. The default 
`snapshotForCriteria`
+implementation delegates to those methods and post-filters their result 
against all bounds,
+preserving safe recovery for existing custom DAOs. It may return no snapshot 
when timestamps
+are not ordered by sequence number even if an older snapshot matches. The 
default
+`deleteByCriteria` implementation preserves the previous upper-bound deletion 
behavior and
+may delete snapshots below a minimum bound, including when the interval is 
empty. Custom DAOs
+should override both criteria methods to query all four bounds directly, as 
the built-in DAOs
+do.
+
+Subclasses of a built-in snapshot DAO that override its upper-bound methods 
must also override
+`snapshotForCriteria` and `deleteByCriteria` if they need to apply custom 
loading or deletion
+behavior. Criteria-based operations in the built-in DAOs execute their 
four-bound queries
+directly.
+
 For more information please review the two default implementations 
`org.apache.pekko.persistence.jdbc.dao.bytea.journal.ByteArrayJournalDao` and 
`org.apache.pekko.persistence.jdbc.dao.bytea.snapshot.ByteArraySnapshotDao` or 
the demo custom DAO example from the 
[demo-akka-persistence](https://github.com/dnvriend/demo-akka-persistence-jdbc) 
site.
 
 @@@warning { title="Binary compatibility" }
@@ -49,4 +73,3 @@ For example 4.0.0 is not binary backwards compatible with 
3.5.x. There may also
 the APIs for customer DAOs if new capabilities must be added to to the traits.
 
 @@@
-


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

Reply via email to