Skip to main content
Version: 4.0.0

Transactions

Stroppy wraps database transactions in a TypeScript API that handles isolation levels, automatic commit/rollback, metrics collection, and error propagation. You can use transactions manually or with a callback that manages the lifecycle for you.

Quick Start​

The fastest way to use a transaction is the callback form — beginTx starts a transaction, runs your function, commits on success, and rolls back on error:

driver.beginTx((tx) => {
tx.exec("UPDATE accounts SET balance = balance - :amount WHERE id = :src", {
src: 1, amount: 100,
});
tx.exec("UPDATE accounts SET balance = balance + :amount WHERE id = :dst", {
dst: 2, amount: 100,
});
});
// committed automatically

If anything inside the callback throws, the transaction is rolled back and the error is re-thrown.

begin() vs beginTx()​

Stroppy provides two ways to work with transactions.

beginTx opens a transaction, calls your function with a TxX object, and commits when the function returns. If the function throws, the transaction is rolled back and the error propagates:

driver.beginTx((tx) => {
tx.exec("INSERT INTO orders (product, qty) VALUES (:p, :q)", {
p: "widget", q: 5,
});
// auto-commit on return
});

You can pass options before the callback:

driver.beginTx({ isolation: "serializable", name: "transfer" }, (tx) => {
tx.exec("UPDATE accounts SET balance = balance - 100 WHERE id = 1");
tx.exec("UPDATE accounts SET balance = balance + 100 WHERE id = 2");
});

begin() — manual form​

begin() returns a TxX object. You are responsible for calling commit() or rollback():

const tx = driver.begin({ isolation: "read_committed" });
try {
tx.exec("INSERT INTO log (msg) VALUES (:m)", { m: "started" });
tx.exec("INSERT INTO log (msg) VALUES (:m)", { m: "finished" });
tx.commit();
} catch (e) {
tx.rollback();
throw e;
}

The callback form is preferred for most cases because it eliminates the risk of forgetting to commit or rollback.

Isolation Levels​

Set the isolation level when starting a transaction:

driver.begin({ isolation: "serializable" });
driver.beginTx({ isolation: "repeatable_read" }, (tx) => { /* ... */ });

Available levels​

LevelBehavior
"read_uncommitted"Allows dirty reads. Lowest isolation.
"read_committed"Each statement sees only committed data. PostgreSQL default.
"repeatable_read"Snapshot at transaction start. No phantom reads in PostgreSQL.
"serializable"Full serializability. Highest isolation.
"db_default"Uses the database server's default isolation. This is Stroppy's default if nothing else is configured.
"conn"Acquires a dedicated connection without starting a database transaction. See below.
"none"No transaction, no connection pinning. Queries go through the connection pool directly. See below.

The "conn" level​

"conn" acquires a dedicated connection from the pool but does not issue BEGIN. This is useful for session-level operations that need a pinned connection — for example, setting session variables, using advisory locks, or working with databases that do not support transactions.

driver.beginTx({ isolation: "conn" }, (tx) => {
tx.exec("SET search_path TO my_schema");
tx.exec("SELECT pg_advisory_lock(:id)", { id: 42 });
// ... work on the pinned connection ...
tx.exec("SELECT pg_advisory_unlock(:id)", { id: 42 });
});
// connection is returned to the pool on commit/rollback

Under the hood, commit() and rollback() both simply release the connection back to the pool — there is no COMMIT or ROLLBACK SQL sent.

The "none" level​

"none" creates a lightweight TxX wrapper with no underlying transaction and no connection pinning. Every query goes directly through the driver's connection pool, just like calling driver.exec(). Commit and rollback are no-ops.

This is useful when you want transaction-shaped code (with metrics and error tracking) but do not need actual transactional guarantees:

driver.beginTx({ isolation: "none" }, (tx) => {
tx.exec("INSERT INTO events (type) VALUES (:t)", { t: "click" });
// this insert is visible to other connections immediately
});

Default Isolation Level​

Instead of passing isolation on every call, set a default for the driver:

In declareDriverSetup​

const driverConfig = declareDriverSetup(0, {
url: "postgres://localhost:5432/mydb",
driverType: "postgres",
defaultTxIsolation: "read_committed",
});

const driver = DriverX.create().setup(driverConfig);

// All transactions now use read_committed unless overridden:
driver.beginTx((tx) => {
tx.exec("SELECT 1"); // read_committed
});
driver.beginTx({ isolation: "serializable" }, (tx) => {
tx.exec("SELECT 1"); // serializable (override)
});

From the CLI​

Override the script default with the -D flag:

stroppy run my_test.ts -D defaultTxIsolation=serializable

The precedence is: per-call isolation option > CLI -D defaultTxIsolation > script declareDriverSetup > built-in default ("db_default").

The TxX API​

TxX implements the same QueryAPI interface as DriverX, so you can use all the same query methods inside a transaction:

MethodDescription
tx.exec(sql, args?)Execute a query, return QueryStats.
tx.queryRows(sql, args?, limit?)Execute a query, return all rows as any[][].
tx.queryRow(sql, args?)Return the first row or undefined.
tx.queryValue<T>(sql, args?)Return the first column of the first row, or undefined.
tx.queryCursor(sql, args?)Return a QueryResult cursor for streaming.
tx.commit()Commit the transaction.
tx.rollback()Rollback the transaction.

Additionally, TxX exposes two read-only properties:

PropertyTypeDescription
tx.isolationTxIsolationNameThe isolation level this transaction was started with.
tx.namestring | undefinedThe optional name passed at creation (used in metric tags).

All query methods (exec, queryRows, etc.) accept raw SQL strings, ParsedQuery objects from structured SQL files, and TaggedQuery objects — same as on DriverX.

Error Handling in Transactions​

Inside a transaction, query errors behave differently than outside one:

  • Outside a transaction: The driver's error mode ("silent", "log", "throw", "fail", "abort") controls what happens on error.
  • Inside a transaction: Errors always throw, regardless of the configured error mode. This ensures that a failing query triggers the rollback path in beginTx, rather than being silently swallowed.

When using beginTx, the thrown error causes an automatic rollback and then re-propagates to the caller:

try {
driver.beginTx((tx) => {
tx.exec("INSERT INTO t VALUES (1)"); // succeeds
tx.exec("INSERT INTO t VALUES (1)"); // unique violation — throws
});
} catch (e) {
// transaction was rolled back automatically
// e contains the original database error
}

When using begin() manually, you must catch errors yourself and call rollback().

Interaction with "fail" and "abort" error modes​

The "fail" and "abort" error modes (set via errorMode in driver config or the STROPPY_ERROR_MODE env var) affect what happens after the transaction error propagates out of beginTx:

  • "fail" — Marks the current k6 iteration as failed via test.fail(). The VU moves on to the next iteration.
  • "abort" — Aborts the entire test run via test.abort().

Inside the transaction itself, however, the error always throws so the rollback can execute first.

Transaction Metrics​

Stroppy automatically tracks several metrics for every transaction:

MetricTypeDescription
tx_total_durationTrend (ms)Wall-clock time from begin() to commit()/rollback().
tx_clean_durationTrend (ms)Sum of actual query execution times within the transaction (excludes time spent in TypeScript between queries).
tx_commit_rateRateFraction of transactions that committed (vs rolled back).
tx_error_rateRateFraction of beginTx calls that threw an error.
tx_queries_per_txTrendNumber of queries executed within each transaction.

Individual queries within a transaction also contribute to the standard run_query_duration, run_query_count, and run_query_error_rate metrics.

Metric tags​

All transaction metrics are tagged with:

  • tx_action — "commit" or "rollback"
  • tx_name — The name you passed (if any)
  • tx_isolation — The isolation level string

This lets you filter and group metrics by transaction type in dashboards and reports:

driver.beginTx({ isolation: "serializable", name: "transfer" }, (tx) => {
// metrics for this tx will carry tags:
// tx_action: "commit"
// tx_name: "transfer"
// tx_isolation: "serializable"
tx.exec("UPDATE accounts SET balance = balance - :amt WHERE id = :src", {
src: 1, amt: 50,
});
tx.exec("UPDATE accounts SET balance = balance + :amt WHERE id = :dst", {
dst: 2, amt: 50,
});
});

Full Example​

Here is a complete test script that creates a table, runs transactional inserts, and verifies results:

import { Options } from "k6/options";
import { Teardown } from "k6/x/stroppy";
import { DriverX, R, declareDriverSetup } from "./helpers.ts";

export const options: Options = {
iterations: 1,
vus: 1,
};

const driverConfig = declareDriverSetup(0, {
url: "postgres://postgres:postgres@localhost:5432",
driverType: "postgres",
defaultTxIsolation: "read_committed",
});

const driver = DriverX.create().setup(driverConfig);

const aidGen = R.int32(1, 100000).gen();
const deltaGen = R.int32(-500, 500).gen();

export function setup() {
driver.exec("DROP TABLE IF EXISTS accounts");
driver.exec(`
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
balance INTEGER NOT NULL DEFAULT 0
)
`);
driver.exec("INSERT INTO accounts (id, balance) VALUES (1, 1000)");
driver.exec("INSERT INTO accounts (id, balance) VALUES (2, 1000)");
}

export default function () {
// Callback form — auto-commit on success, auto-rollback on error
driver.beginTx({ isolation: "serializable", name: "transfer" }, (tx) => {
const delta = deltaGen.next();
tx.exec("UPDATE accounts SET balance = balance - :d WHERE id = 1", { d: delta });
tx.exec("UPDATE accounts SET balance = balance + :d WHERE id = 2", { d: delta });

// Verify invariant inside the transaction
const total = tx.queryValue<number>(
"SELECT SUM(balance) FROM accounts",
);
if (total !== 2000) {
throw new Error(`balance invariant violated: ${total}`);
}
});
}

export function teardown() {
Teardown();
}

Go Driver Side​

On the Go side, the Driver interface exposes a single method for starting transactions:

type Driver interface {
Begin(ctx context.Context, isolation stroppy.TxIsolationLevel) (Tx, error)
RunQuery(ctx context.Context, sql string, args map[string]any) (*QueryResult, error)
InsertValues(ctx context.Context, unit *stroppy.InsertDescriptor) (*stats.Query, error)
Teardown(ctx context.Context) error
}

The returned Tx interface provides:

type Tx interface {
RunQuery(ctx context.Context, sql string, args map[string]any) (*QueryResult, error)
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
Isolation() stroppy.TxIsolationLevel
}

For the CONNECTION_ONLY level, each driver acquires a raw connection from the pool and wraps it in a ConnOnlyTx — where Commit() and Rollback() both release the connection without sending any SQL. For the NONE level, the xk6 bridge creates a thin wrapper where RunQuery delegates directly to the driver's pool-level RunQuery, and Commit/Rollback are no-ops.

Custom driver implementations only need to handle the standard four SQL isolation levels plus CONNECTION_ONLY — the NONE level is handled by the runtime before reaching the driver.