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


The following commit(s) were added to refs/heads/main by this push:
     new 48a6651  Close connection if execution takes too long (#457)
48a6651 is described below

commit 48a6651dce1605d11af0bdc368fd1e3f7e236d16
Author: PJ Fanning <[email protected]>
AuthorDate: Mon Jul 27 10:43:26 2026 +0100

    Close connection if execution takes too long (#457)
    
    * Close connection if execution takes too long
    
    * Update R2dbcExecutorSpec.scala
    
    * Update R2dbcExecutor.scala
    
    * Update R2dbcExecutorSpec.scala
    
    * Update R2dbcExecutor.scala
    
    * try to fix plugin test issue
    
    * scalafmt
    
    * remove blank line
---
 core/src/main/resources/reference.conf             |  18 +++
 .../r2dbc/ConnectionFactoryProvider.scala          |   4 +
 .../pekko/persistence/r2dbc/R2dbcSettings.scala    |  14 +++
 .../persistence/r2dbc/internal/R2dbcExecutor.scala | 106 +++++++++++++----
 .../persistence/r2dbc/journal/JournalDao.scala     |  16 ++-
 .../r2dbc/journal/mysql/MySQLJournalDao.scala      |   6 +-
 .../r2dbc/query/scaladsl/QueryDao.scala            |  14 ++-
 .../r2dbc/query/scaladsl/mysql/MySQLQueryDao.scala |   6 +-
 .../r2dbc/session/scaladsl/R2dbcSession.scala      |   8 +-
 .../persistence/r2dbc/snapshot/SnapshotDao.scala   |  16 ++-
 .../r2dbc/snapshot/mysql/MySQLSnapshotDao.scala    |   7 +-
 .../r2dbc/state/scaladsl/DurableStateDao.scala     |  15 ++-
 .../scaladsl/mysql/MySQLDurableStateDao.scala      |   6 +-
 .../pekko/persistence/r2dbc/TestDbLifecycle.scala  |   9 +-
 .../r2dbc/internal/R2dbcExecutorSpec.scala         | 131 +++++++++++++++++++++
 .../r2dbc/migration/MigrationTool.scala            |   7 +-
 .../r2dbc/migration/MigrationToolDao.scala         |   6 +-
 17 files changed, 338 insertions(+), 51 deletions(-)

diff --git a/core/src/main/resources/reference.conf 
b/core/src/main/resources/reference.conf
index b05b9aa..35c9bc1 100644
--- a/core/src/main/resources/reference.conf
+++ b/core/src/main/resources/reference.conf
@@ -283,6 +283,24 @@ pekko.persistence.r2dbc {
     # A fast query for Postgres is "SELECT 1"
     validation-query = ""
 
+    # Abort any statement that takes more than the specified amount of time.
+    # This timeout is handled by the database server.
+    # This timeout should be less than `close-calls-exceeding`.
+    statement-timeout = off
+
+    # Maximum SQL statement execution duration. The current connection is 
closed if exceeded,
+    # and will not be reused by the pool.
+    # This timeout is handled on the client side and should be used in case 
the database server
+    # is unresponsive or the connection is broken but not closed.
+    # It can be used in combination with `statement-timeout`, which should be 
less than this
+    # timeout.
+    # The timeout is needed to handle some failure scenarios, when the 
database server is
+    # terminated in a non graceful way and there is a load balancer in front. 
The client
+    # connection and current execution in progress would not be completed 
without this timeout,
+    # resulting in a "connection leak".
+    # Set to "off" to disable this timeout.
+    close-calls-exceeding = 20 seconds
+
     # FQCN of a ConnectionFactoryOptionsCustomizer. If non-empty, it must be 
the fully
     # qualified class name of a class implementing the trait 
ConnectionFactoryOptionsCustomizer.
     # The class must have a constructor with a single parameter of type 
ActorSystem[_].
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/ConnectionFactoryProvider.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/ConnectionFactoryProvider.scala
index 2d9a1d8..a65c410 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/ConnectionFactoryProvider.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/ConnectionFactoryProvider.scala
@@ -147,6 +147,10 @@ class ConnectionFactoryProvider(system: ActorSystem[?]) 
extends Extension {
         PostgresqlConnectionFactoryProvider.PREPARED_STATEMENT_CACHE_QUERIES,
         Integer.valueOf(settings.statementCacheSize))
 
+    settings.statementTimeout.foreach { timeout =>
+      builder.option(PostgresqlConnectionFactoryProvider.STATEMENT_TIMEOUT, 
JDuration.ofMillis(timeout.toMillis))
+    }
+
     if (settings.sslEnabled) {
       builder.option(ConnectionFactoryOptions.SSL, java.lang.Boolean.TRUE)
 
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/R2dbcSettings.scala 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/R2dbcSettings.scala
index 76c5630..81d9357 100644
--- a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/R2dbcSettings.scala
+++ b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/R2dbcSettings.scala
@@ -331,6 +331,14 @@ final class ConnectionFactorySettings(config: Config) {
 
   val statementCacheSize: Int = config.getInt("statement-cache-size")
 
+  val statementTimeout: Option[FiniteDuration] =
+    config.getString("statement-timeout").toLowerCase(Locale.ROOT) match {
+      case "off" => None
+      case _     => Some(config.getDuration("statement-timeout").toScala)
+    }
+
+  val closeCallsExceeding: Option[FiniteDuration] = 
ConnectionFactorySettings.closeCallsExceeding(config)
+
   val connectionFactoryOptionsCustomizer: Option[String] =
     
Option(config.getString("connection-factory-options-customizer")).filter(_.trim.nonEmpty)
 }
@@ -342,6 +350,12 @@ final class ConnectionFactorySettings(config: Config) {
 object ConnectionFactorySettings {
   def apply(config: Config): ConnectionFactorySettings =
     new ConnectionFactorySettings(config)
+
+  def closeCallsExceeding(config: Config): Option[FiniteDuration] =
+    config.getString("close-calls-exceeding").toLowerCase(Locale.ROOT) match {
+      case "off" => None
+      case _     => Some(config.getDuration("close-calls-exceeding").toScala)
+    }
 }
 
 /**
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/internal/R2dbcExecutor.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/internal/R2dbcExecutor.scala
index 4d62500..a2808a0 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/internal/R2dbcExecutor.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/internal/R2dbcExecutor.scala
@@ -19,7 +19,11 @@ import scala.collection.immutable
 import scala.collection.mutable
 import scala.concurrent.ExecutionContext
 import scala.concurrent.Future
+import scala.concurrent.duration.DurationInt
 import scala.concurrent.duration.FiniteDuration
+import scala.util.Failure
+import scala.util.Success
+import scala.util.control.Exception.Catcher
 import scala.util.control.NonFatal
 
 import org.apache.pekko
@@ -31,6 +35,7 @@ import io.r2dbc.spi.ConnectionFactory
 import io.r2dbc.spi.Result
 import io.r2dbc.spi.Row
 import io.r2dbc.spi.Statement
+import io.r2dbc.spi.Wrapped
 import org.reactivestreams.Publisher
 import org.slf4j.Logger
 import reactor.core.publisher.Flux
@@ -108,15 +113,25 @@ import reactor.core.publisher.Mono
  * INTERNAL API:
  */
 @InternalStableApi
-class R2dbcExecutor(val connectionFactory: ConnectionFactory, log: Logger, 
logDbCallsExceeding: FiniteDuration)(
-    implicit
-    ec: ExecutionContext,
-    system: ActorSystem[?]) {
+class R2dbcExecutor(
+    val connectionFactory: ConnectionFactory,
+    log: Logger,
+    logDbCallsExceeding: FiniteDuration,
+    closeCallsExceeding: Option[FiniteDuration])(implicit ec: 
ExecutionContext, system: ActorSystem[?]) {
+
   import R2dbcExecutor._
 
   private val logDbCallsExceedingMicros = logDbCallsExceeding.toMicros
   private val logDbCallsExceedingEnabled = logDbCallsExceedingMicros >= 0
 
+  // for backwards compatibility, closeCallsExceeding should be defined
+  @deprecated("Use constructor with closeCallsExceeding", "2.0.0")
+  def this(connectionFactory: ConnectionFactory, log: Logger, 
logDbCallsExceeding: FiniteDuration)(
+      implicit
+      ec: ExecutionContext,
+      system: ActorSystem[?]) =
+    this(connectionFactory, log, logDbCallsExceeding, closeCallsExceeding = 
Some(20.seconds))
+
   private def nanoTime(): Long =
     if (logDbCallsExceedingEnabled) System.nanoTime() else 0L
 
@@ -271,6 +286,10 @@ class R2dbcExecutor(val connectionFactory: 
ConnectionFactory, log: Logger, logDb
     getConnection(logPrefix).flatMap { connection =>
       val startTime = nanoTime()
       connection.beginTransaction().asFutureDone().flatMap { _ =>
+        val timeoutTask = closeCallsExceeding.map { timeout =>
+          system.scheduler.scheduleOnce(timeout, () => 
closeAfterTimeout(connection))
+        }
+
         val result =
           try {
             fun(connection)
@@ -284,16 +303,20 @@ class R2dbcExecutor(val connectionFactory: 
ConnectionFactory, log: Logger, logDb
           if (log.isDebugEnabled())
             log.debug("{} - DB call failed: {}", logPrefix: Any, exc.toString: 
Any)
           // ok to rollback async like this, or should it be before completing 
the returned Future?
-          rollbackAndClose(connection)
+          val done = rollbackAndClose(connection)
+          timeoutTask.foreach { task => done.onComplete(_ => task.cancel()) }
         }
 
         result.flatMap { r =>
-          commitAndClose(connection).map { _ =>
-            val durationMicros = durationInMicros(startTime)
-            if (durationMicros >= logDbCallsExceedingMicros)
-              log.info("{} - DB call completed in [{}] µs", logPrefix, 
durationMicros)
-            r
-          }
+          val done = commitAndClose(connection)
+            .map { _ =>
+              val durationMicros = durationInMicros(startTime)
+              if (durationMicros >= logDbCallsExceedingMicros)
+                log.info("{} - DB call completed in [{}] µs", logPrefix, 
durationMicros)
+              r
+            }
+          timeoutTask.foreach { task => done.onComplete(_ => task.cancel()) }
+          done
         }
 
       }
@@ -306,6 +329,10 @@ class R2dbcExecutor(val connectionFactory: 
ConnectionFactory, log: Logger, logDb
   def withAutoCommitConnection[A](logPrefix: String)(fun: Connection => 
Future[A]): Future[A] = {
     getConnection(logPrefix).flatMap { connection =>
       val startTime = nanoTime()
+      val timeoutTask = closeCallsExceeding.map { timeout =>
+        system.scheduler.scheduleOnce(timeout, () => 
closeAfterTimeout(connection))
+      }
+
       connection.setAutoCommit(true).asFutureDone().flatMap { _ =>
         val result =
           try {
@@ -319,27 +346,64 @@ class R2dbcExecutor(val connectionFactory: 
ConnectionFactory, log: Logger, logDb
         result.failed.foreach { exc =>
           log.debug("{} - DB call failed: {}", logPrefix: Any, exc: Any)
           // auto-commit so nothing to rollback
-          connection.close().asFutureDone()
+          val done = connection.close().asFutureDone()
+          timeoutTask.foreach { task => done.onComplete(_ => task.cancel()) }
         }
 
         result.flatMap { r =>
-          connection.close().asFutureDone().map { _ =>
-            val durationMicros = durationInMicros(startTime)
-            if (durationMicros >= logDbCallsExceedingMicros)
-              log.info("{} - DB call completed [{}] in [{}] µs", logPrefix, 
Option(r).map(_.toString).orNull,
-                durationMicros: java.lang.Long)
-            r
-          }
+          val done = connection
+            .close()
+            .asFutureDone()
+            .map { _ =>
+              val durationMicros = durationInMicros(startTime)
+              if (durationMicros >= logDbCallsExceedingMicros)
+                log.info("{} - DB call completed [{}] in [{}] µs", logPrefix, 
Option(r).map(_.toString).orNull,
+                  durationMicros: java.lang.Long)
+              r
+            }
+          timeoutTask.foreach { task => done.onComplete(_ => task.cancel()) }
+          done
         }
       }
     }
   }
 
   private def commitAndClose(connection: Connection): Future[Done] = {
-    connection.commitTransaction().asFutureDone().andThen { case _ => 
connection.close().asFutureDone() }
+    connection.commitTransaction().asFutureDone().andThen { case _ =>
+      try connection.close().asFutureDone()
+      catch ignoreConnectionClosedException
+    }
   }
 
   private def rollbackAndClose(connection: Connection): Future[Done] = {
-    connection.rollbackTransaction().asFutureDone().andThen { case _ => 
connection.close().asFutureDone() }
+    try connection
+        .rollbackTransaction()
+        .asFutureDone()
+        .andThen { case _ =>
+          try connection.close().asFutureDone()
+          catch ignoreConnectionClosedException
+        }
+    catch ignoreConnectionClosedException
+  }
+
+  private def closeAfterTimeout(connection: Connection): Unit = {
+    log.warn("Timeout after [{}], will try to close connection", 
closeCallsExceeding.map(_.toCoarsest).getOrElse(""))
+
+    val done = connection match {
+      case wrapped: Wrapped[Connection] @unchecked => 
wrapped.unwrap().close.asFutureDone()
+      case _                                       => 
connection.close.asFutureDone()
+    }
+
+    done.onComplete {
+      case Success(_) =>
+        log.debug("Connection closed after timeout")
+      case Failure(exc) =>
+        log.debug("Tried to close connection after timeout, but {}", 
exc.toString)
+    }
+  }
+
+  private def ignoreConnectionClosedException: Catcher[Future[Done]] = { case 
_: IllegalStateException =>
+    // throw by PooledConnection assertNotClosed, if connection closed after 
timeout
+    Future.successful(Done)
   }
 }
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/journal/JournalDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/journal/JournalDao.scala
index afc5867..a66fb52 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/journal/JournalDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/journal/JournalDao.scala
@@ -16,12 +16,14 @@ package org.apache.pekko.persistence.r2dbc.journal
 import java.time.Instant
 
 import scala.concurrent.{ ExecutionContext, Future }
+import scala.concurrent.duration.FiniteDuration
 
 import org.apache.pekko
 import pekko.actor.typed.ActorSystem
 import pekko.annotation.InternalApi
 import pekko.persistence.Persistence
 import pekko.persistence.r2dbc.ConnectionFactoryProvider
+import pekko.persistence.r2dbc.ConnectionFactorySettings
 import pekko.persistence.r2dbc.Dialect
 import pekko.persistence.r2dbc.JournalSettings
 import pekko.persistence.r2dbc.internal.BySliceQuery
@@ -84,11 +86,15 @@ private[r2dbc] object JournalDao {
   )(implicit system: ActorSystem[?], ec: ExecutionContext): JournalDao = {
     val connectionFactory =
       
ConnectionFactoryProvider(system).connectionFactoryFor(settings.useConnectionFactory,
 config)
+    val closeCallsExceeding = {
+      val fullConfig = config.withFallback(system.settings.config)
+      
ConnectionFactorySettings.closeCallsExceeding(fullConfig.getConfig(settings.useConnectionFactory))
+    }
     settings.dialect match {
       case Dialect.Postgres | Dialect.Yugabyte =>
-        new JournalDao(settings, connectionFactory)
+        new JournalDao(settings, connectionFactory, closeCallsExceeding)
       case Dialect.MySQL =>
-        new MySQLJournalDao(settings, connectionFactory)
+        new MySQLJournalDao(settings, connectionFactory, closeCallsExceeding)
     }
   }
 }
@@ -99,7 +105,8 @@ private[r2dbc] object JournalDao {
  * Class for doing db interaction outside of an actor to avoid mistakes in 
future callbacks
  */
 @InternalApi
-private[r2dbc] class JournalDao(val settings: JournalSettings, 
connectionFactory: ConnectionFactory)(
+private[r2dbc] class JournalDao(val settings: JournalSettings, 
connectionFactory: ConnectionFactory,
+    closeCallsExceeding: Option[FiniteDuration] = None)(
     implicit val ec: ExecutionContext, system: ActorSystem[?]) extends 
EventsByPersistenceIdDao
     with HighestSequenceNrDao {
   import JournalDao.SerializedJournalRow
@@ -111,7 +118,8 @@ private[r2dbc] class JournalDao(val settings: 
JournalSettings, connectionFactory
 
   private val persistenceExt = Persistence(system)
 
-  protected val r2dbcExecutor = new R2dbcExecutor(connectionFactory, log, 
settings.logDbCallsExceeding)(ec, system)
+  protected val r2dbcExecutor =
+    new R2dbcExecutor(connectionFactory, log, settings.logDbCallsExceeding, 
closeCallsExceeding)(ec, system)
 
   protected val journalTable: String = settings.journalTableWithSchema
   protected implicit val journalPayloadCodec: PayloadCodec = 
settings.journalPayloadCodec
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/journal/mysql/MySQLJournalDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/journal/mysql/MySQLJournalDao.scala
index 69b1476..be5226c 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/journal/mysql/MySQLJournalDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/journal/mysql/MySQLJournalDao.scala
@@ -20,6 +20,7 @@
 package org.apache.pekko.persistence.r2dbc.journal.mysql
 
 import scala.concurrent.ExecutionContext
+import scala.concurrent.duration.FiniteDuration
 import org.apache.pekko
 import pekko.actor.typed.ActorSystem
 import pekko.annotation.InternalApi
@@ -118,9 +119,10 @@ private[r2dbc] object MySQLJournalDao {
 @InternalApi
 private[r2dbc] class MySQLJournalDao(
     journalSettings: JournalSettings,
-    connectionFactory: ConnectionFactory)(
+    connectionFactory: ConnectionFactory,
+    closeCallsExceeding: Option[FiniteDuration] = None)(
     implicit ec: ExecutionContext, system: ActorSystem[?]
-) extends JournalDao(journalSettings, connectionFactory) {
+) extends JournalDao(journalSettings, connectionFactory, closeCallsExceeding) {
   MySQLJournalDao.settingRequirements(journalSettings)
 
   override lazy val timestampSql: String = "NOW(6)"
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/query/scaladsl/QueryDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/query/scaladsl/QueryDao.scala
index 69c8aa6..9052db1 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/query/scaladsl/QueryDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/query/scaladsl/QueryDao.scala
@@ -24,6 +24,7 @@ import pekko.NotUsed
 import pekko.actor.typed.ActorSystem
 import pekko.annotation.InternalApi
 import pekko.persistence.r2dbc.ConnectionFactoryProvider
+import pekko.persistence.r2dbc.ConnectionFactorySettings
 import pekko.persistence.r2dbc.Dialect
 import pekko.persistence.r2dbc.QuerySettings
 import pekko.persistence.r2dbc.internal.BySliceQuery
@@ -61,11 +62,15 @@ object QueryDao {
   )(implicit system: ActorSystem[?], ec: ExecutionContext): QueryDao = {
     val connectionFactory =
       
ConnectionFactoryProvider(system).connectionFactoryFor(settings.useConnectionFactory,
 config)
+    val closeCallsExceeding = {
+      val fullConfig = config.withFallback(system.settings.config)
+      
ConnectionFactorySettings.closeCallsExceeding(fullConfig.getConfig(settings.useConnectionFactory))
+    }
     settings.dialect match {
       case Dialect.Postgres | Dialect.Yugabyte =>
-        new QueryDao(settings, connectionFactory)
+        new QueryDao(settings, connectionFactory, closeCallsExceeding)
       case Dialect.MySQL =>
-        new MySQLQueryDao(settings, connectionFactory)
+        new MySQLQueryDao(settings, connectionFactory, closeCallsExceeding)
     }
   }
 }
@@ -74,7 +79,8 @@ object QueryDao {
  * INTERNAL API
  */
 @InternalApi
-private[r2dbc] class QueryDao(val settings: QuerySettings, connectionFactory: 
ConnectionFactory)(
+private[r2dbc] class QueryDao(val settings: QuerySettings, connectionFactory: 
ConnectionFactory,
+    closeCallsExceeding: Option[FiniteDuration] = None)(
     implicit val ec: ExecutionContext, system: ActorSystem[?]) extends 
BySliceQuery.Dao[SerializedJournalRow]
     with EventsByPersistenceIdDao with HighestSequenceNrDao {
   import JournalDao.readMetadata
@@ -163,7 +169,7 @@ private[r2dbc] class QueryDao(val settings: QuerySettings, 
connectionFactory: Co
     sql"SELECT DISTINCT(persistence_id) from $journalTable WHERE 
persistence_id LIKE ? AND persistence_id > ? ORDER BY persistence_id LIMIT ?"
 
   protected val r2dbcExecutor =
-    new R2dbcExecutor(connectionFactory, log, 
settings.logDbCallsExceeding)(ec, system)
+    new R2dbcExecutor(connectionFactory, log, settings.logDbCallsExceeding, 
closeCallsExceeding)(ec, system)
 
   def currentDbTimestamp(): Future[Instant] = {
     r2dbcExecutor
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/query/scaladsl/mysql/MySQLQueryDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/query/scaladsl/mysql/MySQLQueryDao.scala
index 5a1bc24..50c25a6 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/query/scaladsl/mysql/MySQLQueryDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/query/scaladsl/mysql/MySQLQueryDao.scala
@@ -41,8 +41,10 @@ import io.r2dbc.spi.Row
 @InternalApi
 private[r2dbc] class MySQLQueryDao(
     querySettings: QuerySettings,
-    connectionFactory: ConnectionFactory
-)(implicit ec: ExecutionContext, system: ActorSystem[?]) extends 
QueryDao(querySettings, connectionFactory) {
+    connectionFactory: ConnectionFactory,
+    closeCallsExceeding: Option[FiniteDuration] = None
+)(implicit ec: ExecutionContext, system: ActorSystem[?])
+    extends QueryDao(querySettings, connectionFactory, closeCallsExceeding) {
 
   override lazy val statementTimestampSql: String = "NOW(6)"
 
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/session/scaladsl/R2dbcSession.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/session/scaladsl/R2dbcSession.scala
index 9c6d5fa..0974f61 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/session/scaladsl/R2dbcSession.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/session/scaladsl/R2dbcSession.scala
@@ -22,6 +22,7 @@ import org.apache.pekko
 import pekko.actor.typed.ActorSystem
 import pekko.annotation.ApiMayChange
 import pekko.persistence.r2dbc.ConnectionFactoryProvider
+import pekko.persistence.r2dbc.ConnectionFactorySettings
 import pekko.persistence.r2dbc.internal.R2dbcExecutor
 import io.r2dbc.spi.Connection
 import io.r2dbc.spi.Row
@@ -45,7 +46,12 @@ object R2dbcSession {
   def withSession[A](system: ActorSystem[?], connectionFactoryConfigPath: 
String)(
       fun: R2dbcSession => Future[A]): Future[A] = {
     val connectionFactory = 
ConnectionFactoryProvider(system).connectionFactoryFor(connectionFactoryConfigPath)
-    val r2dbcExecutor = new R2dbcExecutor(connectionFactory, log, 
logDbCallsDisabled)(system.executionContext, system)
+    val closeCallsExceeding =
+      
ConnectionFactorySettings.closeCallsExceeding(system.settings.config.getConfig(connectionFactoryConfigPath))
+    val r2dbcExecutor =
+      new R2dbcExecutor(connectionFactory, log, logDbCallsDisabled, 
closeCallsExceeding)(
+        system.executionContext,
+        system)
     r2dbcExecutor.withConnection("R2dbcSession") { connection =>
       val session = new R2dbcSession(connection)(system.executionContext, 
system)
       fun(session)
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/snapshot/SnapshotDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/snapshot/SnapshotDao.scala
index e78b321..688e4eb 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/snapshot/SnapshotDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/snapshot/SnapshotDao.scala
@@ -14,6 +14,7 @@
 package org.apache.pekko.persistence.r2dbc.snapshot
 
 import scala.concurrent.{ ExecutionContext, Future }
+import scala.concurrent.duration.FiniteDuration
 
 import org.apache.pekko
 import pekko.actor.typed.ActorSystem
@@ -21,6 +22,7 @@ import pekko.annotation.InternalApi
 import pekko.persistence.Persistence
 import pekko.persistence.SnapshotSelectionCriteria
 import pekko.persistence.r2dbc.ConnectionFactoryProvider
+import pekko.persistence.r2dbc.ConnectionFactorySettings
 import pekko.persistence.r2dbc.Dialect
 import pekko.persistence.r2dbc.SnapshotSettings
 import pekko.persistence.r2dbc.internal.PayloadCodec
@@ -59,11 +61,15 @@ private[r2dbc] object SnapshotDao {
   )(implicit system: ActorSystem[?], ec: ExecutionContext): SnapshotDao = {
     val connectionFactory =
       
ConnectionFactoryProvider(system).connectionFactoryFor(settings.useConnectionFactory,
 config)
+    val closeCallsExceeding = {
+      val fullConfig = config.withFallback(system.settings.config)
+      
ConnectionFactorySettings.closeCallsExceeding(fullConfig.getConfig(settings.useConnectionFactory))
+    }
     settings.dialect match {
       case Dialect.Postgres | Dialect.Yugabyte =>
-        new SnapshotDao(settings, connectionFactory)
+        new SnapshotDao(settings, connectionFactory, closeCallsExceeding)
       case Dialect.MySQL =>
-        new MySQLSnapshotDao(settings, connectionFactory)
+        new MySQLSnapshotDao(settings, connectionFactory, closeCallsExceeding)
     }
   }
 }
@@ -74,7 +80,8 @@ private[r2dbc] object SnapshotDao {
  * Class for doing db interaction outside of an actor to avoid mistakes in 
future callbacks
  */
 @InternalApi
-private[r2dbc] class SnapshotDao(settings: SnapshotSettings, 
connectionFactory: ConnectionFactory)(
+private[r2dbc] class SnapshotDao(settings: SnapshotSettings, 
connectionFactory: ConnectionFactory,
+    closeCallsExceeding: Option[FiniteDuration] = None)(
     implicit
     ec: ExecutionContext,
     system: ActorSystem[?]) {
@@ -84,7 +91,8 @@ private[r2dbc] class SnapshotDao(settings: SnapshotSettings, 
connectionFactory:
 
   protected val snapshotTable: String = settings.snapshotsTableWithSchema
   private val persistenceExt = Persistence(system)
-  private val r2dbcExecutor = new R2dbcExecutor(connectionFactory, log, 
settings.logDbCallsExceeding)(ec, system)
+  private val r2dbcExecutor =
+    new R2dbcExecutor(connectionFactory, log, settings.logDbCallsExceeding, 
closeCallsExceeding)(ec, system)
   protected implicit val snapshotPayloadCodec: PayloadCodec = 
settings.snapshotPayloadCodec
 
   private def collectSerializedSnapshot(row: Row): SerializedSnapshotRow =
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/snapshot/mysql/MySQLSnapshotDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/snapshot/mysql/MySQLSnapshotDao.scala
index ce92df6..75c9743 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/snapshot/mysql/MySQLSnapshotDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/snapshot/mysql/MySQLSnapshotDao.scala
@@ -20,6 +20,7 @@
 package org.apache.pekko.persistence.r2dbc.snapshot.mysql
 
 import scala.concurrent.ExecutionContext
+import scala.concurrent.duration.FiniteDuration
 import io.r2dbc.spi.ConnectionFactory
 import org.apache.pekko
 import pekko.actor.typed.ActorSystem
@@ -33,8 +34,10 @@ import pekko.persistence.r2dbc.snapshot.SnapshotDao
  */
 @InternalApi
 private[r2dbc] class MySQLSnapshotDao(
-    settings: SnapshotSettings, connectionFactory: ConnectionFactory
-)(implicit ec: ExecutionContext, system: ActorSystem[?]) extends 
SnapshotDao(settings, connectionFactory) {
+    settings: SnapshotSettings, connectionFactory: ConnectionFactory,
+    closeCallsExceeding: Option[FiniteDuration] = None
+)(implicit ec: ExecutionContext, system: ActorSystem[?])
+    extends SnapshotDao(settings, connectionFactory, closeCallsExceeding) {
 
   override val upsertSql = sql"""
     INSERT INTO $snapshotTable
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/state/scaladsl/DurableStateDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/state/scaladsl/DurableStateDao.scala
index 93d63e2..5dee155 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/state/scaladsl/DurableStateDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/state/scaladsl/DurableStateDao.scala
@@ -33,6 +33,7 @@ import pekko.persistence.query.DurableStateChange
 import pekko.persistence.query.NoOffset
 import pekko.persistence.query.UpdatedDurableState
 import pekko.persistence.r2dbc.ConnectionFactoryProvider
+import pekko.persistence.r2dbc.ConnectionFactorySettings
 import pekko.persistence.r2dbc.Dialect
 import pekko.persistence.r2dbc.StateSettings
 import pekko.persistence.r2dbc.internal.AdditionalColumnFactory
@@ -94,11 +95,15 @@ import org.slf4j.LoggerFactory
   )(implicit system: ActorSystem[?], ec: ExecutionContext): DurableStateDao = {
     val connectionFactory =
       
ConnectionFactoryProvider(system).connectionFactoryFor(settings.useConnectionFactory,
 config)
+    val closeCallsExceeding = {
+      val fullConfig = config.withFallback(system.settings.config)
+      
ConnectionFactorySettings.closeCallsExceeding(fullConfig.getConfig(settings.useConnectionFactory))
+    }
     settings.dialect match {
       case Dialect.Postgres | Dialect.Yugabyte =>
-        new DurableStateDao(settings, connectionFactory)
+        new DurableStateDao(settings, connectionFactory, closeCallsExceeding)
       case Dialect.MySQL =>
-        new MySQLDurableStateDao(settings, connectionFactory)
+        new MySQLDurableStateDao(settings, connectionFactory, 
closeCallsExceeding)
     }
   }
 }
@@ -109,7 +114,8 @@ import org.slf4j.LoggerFactory
  * Class for encapsulating db interaction.
  */
 @InternalApi
-private[r2dbc] class DurableStateDao(settings: StateSettings, 
connectionFactory: ConnectionFactory)(
+private[r2dbc] class DurableStateDao(settings: StateSettings, 
connectionFactory: ConnectionFactory,
+    closeCallsExceeding: Option[FiniteDuration] = None)(
     implicit
     ec: ExecutionContext,
     system: ActorSystem[?])
@@ -120,7 +126,8 @@ private[r2dbc] class DurableStateDao(settings: 
StateSettings, connectionFactory:
   protected lazy val transactionTimestampSql: String = 
"transaction_timestamp()"
 
   private val persistenceExt = Persistence(system)
-  private val r2dbcExecutor = new R2dbcExecutor(connectionFactory, log, 
settings.logDbCallsExceeding)(ec, system)
+  private val r2dbcExecutor =
+    new R2dbcExecutor(connectionFactory, log, settings.logDbCallsExceeding, 
closeCallsExceeding)(ec, system)
 
   protected val stateTable = settings.durableStateTableWithSchema
   protected implicit val statePayloadCodec: PayloadCodec = 
settings.durableStatePayloadCodec
diff --git 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/state/scaladsl/mysql/MySQLDurableStateDao.scala
 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/state/scaladsl/mysql/MySQLDurableStateDao.scala
index 0656dd1..d481879 100644
--- 
a/core/src/main/scala/org/apache/pekko/persistence/r2dbc/state/scaladsl/mysql/MySQLDurableStateDao.scala
+++ 
b/core/src/main/scala/org/apache/pekko/persistence/r2dbc/state/scaladsl/mysql/MySQLDurableStateDao.scala
@@ -41,8 +41,10 @@ import io.r2dbc.spi.Statement
 @InternalApi
 private[r2dbc] class MySQLDurableStateDao(
     settings: StateSettings,
-    connectionFactory: ConnectionFactory
-)(implicit ec: ExecutionContext, system: ActorSystem[?]) extends 
DurableStateDao(settings, connectionFactory) {
+    connectionFactory: ConnectionFactory,
+    closeCallsExceeding: Option[FiniteDuration] = None
+)(implicit ec: ExecutionContext, system: ActorSystem[?])
+    extends DurableStateDao(settings, connectionFactory, closeCallsExceeding) {
   MySQLJournalDao.settingRequirements(settings)
 
   override lazy val transactionTimestampSql: String = "NOW(6)"
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/r2dbc/TestDbLifecycle.scala 
b/core/src/test/scala/org/apache/pekko/persistence/r2dbc/TestDbLifecycle.scala
index bbbf155..b6a6e30 100644
--- 
a/core/src/test/scala/org/apache/pekko/persistence/r2dbc/TestDbLifecycle.scala
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/r2dbc/TestDbLifecycle.scala
@@ -18,6 +18,7 @@ import scala.concurrent.duration._
 import org.apache.pekko
 import pekko.actor.typed.ActorSystem
 import pekko.persistence.Persistence
+import pekko.persistence.r2dbc.ConnectionFactorySettings
 import pekko.persistence.r2dbc.internal.R2dbcExecutor
 import com.typesafe.config.Config
 import io.r2dbc.spi.ConnectionFactory
@@ -48,11 +49,15 @@ trait TestDbLifecycle extends BeforeAndAfterAll { this: 
Suite =>
         
config.getConfig("pekko.persistence.r2dbc.connection-factory").atPath("test.connection-factory"))
 
   // this assumes that journal, snapshot store and state use same connection 
settings
-  lazy val r2dbcExecutor: R2dbcExecutor =
+  lazy val r2dbcExecutor: R2dbcExecutor = {
+    val closeCallsExceeding =
+      
ConnectionFactorySettings.closeCallsExceeding(config.getConfig("pekko.persistence.r2dbc.connection-factory"))
     new R2dbcExecutor(
       connectionFactoryProvider,
       LoggerFactory.getLogger(getClass),
-      journalSettings.logDbCallsExceeding)(typedSystem.executionContext, 
typedSystem)
+      journalSettings.logDbCallsExceeding,
+      closeCallsExceeding)(typedSystem.executionContext, typedSystem)
+  }
 
   lazy val persistenceExt: Persistence = Persistence(typedSystem)
 
diff --git 
a/core/src/test/scala/org/apache/pekko/persistence/r2dbc/internal/R2dbcExecutorSpec.scala
 
b/core/src/test/scala/org/apache/pekko/persistence/r2dbc/internal/R2dbcExecutorSpec.scala
new file mode 100644
index 0000000..6ca55a8
--- /dev/null
+++ 
b/core/src/test/scala/org/apache/pekko/persistence/r2dbc/internal/R2dbcExecutorSpec.scala
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ *   https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2022 - 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.r2dbc.internal
+
+import scala.concurrent.Await
+import scala.concurrent.duration._
+
+import org.apache.pekko
+import pekko.actor.testkit.typed.scaladsl.LogCapturing
+import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
+import pekko.actor.typed.ActorSystem
+import pekko.persistence.r2dbc.ConnectionFactorySettings
+import pekko.persistence.r2dbc.TestConfig
+import pekko.persistence.r2dbc.TestData
+import pekko.persistence.r2dbc.TestDbLifecycle
+import com.typesafe.config.Config
+import com.typesafe.config.ConfigFactory
+import io.r2dbc.spi.Connection
+import io.r2dbc.spi.R2dbcNonTransientResourceException
+import io.r2dbc.spi.Wrapped
+import org.scalatest.{ Outcome, Pending }
+import org.scalatest.wordspec.AnyWordSpecLike
+
+object R2dbcExecutorSpec {
+  val config: Config = ConfigFactory
+    .parseString("""
+    pekko.persistence.r2dbc.connection-factory {
+      initial-size = 1
+      max-size = 1
+      close-calls-exceeding = 3 seconds
+    }
+    """)
+    .withFallback(TestConfig.config)
+
+  val dialect = config.getString("pekko.persistence.r2dbc.dialect")
+}
+
+class R2dbcExecutorSpec
+    extends ScalaTestWithActorTestKit(R2dbcExecutorSpec.config)
+    with AnyWordSpecLike
+    with TestDbLifecycle
+    with TestData
+    with LogCapturing {
+
+  override def withFixture(test: NoArgTest): Outcome =
+    if (R2dbcExecutorSpec.dialect == "mysql") {
+      Pending // uses pg_sleep which is Postgres-specific
+    } else {
+      super.withFixture(test)
+    }
+
+  override def typedSystem: ActorSystem[?] = system
+  private val table = "r2dbc_executor_spec"
+
+  private val closeCallsExceeding =
+    ConnectionFactorySettings.closeCallsExceeding(
+      
system.settings.config.getConfig("pekko.persistence.r2dbc.connection-factory"))
+
+  case class Row(col: String)
+
+  override protected def beforeAll(): Unit = {
+    super.beforeAll()
+
+    Await.result(
+      r2dbcExecutor.executeDdl(s"beforeAll create table $table")(
+        _.createStatement(s"create table if not exists $table (col text)")),
+      20.seconds)
+
+    r2dbcExecutor.updateOne("test")(_.createStatement(s"delete from 
$table")).futureValue
+  }
+
+  "R2dbcExecutor" should {
+
+    "close connection when no response from update" in {
+      @volatile var c1: Connection = null
+      @volatile var c2: Connection = null
+
+      val result = r2dbcExecutor.update("test") { connection =>
+        c1 = connection.asInstanceOf[Wrapped[Connection]].unwrap()
+        Vector(
+          connection.createStatement(s"insert into $table (col) values ('a')"),
+          connection.createStatement("select pg_sleep(4)"),
+          connection.createStatement(s"insert into $table (col) values ('b')"))
+      }
+
+      Thread.sleep(closeCallsExceeding.get.toMillis)
+
+      // The request will fail with PostgresConnectionClosedException
+      result.failed.futureValue shouldBe a[R2dbcNonTransientResourceException]
+
+      r2dbcExecutor
+        .selectOne("test")(
+          { connection =>
+            c2 = connection.asInstanceOf[Wrapped[Connection]].unwrap()
+            connection.createStatement(s"select col from $table where col = 
'a'")
+          },
+          row => Row(row.get("col", classOf[String])))
+        .futureValue shouldBe None
+
+      r2dbcExecutor
+        .selectOne("test")(
+          _.createStatement(s"select col from $table where col = 'b'"),
+          row => Row(row.get("col", classOf[String])))
+        .futureValue shouldBe None
+
+      // it shouldn't reuse the connection
+      c1 should not be theSameInstanceAs(c2)
+    }
+
+    "close connection when no response from update with auto-commit" in {
+      val result = r2dbcExecutor.updateOne("test")(_.createStatement("select 
pg_sleep(4)"))
+
+      Thread.sleep(closeCallsExceeding.get.toMillis)
+
+      // The request will fail with PostgresConnectionClosedException
+      result.failed.futureValue shouldBe a[R2dbcNonTransientResourceException]
+    }
+
+  }
+}
diff --git 
a/migration/src/main/scala/org/apache/pekko/persistence/r2dbc/migration/MigrationTool.scala
 
b/migration/src/main/scala/org/apache/pekko/persistence/r2dbc/migration/MigrationTool.scala
index 2811d97..c5fd3bc 100644
--- 
a/migration/src/main/scala/org/apache/pekko/persistence/r2dbc/migration/MigrationTool.scala
+++ 
b/migration/src/main/scala/org/apache/pekko/persistence/r2dbc/migration/MigrationTool.scala
@@ -36,6 +36,7 @@ import 
pekko.persistence.query.scaladsl.CurrentPersistenceIdsQuery
 import pekko.persistence.query.scaladsl.ReadJournal
 import pekko.persistence.query.{ EventEnvelope => ClassicEventEnvelope }
 import pekko.persistence.r2dbc.ConnectionFactoryProvider
+import pekko.persistence.r2dbc.ConnectionFactorySettings
 import pekko.persistence.r2dbc.JournalSettings
 import pekko.persistence.r2dbc.SnapshotSettings
 import pekko.persistence.r2dbc.journal.JournalDao
@@ -141,7 +142,11 @@ class MigrationTool(system: ActorSystem[?]) {
   private lazy val sourceSnapshotStore = 
Persistence(system).snapshotStoreFor(sourceSnapshotPluginId)
 
   private[r2dbc] val migrationDao =
-    new MigrationToolDao(targetJournalConnectionFactory, 
targetJournalSettings.logDbCallsExceeding)
+    new MigrationToolDao(
+      targetJournalConnectionFactory,
+      targetJournalSettings.logDbCallsExceeding,
+      ConnectionFactorySettings.closeCallsExceeding(
+        
system.settings.config.getConfig(targetJournalSettings.useConnectionFactory)))
 
   private lazy val createProgressTable: Future[Done] =
     migrationDao.createProgressTable()
diff --git 
a/migration/src/main/scala/org/apache/pekko/persistence/r2dbc/migration/MigrationToolDao.scala
 
b/migration/src/main/scala/org/apache/pekko/persistence/r2dbc/migration/MigrationToolDao.scala
index 7882ee0..7ef0415 100644
--- 
a/migration/src/main/scala/org/apache/pekko/persistence/r2dbc/migration/MigrationToolDao.scala
+++ 
b/migration/src/main/scala/org/apache/pekko/persistence/r2dbc/migration/MigrationToolDao.scala
@@ -37,10 +37,12 @@ import io.r2dbc.spi.ConnectionFactory
  */
 @InternalApi private[r2dbc] class MigrationToolDao(
     connectionFactory: ConnectionFactory,
-    logDbCallsExceeding: FiniteDuration)(implicit ec: ExecutionContext, 
system: ActorSystem[?]) {
+    logDbCallsExceeding: FiniteDuration,
+    closeCallsExceeding: Option[FiniteDuration])(implicit ec: 
ExecutionContext, system: ActorSystem[?]) {
   import MigrationToolDao.CurrentProgress
 
-  private val r2dbcExecutor = new R2dbcExecutor(connectionFactory, log, 
logDbCallsExceeding)(ec, system)
+  private val r2dbcExecutor =
+    new R2dbcExecutor(connectionFactory, log, logDbCallsExceeding, 
closeCallsExceeding)(ec, system)
 
   def createProgressTable(): Future[Done] = {
     r2dbcExecutor.executeDdl("create migration progress table") { connection =>


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


Reply via email to