Transactions
QUARK exposes callback-style transactions for the common case and manual transactions when the calling code needs full control.
Callback transactions
The callback style commits when the function returns nil and rolls back when it returns an error.
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)
})
Callback transactions also roll back safely when a panic is intercepted.
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")
}
return nil
})
Savepoints work on all six engines. The Savepoint / RollbackTo /
ReleaseSavepoint API is uniform; PostgreSQL, MySQL, MariaDB and SQLite use the
ANSI SAVEPOINT / ROLLBACK TO SAVEPOINT / RELEASE SAVEPOINT statements,
while SQL Server (SAVE TRANSACTION / ROLLBACK TRANSACTION, no release) and
Oracle (no RELEASE SAVEPOINT) use their engine-specific statements
transparently.
Rolling back to a savepoint unwinds more than the SQL: the model
After* hooks and OnCommit/OnRollback callbacks queued by CRUD run
after that savepoint are discarded along with it. This keeps the
contract honest — work that was rolled back never fires the
side-effects that would have followed it. A savepoint rollback is a
partial rollback, not a transaction rollback, so it does not fire
the OnRollback callbacks registered in that scope; react to the
partial rollback through the error your nested code returns instead.
ReleaseSavepoint keeps the queued hooks — released work merges into
the surrounding transaction and its side-effects fire with it.
Side-effects on commit/rollback
Tx.OnCommit and Tx.OnRollback register callbacks that fire only
once the transaction reaches its terminal state. They are the
honest place to put work that must not happen until the database has
durably committed (publish an event, invalidate a cache, send a
notification) — or work that should happen specifically because the
transaction was aborted (release a held 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; 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
})
Semantics:
| On commit | On rollback | |
|---|---|---|
Model After* hooks (F5-4) | fire (FIFO) | discarded |
OnCommit callbacks | fire (FIFO, after model After*) | discarded |
OnRollback callbacks | discarded | fire (FIFO) |
- Callbacks run in registration order (FIFO).
- A callback returning an error is logged (slog events
quark.hook.on_commit_error/quark.hook.on_rollback_error) but does not stop the remaining callbacks and does not change the valueClient.Txreturns. Once the database has confirmed the commit, no application-level handler can undo it. - Each callback receives the transaction's context (the one passed
to
Client.Tx/Client.BeginTx). If you set a tight deadline on it, the callbacks may observe an expired context after the tx closes — derive a fresh one fromcontext.Background()inside the callback when that matters. - Registering another
OnCommitfrom inside anOnCommitcallback (i.e. during the drain) is a no-op for the current commit: the queue was already lifted before the drain began. This bounds re-entrancy. The same holds forOnRollback.
Registering from inside a hook
A lifecycle hook only receives a context.Context, not the *Tx.
Use quark.TxFromContext(ctx) to reach the active transaction from
within a hook and register a commit/rollback side-effect there:
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 always nil-check before use. This is the building block
that the EventBus integration wires
automatically via Client.UseEventBus.
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()
Manual transactions are useful when transaction lifetime is owned by a larger workflow or framework integration.
Transaction isolation levels
import "database/sql"
tx, err := client.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
ReadOnly: false,
})
Available levels depend on the database engine:
| Level | PostgreSQL | MySQL | SQLite | MSSQL |
|---|---|---|---|---|
LevelReadUncommitted | ✓ | ✓ | — | ✓ |
LevelReadCommitted | ✓ | ✓ | — | ✓ |
LevelRepeatableRead | ✓ | ✓ | — | ✓ |
LevelSerializable | ✓ | ✓ | ✓ | ✓ |
Batch operations in transactions
UpdateBatch internally wraps all updates in a single transaction. For scenarios
where you need CreateBatch and DeleteBatch to share a transaction, use the
manual style with ForTx:
err := client.Tx(ctx, func(tx *quark.Tx) error {
// Bulk insert new records
if err := quark.ForTx[Order](ctx, tx).CreateBatch(newOrders); err != nil {
return err
}
// Remove cancelled orders atomically
_, err := quark.ForTx[Order](ctx, tx).DeleteBatch(cancelledIDs)
return err
})
Error handling and retry
Callback transactions automatically rollback on any non-nil error return.
For deadlocks specifically, use the built-in WithDeadlockRetry option
on the Client. It re-runs the Client.Tx callback when the engine reports
a deadlock — PG 40P01, MySQL 1213, MSSQL 1205, Oracle ORA-00060 —
with exponential backoff + jitter. Opt-in, ctx-aware, off by default:
client, _ := quark.New("pgx", dsn,
quark.WithDeadlockRetry(3), // up to 3 attempts on a deadlock victim
)
// The closure is run again automatically if the engine picks this tx
// as the deadlock victim.
err := client.Tx(ctx, func(tx *quark.Tx) error {
// ...operations that may deadlock with a concurrent transaction...
return nil
})
For non-deadlock transient failures (or for retries that ignore the engine's own classifier), wrap manually:
var err error
for attempt := 0; attempt < 3; attempt++ {
err = client.Tx(ctx, func(tx *quark.Tx) error {
// ...operations...
return nil
})
if err == nil {
break
}
if !isRetriable(err) {
break
}
}
Read-only transactions
Mark transactions as read-only to let the engine optimize for non-mutating queries:
tx, err := client.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
defer tx.Rollback()
users, err := quark.ForTx[User](ctx, tx).Where("active", "=", true).List()
_ = tx.Commit()