Skip to content

Global Transactions — JTA and XA

Overview and Motivation

A Tentackle transaction is a local transaction: one session, one JDBC connection, one database. That covers almost every application. But sometimes one unit of work has to commit atomically across more than one transactional resource:

  • two databases, for example an operational database and an archive, or two applications sharing a business process;
  • a database and a message broker (JMS) or another XA-capable resource;
  • a Tentackle tier deployed in a Jakarta EE container, where the container demarcates the transactions and expects every resource to take part in them.

This is the domain of global (distributed) transactions: a transaction manager coordinates all resources via the XA two-phase commit protocol, and the Java API for it is JTA (jakarta.transaction). The module tentackle-jta connects local sessions to a JTA transaction manager. The programming model does not change: @Transaction, Session.begin()/commit(), pre- and post-commits, and transparent retry work as before.

When not to use XA

A two-phase commit costs an additional round trip per resource (the prepare) and a log write in the transaction manager for every commit. If a participant or the coordinator fails between prepare and commit, the in-doubt branches keep their locks until the transaction manager's recovery resolves them. Not every JDBC driver handles all of this equally well.

For the most common case, a database update plus a message, the transactional outbox is usually the better choice: write the message into a table within the local transaction and deliver it afterwards. Tentackle's modification log already is such an outbox, written atomically with the business data.

Use XA when you need real atomicity across several databases, or when the container requires it.

Setup

  1. Add the module. It brings the JTA API (jakarta.transaction-api), but no transaction manager:
<dependency>
  <groupId>org.tentackle</groupId>
  <artifactId>tentackle-jta</artifactId>
</dependency>

On the module path, the module jakarta.transaction also requires the CDI and interceptor APIs (jakarta.enterprise.cdi-api and jakarta.interceptor-api) for its @Transactional annotation, even though Tentackle does not use it. A container provides them. On the classpath they are not needed.

  1. Use XA connections. The sessions must be opened with one of the XA connection managers, the counterparts of the database module's managers:

  2. XaConnectionManager — one connection per session, like the DefaultConnectionManager;

  3. XaMpxConnectionManager — N sessions multiplexed over M connections, like the MpxConnectionManager, for application servers.

Pass the manager to the Db or session pool, or return it from the application's ConnectionManagerProvider.getConnectionManager(). The connections are created via the JDBC driver's XADataSource (see Backends). For a jndi: URL, the bound data source must implement XADataSource.

A session taking part in a global transaction keeps its connection until the global transaction completes. Afterwards, the connection is switched back to autocommit mode and a multiplexing manager hands it to the next session.

  1. Provide the transaction manager to JtaTransactionSupport. A standalone application sets it explicitly, for example with Narayana:
JtaTransactionSupport.setTransactionManager(com.arjuna.ats.jta.TransactionManager.transactionManager());

Otherwise, it is looked up via JNDI when a session needs it for the first time. The name is taken from the system property tentackle.jta.transactionManager, or else the common container names are tried (java:comp/TransactionManager, java:appserver/TransactionManager, java:jboss/TransactionManager, java:/TransactionManager). The manager found is kept; setTransactionManager(null) forces a new lookup. If none is found, the session's transaction fails with a PersistenceException, and the lookup is repeated next time.

  1. Choose the mode per session in the backend properties:
url=jdbc:postgresql://localhost/erp
jta=join

Server applications

AbstractServerApplication.createConnectionManager() creates the connection manager for the server's session pool. It always returns an MpxConnectionManager, even if jta is configured in the backend properties. To use XA connections, override it:

@Override
protected ConnectionManager createConnectionManager() {
  return new XaMpxConnectionManager(getSession().getBackendInfo(),
                                    getSession().getConnectionManager().getSessionIdOffset() +
                                    getSession().getConnectionManager().getMaxSessions());
}

The server application does not make this switch automatically, for the following reasons:

  • The module dependencies. tentackle-jta is optional, and tentackle-persistence does not depend on it. The server application cannot create an XaMpxConnectionManager without either making every server depend on the JTA API, or looking the manager up via a service, which would hide a choice that has a noticeable cost.
  • jta selects a transaction mode, not a connection type. The property says how a session takes part in global transactions. Which connections a session gets is decided by whoever creates its connection manager, and the pool's manager is only one of them. The server's main session is opened before the pool exists, with the default connection manager. If only the pool switched automatically, some sessions would get XA connections and others would not, and nothing in the configuration would show which.
  • XA connections cost more. Every connection of the pool becomes an XA connection. It holds a transaction branch in the database, and it stays attached to its session until the transaction manager completes the global transaction. For a jndi: URL, the bound data source must also implement XADataSource. This is a deployment decision, and it should be visible in the code.
  • Usually only a few transactions need XA. In most applications, only certain units of work must commit atomically across several resources, such as the transfer to an archive database. All the others are local, and they gain nothing from XA. If the whole pool were switched, required would turn every transaction into a global one with a two-phase commit, and even with join all sessions would hold XA connections. It is often better to keep the pool local and to open dedicated sessions with an XA connection manager and their own backend properties (jta=required or join) just for the transactions that need them.

A mismatch does not go unnoticed. If a session configured with join or required has no XA connection, its first global transaction fails with a PersistenceException that names both XA connection managers. Keep in mind that jta applies to every session opened with these backend properties, including the server's main session. If a session keeps a non-XA connection manager, switch it to local transactions via setGlobalTransactionMode(GlobalTransactionMode.NONE).

The Two Modes

The property jta selects the GlobalTransactionMode (it can also be set programmatically via Db.setGlobalTransactionMode()):

Value A global transaction is running on the thread No global transaction is running
none (default) ignored: local transaction local transaction
join the session joins it local transaction
required the session joins it the session starts one

join — Tentackle as a participant

Use join when something else demarcates the global transactions: a Jakarta EE container, Spring's JTA transaction manager, or the application code itself via TransactionManager.begin()/commit().

When the session begins a transaction while a global transaction is associated with the current thread, it enlists its connection in the global transaction instead of starting a local one. Level 1 of the session's transaction belongs to the owner of the global transaction. The session itself continues at level 2, just like a nested transaction. So begin() returns a voucher of 0, and no @Transaction method ever commits, rolls back or retries the global transaction. Only its owner does. The session stays enlisted until the global transaction completes, so isTxRunning() returns true until then, and further units of work within the same global transaction simply join it again.

If a unit of work of the session fails, i.e. its outermost joined level is rolled back, the global transaction is marked rollback-only. Its owner can no longer commit it. A session that is not yet enlisted cannot join a global transaction that is already marked rollback-only: its begin() fails with a PersistenceException.

required — Tentackle as the coordinator

Use required in standalone applications that need atomic transactions across several sessions (databases) or other XA resources, without an application server.

The outermost begin() of a session with no global transaction on the thread starts a new global transaction and gets a real voucher. Every other session that begins a transaction on the same thread joins it (see above). The owner's commit(voucher) runs the two-phase commit across all enlisted resources, and rollback(voucher) rolls all of them back:

long voucher = erpSession.begin("transfer");    // starts the global transaction
try {
  ...                                           // work in erpSession
  archiveSession.transaction(() -> ...);        // joins it
  erpSession.commit(voucher);                   // two-phase commit of both databases
}
catch (RuntimeException ex) {
  erpSession.rollback(voucher);                 // rolls back both databases
  throw ex;
}

With PDOs the same is achieved declaratively: a @Transaction method of a PDO in the first session starts the global transaction, and the work of every other session configured for global transactions that is done within this method joins it.

How Tentackle's Features Behave

Feature In a global transaction
Pre-commits, statement batch Run and flushed when the transaction manager calls beforeCompletion, i.e. before the prepare, and the COMMIT entry of the modification log is written. An exception rolls back the global transaction and is rethrown by the owner's commit(). This also holds for an optimistic-lock conflict of a batched update.
Post-commits, modification tracking Run after the global transaction is committed (afterCompletion), and not at all after a rollback. If a post-commit fails and a Tentackle session owns the global transaction, the owner's commit() throws the exception, as it would in a local transaction. If someone else owns it (join), the exception is logged.
Transparent retry Works for the owner: the global transaction is the unit that is retried. A failed commit is temporary if a pre-commit threw a temporary exception or a resource failed with XA_RBDEADLOCK, XA_RBTIMEOUT or XA_RBTRANSIENT. A joined session never retries, just like a nested transaction.
Optimistic locking, token locks Unchanged: plain SQL within the transaction branch.
Isolation and writability Cannot be set: a non-default isolation or writability of @Transaction or begin() throws a PersistenceException when the session starts or joins a global transaction. A nested begin() follows the rules of a local transaction. Configure them on the data source instead.
Savepoints Depend on the JDBC driver: some drivers support savepoints within an XA branch, others don't.
rollbackImmediately() Rolls back the global transaction if the session owns it, otherwise marks it rollback-only. The connection is never rolled back directly, because it is enlisted.
Remote sessions A JTA transaction never crosses a tier boundary. A @Transaction method executed on the server (a @RemoteMethod) takes part in global transactions on the server. A transaction begun by a remote client spans several remote calls and always stays local.

The transaction manager may call afterCompletion from another thread, for example when it rolls back a timed-out transaction. After a rollback, Tentackle only resets the session's state and runs no statements.

Backends

A backend supports XA if it names the JDBC driver's XADataSource (Backend.getXADataSourceClassName()). The data source is created via reflection and configured with the url and credentials from the backend properties.

Backend XA data source Notes
PostgreSQL org.postgresql.xa.PGXADataSource Requires max_prepared_transactions > 0 in the server configuration.
Oracle oracle.jdbc.xa.client.OracleXADataSource
MySQL com.mysql.cj.jdbc.MysqlXADataSource InnoDB only.
MariaDB org.mariadb.jdbc.MariaDbDataSource
SQL Server com.microsoft.sqlserver.jdbc.SQLServerXADataSource Requires the XA stored procedures to be installed and MSDTC to be enabled.
H2 org.h2.jdbcx.JdbcDataSource Used by the module's tests.
CockroachDB — No two-phase commit.
others — Override getXADataSourceClassName(), or bind an XADataSource via JNDI.

Architecture: the Global Transaction SPI

The database module knows nothing about JTA. A session configured with jta=join or jta=required talks to a global transaction manager only through the SPI GlobalTransactionSupport, which it looks up via the ServiceFactory when it needs it for the first time. tentackle-jta provides the implementation, JtaTransactionSupport, as a @Service(GlobalTransactionSupport.class); without the module, a session configured for global transactions fails with no GlobalTransactionSupport service found (module tentackle-jta missing?).

Method Invoked
isGlobalTransactionActive() when a session begins a transaction, to decide whether it joins a global one
begin(session, txName) by a required session starting the global transaction
enlist(session, listener) when a session's attached connection takes part in the global transaction
commit(session) / rollback(session) by the session that started the global transaction
setRollbackOnly(session) by any session that needs the global transaction to fail

The session registers a GlobalTransactionListener when it enlists, the counterpart of a JTA Synchronization: beforeCompletion() runs the pre-commits and flushes the statement batch, afterCompletion(committed) finishes the session's transaction — possibly from another thread, as described above.

All methods are invoked from the thread the global transaction is associated with. An implementation reports failures as a PersistenceException and flags it temporary if the cause is transient, such as a deadlock; this is what makes transparent retry work. JtaTransactionSupport decides this in isTemporary(). To treat further failures as transient, or to couple Tentackle's sessions with a coordinator other than JTA, extend JtaTransactionSupport or implement the interface in your own module, annotated with @Service(GlobalTransactionSupport.class) — as with every service, a module's own provider overrides the ones of the modules it depends on.

Recovery

If the application or a database crashes between prepare and commit, the transaction manager completes the in-doubt branches when it recovers. For that, it needs the transaction log (a durable directory, configured in the transaction manager) and a way to connect to each database. With Narayana, register an XAResourceRecoveryHelper that opens an XA connection per backend:

XAConnection recoveryConnection = backendInfo.connectXA();
XAResource[] resources = { recoveryConnection.getXAResource() };

tentackle-jta does not register such helpers itself, since the recovery API is specific to the transaction manager.