Enabling XA transactions in Quarkus

Introduction

In today’s distributed systems, ensuring data consistency across multiple databases or services can be critical. This is where XA transactions come into play. XA is a standard protocol for coordinating distributed transactions across multiple resources, ensuring atomicity and consistency.

This blog post will dive into the concept of XA transactions, explore their architecture, and provide practical insights on enabling XA transactions in Quarkus. We will also look at some potential drawbacks and the future outlook for XA transactions in distributed systems.

What are XA transactions

XA transactions are a type of distributed transaction that allow multiple resource managers (such as databases, message queues, or other systems) to participate in a single, coordinated transaction. The XA standard, defined by X/Open, ensures that the transaction either commits all changes or rolls them back across all systems involved, maintaining data consistency and integrity.

The importance of XA transactions lies in their ability to guarantee atomicity, consistency, isolation, and durability (ACID) properties across distributed resources. Without XA transactions, there could be scenarios where one system successfully commits its changes, but another system fails to do so. This, in return, can lead to data inconsistency and potentially causing major system failures.

Use Cases

  • Coordinating between different databases: If we want to update customer information in one database and order details in another. XA transactions ensures that both changes are committed or rolled back together. This is essential when you have a multi-database system.
  • Coordinating between databases and messaging systems: We need to ensure that a message is sent to a queue if and only if the database update is successful. An XA transaction can span both the database and the messaging system, ensuring that the message is not sent unless the database commit is successful.

Architecture of XA transactions

The following components play key roles in ensuring that XA transactions work correctly, such as the resource managers, the transaction manager, the 2-Phase Commit (2PC) protocol, and Transaction Logs. Let us explore each component in detail:

Resource Managers

A resource manager is any system or component that stores or manages data and participates in an XA transaction. Common examples include databases (e.g., MySQL, PostgreSQL), message queues (e.g., JMS), or any other persistent data store. The resource manager is a software component responsible for managing access to a specific resource within the context of an XA transaction. Resources could be things like databases, message queues, file systems, etc. Each resource manager ensures that its resource remains in a consistent state during the transaction. It is responsible for adhering to the XA protocol, either committing or rolling back its changes based on the command it gets from the transaction manager.

Each respurce manager plays the following roles:

  • Supporting XA protocols: Resource managers must implement the XA protocol defined by the X/Open standard, ensuring that they can work with the transaction manager and follow the 2PC protocol to either commit or rollback changes in a coordinated manner.
  • Responding to the commands sent by the transaction manager: During the prepare phase of the 2PC protocol, the resource manager checks whether it can safely commit the changes. If everything is fine, it reports back to the Transaction Manager with a „ready to commit“ message. If it encounters any problems (e.g., data integrity issues, connectivity problems), it sends an abort message to indicate that the transaction should be rolled back.

Transaction Manager

The transaction manager is responsible for coordinating the transaction across all participating resource managers. Its key responsibilities include:

  • Transaction Coordination: The transaction manager is the central authority that controls the lifecycle of the transaction. It manages the beginning, commit, and rollback phases of the transaction. When an XA transaction is initiated, the transaction manager starts the transaction and ensures that all resource managers involved in the transaction follow the 2PC protocol.
  • Communication with resource managers: The transaction manager sends commands to resource managers, instructing them to prepare for the transaction. It also collects responses from all resource managers to determine whether the transaction can be committed or must be rolled back.
  • Decision Making: Based on the feedback from all resource managers, the transaction manager makes the decision to either commit or abort the transaction. If all resource managers signal that they are ready to commit, the transaction manager will send a commit command; if any resource manager reports a failure, the transaction manager will instruct all resources to roll back the transaction.
  • Transaction Context Management: The transaction manager maintains the context and state of each transaction. It ensures that all participating resource managers are synchronized and aware of the current status of the transaction.

In summary, the transaction manager acts as the orchestrator, ensuring consistency and effective communication among all resource managers involved in an XA transaction.

2-Phase Commit

The 2-Phase Commit protocol is the mechanism that ensures the atomicity and consistency of distributed transactions. The 2PC protocol works as follows:

Phase 1: Prepare Phase

  • The transaction manager sends a prepare command to all resource managers that are participating in the transaction. Each resource manager prepares to commit the changes but does not finalize them yet.
  • Each resource manager checks its local conditions to determine whether the transaction can be committed (e.g., no integrity violations, no conflicting operations). If the resource manager is ready to commit, it responds with a „commit-ready“ message to the transaction manager to ensures that the commit can be commited in the future. Otherwise, it sends an abort message, signaling that the transaction cannot proceed.

Phase 2: Commit Phase

  • If all resource managers respond positively (i.e., „commit-ready“) during the prepare phase, the transaction manager sends a commit command to all the RMs, instructing them to finalize the transaction and apply the changes permanently.
  • If any resource manager reports a failure (abort) during the prepare phase, the transaction manager sends a rollback command to all participating RMs to undo any changes made during the transaction, ensuring the system is left in a consistent state.

The 2PC protocol ensures that XA transactions maintain the ACID properties (Atomicity, Consistency, Isolation, Durability) even in a distributed environment.

Transaction Logs and Logging

The transaction manager and the resource managers uses transaction logs to ensure that the system remains consistent and fault-tolerant. Transaction logs are essential for recovering from failures and ensuring that the state of a transaction is accurately tracked.

Resource Manager Logs

Resource managers maintain transaction logs that record the state of the transaction (e.g., prepared, committed, rolled back). These logs help to ensure that if a resource manager crashes or the system experiences a failure, the system can recover to a consistent state by using the logs.
If a resource manager receives a commit command but crashes before the transaction is applied, it can use its logs to recover and apply the changes once the system is restored.

Transaction Manager Logs

The transaction manager itself also maintains logs that track the state of the transaction. These logs ensure that even if the transaction manager crashes during the commit or rollback process, the transaction can be recovered.
The transaction manager’s logs also contain information about the interactions with resource managers, including their responses (commit-ready, abort), ensuring a complete record of the transaction’s lifecycle.

This architecture guarantees that distributed transactions are handled correctly and consistently, even in the face of system failures, ensuring that data integrity is maintained across all systems involved in the XA transaction.

How to Enable XA transactions in Quarkus

In this section we will see code examples how to enable XA transactions in Quarkus.
First we see an example how to enable XA in Camel, then we see an annotation-based approach, then the QuarkusTransaction based and last but not least the programatically approach.

Camel

To enable XA in Camel we need to provide a PlatformTransactionManager which we can create with the UserTransaction and TransactionManager from Jakarta.
This PlatformTransactionManager then needs to be set as TransactionManager to all resources participating in the XA transaction.
In the following example the Camel JMS component is configured to enable XA transactions. Additionally, we will see how Camel components participate in the XA transaction using the .sql() and .jdbc() components.

Dependencies

  • camel-quarkus-jta which includes jakarta.transaction-api for XA-support (UserTransaction, TransactionManager)
  • camel-quarkus-sql for SqlComponent
  • camel-quarkus-jdbc for JdbcComponent
  • camel-quarkus-jms for JmsComponent includes spring-tx for the PlatformTransactionManager, JtaTransactionManager
  • quarkus-pooled-jms for XA in JMS

Configuration

  • quarkus.pooled-jms-transaction=xa so JMS uses XA transactions
  • quarkus.datasource.<MYDB>.jdbc.transactions=xa so the DB uses XA transactions

Implementation of enabling XA in Camel

@ApplicationScoped
@Slf4j
public class CamelComponentConfigurer {

    private final PlatformTransactionManager platformTransactionManager;

    public CamelComponentConfigurer(UserTransaction userTransaction, (1)
            TransactionManager transactionManager) { (2)
        platformTransactionManager =
                new JtaTransactionManager(
                	Objects.requireNonNull(userTransaction,
                	 "User transaction must not be null."),
                    Objects.requireNonNull(transactionManager,
                     "Transaction manager must not be null.")); (3)
         log.info(
                "Using the runtime user transaction '{}' and transaction manager '{}'"
                 + "to create the platform transaction manager '{}'.",
                userTransaction, transactionManager, platformTransactionManager);
    }

    public void onComponentAdd(@Observes ComponentAddEvent event,
            ConnectionFactory connectionFactory) { (4)
        (4) if (event.getComponent() instanceof JmsComponent jmsComponent) { (5)
            jmsComponent.getConfiguration().setSynchronous(true);
            jmsComponent.setConnectionFactory(connectionFactory);
            jmsComponent.setTransactionManager(platformTransactionManager); (6)
            log.info("Using PlatformTransactionManager: {} for the JMS-component",
                    platformTransactionManager);
        }
    }
}

1,2) UserTransaction, TransactionManager objects provided by Jakarta

3) Creating the PlatformTransactionManager object from SpringFramework

4) Event triggered method via ComponentAddEvent, which fires whenever a Camel component is added

5) Checks if added component is a JmsComponent, if so 6) adds the created platformTransactionManager as TransactionManager

Now we have enabled XA and each incoming message on the JMS endpoint now automatically opens an XA transaction. This also means, that no .transacted() should be set on components and routes. Doing so would open nested local transactions, leading to a SynchedLocalTransactionFailedException: 'Local <Endpoint> transaction failed to commit'.
In the following route, a message is read from a JMS queue and then via the .sql() endpoint data is inserted into the DB. Since the JMS endpoint opens an XA transaction, Camel can automatically register the .sql() component within this XA transaction. If an error were to occur, like i forced it in the example via the IllegalStateException, both the SQL insert and the JMS message would be rolled back.

from(jms("queue:" + "mySqlQueue"))
    .routeId("mySqlRoute")
    .log("Received message: ${body}")
    .setHeader("fileName", constant("myFile")) 
    .to(sql("INSERT INTO FILES (FILENAME) VALUES (:#fileName)?dataSource=#MYDB"))
    .log("File with fileName=${header.fileName} inserted into the database")
    .throwException(new IllegalStateException("Triggers rollback to showcase XA transaction"))
    .end();

Camel is very helpful here and allows components that are XA-capable to automatically participate in the XA transaction. However, there are exceptions. Naively, let us try the same approach with the JDBC component in the following route.

from(jms("queue:myJdbcFailQueue"))
    .routeId("myJdbcFailRoute")
    .log("Received message: ${body}")
    .setHeader("fileName", constant("myFile"))
    .setBody(constant("INSERT INTO FILES (FILENAME) VALUES (:?fileName)"))
    .to(jdbc("MYDB").useHeadersAsParameters(true))
    .log("File with fileName=${header.fileName} inserted into the database")
    .throwException(new IllegalStateException("Triggers rollback to showcase XA transaction"))
    .end();

Contrary to our expectations, the SQLException: 'Attempting to commit while taking part in a transaction' is thrown. This happens because, within the XA transaction opened by the JMS endpoint, a new transaction is started and committed by the JDBC endpoint. By reviewing the JDBC component documentation, we learn that we need to set the parameter resetAutoCommit=false, so that the JDBC endpoint’s transaction manager is responsible for committing the transaction. Now the JDBC endpoint participates in the XA transaction opened via the JMS endpoint. With this in mind, the JDBC Endpoint need to be created with resetAutoCommit=false as follows:
.to(jdbc("MYDB").useHeadersAsParameters(true).resetAutoCommit(false))

We have learned how to enable XA transactions in Camel and how Camel automatically allows XA-capable components to participate in the XA transaction. However, we have also seen that this doesn’t always work out of the box, and the components may need to be configured to enable XA.

Annotation-based approach

In this Approach we use @Transactional and let the magic happen via the Quarkus-Annotation

Dependencies

  • quarkus-narayana-jta for XA-support
  • quarkus-jdbc-…​ for the JDBC configuration (in this case: quarkus-jdbc-postgresql)
  • quarkus-hibernate-orm for the EntityManager
  • quarkus-artemis-jms for the general JMS setup
  • quarkus-pooled-jms for the annotation-based XA integration

Configuration

  • quarkus.datasource.transaction=xa so the datasource supports XA
  • quakrus.artemis.xa-enable=false
  • quarkus.pooled-jms-transaction=xa so quarkus-pooled-jms handles XA transactions

Implementation of the Annotation-based approach

public class Endpoint {
  ...
  @POST
  @Transactional (1)
  public Response createNumber(long number) {
    Number toCreate = Number.of(number);
    entityManager.persist(toCreate);
    try (JMSContext context = connectionFactory.createContext(Session.SESSION_TRANSACTED)) { (2)
      context.createProducer().send(context.createTopic(TOPIC_NUMBERS_CREATED), number);
    }
    finalizer.end();
    // @formatter:off
    return Response
        .created(URI.create("%s/%d".formatted(PATH, number)))
        .entity(toCreate)
        .build();
    // @formatter:on
  }
  ...
}

1) Marks this method as transactional. Since we use XA-resources, the method uses XA-transactions

2) We can auto-close the context. Everything else will be handled by quarkus-pooled-jms

Using QuarkusTransaction

In this approach we use QuarkusTransaction.begin(), QuarkusTransaction.commit(), and QuarkusTransaction.rollback() instead of @Transactional to control the transactional behaviour.

Dependencies

  • quarkus-narayana-jta for XA-support
  • quarkus-jdbc-…​ for the JDBC configuration (in this case: quarkus-jdbc-postgresql)
  • quarkus-hibernate-orm for the EntityManager
  • quarkus-artemis-jms for the general JMS setup
  • quarkus-pooled-jms for the annotation-based XA integration

Configuration

  • quarkus.datasource.transaction=xa so the datasource supports XA
  • quakrus.artemis.xa-enable=false
  • quarkus.pooled-jms-transaction=xa so quarkus-pooled-jms handles XA transactions

Implementation of the QuarkusTransaction-based Approach

public class Endpoint {
  ...
  @POST
  public Response createNumber(long number) {
    Number toCreate = Number.of(number);
    QuarkusTransaction.begin(); (1)
    entityManager.persist(toCreate);
    try (JMSContext context = connectionFactory.createContext(Session.SESSION_TRANSACTED)) { (2)
      context.createProducer().send(context.createTopic(TOPIC_NUMBERS_CREATED), number);
    }
    finalizer.end();
    QuarkusTransaction.commit(); (3)
    // @formatter:off
    return Response
        .created(URI.create("%s/%d".formatted(PATH, number)))
        .entity(toCreate)
        .build();
    // @formatter:on
  }
  ...
}

1) Explicit start of transaction

2) We can auto-close the context. Everything else will be handled by quarkus-pooled-jms

3) If no exception occurs, we commit the transaction

Notice that we do not need to catch the exception. If an exception is thrown while the transaction is still open, the transaction is automatically rolled back.

Programmatically approach

In this approach, we will register Artemis JMS and the DB (via EntityManager) programmatically to participate in a XA transaction.

Dependencies

  • quarkus-narayana-jta for XA-support
  • quarkus-jdbc-…​ for the JDBC configuration (in this case: quarkus-jdbc-postgresql)
  • quarkus-hibernate-orm for the EntityManager
  • quarkus-artemis-jms for the general JMS setup

Configuration

  • quakrus.artemis.xa-enable=true so we can inject a XAConnectionFactoryinto our bean

Implementation of the programmatically approach

public class Endpoint {
  public static final String PATH = "numbers";
  public static final String TOPIC_NUMBERS_CREATED = "numbers-created";

  private final TransactionManager transactionManager;
  private final EntityManager entityManager;
  private final XAConnectionFactory xaConnectionFactory;
  private final Finalizer finalizer;

  @POST
  public Response createNumber(long number) throws RollbackException, HeuristicRollbackException,
      HeuristicMixedException, NotSupportedException, SystemException {
    Number toCreate = Number.of(number);
    try {
      transactionManager.begin(); (1)
      entityManager.joinTransaction(); (2)
      entityManager.persist(toCreate); 
      XAJMSContext context = createContextAndRegisterWithTransaction();
      context.createProducer().send(context.createTopic(TOPIC_NUMBERS_CREATED), number);
      finalizer.end();
      transactionManager.commit();
      // @formatter:off
      return Response
          .created(URI.create("%s/%d".formatted(PATH, number)))
          .entity(toCreate)
          .build();
      // @formatter:on
    } catch (HeuristicRollbackException | HeuristicMixedException | NotSupportedException
        | RuntimeException e) {
      transactionManager.rollback();
      throw e;
    }
  }

  private XAJMSContext createContextAndRegisterWithTransaction()
      throws SystemException, RollbackException {
    XAJMSContext context = xaConnectionFactory.createXAContext(); (3)
    Transaction transaction = transactionManager.getTransaction(); (4)
    transaction.enlistResource(context.getXAResource()); (5)
    transaction.registerSynchronization(new Synchronization() {
      @Override
      public void beforeCompletion() {
        // nothing to do
      }

      @Override
      public void afterCompletion(int status) {
        context.close(); (6)
      }
    });
    return context;
  }

1) Begins and opens a transaction

2) Enlist the EnitiyManagerin the transaction

3) Get the current JMS context

4) Get the active transaction

5) Register the current JMS context in the active transaction

6) When the transaction is completed, we need to close the JMS context in order to not leak resources

Enabling Recorvery

The recovery process in XA transactions is crucial for ensuring consistency and durability in distributed transactions, especially when dealing with failures such as system crashes, network issues, or other interruptions that can leave transactions in an incomplete state. If a failure appearse the transaction manager checks ist logs to determine the failure and to begin with the recovery process.

We cannot use the pods filesystem to store the transaction logs, when the pod crashes the logs for the recovery would be lost. Therefore we need a safe place to store the TransactionLogs e.g. a persistend volume or a database.
To write the logs in a persitendVolume we need to set the following config values:

First we need to config our transaction manager to be able to recover from failures.
quarkus.transaction-manager.enable-recovery=true
Then we need to pass the path to the persistent volume to the transaction manager.
quarkus.transaction-manager.object-store.directory= <PathtoPersistentVolume>

To ensure that each transaction is correctly recovered, it’s important to configure the TransactionManager and ResourceManagers with identifiers that are unique to each instance. This allows transactions to „know“ where they belong and ensures that logs can be tied back to the correct resource.

For other log storage e.g. DB visit: https://quarkus.io/guides/all-config#quarkus-narayana-jta_quarkus-transaction-manager-object-store-type:~:text=quarkus.transaction%2Dmanager.object%2Dstore.datasource

Drawbacks

What are the drawbacks using XA transactions

Overhead from Two-Phase Commit

The protocol requires multiple round trips between the transaction manager and the participating resource managers. While the 2PC protocol assures atomicity, it also increase latency and reduce throughput, particularly when dealing with a high volume of transactions

Complexity in Failure Handling

Handling failures in XA transactions is more complicated compared to local transactions. Since there are multiple participants, recovering from failures or rollbacks can be tricky. In the worst case, a transaction may enter a „pending“ state, and manual intervention might be needed to resolve inconsistencies.

Resource Locking

XA transactions typically lock resources for a longer period due to the two-phase commit protocol. This may cause bottlenecks, especially if there are many concurrent transactions. This increases the risk of contention and resource exhaustion, particularly in systems with heavy transactional load.

Scalability Concerns

As the number of participating resource managers increases, the coordination required in the two-phase commit grows more and more complex and therefore becomes slower. This can lead to scalability issues, especially in large systems where transactions span multiple services and databases.

Integration Complexity

Integrating various systems and services (e.g., different databases, message brokers, and queues) into a distributed transaction using XA can be challenging. Ensuring that all resources support XA and configuring them correctly can be time-consuming and error-prone.

Future Outlook

When looking ahead, the use of XA transactions continues to be relevant for applications that require strong consistency and atomicity across distributed systems. However, there are several evolving trends and potential improvements that could mitigate the challenges associated with XA transactions:

Integration with SAGA and Compensation-Based Patterns

As an alternative to XA, the SAGA pattern is gaining popularity for managing distributed transactions. Instead of relying on a two-phase commit, SAGA splits a transaction into a series of smaller, compensatable steps. This approach can provide more flexibility and better performance in distributed systems, particularly where failure handling is concerned. Tools and frameworks for orchestrating SAGA transactions, such as the „Compensating Transaction“ pattern, are becoming more widely used and could provide a more scalable alternative.

Performance Improvements in Transaction Managers

As Narayana and other transaction managers continue to evolve, we can expect performance improvements. Techniques such as optimized communication between resources, more efficient coordination during 2PC, and reduced lock contention may help alleviate some of the performance concerns associated with XA transactions.

Sources

Quarkus Transactions documentation:
https://quarkus.io/guides/transaction
Apache Camel Transactions documentation:
https://camel.apache.org/components/4.8.x/eips/transactional-client.html
QuarkusTransaction, Annotation-based and programmatically approach:
https://github.com/turing85/quarkus-xa
SAGA-Pattern:
https://de.wikipedia.org/wiki/Saga_(Entwurfsmuster)

Acknowledgments

I would like to express my gratitude to Marco, Bungart aka turing85, who provided the code for the Quarkus XA Annotation-based, QuarkusTransaction and programmatically approaches. He has been extremely helpful and always available to assist with any questions I had. Thank you for your invaluable support!

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Nach oben scrollen
WordPress Cookie Hinweis von Real Cookie Banner