Skip to content
Exposed docs1.11

Cancellation, failure, and testing

Latest stable Based on Exposed release 1.11.0

Cancellation is a control signal from the caller, timeout, or shutdown path. Treating it as an ordinary repository failure can trigger retries, error logs, or fallback writes after the caller has already abandoned the operation.

suspend fun loadActor(id: Long): ActorRecord? = try {
suspendTransaction(db = database) {
repository.findByIdOrNull(id)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.warn(e) { "actor lookup failed: id=$id" }
null
}

Prefer narrow catches around errors the application can actually handle. If a broad catch is required, rethrow CancellationException before mapping other failures.

Caller cancellation requests that the coroutine stop. It does not, by itself, prove when the driver cancels an in-flight statement, when the database observes that cancellation, whether a transaction rollback has completed, or when a pooled connection becomes reusable. Those behaviors span Exposed, the R2DBC driver, the pool, and the database server.

Therefore test observable outcomes instead of documenting a universal close sequence:

  • the caller receives cancellation rather than a fallback result;
  • an interrupted write does not leave an accepted partial business state;
  • the connection pool returns to its expected in-use count;
  • a subsequent transaction can acquire a connection and execute;
  • shutdown finishes within the application’s stated deadline.

Use H2 for mapper and basic repository feedback. Use the production database’s R2DBC driver with Testcontainers for SQL, isolation, generated IDs, conflict behavior, cancellation, and pool lifecycle.

class ActorRepositoryTest : AbstractExposedR2dbcTest() {
@Test
fun `save and read actor`() = runTest {
withTables(TestDB.POSTGRESQL, ActorTable) {
val repository = ActorRepository()
val ids = repository.saveAll(listOf(ActorRecord(name = "Ada")))
repository.findById(ids.single()).name shouldBeEqualTo "Ada"
}
}
}

The shared withDb helper serializes tests that use the same TestDB, opens suspendTransaction with maxAttempts = 1, and restores a temporary database configuration. withTables creates the requested tables and attempts cleanup in finally. These are test-support contracts, not promises about an application’s production shutdown order.

Use a deterministic barrier instead of timing a random delay. Start a transaction, wait until the statement reaches the intended boundary, cancel the caller, and then assert the observable database and pool state. Run this test sequentially because it shares a database and connection pool.

val job = launch {
suspendTransaction(db = database) {
repository.runBlockedOperation(started)
}
}
started.await()
job.cancelAndJoin()
assertTrue(job.isCancelled)

The exact blocked operation is driver-specific. Keep the assertion aligned with what that driver and database can reliably expose.

FailureExpected application response
Mapping or constraint errorPropagate or map once at the service boundary
Pool acquisition timeoutRecord pool pressure and reject/retry only under an explicit policy
Caller cancellationRethrow cancellation; do not count it as an ordinary repository error
Connection lossTreat transaction outcome as uncertain until the database contract proves otherwise
Cleanup failure in a testFail or quarantine the fixture; do not let contaminated state reach the next test

For the cross-runtime decision, continue to JDBC vs R2DBC.