Skip to main content

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:

  1. flushes pending ORM changes,
  2. prepares every enlisted two-phase transaction in branch order, for example 0 -> 1 -> 2,
  3. 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 raises InvalidRecoveryStateError. 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sqlalchemy_xa_recovery-0.1.0.tar.gz (66.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sqlalchemy_xa_recovery-0.1.0-py3-none-any.whl (14.7 kB view details)

Uploaded Python 3

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

Hashes for sqlalchemy_xa_recovery-0.1.0.tar.gz
Algorithm Hash digest
SHA256 41caf09571616e6429ea34f0292f441bd37baed2aa09fc495ed2f054b551465f
MD5 671224aec31dd735e1f4ddace4a8fc73
BLAKE2b-256 4322f199b580a3755fc9f1015205ad9082020c5a91f7dee45870fbcd90b07ebe

See more details on using hashes here.

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

Hashes for sqlalchemy_xa_recovery-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 1161686490abdf9dea082bc9f49cf37629441a3ab229b7552fa1185890f5f235
MD5 2fd1a8171b61705968e7eb522f163823
BLAKE2b-256 f7b941b53e35b27e547aa9ed528402875cadf0044cb8ab29ed79be388a79f8ff

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page