sqlalchemy-xa-recovery 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sqlalchemy_xa_recovery-0.1.0/.github/workflows/ci.yml +81 -0
- sqlalchemy_xa_recovery-0.1.0/.github/workflows/publish.yml +33 -0
- sqlalchemy_xa_recovery-0.1.0/.gitignore +12 -0
- sqlalchemy_xa_recovery-0.1.0/.pre-commit-config.yaml +21 -0
- sqlalchemy_xa_recovery-0.1.0/LICENSE +21 -0
- sqlalchemy_xa_recovery-0.1.0/PKG-INFO +254 -0
- sqlalchemy_xa_recovery-0.1.0/README.md +232 -0
- sqlalchemy_xa_recovery-0.1.0/pyproject.toml +68 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/__init__.py +25 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_context_managers/__init__.py +0 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_context_managers/_helpers.py +24 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_context_managers/asynchronous.py +61 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_context_managers/synchronous.py +65 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/__init__.py +0 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/base.py +35 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/factory.py +25 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/mysql.py +31 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/postgres.py +35 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_recovery.py +104 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_session.py +100 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_value_objects.py +116 -0
- sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/py.typed +1 -0
- sqlalchemy_xa_recovery-0.1.0/tests/asynchronous/__init__.py +0 -0
- sqlalchemy_xa_recovery-0.1.0/tests/asynchronous/test_context_manager.py +78 -0
- sqlalchemy_xa_recovery-0.1.0/tests/asynchronous/test_recover_xa_transactions.py +151 -0
- sqlalchemy_xa_recovery-0.1.0/tests/asynchronous/test_session.py +80 -0
- sqlalchemy_xa_recovery-0.1.0/tests/conftest.py +145 -0
- sqlalchemy_xa_recovery-0.1.0/tests/support/__init__.py +1 -0
- sqlalchemy_xa_recovery-0.1.0/tests/support/containers.py +56 -0
- sqlalchemy_xa_recovery-0.1.0/tests/support/helpers.py +74 -0
- sqlalchemy_xa_recovery-0.1.0/tests/support/orm.py +80 -0
- sqlalchemy_xa_recovery-0.1.0/tests/synchronous/__init__.py +0 -0
- sqlalchemy_xa_recovery-0.1.0/tests/synchronous/test_context_manager.py +58 -0
- sqlalchemy_xa_recovery-0.1.0/tests/synchronous/test_recover_xa_transactions.py +125 -0
- sqlalchemy_xa_recovery-0.1.0/tests/synchronous/test_session.py +59 -0
- sqlalchemy_xa_recovery-0.1.0/tests/test_value_objects.py +64 -0
- sqlalchemy_xa_recovery-0.1.0/uv.lock +826 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
quality:
|
|
9
|
+
name: Quality
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
timeout-minutes: 15
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- name: Check out repository
|
|
15
|
+
uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Install uv
|
|
18
|
+
uses: astral-sh/setup-uv@v6
|
|
19
|
+
with:
|
|
20
|
+
enable-cache: true
|
|
21
|
+
|
|
22
|
+
- name: Install Python
|
|
23
|
+
run: uv python install 3.14
|
|
24
|
+
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: uv sync --python 3.14 --all-groups --frozen
|
|
27
|
+
|
|
28
|
+
- name: Check formatting
|
|
29
|
+
run: uv run --no-sync ruff format --check --config pyproject.toml
|
|
30
|
+
|
|
31
|
+
- name: Lint
|
|
32
|
+
run: uv run --no-sync ruff check --config pyproject.toml
|
|
33
|
+
|
|
34
|
+
- name: Type check
|
|
35
|
+
run: uv run --no-sync ty check .
|
|
36
|
+
|
|
37
|
+
tests:
|
|
38
|
+
name: Python ${{ matrix.python-version }} / ${{ matrix.sqlalchemy-label }}
|
|
39
|
+
runs-on: ubuntu-latest
|
|
40
|
+
timeout-minutes: 60
|
|
41
|
+
|
|
42
|
+
strategy:
|
|
43
|
+
fail-fast: false
|
|
44
|
+
matrix:
|
|
45
|
+
include:
|
|
46
|
+
- python-version: "3.11"
|
|
47
|
+
sqlalchemy-label: "SQLAlchemy 2.0.50"
|
|
48
|
+
sqlalchemy-spec: "sqlalchemy[asyncio]==2.0.50"
|
|
49
|
+
- python-version: "3.12"
|
|
50
|
+
sqlalchemy-label: "latest SQLAlchemy 2.x"
|
|
51
|
+
sqlalchemy-spec: "sqlalchemy[asyncio]>=2,<3"
|
|
52
|
+
- python-version: "3.13"
|
|
53
|
+
sqlalchemy-label: "latest SQLAlchemy 2.x"
|
|
54
|
+
sqlalchemy-spec: "sqlalchemy[asyncio]>=2,<3"
|
|
55
|
+
- python-version: "3.14"
|
|
56
|
+
sqlalchemy-label: "latest SQLAlchemy 2.x"
|
|
57
|
+
sqlalchemy-spec: "sqlalchemy[asyncio]>=2,<3"
|
|
58
|
+
|
|
59
|
+
steps:
|
|
60
|
+
- name: Check out repository
|
|
61
|
+
uses: actions/checkout@v4
|
|
62
|
+
|
|
63
|
+
- name: Install uv
|
|
64
|
+
uses: astral-sh/setup-uv@v6
|
|
65
|
+
with:
|
|
66
|
+
enable-cache: true
|
|
67
|
+
|
|
68
|
+
- name: Install Python
|
|
69
|
+
run: uv python install ${{ matrix.python-version }}
|
|
70
|
+
|
|
71
|
+
- name: Install project dependencies
|
|
72
|
+
run: uv sync --python ${{ matrix.python-version }} --all-groups --frozen
|
|
73
|
+
|
|
74
|
+
- name: Select SQLAlchemy version
|
|
75
|
+
run: uv pip install --python .venv/bin/python --upgrade "${{ matrix.sqlalchemy-spec }}"
|
|
76
|
+
|
|
77
|
+
- name: Show versions
|
|
78
|
+
run: uv run --no-sync python -c "import sys, sqlalchemy; print(sys.version); print(sqlalchemy.__version__)"
|
|
79
|
+
|
|
80
|
+
- name: Run tests
|
|
81
|
+
run: uv run --no-sync pytest
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
name: Build and publish package
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
environment: pypi
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
id-token: write
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- name: Check out repository
|
|
19
|
+
uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
uses: astral-sh/setup-uv@v8.2.0
|
|
23
|
+
|
|
24
|
+
- name: Set up Python
|
|
25
|
+
uses: actions/setup-python@v6
|
|
26
|
+
with:
|
|
27
|
+
python-version: "3.11"
|
|
28
|
+
|
|
29
|
+
- name: Build package
|
|
30
|
+
run: uv build --no-sources
|
|
31
|
+
|
|
32
|
+
- name: Publish package
|
|
33
|
+
run: uv publish
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: local
|
|
3
|
+
hooks:
|
|
4
|
+
- id: ruff-check
|
|
5
|
+
name: ruff check
|
|
6
|
+
entry: ruff check --fix --config pyproject.toml
|
|
7
|
+
language: system
|
|
8
|
+
types: [python]
|
|
9
|
+
|
|
10
|
+
- id: ruff-format
|
|
11
|
+
name: ruff format
|
|
12
|
+
entry: ruff format --config pyproject.toml
|
|
13
|
+
language: system
|
|
14
|
+
types: [python]
|
|
15
|
+
|
|
16
|
+
- id: ty
|
|
17
|
+
name: ty check
|
|
18
|
+
entry: ty check .
|
|
19
|
+
language: system
|
|
20
|
+
pass_filenames: false
|
|
21
|
+
always_run: true
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sqlalchemy-xa-recovery contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sqlalchemy-xa-recovery
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Recoverable two-phase transaction helpers for SQLAlchemy
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: 2pc,distributed-transactions,mariadb,mysql,postgresql,prepared-transactions,sqlalchemy,transaction-coordinator,transaction-recovery,two-phase-commit,xa-transactions
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Topic :: Database
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: sqlalchemy[asyncio]<3,>=2.0.50
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# sqlalchemy-xa-recovery
|
|
24
|
+
|
|
25
|
+
Recoverable two-phase transaction helpers for SQLAlchemy.
|
|
26
|
+
|
|
27
|
+
`sqlalchemy-xa-recovery` helps coordinate one logical transaction across multiple
|
|
28
|
+
SQLAlchemy engines and provides a recovery routine for prepared XA transactions
|
|
29
|
+
left behind after process crashes, connection failures, or uncertain commit
|
|
30
|
+
outcomes.
|
|
31
|
+
|
|
32
|
+
## Why this library exists
|
|
33
|
+
|
|
34
|
+
SQLAlchemy already exposes two-phase transaction primitives, but using them
|
|
35
|
+
directly leaves important operational work to the application:
|
|
36
|
+
|
|
37
|
+
- generating transaction identifiers that can be understood later,
|
|
38
|
+
- starting matching transaction branches across multiple engines,
|
|
39
|
+
- handling failures during prepare or commit,
|
|
40
|
+
- discovering prepared transactions after a crash,
|
|
41
|
+
- deciding whether an incomplete distributed transaction should be committed or
|
|
42
|
+
rolled back.
|
|
43
|
+
|
|
44
|
+
This library adds those missing pieces around SQLAlchemy's two-phase support. It
|
|
45
|
+
does not replace your database XA implementation; it gives your application a
|
|
46
|
+
small, recoverable coordination layer on top of it.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install sqlalchemy-xa-recovery
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The package requires Python 3.11 or newer and SQLAlchemy 2.x.
|
|
55
|
+
|
|
56
|
+
## Supported databases
|
|
57
|
+
|
|
58
|
+
The current implementation supports:
|
|
59
|
+
|
|
60
|
+
- PostgreSQL
|
|
61
|
+
- MySQL
|
|
62
|
+
- MariaDB
|
|
63
|
+
|
|
64
|
+
Database-specific recovery is handled internally. PostgreSQL uses
|
|
65
|
+
`pg_prepared_xacts`, MySQL and MariaDB use `XA RECOVER`.
|
|
66
|
+
|
|
67
|
+
## Basic usage
|
|
68
|
+
|
|
69
|
+
Pass a mapping of ORM mapped classes to SQLAlchemy engines. The context manager
|
|
70
|
+
opens one connection per participating engine, starts one two-phase transaction
|
|
71
|
+
branch per engine, and returns a SQLAlchemy session bound to those connections.
|
|
72
|
+
If several mapped classes are bound to the same engine, they share that engine's
|
|
73
|
+
connection and transaction branch.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from sqlalchemy import create_engine
|
|
77
|
+
|
|
78
|
+
from sqlalchemy_xa_recovery import two_phase_session
|
|
79
|
+
from myapp.models import Account, LedgerEntry
|
|
80
|
+
|
|
81
|
+
accounts_engine = create_engine("postgresql+psycopg://user:pass@db-a/app")
|
|
82
|
+
ledger_engine = create_engine("postgresql+psycopg://user:pass@db-b/app")
|
|
83
|
+
|
|
84
|
+
binds = {
|
|
85
|
+
Account: accounts_engine,
|
|
86
|
+
LedgerEntry: ledger_engine,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
with two_phase_session(binds) as session:
|
|
90
|
+
session.add(Account(id=1, balance=90))
|
|
91
|
+
session.add(LedgerEntry(account_id=1, amount=-10))
|
|
92
|
+
|
|
93
|
+
session.commit()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
If the commit completes successfully, all transaction branches are committed. If
|
|
97
|
+
regular application code raises before commit, the active branches are rolled
|
|
98
|
+
back.
|
|
99
|
+
|
|
100
|
+
By default, generated XA identifiers use the `sxr` prefix so recovery can
|
|
101
|
+
distinguish this library's transactions from other prepared transactions in the
|
|
102
|
+
same database. Use `XidPrefix` to customize that marker for a deployment; custom
|
|
103
|
+
prefixes must contain only lowercase letters and be between one and eight
|
|
104
|
+
characters long:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from sqlalchemy_xa_recovery import XidPrefix
|
|
108
|
+
|
|
109
|
+
with two_phase_session(binds, xid_prefix=XidPrefix("billing")) as session:
|
|
110
|
+
# write to multiple databases
|
|
111
|
+
session.commit()
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Async usage
|
|
115
|
+
|
|
116
|
+
The async API follows the same shape, using `AsyncEngine` objects and
|
|
117
|
+
`async_two_phase_session`.
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
121
|
+
|
|
122
|
+
from sqlalchemy_xa_recovery import async_two_phase_session
|
|
123
|
+
from myapp.models import Account, LedgerEntry
|
|
124
|
+
|
|
125
|
+
accounts_engine = create_async_engine("postgresql+psycopg_async://user:pass@db-a/app")
|
|
126
|
+
ledger_engine = create_async_engine("postgresql+psycopg_async://user:pass@db-b/app")
|
|
127
|
+
|
|
128
|
+
binds = {
|
|
129
|
+
Account: accounts_engine,
|
|
130
|
+
LedgerEntry: ledger_engine,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async with async_two_phase_session(binds) as session:
|
|
134
|
+
session.add(Account(id=1, balance=90))
|
|
135
|
+
session.add(LedgerEntry(account_id=1, amount=-10))
|
|
136
|
+
|
|
137
|
+
await session.commit()
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Recovery
|
|
141
|
+
|
|
142
|
+
When a failure happens during two-phase commit, the outcome may be unknown: some
|
|
143
|
+
databases may already have committed while others may still hold prepared
|
|
144
|
+
transactions. In that case the session raises `XAOutcomeUnknownError`.
|
|
145
|
+
|
|
146
|
+
Recovery is designed to run as a separate recurring operational task. That task
|
|
147
|
+
should call `recover_xa_transactions()` often enough for your tolerance for
|
|
148
|
+
stuck prepared transactions.
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from datetime import timedelta
|
|
152
|
+
|
|
153
|
+
from sqlalchemy import create_engine
|
|
154
|
+
from sqlalchemy_xa_recovery import XidPrefix, recover_xa_transactions
|
|
155
|
+
|
|
156
|
+
engines = [
|
|
157
|
+
create_engine("postgresql+psycopg://user:pass@db-a/app"),
|
|
158
|
+
create_engine("postgresql+psycopg://user:pass@db-b/app"),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
recover_xa_transactions(
|
|
162
|
+
all_engines=engines,
|
|
163
|
+
grace_period=timedelta(seconds=15),
|
|
164
|
+
xid_prefix=XidPrefix("billing"),
|
|
165
|
+
)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Run this script with cron, a scheduler, a maintenance worker, or your
|
|
169
|
+
orchestration platform.
|
|
170
|
+
|
|
171
|
+
Always pass every engine that can be part of the distributed transaction group.
|
|
172
|
+
Recovery decisions are made by comparing prepared transaction identifiers across
|
|
173
|
+
the full set of participating databases. If you customize `xid_prefix` for
|
|
174
|
+
writers, use the same prefix in the recurring recovery process.
|
|
175
|
+
|
|
176
|
+
`recover_xa_transactions()` is synchronous. For transactions written with
|
|
177
|
+
`async_two_phase_session()`, pass the synchronous engines behind the async
|
|
178
|
+
engines, for example `my_async_engine.sync_engine`.
|
|
179
|
+
|
|
180
|
+
## How it works
|
|
181
|
+
|
|
182
|
+
For each distributed transaction, the library generates a shared base identifier
|
|
183
|
+
and one database-specific transaction identifier. The serialized identifier
|
|
184
|
+
contains the prefix, a shared random transaction part, the number of
|
|
185
|
+
participating databases, and the branch index.
|
|
186
|
+
|
|
187
|
+
For a transaction spanning three databases, the branch XIDs may look like this:
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
sxr:4f9c2a1b0e3d7788:3:0
|
|
191
|
+
sxr:4f9c2a1b0e3d7788:3:1
|
|
192
|
+
sxr:4f9c2a1b0e3d7788:3:2
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Here `sxr` is the prefix, `4f9c2a1b0e3d7788` is the shared random part, `3` is
|
|
196
|
+
the database count, and the final value is the branch index.
|
|
197
|
+
|
|
198
|
+
During `commit()`, the session:
|
|
199
|
+
|
|
200
|
+
1. flushes pending ORM changes,
|
|
201
|
+
2. prepares every enlisted two-phase transaction in branch order, for example
|
|
202
|
+
`0 -> 1 -> 2`,
|
|
203
|
+
3. commits the prepared transactions in the same order, for example
|
|
204
|
+
`0 -> 1 -> 2`.
|
|
205
|
+
|
|
206
|
+
If prepare or commit fails, the library raises `XAOutcomeUnknownError` because
|
|
207
|
+
the application can no longer safely infer the final outcome from the local
|
|
208
|
+
process alone.
|
|
209
|
+
|
|
210
|
+
Recovery works by scanning each database for prepared transactions, waiting for
|
|
211
|
+
the configured grace period, and scanning again. Only transaction identifiers
|
|
212
|
+
that are still present after the grace period are considered stuck.
|
|
213
|
+
|
|
214
|
+
Stuck prepared branches are grouped by their shared transaction identifier. With
|
|
215
|
+
full branch order `[0, 1, 2]`, sequential `PREPARE` adds prepared branches from
|
|
216
|
+
the start: `[0]`, then `[0, 1]`, then `[0, 1, 2]`. Sequential `COMMIT` removes
|
|
217
|
+
prepared branches from the start: `[0, 1, 2]`, then `[1, 2]`, then `[2]`, then
|
|
218
|
+
none.
|
|
219
|
+
|
|
220
|
+
Recovery decisions follow from that shape:
|
|
221
|
+
|
|
222
|
+
- `[0]` and `[0, 1]` are prefixes, so recovery treats the prepare phase as
|
|
223
|
+
incomplete and rolls them back in descending branch order;
|
|
224
|
+
- `[0, 1, 2]`, `[1, 2]`, and `[2]` are treated as suffixes, so recovery treats
|
|
225
|
+
the transaction as fully prepared or already committing and commits them in
|
|
226
|
+
ascending branch order;
|
|
227
|
+
- `[1]` and `[0, 2]` are neither prefixes nor suffixes, so recovery raises
|
|
228
|
+
`InvalidRecoveryStateError`. The same error is raised if the XID references
|
|
229
|
+
more databases than the configured engine list contains.
|
|
230
|
+
|
|
231
|
+
This gives the application a deterministic way to finish incomplete distributed
|
|
232
|
+
transactions after an uncertain failure.
|
|
233
|
+
|
|
234
|
+
## Operational notes
|
|
235
|
+
|
|
236
|
+
PostgreSQL must allow prepared transactions (`max_prepared_transactions > 0`).
|
|
237
|
+
|
|
238
|
+
Prepared transactions hold database resources until they are committed or rolled
|
|
239
|
+
back. Monitor them in production and run recovery regularly. The grace period
|
|
240
|
+
keeps recovery from touching transactions that are still being actively completed
|
|
241
|
+
by another process.
|
|
242
|
+
|
|
243
|
+
## Development
|
|
244
|
+
|
|
245
|
+
This repository uses `uv` for local development.
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
uv sync
|
|
249
|
+
uv run pytest
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The test suite uses Testcontainers and requires Docker. It starts PostgreSQL,
|
|
253
|
+
MySQL, and MariaDB containers to verify both synchronous and asynchronous
|
|
254
|
+
transaction flows.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# sqlalchemy-xa-recovery
|
|
2
|
+
|
|
3
|
+
Recoverable two-phase transaction helpers for SQLAlchemy.
|
|
4
|
+
|
|
5
|
+
`sqlalchemy-xa-recovery` helps coordinate one logical transaction across multiple
|
|
6
|
+
SQLAlchemy engines and provides a recovery routine for prepared XA transactions
|
|
7
|
+
left behind after process crashes, connection failures, or uncertain commit
|
|
8
|
+
outcomes.
|
|
9
|
+
|
|
10
|
+
## Why this library exists
|
|
11
|
+
|
|
12
|
+
SQLAlchemy already exposes two-phase transaction primitives, but using them
|
|
13
|
+
directly leaves important operational work to the application:
|
|
14
|
+
|
|
15
|
+
- generating transaction identifiers that can be understood later,
|
|
16
|
+
- starting matching transaction branches across multiple engines,
|
|
17
|
+
- handling failures during prepare or commit,
|
|
18
|
+
- discovering prepared transactions after a crash,
|
|
19
|
+
- deciding whether an incomplete distributed transaction should be committed or
|
|
20
|
+
rolled back.
|
|
21
|
+
|
|
22
|
+
This library adds those missing pieces around SQLAlchemy's two-phase support. It
|
|
23
|
+
does not replace your database XA implementation; it gives your application a
|
|
24
|
+
small, recoverable coordination layer on top of it.
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install sqlalchemy-xa-recovery
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The package requires Python 3.11 or newer and SQLAlchemy 2.x.
|
|
33
|
+
|
|
34
|
+
## Supported databases
|
|
35
|
+
|
|
36
|
+
The current implementation supports:
|
|
37
|
+
|
|
38
|
+
- PostgreSQL
|
|
39
|
+
- MySQL
|
|
40
|
+
- MariaDB
|
|
41
|
+
|
|
42
|
+
Database-specific recovery is handled internally. PostgreSQL uses
|
|
43
|
+
`pg_prepared_xacts`, MySQL and MariaDB use `XA RECOVER`.
|
|
44
|
+
|
|
45
|
+
## Basic usage
|
|
46
|
+
|
|
47
|
+
Pass a mapping of ORM mapped classes to SQLAlchemy engines. The context manager
|
|
48
|
+
opens one connection per participating engine, starts one two-phase transaction
|
|
49
|
+
branch per engine, and returns a SQLAlchemy session bound to those connections.
|
|
50
|
+
If several mapped classes are bound to the same engine, they share that engine's
|
|
51
|
+
connection and transaction branch.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from sqlalchemy import create_engine
|
|
55
|
+
|
|
56
|
+
from sqlalchemy_xa_recovery import two_phase_session
|
|
57
|
+
from myapp.models import Account, LedgerEntry
|
|
58
|
+
|
|
59
|
+
accounts_engine = create_engine("postgresql+psycopg://user:pass@db-a/app")
|
|
60
|
+
ledger_engine = create_engine("postgresql+psycopg://user:pass@db-b/app")
|
|
61
|
+
|
|
62
|
+
binds = {
|
|
63
|
+
Account: accounts_engine,
|
|
64
|
+
LedgerEntry: ledger_engine,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
with two_phase_session(binds) as session:
|
|
68
|
+
session.add(Account(id=1, balance=90))
|
|
69
|
+
session.add(LedgerEntry(account_id=1, amount=-10))
|
|
70
|
+
|
|
71
|
+
session.commit()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If the commit completes successfully, all transaction branches are committed. If
|
|
75
|
+
regular application code raises before commit, the active branches are rolled
|
|
76
|
+
back.
|
|
77
|
+
|
|
78
|
+
By default, generated XA identifiers use the `sxr` prefix so recovery can
|
|
79
|
+
distinguish this library's transactions from other prepared transactions in the
|
|
80
|
+
same database. Use `XidPrefix` to customize that marker for a deployment; custom
|
|
81
|
+
prefixes must contain only lowercase letters and be between one and eight
|
|
82
|
+
characters long:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from sqlalchemy_xa_recovery import XidPrefix
|
|
86
|
+
|
|
87
|
+
with two_phase_session(binds, xid_prefix=XidPrefix("billing")) as session:
|
|
88
|
+
# write to multiple databases
|
|
89
|
+
session.commit()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Async usage
|
|
93
|
+
|
|
94
|
+
The async API follows the same shape, using `AsyncEngine` objects and
|
|
95
|
+
`async_two_phase_session`.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from sqlalchemy.ext.asyncio import create_async_engine
|
|
99
|
+
|
|
100
|
+
from sqlalchemy_xa_recovery import async_two_phase_session
|
|
101
|
+
from myapp.models import Account, LedgerEntry
|
|
102
|
+
|
|
103
|
+
accounts_engine = create_async_engine("postgresql+psycopg_async://user:pass@db-a/app")
|
|
104
|
+
ledger_engine = create_async_engine("postgresql+psycopg_async://user:pass@db-b/app")
|
|
105
|
+
|
|
106
|
+
binds = {
|
|
107
|
+
Account: accounts_engine,
|
|
108
|
+
LedgerEntry: ledger_engine,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async with async_two_phase_session(binds) as session:
|
|
112
|
+
session.add(Account(id=1, balance=90))
|
|
113
|
+
session.add(LedgerEntry(account_id=1, amount=-10))
|
|
114
|
+
|
|
115
|
+
await session.commit()
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Recovery
|
|
119
|
+
|
|
120
|
+
When a failure happens during two-phase commit, the outcome may be unknown: some
|
|
121
|
+
databases may already have committed while others may still hold prepared
|
|
122
|
+
transactions. In that case the session raises `XAOutcomeUnknownError`.
|
|
123
|
+
|
|
124
|
+
Recovery is designed to run as a separate recurring operational task. That task
|
|
125
|
+
should call `recover_xa_transactions()` often enough for your tolerance for
|
|
126
|
+
stuck prepared transactions.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from datetime import timedelta
|
|
130
|
+
|
|
131
|
+
from sqlalchemy import create_engine
|
|
132
|
+
from sqlalchemy_xa_recovery import XidPrefix, recover_xa_transactions
|
|
133
|
+
|
|
134
|
+
engines = [
|
|
135
|
+
create_engine("postgresql+psycopg://user:pass@db-a/app"),
|
|
136
|
+
create_engine("postgresql+psycopg://user:pass@db-b/app"),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
recover_xa_transactions(
|
|
140
|
+
all_engines=engines,
|
|
141
|
+
grace_period=timedelta(seconds=15),
|
|
142
|
+
xid_prefix=XidPrefix("billing"),
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Run this script with cron, a scheduler, a maintenance worker, or your
|
|
147
|
+
orchestration platform.
|
|
148
|
+
|
|
149
|
+
Always pass every engine that can be part of the distributed transaction group.
|
|
150
|
+
Recovery decisions are made by comparing prepared transaction identifiers across
|
|
151
|
+
the full set of participating databases. If you customize `xid_prefix` for
|
|
152
|
+
writers, use the same prefix in the recurring recovery process.
|
|
153
|
+
|
|
154
|
+
`recover_xa_transactions()` is synchronous. For transactions written with
|
|
155
|
+
`async_two_phase_session()`, pass the synchronous engines behind the async
|
|
156
|
+
engines, for example `my_async_engine.sync_engine`.
|
|
157
|
+
|
|
158
|
+
## How it works
|
|
159
|
+
|
|
160
|
+
For each distributed transaction, the library generates a shared base identifier
|
|
161
|
+
and one database-specific transaction identifier. The serialized identifier
|
|
162
|
+
contains the prefix, a shared random transaction part, the number of
|
|
163
|
+
participating databases, and the branch index.
|
|
164
|
+
|
|
165
|
+
For a transaction spanning three databases, the branch XIDs may look like this:
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
sxr:4f9c2a1b0e3d7788:3:0
|
|
169
|
+
sxr:4f9c2a1b0e3d7788:3:1
|
|
170
|
+
sxr:4f9c2a1b0e3d7788:3:2
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Here `sxr` is the prefix, `4f9c2a1b0e3d7788` is the shared random part, `3` is
|
|
174
|
+
the database count, and the final value is the branch index.
|
|
175
|
+
|
|
176
|
+
During `commit()`, the session:
|
|
177
|
+
|
|
178
|
+
1. flushes pending ORM changes,
|
|
179
|
+
2. prepares every enlisted two-phase transaction in branch order, for example
|
|
180
|
+
`0 -> 1 -> 2`,
|
|
181
|
+
3. commits the prepared transactions in the same order, for example
|
|
182
|
+
`0 -> 1 -> 2`.
|
|
183
|
+
|
|
184
|
+
If prepare or commit fails, the library raises `XAOutcomeUnknownError` because
|
|
185
|
+
the application can no longer safely infer the final outcome from the local
|
|
186
|
+
process alone.
|
|
187
|
+
|
|
188
|
+
Recovery works by scanning each database for prepared transactions, waiting for
|
|
189
|
+
the configured grace period, and scanning again. Only transaction identifiers
|
|
190
|
+
that are still present after the grace period are considered stuck.
|
|
191
|
+
|
|
192
|
+
Stuck prepared branches are grouped by their shared transaction identifier. With
|
|
193
|
+
full branch order `[0, 1, 2]`, sequential `PREPARE` adds prepared branches from
|
|
194
|
+
the start: `[0]`, then `[0, 1]`, then `[0, 1, 2]`. Sequential `COMMIT` removes
|
|
195
|
+
prepared branches from the start: `[0, 1, 2]`, then `[1, 2]`, then `[2]`, then
|
|
196
|
+
none.
|
|
197
|
+
|
|
198
|
+
Recovery decisions follow from that shape:
|
|
199
|
+
|
|
200
|
+
- `[0]` and `[0, 1]` are prefixes, so recovery treats the prepare phase as
|
|
201
|
+
incomplete and rolls them back in descending branch order;
|
|
202
|
+
- `[0, 1, 2]`, `[1, 2]`, and `[2]` are treated as suffixes, so recovery treats
|
|
203
|
+
the transaction as fully prepared or already committing and commits them in
|
|
204
|
+
ascending branch order;
|
|
205
|
+
- `[1]` and `[0, 2]` are neither prefixes nor suffixes, so recovery raises
|
|
206
|
+
`InvalidRecoveryStateError`. The same error is raised if the XID references
|
|
207
|
+
more databases than the configured engine list contains.
|
|
208
|
+
|
|
209
|
+
This gives the application a deterministic way to finish incomplete distributed
|
|
210
|
+
transactions after an uncertain failure.
|
|
211
|
+
|
|
212
|
+
## Operational notes
|
|
213
|
+
|
|
214
|
+
PostgreSQL must allow prepared transactions (`max_prepared_transactions > 0`).
|
|
215
|
+
|
|
216
|
+
Prepared transactions hold database resources until they are committed or rolled
|
|
217
|
+
back. Monitor them in production and run recovery regularly. The grace period
|
|
218
|
+
keeps recovery from touching transactions that are still being actively completed
|
|
219
|
+
by another process.
|
|
220
|
+
|
|
221
|
+
## Development
|
|
222
|
+
|
|
223
|
+
This repository uses `uv` for local development.
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
uv sync
|
|
227
|
+
uv run pytest
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The test suite uses Testcontainers and requires Docker. It starts PostgreSQL,
|
|
231
|
+
MySQL, and MariaDB containers to verify both synchronous and asynchronous
|
|
232
|
+
transaction flows.
|