Skip to content

Changes in your own transaction

Adding a member to an organisation usually writes several things: your own members row, an audit record, perhaps an outbox message for a welcome email, and wardn’s role assignment. They should all happen, or none of them should.

With the SQL store (database/sql) or the pgx store, wardn can write inside your transaction. The simplest way is store.RunInTx, which starts the transaction, commits or rolls it back, and keeps wardn’s cache in step:

err := store.RunInTx(ctx, w, nil, func(ctx context.Context, tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx, `INSERT INTO members (org_id, user_id) VALUES ($1, $2)`, orgID, userID); err != nil {
return err
}
return w.Assign(ctx, userID, "agent", product) // if this fails, the member row is rolled back too
})

Use the ctx that RunInTx passes to your function for every call inside it, wardn’s and your own.

Your function What RunInTx does
returns nil Commits, then tells wardn the transaction is over.
returns an error Rolls back, and returns your error.
panics Rolls back, tells wardn, and lets the panic continue.

The third argument sets the isolation level and read-only mode: a *sql.TxOptions for the SQL store, or a pgx.TxOptions for the pgx store. nil (or pgx.TxOptions{}) uses the database’s defaults.

If your code opens transactions itself, or through a helper of its own, make the two calls RunInTx makes for you:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
ctx, ended := w.Transaction(store.WithTx(ctx, tx))
if _, err := tx.ExecContext(ctx, `INSERT INTO members (org_id, user_id) VALUES ($1, $2)`, orgID, userID); err != nil {
return err
}
if err := w.Assign(ctx, userID, "agent", product); err != nil {
return err // the member row is rolled back too
}
err = tx.Commit()
if endErr := ended(ctx); endErr != nil {
log.Warn("wardn cache invalidation failed", "err", endErr)
}
return err
Call What it does
store.WithTx(ctx, tx) The store reads and writes through tx instead of starting its own transaction. tx must be on the store’s database.
w.Transaction(ctx) Tells wardn the changes commit later, and returns ended. Call ended once the transaction is over, after a commit or a rollback.

With a helper that commits for you, call w.Transaction around the helper and store.WithTx inside it, then call ended after the helper returns. Calling ended inside the callback would run it before the commit:

wctx, ended := w.Transaction(ctx)
err := inTx(wctx, func(tx *sql.Tx) error {
return w.Assign(store.WithTx(wctx, tx), userID, "agent", product)
})
_ = ended(ctx) // the helper has committed or rolled back

Everything wardn offers works inside: Assign, Revoke, Link, Move, CreateRole, UpdateRole, DeleteRole, and checks such as Can. The escalation guard and OnChange run as usual. A check inside the transaction sees the transaction’s own uncommitted changes. A check outside it doesn’t see them until you commit.

  • Savepoints. Each of wardn’s writes runs in a savepoint. If one fails, for example a custom role whose name is taken (ErrRoleNameTaken), wardn rolls back to the savepoint. Your transaction stays usable, which matters on PostgreSQL, where a failed statement otherwise aborts the whole transaction.
  • No uncommitted data in the cache. Inside the transaction, wardn neither reads nor fills the shared cache or the request memo. Uncommitted access can never reach another request, even if the transaction rolls back.
  • Invalidation at the end. The cache is invalidated before each change and again when you call ended, so once the transaction commits, no instance serves the old state.
  • Forgetting w.Transaction. A change made with store.WithTx but without w.Transaction fails with wardn.ErrNoTransaction before anything is written or audited. wardn couldn’t otherwise keep its cache in step with your commit.
  • Forgetting store.WithTx. A change made with w.Transaction alone would quietly write outside your transaction, so it also fails with ErrNoTransaction.
  • Sweeping expiries inside a transaction. SweepExpired manages its own writes and refuses a transaction context.

OnChange receives the same ctx. If your audit log lives in the same database, write it with the same transaction, and the audit record commits or rolls back with the change it describes:

OnChange: func(ctx context.Context, e wardn.Event) error {
tx, ok := txFrom(ctx) // your own helper: the *sql.Tx you put in ctx
if !ok {
return writeAudit(ctx, db, e)
}
return writeAudit(ctx, tx, e)
},

OnChange still runs before the write and can veto it. If the write then fails, it is called again with Event.Err set, and wardn has already rolled back to its savepoint, so that second write works too.

If your application uses pgx directly, use the pgx store (wardn/store/pgx, v1.2). It takes your pgx.Tx as it is:

store := wardnpgx.MustNew(pool) // your *pgxpool.Pool
err := store.RunInTx(ctx, w, pgx.TxOptions{}, func(ctx context.Context, tx pgx.Tx) error {
if _, err := tx.Exec(ctx, `INSERT INTO members (org_id, user_id) VALUES ($1, $2)`, orgID, userID); err != nil {
return err
}
return w.Assign(ctx, userID, "agent", product)
})

Your existing pgx transaction helpers work too, with WithTx and w.Transaction as above:

wctx, ended := w.Transaction(ctx)
err := pgx.BeginFunc(wctx, pool, func(tx pgx.Tx) error {
ctx := store.WithTx(wctx, tx)
if _, err := tx.Exec(ctx, `INSERT INTO members (org_id, user_id) VALUES ($1, $2)`, orgID, userID); err != nil {
return err
}
return w.Assign(ctx, userID, "agent", product)
})
_ = ended(ctx) // after BeginFunc has committed or rolled back

sqlc’s generated Queries.WithTx(tx) works on the same tx. wardn’s writes become pgx savepoints (tx.Begin), so a failed wardn write never aborts your transaction.

The pgx store uses the same tables as the SQL store, so you can adopt it without moving any data.

Keeping database/sql for wardn: open a *sql.DB over your pool with stdlib.OpenDBFromPool(pool) and share one connection:

conn, err := sqlDB.Conn(ctx) // one connection from the shared pool
tx, err := conn.BeginTx(ctx, nil) // the transaction wardn uses
err = conn.Raw(func(dc any) error { // the same connection, as pgx
pgxConn := dc.(*stdlib.Conn).Conn()
// run your pgx or sqlc queries on pgxConn: they are inside tx
return nil
})

Transactions are available in the SQL store (PostgreSQL, MySQL, MariaDB, SQLite) and the pgx store (PostgreSQL). A store opts in by implementing wardn.TxStore. The MongoDB store doesn’t yet.

Questions about this page?

Ask below, point out something wrong or unclear, or suggest an improvement. Reading needs no account; to post, sign in with GitHub.