Recoverable two-phase transaction helpers for SQLAlchemy
Project description
sqlalchemy-xa-recovery
Recoverable two-phase transaction helpers for SQLAlchemy.
sqlalchemy-xa-recovery helps coordinate one logical transaction across multiple
SQLAlchemy engines and provides a recovery routine for prepared XA transactions
left behind after process crashes, connection failures, or uncertain commit
outcomes.
Why this library exists
SQLAlchemy already exposes two-phase transaction primitives, but using them directly leaves important operational work to the application:
- generating transaction identifiers that can be understood later,
- starting matching transaction branches across multiple engines,
- handling failures during prepare or commit,
- discovering prepared transactions after a crash,
- deciding whether an incomplete distributed transaction should be committed or rolled back.
This library adds those missing pieces around SQLAlchemy's two-phase support. It does not replace your database XA implementation; it gives your application a small, recoverable coordination layer on top of it.
Installation
pip install sqlalchemy-xa-recovery
The package requires Python 3.11 or newer and SQLAlchemy 2.x.
Supported databases
The current implementation supports:
- PostgreSQL
- MySQL
- MariaDB
Database-specific recovery is handled internally. PostgreSQL uses
pg_prepared_xacts, MySQL and MariaDB use XA RECOVER.
Basic usage
Pass a mapping of ORM mapped classes to SQLAlchemy engines. The context manager opens one connection per participating engine, starts one two-phase transaction branch per engine, and returns a SQLAlchemy session bound to those connections. If several mapped classes are bound to the same engine, they share that engine's connection and transaction branch.
from sqlalchemy import create_engine
from sqlalchemy_xa_recovery import two_phase_session
from myapp.models import Account, LedgerEntry
accounts_engine = create_engine("postgresql+psycopg://user:pass@db-a/app")
ledger_engine = create_engine("postgresql+psycopg://user:pass@db-b/app")
binds = {
Account: accounts_engine,
LedgerEntry: ledger_engine,
}
with two_phase_session(binds) as session:
session.add(Account(id=1, balance=90))
session.add(LedgerEntry(account_id=1, amount=-10))
session.commit()
If the commit completes successfully, all transaction branches are committed. If regular application code raises before commit, the active branches are rolled back.
By default, generated XA identifiers use the sxr prefix so recovery can
distinguish this library's transactions from other prepared transactions in the
same database. Use XidPrefix to customize that marker for a deployment; custom
prefixes must contain only lowercase letters and be between one and eight
characters long:
from sqlalchemy_xa_recovery import XidPrefix
with two_phase_session(binds, xid_prefix=XidPrefix("billing")) as session:
# write to multiple databases
session.commit()
Async usage
The async API follows the same shape, using AsyncEngine objects and
async_two_phase_session.
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy_xa_recovery import async_two_phase_session
from myapp.models import Account, LedgerEntry
accounts_engine = create_async_engine("postgresql+psycopg_async://user:pass@db-a/app")
ledger_engine = create_async_engine("postgresql+psycopg_async://user:pass@db-b/app")
binds = {
Account: accounts_engine,
LedgerEntry: ledger_engine,
}
async with async_two_phase_session(binds) as session:
session.add(Account(id=1, balance=90))
session.add(LedgerEntry(account_id=1, amount=-10))
await session.commit()
Recovery
When a failure happens during two-phase commit, the outcome may be unknown: some
databases may already have committed while others may still hold prepared
transactions. In that case the session raises XAOutcomeUnknownError.
Recovery is designed to run as a separate recurring operational task. That task
should call recover_xa_transactions() often enough for your tolerance for
stuck prepared transactions.
from datetime import timedelta
from sqlalchemy import create_engine
from sqlalchemy_xa_recovery import XidPrefix, recover_xa_transactions
engines = [
create_engine("postgresql+psycopg://user:pass@db-a/app"),
create_engine("postgresql+psycopg://user:pass@db-b/app"),
]
recover_xa_transactions(
all_engines=engines,
grace_period=timedelta(seconds=15),
xid_prefix=XidPrefix("billing"),
)
Run this script with cron, a scheduler, a maintenance worker, or your orchestration platform.
Always pass every engine that can be part of the distributed transaction group.
Recovery decisions are made by comparing prepared transaction identifiers across
the full set of participating databases. If you customize xid_prefix for
writers, use the same prefix in the recurring recovery process.
recover_xa_transactions() is synchronous. For transactions written with
async_two_phase_session(), pass the synchronous engines behind the async
engines, for example my_async_engine.sync_engine.
How it works
For each distributed transaction, the library generates a shared base identifier and one database-specific transaction identifier. The serialized identifier contains the prefix, a shared random transaction part, the number of participating databases, and the branch index.
For a transaction spanning three databases, the branch XIDs may look like this:
sxr:4f9c2a1b0e3d7788:3:0
sxr:4f9c2a1b0e3d7788:3:1
sxr:4f9c2a1b0e3d7788:3:2
Here sxr is the prefix, 4f9c2a1b0e3d7788 is the shared random part, 3 is
the database count, and the final value is the branch index.
During commit(), the session:
- flushes pending ORM changes,
- prepares every enlisted two-phase transaction in branch order, for example
0 -> 1 -> 2, - commits the prepared transactions in the same order, for example
0 -> 1 -> 2.
If prepare or commit fails, the library raises XAOutcomeUnknownError because
the application can no longer safely infer the final outcome from the local
process alone.
Recovery works by scanning each database for prepared transactions, waiting for the configured grace period, and scanning again. Only transaction identifiers that are still present after the grace period are considered stuck.
Stuck prepared branches are grouped by their shared transaction identifier. With
full branch order [0, 1, 2], sequential PREPARE adds prepared branches from
the start: [0], then [0, 1], then [0, 1, 2]. Sequential COMMIT removes
prepared branches from the start: [0, 1, 2], then [1, 2], then [2], then
none.
Recovery decisions follow from that shape:
[0]and[0, 1]are prefixes, so recovery treats the prepare phase as incomplete and rolls them back in descending branch order;[0, 1, 2],[1, 2], and[2]are treated as suffixes, so recovery treats the transaction as fully prepared or already committing and commits them in ascending branch order;[1]and[0, 2]are neither prefixes nor suffixes, so recovery raisesInvalidRecoveryStateError. The same error is raised if the XID references more databases than the configured engine list contains.
This gives the application a deterministic way to finish incomplete distributed transactions after an uncertain failure.
Operational notes
PostgreSQL must allow prepared transactions (max_prepared_transactions > 0).
Prepared transactions hold database resources until they are committed or rolled back. Monitor them in production and run recovery regularly. The grace period keeps recovery from touching transactions that are still being actively completed by another process.
Development
This repository uses uv for local development.
uv sync
uv run pytest
The test suite uses Testcontainers and requires Docker. It starts PostgreSQL, MySQL, and MariaDB containers to verify both synchronous and asynchronous transaction flows.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sqlalchemy_xa_recovery-0.1.0.tar.gz.
File metadata
- Download URL: sqlalchemy_xa_recovery-0.1.0.tar.gz
- Upload date:
- Size: 66.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
41caf09571616e6429ea34f0292f441bd37baed2aa09fc495ed2f054b551465f
|
|
| MD5 |
671224aec31dd735e1f4ddace4a8fc73
|
|
| BLAKE2b-256 |
4322f199b580a3755fc9f1015205ad9082020c5a91f7dee45870fbcd90b07ebe
|
File details
Details for the file sqlalchemy_xa_recovery-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sqlalchemy_xa_recovery-0.1.0-py3-none-any.whl
- Upload date:
- Size: 14.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.26 {"installer":{"name":"uv","version":"0.11.26","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1161686490abdf9dea082bc9f49cf37629441a3ab229b7552fa1185890f5f235
|
|
| MD5 |
2fd1a8171b61705968e7eb522f163823
|
|
| BLAKE2b-256 |
f7b941b53e35b27e547aa9ed528402875cadf0044cb8ab29ed79be388a79f8ff
|