Skip to main content
Version: 1.8.0

Transactions

Quark gives you two ways to run a transaction. client.Tx takes a callback and handles commit and rollback for you — reach for that first. client.BeginTx hands you the transaction object when a larger workflow, or a framework you're integrating with, owns its lifetime.

Both give you the same extras: savepoints on all six engines, callbacks that fire only after a real commit or rollback, and opt-in deadlock retry.

Callback transactions

The callback commits when it returns nil and rolls back when it returns an error (or panics):

err := client.Tx(ctx, func(tx *quark.Tx) error {
user := User{Name: "Charlie", Email: "charlie@example.com"}
if err := quark.ForTx[User](ctx, tx).Create(&user); err != nil {
return err
}
order := Order{UserID: user.ID, Total: 42}
return quark.ForTx[Order](ctx, tx).Create(&order)
})
// err == nil → both rows committed. Return an error from the closure and
// neither row is written.

Inside the closure, build queries with quark.ForTx[T](ctx, tx) instead of quark.For[T](ctx, client) so they run on the transaction.

Savepoints

err := client.Tx(ctx, func(tx *quark.Tx) error {
if err := tx.Savepoint("before_optional_work"); err != nil {
return err
}
if err := runOptionalWork(ctx, tx); err != nil {
return tx.RollbackTo("before_optional_work") // undo just the optional work
}
return nil
})

Savepoints work on all six engines through a uniform Savepoint / RollbackTo / ReleaseSavepoint API. PostgreSQL, MySQL, MariaDB, and SQLite use ANSI SAVEPOINT statements; SQL Server (SAVE TRANSACTION) and Oracle (no RELEASE SAVEPOINT) use their own forms transparently.

Rolling back to a savepoint unwinds more than the SQL:

  • Queued side-effects go with it. Any model After* hooks and OnCommit/OnRollback callbacks registered after that savepoint are discarded, so rolled-back work never fires the side-effects that would have followed it.
  • It does not fire OnRollback. A savepoint rollback is a partial rollback, not a terminal state for the transaction. React to it through the error your nested code returns instead.
  • ReleaseSavepoint keeps the queued work, merging it into the surrounding transaction.

Side-effects on commit or rollback

Tx.OnCommit and Tx.OnRollback register callbacks that fire only once the transaction reaches its terminal state. That is the honest place for two kinds of work: anything that must wait for a durable commit (publish an event, invalidate a cache), and anything that should happen because the transaction aborted (release a reservation, emit a "cancelled" signal).

err := client.Tx(ctx, func(tx *quark.Tx) error {
order := &Order{SKU: "A-1", Qty: 3}
if err := quark.ForTx[Order](ctx, tx).Create(order); err != nil {
return err // rolls back; the OnCommit below never fires
}
tx.OnCommit(func(ctx context.Context) error {
return bus.Publish(ctx, OrderCreated{ID: order.ID})
})
tx.OnRollback(func(ctx context.Context) error {
metrics.Inc("orders.create.rolled_back")
return nil
})
return nil
})
On commitOn rollback
Model After* hooksfire (FIFO)discarded
OnCommit callbacksfire (FIFO, after the After* hooks)discarded
OnRollback callbacksdiscardedfire (FIFO)
  • Callbacks run in registration order.
  • A callback that returns an error is logged (quark.hook.on_commit_error or quark.hook.on_rollback_error). It does not stop the other callbacks and does not change what Client.Tx returns: once the database has committed, no application handler can undo it.
  • Each callback gets the transaction's context. If you set a tight deadline on it, derive a fresh context from context.Background() inside the callback when the work must outlive the transaction.
  • Registering another OnCommit from inside an OnCommit callback is a no-op for the current commit — the queue is already draining, so the new callback won't run this time. (The same holds for OnRollback.)

Registering from inside a hook

A lifecycle hook only receives a context.Context, not the *Tx. Reach the active transaction with quark.TxFromContext(ctx):

func (o *Order) AfterCreate(ctx context.Context) error {
if tx := quark.TxFromContext(ctx); tx != nil {
tx.OnCommit(func(ctx context.Context) error {
return bus.Publish(ctx, OrderCreated{ID: o.ID})
})
}
return nil
}

TxFromContext returns nil outside a transaction (plain For[T] CRUD), so nil-check it. This is the building block the EventBus integration wires automatically.

Manual transactions

tx, err := client.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()

if err := quark.ForTx[User](ctx, tx).Create(&user); err != nil {
return err
}
return tx.Commit()

You own commit and rollback here, so defer tx.Rollback() is the safety net that covers every early return. After a successful Commit it has nothing left to do — the transaction is already closed and its OnRollback callbacks were discarded at commit time. Use this form when the transaction's lifetime belongs to a larger workflow or a framework integration rather than to a single function.

Isolation levels

tx, err := client.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
ReadOnly: false,
})

Quark passes the level straight through to database/sql; which ones an engine honors:

LevelPostgreSQLMySQL / MariaDBSQL ServerOracleSQLite
LevelReadUncommitted
LevelReadCommitted
LevelRepeatableRead
LevelSerializable

Mark a transaction read-only to let the engine optimize non-mutating queries:

tx, _ := client.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
defer tx.Rollback()
users, _ := quark.ForTx[User](ctx, tx).Where("active", "=", true).List()
_ = tx.Commit()

Batch operations in a transaction

UpdateBatch already wraps its updates in one transaction. To make CreateBatch and DeleteBatch share a transaction, use ForTx:

err := client.Tx(ctx, func(tx *quark.Tx) error {
if err := quark.ForTx[Order](ctx, tx).CreateBatch(newOrders); err != nil {
return err
}
_, err := quark.ForTx[Order](ctx, tx).DeleteBatch(cancelledIDs)
return err
})

Retrying deadlocks

Callback transactions roll back on any non-nil error. For deadlocks specifically, turn on WithDeadlockRetry: it re-runs the closure, with exponential backoff and jitter, when the engine reports a deadlock (PostgreSQL 40P01, MySQL 1213, SQL Server 1205, Oracle ORA-00060). It is opt-in, context-aware, and off by default:

client, _ := quark.New("pgx", dsn, quark.WithDeadlockRetry(3))

err := client.Tx(ctx, func(tx *quark.Tx) error {
// ...operations that might deadlock with a concurrent transaction...
return nil
})

For non-deadlock transient failures, wrap the call in your own retry loop with whatever backoff and classifier your application uses.