Distributed XA¶
A single XA transaction spanning both JMS and JDBC, demonstrating two-phase commit with all-or-nothing guarantees across ActiveMQ Artemis and PostgreSQL.
What You'll Learn¶
- How to configure distributed XA transactions across JMS and JDBC resources
- How Forage coordinates a shared Narayana transaction manager for both providers
- Two-phase commit behavior: either all operations succeed or all roll back
- How rollback messages trigger automatic redelivery and eventually reach the dead letter queue
Prerequisites¶
- Java 17 or later
- Camel JBang with the Forage plugin installed
Start ActiveMQ Artemis:
Start PostgreSQL with prepared transactions enabled:
docker run -d --name camel-postgres -p 5432:5432 \
-e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=postgres \
postgres:16 -c max_prepared_transactions=100
PostgreSQL must allow prepared transactions
A transaction spanning two resources uses two-phase commit: Narayana issues PREPARE TRANSACTION against PostgreSQL, which fails with ERROR: prepared transactions are disabled when max_prepared_transactions is 0 — and 0 is both the PostgreSQL default and what camel infra run postgres starts with. Start PostgreSQL with -c max_prepared_transactions=100 as shown above, or on an existing server run ALTER SYSTEM SET max_prepared_transactions = 100; and restart it. The single-resource JDBC examples don't need this: with only one participant, Narayana uses the one-phase-commit optimization and never issues PREPARE TRANSACTION.
Create the database schema:
docker exec -i camel-postgres psql -U test -d postgres -c \
"CREATE TABLE IF NOT EXISTS test (id INTEGER PRIMARY KEY, action VARCHAR(255));"
Configuration¶
Create application.properties:
# ── JMS (ActiveMQ Artemis) ──────────────────────────────────
forage.myBroker.jms.kind=artemis
forage.myBroker.jms.broker.url=tcp://localhost:61616
forage.myBroker.jms.username=artemis
forage.myBroker.jms.password=artemis
# Connection pool
forage.myBroker.jms.pool.enabled=true
forage.myBroker.jms.pool.max.connections=10
forage.myBroker.jms.pool.max.sessions.per.connection=500
forage.myBroker.jms.pool.idle.timeout.millis=30000
forage.myBroker.jms.pool.connection.timeout.millis=30000
forage.myBroker.jms.pool.block.if.full=true
# XA transactions — shared node ID links JMS and JDBC
# into the same Narayana transaction manager instance
forage.myBroker.jms.transaction.enabled=true
forage.myBroker.jms.transaction.timeout.seconds=30
forage.myBroker.jms.transaction.node.id=xa-node1
forage.myBroker.jms.transaction.enable.recovery=true
forage.myBroker.jms.transaction.object.store.directory=tx-object-store
forage.myBroker.jms.transaction.object.store.type=file-system
# ── JDBC (PostgreSQL) ──────────────────────────────────────
forage.myDatabase.jdbc.db.kind=postgresql
forage.myDatabase.jdbc.url=jdbc:postgresql://localhost:5432/postgres
forage.myDatabase.jdbc.username=test
forage.myDatabase.jdbc.password=test
# Connection pool
forage.myDatabase.jdbc.pool.initial.size=5
forage.myDatabase.jdbc.pool.min.size=2
forage.myDatabase.jdbc.pool.max.size=20
forage.myDatabase.jdbc.pool.acquisition.timeout.seconds=5
forage.myDatabase.jdbc.pool.validation.timeout.seconds=3
forage.myDatabase.jdbc.pool.leak.timeout.minutes=10
forage.myDatabase.jdbc.pool.idle.validation.timeout.minutes=3
# XA transactions — same node ID as JMS
forage.myDatabase.jdbc.transaction.enabled=true
forage.myDatabase.jdbc.transaction.timeout.seconds=30
forage.myDatabase.jdbc.transaction.node.id=xa-node1
forage.myDatabase.jdbc.transaction.enable.recovery=true
forage.myDatabase.jdbc.transaction.object.store.directory=tx-object-store
forage.myDatabase.jdbc.transaction.object.store.type=file-system
The critical detail is the shared transaction.node.id=xa-node1 across both JMS and JDBC configurations. This tells Forage to use the same Narayana transaction manager instance for both resources, enabling distributed two-phase commit.
Route¶
# Transaction processing route — consumes from JMS, inserts into DB,
# produces to JMS, all within a single XA transaction
- route:
id: xa-processing-route
from:
uri: jms
parameters:
destinationName: in
# transacted stays false with XA: the JTA transaction manager wired into the
# JMS component drives the transaction (a local JMS transaction on an XA
# connection is rejected by brokers such as IBM MQ)
cacheLevelName: CACHE_NONE
steps:
- transacted:
ref: PROPAGATION_REQUIRED
- log:
message: >-
Start transaction with message ${body}
and event id ${headers.eventId}
- toD:
uri: sql
parameters:
dataSource: "#myDatabase"
query: >-
INSERT INTO test (id, action)
VALUES (${headers.eventId}, 'test')
- log:
message: Query executed successfully
- to:
uri: jms
parameters:
destinationName: out
- choice:
when:
- simple:
expression: ${body} == "ROLLBACK"
steps:
- throwException:
exceptionType: java.lang.RuntimeException
message: Rollback transaction
otherwise:
steps:
- log:
message: End transaction with message ${body}
# Message generator — produces test messages every 5 seconds,
# ~40% are ROLLBACK messages to demonstrate rollback behavior.
# The transacted step is required: with XA enabled, sends outside a JTA
# transaction are never enlisted and brokers such as IBM MQ silently
# discard them (Artemis auto-commits them, masking the problem)
- route:
id: message-generator-route
from:
uri: timer:template
parameters:
period: "5000"
steps:
- transacted:
ref: PROPAGATION_REQUIRED
- setHeader:
name: eventId
simple:
expression: ${random(0,10000)}
- choice:
when:
- simple:
expression: ${random(0,10)} > 5
steps:
- log:
message: Sending rollback message
- setBody:
simple:
expression: ROLLBACK
- to:
uri: jms
parameters:
destinationName: in
otherwise:
steps:
- log:
message: Sending OK message
- setBody:
simple:
expression: OK
- to:
uri: jms
parameters:
destinationName: in
The example has two routes:
Transaction processing route -- consumes a message from the in queue inside an XA transaction, then performs three operations within the same transaction boundary:
- Inserts a row into the
testtable using theeventIdheader as the primary key - Sends the message to the
outqueue - Checks the body -- if it is
ROLLBACK, throws an exception to trigger a full rollback
When the exception is thrown, all three operations are undone atomically: the database insert is rolled back, the message to out is cancelled, and the original message returns to the in queue for redelivery.
Message generator route -- a timer fires every 5 seconds, generates a random eventId header, and sends either an OK or ROLLBACK message to the in queue (roughly 40% rollbacks).
Running¶
Watch the logs to observe the two flows:
Successful flow (body = OK):
Start transaction with message OK and event id 4821
Query executed successfully
End transaction with message OK
The database row persists and the message arrives in the out queue.
Rollback flow (body = ROLLBACK):
Start transaction with message ROLLBACK and event id 7392
Query executed successfully
[Exception: Rollback transaction]
Despite the insert having executed, the database row is removed by the XA rollback. The message returns to in for redelivery. After the broker's maximum redelivery attempts, the message moves to the DLQ.
You can verify atomicity by checking the database -- no rows will exist for ROLLBACK messages, even though the INSERT was executed before the rollback:
Monitor queue depths in the Artemis web console at http://localhost:8161/console (credentials: artemis / artemis).
Key Takeaways¶
- Shared transaction manager -- using the same
transaction.node.idacross JMS and JDBC configurations links both resources into a single Narayana XA coordinator. - True atomicity -- the two-phase commit protocol guarantees that either all operations (JMS consume, DB insert, JMS produce) succeed together or all roll back together.
- No partial state -- without XA, a successful database insert followed by a failed JMS send would leave orphaned data. With XA, this cannot happen.
- Recovery after crashes -- with
transaction.enable.recovery=trueForage starts Narayana's periodic recovery manager and registers a recovery helper per broker and datasource; the file-system object store (tx-object-store/) persists the transaction logs it replays to resolve in-doubt transactions after a restart. Use an absolute object store path and a stabletransaction.node.idso the restarted instance finds its own logs (see the JMS and JDBC module pages). - Zero coordination code -- Forage handles all XA wiring (enlisting resources, two-phase commit, recovery) through properties alone.