Saltar al contenido principal
Version: 1.1.0

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 commitOn rollback
Model After* hooks (F5-4)fire (FIFO)discarded
OnCommit callbacksfire (FIFO, after model After*)discarded
OnRollback callbacksdiscardedfire (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 value Client.Tx returns. 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 from context.Background() inside the callback when that matters.
  • Registering another OnCommit from inside an OnCommit callback (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 for OnRollback.

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:

LevelPostgreSQLMySQLSQLiteMSSQL
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()