Pyranid Logo

Core Concepts

Transactions

Transactions are a critical part of any DBMS - they allow multiple discrete operations to be combined into atomic units of work. While a transaction is in-progress, any work it performs is not visible to concurrent transactions until the transaction has successfully completed. In SQL, transactions are performed via BEGIN and COMMIT commands.

In Pyranid, the default behavior is for data access operations to be performed as if autocommit=true were set on the session - there is no transaction, all state changes are performed immediately.

However, any code that runs inside of the Database::transaction(TransactionalOperation) closure operates within the context of a transaction - no state changes will propagate until closure finishes its execution non-exceptionally.

database.transaction(() -> {
  // Anything that happens in here is part of the transaction.
  // If an exception bubbles out, the transaction will be rolled back.
});

All data access performed via Database::query(String) detects the presence of a transaction on the current thread of execution and automatically participates when the transaction belongs to the same DataSource instance. This is usually the behavior you want. See the Context section below for more information on the mechanics, and for how to get a handle to the current transaction in order to manipulate it (e.g. creating a java.sql.Savepoint).

While less common, it is sometimes useful to share a transaction across multiple threads of execution. The Sharing Across Threads section below describes this functionality in more detail.

For performance reasons, no java.sql.Connection is fetched from the javax.sql.DataSource until the first data access operation occurs.


Basics

The canonical example - a bank transfer between two accounts.

database.transaction(() -> {
  // Pull initial account balances
  BigDecimal balance1 = database.query("SELECT balance FROM account WHERE id = :id")
    .bind("id", 1)
    .fetchObject(BigDecimal.class)
    .orElseThrow();
  BigDecimal balance2 = database.query("SELECT balance FROM account WHERE id = :id")
    .bind("id", 2)
    .fetchObject(BigDecimal.class)
    .orElseThrow();
  
  // Debit one and credit the other 
  balance1 = balance1.subtract(amount);
  balance2 = balance2.add(amount);

  // Persist changes
  database.query("UPDATE account SET balance = :balance WHERE id = :id")
    .bind("balance", balance1)
    .bind("id", 1)
    .execute();
  database.query("UPDATE account SET balance = :balance WHERE id = :id")
    .bind("balance", balance2)
    .bind("id", 2)
    .execute();
});

// For convenience, transactional operations may return values
Optional<BigDecimal> newBalance = database.transaction(() -> {
  // Make some changes
  database.query("UPDATE account SET balance = balance - 10 WHERE id = :id")
    .bind("id", 1)
    .execute();
  database.query("UPDATE account SET balance = balance + 10 WHERE id = :id")
    .bind("id", 2)
    .execute();

  // Return the new value
  return database.query("SELECT balance FROM account WHERE id = :id")
    .bind("id", 2)
    .fetchObject(BigDecimal.class);
});

References:

Context

Internally, Database manages a thread-local stack of Transaction instances, so it can always provide a handle to the currently-scoped transaction, if one exists.

The handle is useful for creating/restoring savepoints, marking as "force rollback", registering post-transaction operations, etc.

You cannot directly issue commits or rollbacks via Transaction. In order to provide a simple closure-based API, Pyranid performs those operations internally.

// Gets a handle to the current transaction, if any.
Optional<Transaction> transaction = database.currentTransaction();

// Output is "false"
out.println(transaction.isPresent());

database.transaction(() -> {
  // A transaction only exists for the life of the closure
  Optional<Transaction> actualTransaction = database.currentTransaction();

  // Output is "true"
  out.println(actualTransaction.isPresent());
});

References:

Sharing Across Threads

Should you need to share the same transaction across multiple threads, use the Database::participate(Transaction, TransactionalOperation) API.

Transactions are scoped to the DataSource instance that created them. A second Database built with the same DataSource instance can participate in the transaction; a Database built with a different DataSource fails fast instead of silently joining the wrong connection.

On Java 21+, a clean way to do this is with a virtual-thread executor:

database.transaction(() -> {
  database.query("UPDATE account SET balance = balance - 10 WHERE id = :id")
    .bind("id", 1)
    .execute();

  // Get a handle to the current transaction
  Transaction transaction = database.currentTransaction().orElseThrow();

  try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    CompletableFuture.runAsync(() -> {
      // In a different thread and participating in the existing transaction.
      // No commit or rollback will occur when the closure completes, but if an 
      // exception bubbles out the transaction will be marked as rollback-only
      database.participate(transaction, () -> {
        database.query("UPDATE account SET balance = balance + 10 WHERE id = :id")
          .bind("id", 2)
          .execute();
      });
    }, executor).join();
  }
});

If you are already using an executor-based or virtual-thread-based concurrency model, the pattern is the same: capture the current Transaction on the originating thread, then call Database::participate(Transaction, TransactionalOperation) inside the worker thread.

Transaction Context Is Thread-Local

Pyranid transaction context follows the current Java thread. Async frameworks that suspend work and resume it on another thread do not automatically carry the transaction context with them. This includes Kotlin coroutines when a suspension point resumes on a different dispatcher thread. Keep transactional database access on the transaction thread, or capture the current Transaction and call Database::participate(...) on the thread that performs the database work. All participating work must complete before the owning transaction closure returns.

If a participating thread is interrupted while waiting for another participant to release the transaction connection, Pyranid restores the interrupt flag and throws DatabaseException. This makes blocked participants cancellable; the thread currently executing a JDBC call still relies on driver behavior, Query::queryTimeout(...), or application-managed Statement::cancel().

References:

Rolling Back

Rollbacks are performed by Pyranid in 3 scenarios:

  • An exception bubbles out of a transaction closure
  • You explicitly mark a transaction as "rollback only"
  • You create a savepoint and roll back to it
// Any exception that bubbles out will cause a rollback
database.transaction(() -> {
  database.query("UPDATE account SET balance = balance - 10 WHERE id = :id")
    .bind("id", 1)
    .execute();
  throw new IllegalStateException("Something's wrong!");
});

// You may mark a transaction as rollback-only, and it will roll back 
// after the closure execution has completed
database.transaction(() -> {
  database.query("UPDATE account SET balance = balance - 10 WHERE id = :id")
    .bind("id", 1)
    .execute();

  // Hmm...I changed my mind
  Transaction transaction = database.currentTransaction().orElseThrow();
  transaction.setRollbackOnly(true);
});

// You may roll back part of a transaction with a savepoint
database.transaction(() -> {
  Transaction transaction = database.currentTransaction().orElseThrow();

  try {
    transaction.withSavepoint(() -> {
      database.query("UPDATE account SET balance = balance - 10 WHERE id = :id")
        .bind("id", 1)
        .execute();

      // Hmm...I changed my mind about this part of the transaction
      throw new IllegalStateException("Undo the savepoint work");
    });
  } catch (IllegalStateException ignored) {
    // Work inside the savepoint was rolled back, and the outer transaction continues
  }
});

The lower-level Transaction::createSavepoint(), Transaction::rollback(Savepoint), and Transaction::releaseSavepoint(Savepoint) methods remain available for callers that need manual control. Prefer Transaction::withSavepoint(...) for common partial-rollback workflows because it handles rollback, release, and suppressed cleanup failures consistently.

Savepoints are stack-like on most drivers. Avoid manual out-of-order rollback or release of nested savepoints unless your driver documents the behavior you need.

References:

Nesting

Each lexically-scoped "nested" transaction is independent of its enclosing transaction. There is no parent-child relationship.

However, if you would like this behavior, you may explicitly use the Database::participate(Transaction, TransactionalOperation) API to have a "child" participate in its parent transaction.

Nested transactions borrow separate physical connections and can pressure small connection pools. Participating worker threads must use a transaction from the same DataSource instance and must complete before the owning transaction closure returns; coordinate this with application primitives such as CompletableFuture::join, ExecutorService::awaitTermination, or a CountDownLatch.

database.transaction(() -> {
  database.query("UPDATE account SET balance = balance - 10 WHERE id = :id")
    .bind("id", 1)
    .execute();

  // This transaction will commit
  database.transaction(() -> {
    database.query("UPDATE account SET balance = balance + 10 WHERE id = :id")
      .bind("id", 2)
      .execute();
  });

  // This transaction will not!
  throw new IllegalStateException("I should not have used nested transactions here...");
});

Isolation

You may specify the normal DBMS isolation levels per-transaction as needed:

// If not specified, DEFAULT is assumed (whatever your DBMS prefers)
database.transaction(TransactionOptions.withIsolation(TransactionIsolation.SERIALIZABLE).build(), () -> {
  database.query("UPDATE account SET balance = balance - 10 WHERE id = :id")
    .bind("id", 1)
    .execute();
  database.query("UPDATE account SET balance = balance + 10 WHERE id = :id")
    .bind("id", 2)
    .execute();
});

References:

Read-Only Transactions

database.transaction(TransactionOptions.withReadOnly(true).build(), () -> {
  List<Account> accounts = database.query("SELECT * FROM account")
    .fetchList(Account.class);
});

database.transaction(
  TransactionOptions.withIsolation(TransactionIsolation.REPEATABLE_READ)
    .readOnly(true)
    .build(),
  () -> {
    List<Account> accounts = database.query("SELECT * FROM account")
      .fetchList(Account.class);
  });

TransactionOptions::withReadOnly(true) maps to JDBC Connection::setReadOnly(true) for the physical transaction connection. Pyranid restores the connection's original read-only setting before returning it to the pool.

Read-Only Is a JDBC/DBMS Signal

Read-only behavior is enforced, optimized, routed, or ignored by the JDBC driver and DBMS. SQL Server, Oracle, and SQLite drivers may treat it as advisory or ignore it for normal transactions. Pyranid does not parse SQL to reject writes itself.

Post-Transaction Operations

It is useful to be able to schedule code to run after a transaction has completed. Often, transaction management happens at a higher layer of code than business logic (e.g. a transaction-per-web-request pattern), so it is helpful to have a mechanism to "warp" local logic out to the higher layer.

Without this, you might run into subtle bugs like

  1. Write to database
  2. Send out notifications of system state change
  3. (Sometime later) transaction is rolled back
  4. Notification consumers are in an inconsistent state because they were notified of a change that was reversed by the rollback
// Business logic
class EmployeeService {
  public void giveEveryoneRaises() {
    database.query("UPDATE employee SET salary = salary * 2")
      .execute();
    payrollSystem.startLengthyWarmupProcess();

    // Only send emails after the current transaction ends
    database.currentTransaction().get().addPostTransactionOperation((transactionResult) -> {
      if(transactionResult == TransactionResult.COMMITTED) {
        // Successful commit? email everyone with the good news
        for(Employee employee : findAllEmployees())
          sendCongratulationsEmail(employee);
      } else if(transactionResult == TransactionResult.ROLLED_BACK) {
        // Rolled back? We can clean up
        payrollSystem.cancelLengthyWarmupProcess();	
      } else if(transactionResult == TransactionResult.IN_DOUBT) {
        // Commit was attempted but the final database outcome is unknown.
        // Avoid commit-only side effects and reconcile separately.
        payrollSystem.queueReconciliation();
      }
    });
  }
	
  // Rest of implementation elided
}

// Servlet filter which wraps requests in transactions
class DatabaseTransactionFilter implements Filter {
  @Override
  public void doFilter(ServletRequest servletRequest,
                       ServletResponse servletResponse,
                       FilterChain filterChain) throws IOException, ServletException {
    database.transaction(() -> {
      // Above business logic would happen somewhere down the filter chain
      filterChain.doFilter(servletRequest, servletResponse);

      // Business logic has completed at this point but post-transaction
      // operations will not run until the closure exits
    });

    // By this point, post-transaction operations will have been run
  }

  // Rest of implementation elided
}

Transaction Result

Post-transaction callbacks receive a TransactionResult enum value indicating how the transaction finished:

  • COMMITTED (successful commit)
  • ROLLED_BACK (rollback path before commit, e.g. exception bubbled out)
  • IN_DOUBT (commit was attempted, but the commit call failed and the final database outcome is unknown)

Post-transaction callbacks are fail-fast. If a callback throws, Pyranid wraps the failure in a PostTransactionOperationException. When there is no other transaction or cleanup failure, Database::transaction(TransactionalOperation) throws this exception as the primary failure. When the transaction operation, commit, rollback, or cleanup already failed, Pyranid suppresses it onto the primary exception. Check PostTransactionOperationException::getTransactionResult() to distinguish a successful commit followed by callback failure from a failed or in-doubt transaction.

Retrying Serialization Failures

Use Database::transactionWithRetry(RetryPolicy, TransactionalOperation) when concurrent database transactions may safely be re-run after the database rolls one of them back, such as serialization conflicts or deadlocks.

Retry behavior is explicit: you choose total attempts, the retry condition, and the backoff strategy.

RetryPolicy retryPolicy = RetryPolicy.ofMaxAttempts(
  3,
  RetryPolicy.Backoff.fixed(Duration.ofMillis(25)),
  RetryPolicy.Condition.serializationFailureOrDeadlock());

TransactionRetryResult<Void> retryResult = database.transactionWithRetry(retryPolicy, () -> {
  database.query("""
      UPDATE account
      SET balance = balance - :amount
      WHERE account_id = :accountId
      """)
    .bind("accountId", accountId)
    .bind("amount", amount)
    .execute();
});

RetryPolicy::ofMaxAttempts(Integer, RetryPolicy.Backoff, RetryPolicy.Condition) means three total attempts in this example: the initial transaction plus up to two retries. Pyranid starts a fresh physical transaction for each attempt. The whole closure may run more than once, so keep non-idempotent external side effects outside the retried closure.

Unlike Database::transaction(ReturningTransactionalOperation<T>), Database::transactionWithRetry(RetryPolicy, TransactionalOperation) returns a TransactionRetryResult so callers can inspect failures recovered before success. TransactionRetryResult::getValue() contains the successful transaction value, and TransactionRetryResult::getFailures() contains failed attempts that Pyranid retried before the transaction eventually succeeded.

// Risky: the email may send more than once
database.transactionWithRetry(retryPolicy, () -> {
  database.query("UPDATE orders SET status = 'PAID' WHERE order_id = :orderId")
    .bind("orderId", orderId)
    .execute();

  emailClient.sendReceipt(orderId);
});

Instead, return enough information to run the side effect after the retry helper succeeds:

TransactionRetryResult<ReceiptEmail> retryResult = database.transactionWithRetry(retryPolicy, () -> {
  database.query("UPDATE orders SET status = 'PAID' WHERE order_id = :orderId")
    .bind("orderId", orderId)
    .execute();

  return Optional.of(new ReceiptEmail(orderId));
});

retryResult.getValue().ifPresent(emailClient::sendReceipt);

for (DatabaseException failure : retryResult.getFailures()) {
  logger.info("Recovered transaction failure", failure);
}

For durable side effects, prefer the outbox pattern: write an outbox row inside the retried transaction and let a separate worker deliver it after commit.

RetryPolicy.Backoff::fixed(Duration) and RetryPolicy.Backoff::exponential(Duration, Duration) backoff strategies are built in:

RetryPolicy exponentialRetryPolicy = RetryPolicy.ofMaxAttempts(
  5,
  RetryPolicy.Backoff.exponential(
    Duration.ofMillis(25),
    Duration.ofSeconds(1)),
  RetryPolicy.Condition.serializationFailureOrDeadlock());

Timeouts can be classified with DatabaseException::isTimeout() and opted into with RetryPolicy.Condition::timeout(), but Pyranid does not treat them as automatically safe to retry. A timeout may surface after the database has already performed part or all of the work.

Post-transaction operations registered with Transaction::addPostTransactionOperation(Consumer<TransactionResult>) remain per-attempt hooks. Under retry, a hook registered during a rolled-back attempt runs with ROLLED_BACK, and a hook registered during the successful attempt runs with COMMITTED.

Previous
Statements