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.
Files changed (37) hide show
  1. sqlalchemy_xa_recovery-0.1.0/.github/workflows/ci.yml +81 -0
  2. sqlalchemy_xa_recovery-0.1.0/.github/workflows/publish.yml +33 -0
  3. sqlalchemy_xa_recovery-0.1.0/.gitignore +12 -0
  4. sqlalchemy_xa_recovery-0.1.0/.pre-commit-config.yaml +21 -0
  5. sqlalchemy_xa_recovery-0.1.0/LICENSE +21 -0
  6. sqlalchemy_xa_recovery-0.1.0/PKG-INFO +254 -0
  7. sqlalchemy_xa_recovery-0.1.0/README.md +232 -0
  8. sqlalchemy_xa_recovery-0.1.0/pyproject.toml +68 -0
  9. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/__init__.py +25 -0
  10. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_context_managers/__init__.py +0 -0
  11. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_context_managers/_helpers.py +24 -0
  12. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_context_managers/asynchronous.py +61 -0
  13. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_context_managers/synchronous.py +65 -0
  14. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/__init__.py +0 -0
  15. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/base.py +35 -0
  16. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/factory.py +25 -0
  17. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/mysql.py +31 -0
  18. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_dialects/postgres.py +35 -0
  19. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_recovery.py +104 -0
  20. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_session.py +100 -0
  21. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/_value_objects.py +116 -0
  22. sqlalchemy_xa_recovery-0.1.0/src/sqlalchemy_xa_recovery/py.typed +1 -0
  23. sqlalchemy_xa_recovery-0.1.0/tests/asynchronous/__init__.py +0 -0
  24. sqlalchemy_xa_recovery-0.1.0/tests/asynchronous/test_context_manager.py +78 -0
  25. sqlalchemy_xa_recovery-0.1.0/tests/asynchronous/test_recover_xa_transactions.py +151 -0
  26. sqlalchemy_xa_recovery-0.1.0/tests/asynchronous/test_session.py +80 -0
  27. sqlalchemy_xa_recovery-0.1.0/tests/conftest.py +145 -0
  28. sqlalchemy_xa_recovery-0.1.0/tests/support/__init__.py +1 -0
  29. sqlalchemy_xa_recovery-0.1.0/tests/support/containers.py +56 -0
  30. sqlalchemy_xa_recovery-0.1.0/tests/support/helpers.py +74 -0
  31. sqlalchemy_xa_recovery-0.1.0/tests/support/orm.py +80 -0
  32. sqlalchemy_xa_recovery-0.1.0/tests/synchronous/__init__.py +0 -0
  33. sqlalchemy_xa_recovery-0.1.0/tests/synchronous/test_context_manager.py +58 -0
  34. sqlalchemy_xa_recovery-0.1.0/tests/synchronous/test_recover_xa_transactions.py +125 -0
  35. sqlalchemy_xa_recovery-0.1.0/tests/synchronous/test_session.py +59 -0
  36. sqlalchemy_xa_recovery-0.1.0/tests/test_value_objects.py +64 -0
  37. 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,12 @@
1
+ .venv/
2
+ .idea/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .mypy_cache/
8
+ .ty/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ .uv-cache/
@@ -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.