Statement Timeouts

Long-running queries can hold a database connection for minutes and starve a pool. Saferis lets you cap the time the database spends on a statement using JDBC's Statement.setQueryTimeout, which, unlike ZIO.timeout, asks the driver to actually cancel the query server-side.

There are two composable layers for setting a timeout:

  1. Saferis.queryTimeout(d) aspect: scopes a timeout to any Saferis fragments executed inside the decorated effect. Composes with other ZIO aspects.

  2. Transactor defaultTimeout: applies to every statement run through that Transactor.

When both apply, the aspect wins (per-scope ad-hoc overrides beat the Transactor-wide default).

The `Saferis.queryTimeout` aspect

{
  def slowReport(xa: Transactor) =
    xa.run(sql"SELECT * FROM ${Table[User]}".query[User]) @@ Saferis.queryTimeout(5.seconds)
  slowReport
}
saferis.docs.StatementTimeouts$$$Lambda/0x0000000092343828@2b5ec5d3

Because queryTimeout is a regular ZIOAspect, it composes with the rest of ZIO's aspect ecosystem (@@ ZIOAspect.loggedWith(...), etc.) and stacks naturally with >>=/flatMap.

Transactor-wide default

{
  // Every statement run through this Transactor is bounded by 30 seconds, unless overridden by the aspect.
  val xaLayer = Transactor.layer(defaultTimeout = Some(30.seconds))
  xaLayer
}
Suspend(zio.ZLayer$ZLayerProvideSomeOps$$$Lambda/0x00000000920c87a0@47567634)

Resolution order

When a statement is about to execute, Saferis picks the timeout in this priority order:

  1. The value installed by Saferis.queryTimeout(d) for the current fiber, if any.

  2. The Transactor's defaultTimeout, if set.

  3. No timeout (current default behavior).

Granularity

JDBC's setQueryTimeout accepts whole seconds. Saferis accepts a zio.Duration and rounds up to the nearest second, with a minimum of 1 second. This is intentional: setQueryTimeout(0) means no limit in JDBC, so a sub-second duration must not silently disable the cap.

Handling timeout errors

A timed-out statement surfaces as SaferisError.Timeout. This applies to both client-side timeouts triggered by setQueryTimeout and server-side cancellations (SQLState 57014):

{
  def safeReport(xa: Transactor) =
    xa.run(sql"SELECT * FROM ${Table[User]}".query[User])
      .catchSome:
        case _: SaferisError.Timeout =>
          ZIO.logWarning("Report query timed out, returning empty result").as(Chunk.empty)
    @@ Saferis.queryTimeout(5.seconds)
  safeReport
}
saferis.docs.StatementTimeouts$$$Lambda/0x000000009234af20@41e87627

A timeout firing for real

Here is a timeout actually firing against the live database: a SELECT pg_sleep(3) bounded by a 1-second timeout. The effect fails, and the real SaferisError.Timeout is shown beneath the snippet:

(xa.run(sql"SELECT pg_sleep(3)".queryValue[Int]) @@ Saferis.queryTimeout(
  1.second
)).unit: ZIO[Scope, SaferisError, Unit]
saferis.SaferisError$Timeout: Timeout(org.postgresql.util.PSQLException: ERROR: canceling statement due to user request,Some(SELECT pg_sleep(3)))