Answer the Question
In ZIO 2.x, Aff.bracket guarantees that the release function runs whenever the resource acquisition has successfully finished, regardless of whether the use part succeeds, fails, or is cancelled. If the cancellation happens before the resource is acquired, the release is not executed because there is nothing to release.
Likely Explanation
- Cancellation via
Async.cancelUnless propagates to the bracket finalizer only after the acquisition has completed.
- Schema migrations that abort after obtaining a transaction lock trigger the release (e.g., a rollback), but migrations that abort before the lock is taken do not invoke the release.
- No new design proposal is needed; the existing
bracket / guarantee primitives already provide the needed cancellation safety.
Steps for Your Migration Routine
- Wrap the entire migration in a bracket:
val migration: Aff[Throwable, Unit] =
Aff.bracket(
acquire = acquireTransaction, // returns Aff[Throwable, Transaction]
use = tx => runSchemaChanges(tx),
release = tx => tx.rollback.orDie
)
- Guard the acquisition: Ensure
acquireTransaction is idempotent and completes quickly; if it can block for a long time, consider adding a timeout that falls back to a safe state.
- Cancel after acquisition: If you need to abort the migration, trigger cancellation only after the transaction has been obtained:
migration.timeout(5.seconds).orDie
.provideLayer(myDbLayer)
.fork
.flatMap { fiber =>
// ... decide to cancel
fiber.interrupt
}
- Verify deterministically: Use ZIO Test with
TestClock to control timing:
import zio.test._
import zio.test.Assertion._
val test = test("release runs on cancel after acquire") {
val flag = Ref.make(false).map(_.set(true))
val acquire = ZIO.succeed(Resource(flag))
val release = (res: Resource) => flag.getAndSet(false).flatMap(_ => ZIO.unit)
val use = ZIO.never
val aff = Aff.bracket(acquire, use, release)
val fiber = aff.fork
TestClock.adjust(1.second) // let acquire finish
fiber.interrupt
flag.get.map(f => assert(f)(isFalse))
}
Missing Diagnostic Detail
To tailor the recommendation precisely, could you confirm whether the cancellation signal is sent before or after the transaction lock is acquired in your current migration code?
Practical Verification Checklist
- Instrument the release to log or set a flag.
- Trigger cancellation at different points: immediately, after a short delay, and after the acquisition completes.
- Assert that the flag is set only when acquisition succeeded.
- Repeat with
TestClock to remove nondeterminism.
Takeaway
Use Aff.bracket for migrations and cancel only after the resource is obtained. The release will run automatically, guaranteeing a clean rollback. If cancellation can occur before acquisition, add explicit checks or a higher‑level transaction manager to avoid leaving the database in an inconsistent state.