outboxed 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.
- outboxed-0.1.0/.github/workflows/ci.yml +33 -0
- outboxed-0.1.0/.github/workflows/publish.yml +29 -0
- outboxed-0.1.0/.gitignore +14 -0
- outboxed-0.1.0/CHANGELOG.md +18 -0
- outboxed-0.1.0/CONTRIBUTING.md +31 -0
- outboxed-0.1.0/LICENSE +21 -0
- outboxed-0.1.0/PKG-INFO +93 -0
- outboxed-0.1.0/README.md +68 -0
- outboxed-0.1.0/docs/backends.md +42 -0
- outboxed-0.1.0/docs/dual-write-problem.md +50 -0
- outboxed-0.1.0/docs/guarantees.md +37 -0
- outboxed-0.1.0/docs/index.md +45 -0
- outboxed-0.1.0/examples/docker-compose.yml +23 -0
- outboxed-0.1.0/examples/fastapi_order_service/main.py +62 -0
- outboxed-0.1.0/examples/fastapi_order_service/relay.py +26 -0
- outboxed-0.1.0/pyproject.toml +48 -0
- outboxed-0.1.0/src/outboxed/__init__.py +9 -0
- outboxed-0.1.0/src/outboxed/backends/__init__.py +6 -0
- outboxed-0.1.0/src/outboxed/backends/base.py +13 -0
- outboxed-0.1.0/src/outboxed/backends/in_memory.py +22 -0
- outboxed-0.1.0/src/outboxed/backends/kafka.py +21 -0
- outboxed-0.1.0/src/outboxed/backends/redis_streams.py +17 -0
- outboxed-0.1.0/src/outboxed/backends/sqs.py +24 -0
- outboxed-0.1.0/src/outboxed/dedup.py +20 -0
- outboxed-0.1.0/src/outboxed/models.py +31 -0
- outboxed-0.1.0/src/outboxed/outbox.py +25 -0
- outboxed-0.1.0/src/outboxed/py.typed +0 -0
- outboxed-0.1.0/src/outboxed/relay.py +71 -0
- outboxed-0.1.0/tests/backends/test_in_memory.py +15 -0
- outboxed-0.1.0/tests/backends/test_kafka.py +14 -0
- outboxed-0.1.0/tests/backends/test_redis_streams.py +40 -0
- outboxed-0.1.0/tests/backends/test_sqs.py +45 -0
- outboxed-0.1.0/tests/test_chaos_crash_recovery.py +59 -0
- outboxed-0.1.0/tests/test_dedup.py +13 -0
- outboxed-0.1.0/tests/test_outbox_enqueue.py +44 -0
- outboxed-0.1.0/tests/test_relay_delivery.py +46 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
|
|
18
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
|
|
23
|
+
- name: Install dependencies
|
|
24
|
+
run: pip install -e ".[dev]"
|
|
25
|
+
|
|
26
|
+
- name: Lint
|
|
27
|
+
run: ruff check src tests
|
|
28
|
+
|
|
29
|
+
- name: Type check
|
|
30
|
+
run: mypy src
|
|
31
|
+
|
|
32
|
+
- name: Unit and integration tests
|
|
33
|
+
run: pytest -v
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build-and-publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- name: Set up Python
|
|
18
|
+
uses: actions/setup-python@v5
|
|
19
|
+
with:
|
|
20
|
+
python-version: "3.12"
|
|
21
|
+
|
|
22
|
+
- name: Install build tooling
|
|
23
|
+
run: pip install build
|
|
24
|
+
|
|
25
|
+
- name: Build distribution
|
|
26
|
+
run: python -m build
|
|
27
|
+
|
|
28
|
+
- name: Upload to PyPI
|
|
29
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-11-05
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Initial project scaffold: `Outbox` (write side), `Relay` (read side),
|
|
14
|
+
`DeliveryTracker` (dedup), and `Backend` protocol.
|
|
15
|
+
- Built-in backends: in-memory, Kafka, Redis Streams, SQS.
|
|
16
|
+
- Chaos test for crash/recovery semantics.
|
|
17
|
+
- Docs: the dual-write problem, delivery guarantees, writing a backend.
|
|
18
|
+
- FastAPI order-service example.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
## Setup
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install -e ".[dev]"
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Running checks locally
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
ruff check src tests
|
|
13
|
+
mypy src
|
|
14
|
+
pytest -v
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Integration tests for a specific broker (e.g. `tests/backends/test_kafka.py`)
|
|
18
|
+
are skipped unless that broker's environment variable is set. You can bring
|
|
19
|
+
up local infra with `docker compose -f examples/docker-compose.yml up`.
|
|
20
|
+
|
|
21
|
+
## Adding a backend
|
|
22
|
+
|
|
23
|
+
See [docs/backends.md](docs/backends.md) for the `Backend` protocol and
|
|
24
|
+
guidelines for a new adapter.
|
|
25
|
+
|
|
26
|
+
## Pull requests
|
|
27
|
+
|
|
28
|
+
- Keep the core (`outbox.py`, `relay.py`, `dedup.py`, `models.py`) free of
|
|
29
|
+
any specific broker's SDK dependency.
|
|
30
|
+
- Add or update tests for any behavior change, and update
|
|
31
|
+
[CHANGELOG.md](CHANGELOG.md) under `[Unreleased]`.
|
outboxed-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amin Albooyeh
|
|
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.
|
outboxed-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: outboxed
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Transactional outbox pattern for Python backends
|
|
5
|
+
Project-URL: Homepage, https://github.com/amin-albooyeh/outboxed
|
|
6
|
+
Project-URL: Documentation, https://github.com/amin-albooyeh/outboxed/tree/main/docs
|
|
7
|
+
Project-URL: Issues, https://github.com/amin-albooyeh/outboxed/issues
|
|
8
|
+
Author-email: Amin Albooyeh <amin.albooyeh@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy>=1.9; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
18
|
+
Provides-Extra: kafka
|
|
19
|
+
Requires-Dist: confluent-kafka>=2.0; extra == 'kafka'
|
|
20
|
+
Provides-Extra: redis
|
|
21
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
22
|
+
Provides-Extra: sqs
|
|
23
|
+
Requires-Dist: boto3>=1.34; extra == 'sqs'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# outboxed
|
|
27
|
+
|
|
28
|
+
The transactional outbox pattern for Python backends: reliably publish an
|
|
29
|
+
event every time — and only when — a database write actually commits.
|
|
30
|
+
|
|
31
|
+
## The problem
|
|
32
|
+
|
|
33
|
+
Writing a database row and then publishing an event about it, as two
|
|
34
|
+
separate steps, is the dual-write problem: a crash between them loses the
|
|
35
|
+
event forever, and blind retries trade that for duplicate events instead.
|
|
36
|
+
See [docs/dual-write-problem.md](docs/dual-write-problem.md).
|
|
37
|
+
|
|
38
|
+
## The fix
|
|
39
|
+
|
|
40
|
+
Enqueue the event as a row in an outbox table, in the same transaction as
|
|
41
|
+
the business write. A separate relay process polls that table and does the
|
|
42
|
+
actual publishing, with retries and deduplication handled on the relay
|
|
43
|
+
side.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from sqlalchemy import create_engine
|
|
47
|
+
from outboxed import Outbox, Event
|
|
48
|
+
from outboxed.models import metadata
|
|
49
|
+
|
|
50
|
+
engine = create_engine("postgresql://...")
|
|
51
|
+
metadata.create_all(engine)
|
|
52
|
+
|
|
53
|
+
outbox = Outbox()
|
|
54
|
+
|
|
55
|
+
with engine.begin() as conn:
|
|
56
|
+
# ... your business write ...
|
|
57
|
+
outbox.enqueue(conn, Event(topic="orders", payload='{"id": 1}'))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from outboxed import Relay
|
|
62
|
+
from outboxed.backends.in_memory import InMemoryBackend
|
|
63
|
+
|
|
64
|
+
relay = Relay(engine, backend=InMemoryBackend())
|
|
65
|
+
relay.run_forever()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## The guarantee
|
|
69
|
+
|
|
70
|
+
Once a transaction commits, its event is delivered at least once, and
|
|
71
|
+
never silently dropped. This is verified by a chaos test
|
|
72
|
+
(`tests/test_chaos_crash_recovery.py`) that runs in CI on every commit. See
|
|
73
|
+
[docs/guarantees.md](docs/guarantees.md) for exact semantics.
|
|
74
|
+
|
|
75
|
+
## Backends
|
|
76
|
+
|
|
77
|
+
Kafka, Redis Streams, and SQS adapters ship built in, alongside an
|
|
78
|
+
in-memory one for tests. The core has no required broker dependency — a
|
|
79
|
+
backend is a small protocol (`outboxed.backends.base.Backend`) you can
|
|
80
|
+
implement yourself. See [docs/backends.md](docs/backends.md).
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install outboxed
|
|
84
|
+
pip install "outboxed[kafka]" # or [redis], [sqs]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Status
|
|
88
|
+
|
|
89
|
+
v0.1.0. Python 3.10+. MIT licensed.
|
|
90
|
+
|
|
91
|
+
## Contributing
|
|
92
|
+
|
|
93
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
outboxed-0.1.0/README.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# outboxed
|
|
2
|
+
|
|
3
|
+
The transactional outbox pattern for Python backends: reliably publish an
|
|
4
|
+
event every time — and only when — a database write actually commits.
|
|
5
|
+
|
|
6
|
+
## The problem
|
|
7
|
+
|
|
8
|
+
Writing a database row and then publishing an event about it, as two
|
|
9
|
+
separate steps, is the dual-write problem: a crash between them loses the
|
|
10
|
+
event forever, and blind retries trade that for duplicate events instead.
|
|
11
|
+
See [docs/dual-write-problem.md](docs/dual-write-problem.md).
|
|
12
|
+
|
|
13
|
+
## The fix
|
|
14
|
+
|
|
15
|
+
Enqueue the event as a row in an outbox table, in the same transaction as
|
|
16
|
+
the business write. A separate relay process polls that table and does the
|
|
17
|
+
actual publishing, with retries and deduplication handled on the relay
|
|
18
|
+
side.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from sqlalchemy import create_engine
|
|
22
|
+
from outboxed import Outbox, Event
|
|
23
|
+
from outboxed.models import metadata
|
|
24
|
+
|
|
25
|
+
engine = create_engine("postgresql://...")
|
|
26
|
+
metadata.create_all(engine)
|
|
27
|
+
|
|
28
|
+
outbox = Outbox()
|
|
29
|
+
|
|
30
|
+
with engine.begin() as conn:
|
|
31
|
+
# ... your business write ...
|
|
32
|
+
outbox.enqueue(conn, Event(topic="orders", payload='{"id": 1}'))
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from outboxed import Relay
|
|
37
|
+
from outboxed.backends.in_memory import InMemoryBackend
|
|
38
|
+
|
|
39
|
+
relay = Relay(engine, backend=InMemoryBackend())
|
|
40
|
+
relay.run_forever()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The guarantee
|
|
44
|
+
|
|
45
|
+
Once a transaction commits, its event is delivered at least once, and
|
|
46
|
+
never silently dropped. This is verified by a chaos test
|
|
47
|
+
(`tests/test_chaos_crash_recovery.py`) that runs in CI on every commit. See
|
|
48
|
+
[docs/guarantees.md](docs/guarantees.md) for exact semantics.
|
|
49
|
+
|
|
50
|
+
## Backends
|
|
51
|
+
|
|
52
|
+
Kafka, Redis Streams, and SQS adapters ship built in, alongside an
|
|
53
|
+
in-memory one for tests. The core has no required broker dependency — a
|
|
54
|
+
backend is a small protocol (`outboxed.backends.base.Backend`) you can
|
|
55
|
+
implement yourself. See [docs/backends.md](docs/backends.md).
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install outboxed
|
|
59
|
+
pip install "outboxed[kafka]" # or [redis], [sqs]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Status
|
|
63
|
+
|
|
64
|
+
v0.1.0. Python 3.10+. MIT licensed.
|
|
65
|
+
|
|
66
|
+
## Contributing
|
|
67
|
+
|
|
68
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Writing a new Backend adapter
|
|
2
|
+
|
|
3
|
+
A backend is anything that implements the `Backend` protocol in
|
|
4
|
+
`outboxed/backends/base.py`:
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
class Backend(Protocol):
|
|
10
|
+
def publish(self, *, topic: str, key: str | None, payload: str) -> None:
|
|
11
|
+
...
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
That's the entire contract. `publish` is called once per outbox row, in
|
|
15
|
+
creation order, by the relay. Raise an exception to signal failure — the
|
|
16
|
+
relay will leave that row undelivered and retry it on the next poll.
|
|
17
|
+
|
|
18
|
+
## Example: a minimal webhook backend
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
class WebhookBackend:
|
|
24
|
+
def __init__(self, url: str):
|
|
25
|
+
self._url = url
|
|
26
|
+
|
|
27
|
+
def publish(self, *, topic: str, key: str | None, payload: str) -> None:
|
|
28
|
+
response = httpx.post(self._url, json={"topic": topic, "key": key, "payload": payload})
|
|
29
|
+
response.raise_for_status()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Guidelines
|
|
33
|
+
|
|
34
|
+
- Keep broker SDK imports inside the adapter module, not in `outboxed`'s
|
|
35
|
+
core, so the core has no required dependency on any specific broker.
|
|
36
|
+
- Don't swallow exceptions — let them propagate so the relay retries.
|
|
37
|
+
- If your broker needs a flush/sync step (as Kafka producers do), do it
|
|
38
|
+
inside `publish` so the call only returns once delivery is confirmed (or
|
|
39
|
+
failed) to the broker.
|
|
40
|
+
- Add your adapter under `src/outboxed/backends/`, with an optional extra
|
|
41
|
+
in `pyproject.toml` if it needs a broker SDK, and a corresponding test
|
|
42
|
+
under `tests/backends/`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# The dual-write problem
|
|
2
|
+
|
|
3
|
+
A common backend shape is: write a row to the database, then publish an
|
|
4
|
+
event about it (an order placed, a payment captured, a user created). Doing
|
|
5
|
+
these as two separate operations is the dual-write problem.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
business write publish event
|
|
9
|
+
┌───────────────┐ ┌─────────────────┐
|
|
10
|
+
│ INSERT order │ ---> │ producer.send │
|
|
11
|
+
└───────────────┘ └─────────────────┘
|
|
12
|
+
commits ??? crash here means the
|
|
13
|
+
event is lost forever
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
If the process crashes between the database commit and the publish call,
|
|
17
|
+
the event is lost forever, and nothing else in the system knows the write
|
|
18
|
+
ever happened. Retrying blindly trades that failure for the opposite one:
|
|
19
|
+
duplicate events on every retry that succeeds after a delayed
|
|
20
|
+
acknowledgment.
|
|
21
|
+
|
|
22
|
+
## The fix
|
|
23
|
+
|
|
24
|
+
Never make that second call directly. Instead, insert the event as a row in
|
|
25
|
+
an "outbox" table, in the *same database transaction* as the business
|
|
26
|
+
write:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
┌─────────────────────────────┐
|
|
30
|
+
│ transaction │
|
|
31
|
+
│ INSERT order │
|
|
32
|
+
│ INSERT outbox_entries row │
|
|
33
|
+
└─────────────────────────────┘
|
|
34
|
+
commits atomically
|
|
35
|
+
│
|
|
36
|
+
v
|
|
37
|
+
┌───────────────────┐
|
|
38
|
+
│ relay (separate │
|
|
39
|
+
│ process) polls │
|
|
40
|
+
│ outbox_entries │
|
|
41
|
+
└───────────────────┘
|
|
42
|
+
│
|
|
43
|
+
v
|
|
44
|
+
publish to broker
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A separate relay process polls that table and does the actual publishing,
|
|
48
|
+
with retries and deduplication handled entirely on the relay side —
|
|
49
|
+
decoupled from the request path. See [guarantees.md](guarantees.md) for the
|
|
50
|
+
exact delivery semantics this gives you.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Delivery guarantees
|
|
2
|
+
|
|
3
|
+
outboxed is built around one explicit claim:
|
|
4
|
+
|
|
5
|
+
> Once a transaction commits, its event is delivered at least once, and
|
|
6
|
+
> never silently dropped.
|
|
7
|
+
|
|
8
|
+
## What this means precisely
|
|
9
|
+
|
|
10
|
+
- **Write side**: `Outbox.enqueue()` only has an effect if the surrounding
|
|
11
|
+
transaction commits. A rollback removes the row along with everything
|
|
12
|
+
else in that transaction. There is no window where the write succeeds but
|
|
13
|
+
the event is lost, because they're the same atomic operation.
|
|
14
|
+
- **Relay side**: the relay polls for undelivered rows, dispatches them to
|
|
15
|
+
the backend, and only then marks them delivered. If the relay process
|
|
16
|
+
dies after dispatch but before marking the row delivered, the row is
|
|
17
|
+
redelivered on the next poll.
|
|
18
|
+
- **At-least-once, not exactly-once, at the transport layer**: a crash in
|
|
19
|
+
that window means the backend may see the same event twice. outboxed's
|
|
20
|
+
`DeliveryTracker` filters those duplicates by event id before they reach
|
|
21
|
+
your handler, so the event is *observed* exactly once downstream even
|
|
22
|
+
though the broker may have transported it more than once.
|
|
23
|
+
|
|
24
|
+
## How this is tested
|
|
25
|
+
|
|
26
|
+
`tests/test_chaos_crash_recovery.py` starts a relay, kills the process
|
|
27
|
+
(simulated) mid-batch at a randomized point, restarts it, and asserts that
|
|
28
|
+
every committed event eventually reaches the backend, and that none of them
|
|
29
|
+
are observed twice. That test runs in CI on every commit — it is not a
|
|
30
|
+
README promise.
|
|
31
|
+
|
|
32
|
+
## What outboxed does not guarantee
|
|
33
|
+
|
|
34
|
+
- **Ordering across topics**: ordering is only guaranteed within a single
|
|
35
|
+
topic/key, and only as strongly as the underlying broker preserves it.
|
|
36
|
+
- **Delivery latency**: this is a polling relay, not a push system. Latency
|
|
37
|
+
is bounded by the poll interval, not by the broker's own latency.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# outboxed
|
|
2
|
+
|
|
3
|
+
The transactional outbox pattern for Python backends: reliably publish an
|
|
4
|
+
event every time — and only when — a database write actually commits.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install outboxed
|
|
10
|
+
# or, with a broker adapter's optional extra:
|
|
11
|
+
pip install "outboxed[kafka]"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Quickstart
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from sqlalchemy import create_engine
|
|
18
|
+
from outboxed import Outbox, Event
|
|
19
|
+
from outboxed.models import metadata
|
|
20
|
+
|
|
21
|
+
engine = create_engine("postgresql://...")
|
|
22
|
+
metadata.create_all(engine)
|
|
23
|
+
|
|
24
|
+
outbox = Outbox()
|
|
25
|
+
|
|
26
|
+
with engine.begin() as conn:
|
|
27
|
+
# ... your business write, e.g. inserting an order row ...
|
|
28
|
+
outbox.enqueue(conn, Event(topic="orders", payload='{"id": 1}'))
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Run a relay process separately to deliver enqueued events to a backend:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from outboxed import Relay
|
|
35
|
+
from outboxed.backends.in_memory import InMemoryBackend
|
|
36
|
+
|
|
37
|
+
relay = Relay(engine, backend=InMemoryBackend())
|
|
38
|
+
relay.run_forever()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Further reading
|
|
42
|
+
|
|
43
|
+
- [The dual-write problem](dual-write-problem.md)
|
|
44
|
+
- [Delivery guarantees](guarantees.md)
|
|
45
|
+
- [Writing a backend adapter](backends.md)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
services:
|
|
2
|
+
postgres:
|
|
3
|
+
image: postgres:16
|
|
4
|
+
environment:
|
|
5
|
+
POSTGRES_USER: outboxed
|
|
6
|
+
POSTGRES_PASSWORD: outboxed
|
|
7
|
+
POSTGRES_DB: outboxed
|
|
8
|
+
ports:
|
|
9
|
+
- "5432:5432"
|
|
10
|
+
|
|
11
|
+
kafka:
|
|
12
|
+
image: confluentinc/cp-kafka:7.6.0
|
|
13
|
+
environment:
|
|
14
|
+
KAFKA_NODE_ID: 1
|
|
15
|
+
KAFKA_PROCESS_ROLES: broker,controller
|
|
16
|
+
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
|
|
17
|
+
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
|
|
18
|
+
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
|
|
19
|
+
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
|
|
20
|
+
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
|
|
21
|
+
CLUSTER_ID: outboxed-local-cluster
|
|
22
|
+
ports:
|
|
23
|
+
- "9092:9092"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""A minimal order API using outboxed end-to-end.
|
|
2
|
+
|
|
3
|
+
Run Postgres via ../docker-compose.yml, then:
|
|
4
|
+
|
|
5
|
+
uvicorn main:app --reload
|
|
6
|
+
|
|
7
|
+
POST /orders inserts an order row and enqueues an "order.placed" event in
|
|
8
|
+
the same transaction. Run relay.py separately to deliver it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
from sqlalchemy import Column, Integer, MetaData, String, Table, create_engine
|
|
18
|
+
|
|
19
|
+
from outboxed import Event, Outbox
|
|
20
|
+
from outboxed.models import metadata as outbox_metadata
|
|
21
|
+
|
|
22
|
+
DATABASE_URL = "postgresql://outboxed:outboxed@localhost:5432/outboxed"
|
|
23
|
+
|
|
24
|
+
engine = create_engine(DATABASE_URL)
|
|
25
|
+
app_metadata = MetaData()
|
|
26
|
+
|
|
27
|
+
orders_table = Table(
|
|
28
|
+
"orders",
|
|
29
|
+
app_metadata,
|
|
30
|
+
Column("id", Integer, primary_key=True, autoincrement=True),
|
|
31
|
+
Column("item", String, nullable=False),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
outbox = Outbox()
|
|
35
|
+
app = FastAPI(title="outboxed example: order service")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class OrderRequest(BaseModel):
|
|
39
|
+
item: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@app.on_event("startup")
|
|
43
|
+
def create_tables() -> None:
|
|
44
|
+
app_metadata.create_all(engine)
|
|
45
|
+
outbox_metadata.create_all(engine)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.post("/orders")
|
|
49
|
+
def place_order(order: OrderRequest) -> dict:
|
|
50
|
+
with engine.begin() as conn:
|
|
51
|
+
result = conn.execute(orders_table.insert().values(item=order.item))
|
|
52
|
+
order_id = result.inserted_primary_key[0]
|
|
53
|
+
|
|
54
|
+
outbox.enqueue(
|
|
55
|
+
conn,
|
|
56
|
+
Event(topic="order.placed", key=str(order_id), payload=json.dumps({
|
|
57
|
+
"order_id": order_id,
|
|
58
|
+
"item": order.item,
|
|
59
|
+
})),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return {"id": order_id, "item": order.item}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Standalone relay process for the order service example.
|
|
2
|
+
|
|
3
|
+
Run alongside main.py:
|
|
4
|
+
|
|
5
|
+
python relay.py
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import create_engine
|
|
9
|
+
|
|
10
|
+
from outboxed import Relay
|
|
11
|
+
from outboxed.backends.in_memory import InMemoryBackend
|
|
12
|
+
|
|
13
|
+
DATABASE_URL = "postgresql://outboxed:outboxed@localhost:5432/outboxed"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
engine = create_engine(DATABASE_URL)
|
|
18
|
+
backend = InMemoryBackend() # swap for KafkaBackend/RedisStreamsBackend/SQSBackend
|
|
19
|
+
relay = Relay(engine, backend)
|
|
20
|
+
|
|
21
|
+
print("relay running, polling for outbox entries...")
|
|
22
|
+
relay.run_forever()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "outboxed"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Transactional outbox pattern for Python backends"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Amin Albooyeh", email = "amin.albooyeh@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"SQLAlchemy>=2.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
kafka = ["confluent-kafka>=2.0"]
|
|
21
|
+
redis = ["redis>=5.0"]
|
|
22
|
+
sqs = ["boto3>=1.34"]
|
|
23
|
+
dev = [
|
|
24
|
+
"pytest>=8.0",
|
|
25
|
+
"pytest-asyncio>=0.23",
|
|
26
|
+
"mypy>=1.9",
|
|
27
|
+
"ruff>=0.4",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/amin-albooyeh/outboxed"
|
|
32
|
+
Documentation = "https://github.com/amin-albooyeh/outboxed/tree/main/docs"
|
|
33
|
+
Issues = "https://github.com/amin-albooyeh/outboxed/issues"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.build.targets.wheel]
|
|
36
|
+
packages = ["src/outboxed"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100
|
|
40
|
+
target-version = "py310"
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
python_version = "3.10"
|
|
44
|
+
strict = true
|
|
45
|
+
packages = ["outboxed"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The protocol every broker adapter implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Backend(Protocol):
|
|
9
|
+
"""A destination the relay publishes outbox entries to."""
|
|
10
|
+
|
|
11
|
+
def publish(self, *, topic: str, key: str | None, payload: str) -> None:
|
|
12
|
+
"""Publish one entry. Raise to signal failure; the relay will retry."""
|
|
13
|
+
...
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""In-process backend for tests and for trying the library with no broker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class _PublishedMessage:
|
|
10
|
+
topic: str
|
|
11
|
+
key: str | None
|
|
12
|
+
payload: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InMemoryBackend:
|
|
16
|
+
"""Collects published messages in memory instead of talking to a broker."""
|
|
17
|
+
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self.messages: list[_PublishedMessage] = []
|
|
20
|
+
|
|
21
|
+
def publish(self, *, topic: str, key: str | None, payload: str) -> None:
|
|
22
|
+
self.messages.append(_PublishedMessage(topic=topic, key=key, payload=payload))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Kafka backend. Requires the ``outboxed[kafka]`` extra."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KafkaBackend:
|
|
9
|
+
"""Publishes outbox entries to a Kafka topic via confluent-kafka."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, producer: Any, *, flush_timeout: float = 10.0) -> None:
|
|
12
|
+
self._producer = producer
|
|
13
|
+
self._flush_timeout = flush_timeout
|
|
14
|
+
|
|
15
|
+
def publish(self, *, topic: str, key: str | None, payload: str) -> None:
|
|
16
|
+
self._producer.produce(
|
|
17
|
+
topic,
|
|
18
|
+
key=key.encode() if key is not None else None,
|
|
19
|
+
value=payload.encode(),
|
|
20
|
+
)
|
|
21
|
+
self._producer.flush(self._flush_timeout)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Redis Streams backend. Requires the ``outboxed[redis]`` extra."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RedisStreamsBackend:
|
|
9
|
+
"""Publishes outbox entries to a Redis stream via XADD."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, client: Any, *, stream_prefix: str = "") -> None:
|
|
12
|
+
self._client = client
|
|
13
|
+
self._stream_prefix = stream_prefix
|
|
14
|
+
|
|
15
|
+
def publish(self, *, topic: str, key: str | None, payload: str) -> None:
|
|
16
|
+
stream = f"{self._stream_prefix}{topic}"
|
|
17
|
+
self._client.xadd(stream, {"key": key or "", "payload": payload})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""SQS backend. Requires the ``outboxed[sqs]`` extra."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SQSBackend:
|
|
9
|
+
"""Publishes outbox entries to an SQS queue via send_message.
|
|
10
|
+
|
|
11
|
+
``topic`` is mapped to a queue URL through ``queue_urls`` since SQS has
|
|
12
|
+
no native topic concept.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, client: Any, *, queue_urls: dict[str, str]) -> None:
|
|
16
|
+
self._client = client
|
|
17
|
+
self._queue_urls = queue_urls
|
|
18
|
+
|
|
19
|
+
def publish(self, *, topic: str, key: str | None, payload: str) -> None:
|
|
20
|
+
queue_url = self._queue_urls[topic]
|
|
21
|
+
kwargs: dict[str, Any] = {"QueueUrl": queue_url, "MessageBody": payload}
|
|
22
|
+
if key is not None:
|
|
23
|
+
kwargs["MessageGroupId"] = key
|
|
24
|
+
self._client.send_message(**kwargs)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Delivery-id tracking so retries after a crash don't double-publish downstream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DeliveryTracker:
|
|
7
|
+
"""Tracks which event ids have already been handed to the backend.
|
|
8
|
+
|
|
9
|
+
Backed by whatever store the relay is configured with; the default
|
|
10
|
+
in-process set is only suitable for a single relay instance and tests.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self._seen: set[str] = set()
|
|
15
|
+
|
|
16
|
+
def is_duplicate(self, event_id: str) -> bool:
|
|
17
|
+
return event_id in self._seen
|
|
18
|
+
|
|
19
|
+
def mark_delivered(self, event_id: str) -> None:
|
|
20
|
+
self._seen.add(event_id)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""OutboxEntry table definition and the Event the caller enqueues."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import Column, DateTime, MetaData, String, Table, func
|
|
9
|
+
|
|
10
|
+
metadata = MetaData()
|
|
11
|
+
|
|
12
|
+
outbox_table = Table(
|
|
13
|
+
"outboxed_entries",
|
|
14
|
+
metadata,
|
|
15
|
+
Column("id", String(36), primary_key=True),
|
|
16
|
+
Column("topic", String(255), nullable=False),
|
|
17
|
+
Column("key", String(255), nullable=True),
|
|
18
|
+
Column("payload", String, nullable=False),
|
|
19
|
+
Column("created_at", DateTime(timezone=True), server_default=func.now(), nullable=False),
|
|
20
|
+
Column("delivered_at", DateTime(timezone=True), nullable=True),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Event:
|
|
26
|
+
"""A single event to be delivered to a backend, exactly once observable."""
|
|
27
|
+
|
|
28
|
+
topic: str
|
|
29
|
+
payload: str
|
|
30
|
+
key: str | None = None
|
|
31
|
+
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The write side: enqueue an event inside the caller's own transaction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.engine import Connection
|
|
6
|
+
|
|
7
|
+
from outboxed.models import Event, outbox_table
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Outbox:
|
|
11
|
+
"""Writes events into the outbox table using the caller's connection.
|
|
12
|
+
|
|
13
|
+
Call ``enqueue`` with the same ``Connection``/transaction used for the
|
|
14
|
+
business write. If that transaction rolls back, the event never existed.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def enqueue(self, connection: Connection, event: Event) -> None:
|
|
18
|
+
connection.execute(
|
|
19
|
+
outbox_table.insert().values(
|
|
20
|
+
id=event.id,
|
|
21
|
+
topic=event.topic,
|
|
22
|
+
key=event.key,
|
|
23
|
+
payload=event.payload,
|
|
24
|
+
)
|
|
25
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The read side: polls the outbox table, dispatches to a backend, marks sent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import func
|
|
8
|
+
from sqlalchemy.engine import Engine
|
|
9
|
+
|
|
10
|
+
from outboxed.backends.base import Backend
|
|
11
|
+
from outboxed.dedup import DeliveryTracker
|
|
12
|
+
from outboxed.models import outbox_table
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Relay:
|
|
16
|
+
"""Polls the outbox table and delivers undelivered events to a backend.
|
|
17
|
+
|
|
18
|
+
At-least-once delivery: a crash between dispatch and marking an entry
|
|
19
|
+
delivered means it is redelivered on restart. The ``DeliveryTracker``
|
|
20
|
+
filters duplicates before they reach the backend's handler.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
engine: Engine,
|
|
26
|
+
backend: Backend,
|
|
27
|
+
*,
|
|
28
|
+
batch_size: int = 100,
|
|
29
|
+
poll_interval: float = 1.0,
|
|
30
|
+
tracker: DeliveryTracker | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._engine = engine
|
|
33
|
+
self._backend = backend
|
|
34
|
+
self._batch_size = batch_size
|
|
35
|
+
self._poll_interval = poll_interval
|
|
36
|
+
self._tracker = tracker or DeliveryTracker()
|
|
37
|
+
self._running = False
|
|
38
|
+
|
|
39
|
+
def run_forever(self) -> None:
|
|
40
|
+
self._running = True
|
|
41
|
+
while self._running:
|
|
42
|
+
delivered = self.poll_once()
|
|
43
|
+
if delivered == 0:
|
|
44
|
+
time.sleep(self._poll_interval)
|
|
45
|
+
|
|
46
|
+
def stop(self) -> None:
|
|
47
|
+
self._running = False
|
|
48
|
+
|
|
49
|
+
def poll_once(self) -> int:
|
|
50
|
+
delivered = 0
|
|
51
|
+
with self._engine.begin() as conn:
|
|
52
|
+
rows = conn.execute(
|
|
53
|
+
outbox_table.select()
|
|
54
|
+
.where(outbox_table.c.delivered_at.is_(None))
|
|
55
|
+
.order_by(outbox_table.c.created_at)
|
|
56
|
+
.limit(self._batch_size)
|
|
57
|
+
).fetchall()
|
|
58
|
+
|
|
59
|
+
for row in rows:
|
|
60
|
+
if not self._tracker.is_duplicate(row.id):
|
|
61
|
+
self._backend.publish(topic=row.topic, key=row.key, payload=row.payload)
|
|
62
|
+
self._tracker.mark_delivered(row.id)
|
|
63
|
+
|
|
64
|
+
conn.execute(
|
|
65
|
+
outbox_table.update()
|
|
66
|
+
.where(outbox_table.c.id == row.id)
|
|
67
|
+
.values(delivered_at=func.now())
|
|
68
|
+
)
|
|
69
|
+
delivered += 1
|
|
70
|
+
|
|
71
|
+
return delivered
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The in-memory backend just records what it's given."""
|
|
2
|
+
|
|
3
|
+
from outboxed.backends.in_memory import InMemoryBackend
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_publish_records_message():
|
|
7
|
+
backend = InMemoryBackend()
|
|
8
|
+
|
|
9
|
+
backend.publish(topic="orders", key="order-1", payload='{"id": 1}')
|
|
10
|
+
|
|
11
|
+
assert len(backend.messages) == 1
|
|
12
|
+
message = backend.messages[0]
|
|
13
|
+
assert message.topic == "orders"
|
|
14
|
+
assert message.key == "order-1"
|
|
15
|
+
assert message.payload == '{"id": 1}'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Kafka backend tests — skipped unless a broker is available in CI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
pytestmark = pytest.mark.skipif(
|
|
8
|
+
not os.environ.get("KAFKA_BOOTSTRAP_SERVERS"),
|
|
9
|
+
reason="requires a running Kafka broker (set KAFKA_BOOTSTRAP_SERVERS)",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_publish_sends_to_topic():
|
|
14
|
+
pytest.skip("integration test: implement once a Kafka broker is wired into CI")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Unit tests for the Redis Streams backend, using a fake client (no broker needed)."""
|
|
2
|
+
|
|
3
|
+
from outboxed.backends.redis_streams import RedisStreamsBackend
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeRedisClient:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.calls = []
|
|
9
|
+
|
|
10
|
+
def xadd(self, stream, fields):
|
|
11
|
+
self.calls.append((stream, fields))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_publish_sends_to_stream():
|
|
15
|
+
client = FakeRedisClient()
|
|
16
|
+
backend = RedisStreamsBackend(client)
|
|
17
|
+
|
|
18
|
+
backend.publish(topic="orders", key="order-1", payload='{"id": 1}')
|
|
19
|
+
|
|
20
|
+
assert client.calls == [("orders", {"key": "order-1", "payload": '{"id": 1}'})]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_publish_applies_stream_prefix():
|
|
24
|
+
client = FakeRedisClient()
|
|
25
|
+
backend = RedisStreamsBackend(client, stream_prefix="prod.")
|
|
26
|
+
|
|
27
|
+
backend.publish(topic="orders", key="order-1", payload="payload")
|
|
28
|
+
|
|
29
|
+
stream, _ = client.calls[0]
|
|
30
|
+
assert stream == "prod.orders"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_publish_defaults_missing_key_to_empty_string():
|
|
34
|
+
client = FakeRedisClient()
|
|
35
|
+
backend = RedisStreamsBackend(client)
|
|
36
|
+
|
|
37
|
+
backend.publish(topic="orders", key=None, payload="payload")
|
|
38
|
+
|
|
39
|
+
_, fields = client.calls[0]
|
|
40
|
+
assert fields["key"] == ""
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Unit tests for the SQS backend, using a fake client (no AWS needed)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from outboxed.backends.sqs import SQSBackend
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FakeSQSClient:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.calls = []
|
|
11
|
+
|
|
12
|
+
def send_message(self, **kwargs):
|
|
13
|
+
self.calls.append(kwargs)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_publish_sends_to_mapped_queue_url():
|
|
17
|
+
client = FakeSQSClient()
|
|
18
|
+
backend = SQSBackend(client, queue_urls={"orders": "https://sqs.example/orders"})
|
|
19
|
+
|
|
20
|
+
backend.publish(topic="orders", key="order-1", payload='{"id": 1}')
|
|
21
|
+
|
|
22
|
+
assert client.calls == [
|
|
23
|
+
{
|
|
24
|
+
"QueueUrl": "https://sqs.example/orders",
|
|
25
|
+
"MessageBody": '{"id": 1}',
|
|
26
|
+
"MessageGroupId": "order-1",
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_publish_omits_message_group_id_when_key_is_none():
|
|
32
|
+
client = FakeSQSClient()
|
|
33
|
+
backend = SQSBackend(client, queue_urls={"orders": "https://sqs.example/orders"})
|
|
34
|
+
|
|
35
|
+
backend.publish(topic="orders", key=None, payload="payload")
|
|
36
|
+
|
|
37
|
+
assert "MessageGroupId" not in client.calls[0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_publish_raises_on_unmapped_topic():
|
|
41
|
+
client = FakeSQSClient()
|
|
42
|
+
backend = SQSBackend(client, queue_urls={"orders": "https://sqs.example/orders"})
|
|
43
|
+
|
|
44
|
+
with pytest.raises(KeyError):
|
|
45
|
+
backend.publish(topic="unknown", key=None, payload="payload")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Kill the relay mid-batch and assert no loss and no duplicate delivery.
|
|
2
|
+
|
|
3
|
+
This is the test behind outboxed's core guarantee: once a transaction
|
|
4
|
+
commits, its event is delivered at least once and is observed exactly once
|
|
5
|
+
by the backend's handler (the dedup layer filters transport-level repeats).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import random
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import create_engine
|
|
11
|
+
|
|
12
|
+
from outboxed.backends.in_memory import InMemoryBackend
|
|
13
|
+
from outboxed.dedup import DeliveryTracker
|
|
14
|
+
from outboxed.models import Event, metadata
|
|
15
|
+
from outboxed.outbox import Outbox
|
|
16
|
+
from outboxed.relay import Relay
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_engine():
|
|
20
|
+
engine = create_engine("sqlite:///:memory:")
|
|
21
|
+
metadata.create_all(engine)
|
|
22
|
+
return engine
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_crash_mid_batch_then_restart_delivers_everything_once(monkeypatch):
|
|
26
|
+
engine = make_engine()
|
|
27
|
+
outbox = Outbox()
|
|
28
|
+
backend = InMemoryBackend()
|
|
29
|
+
tracker = DeliveryTracker()
|
|
30
|
+
|
|
31
|
+
event_count = 20
|
|
32
|
+
with engine.begin() as conn:
|
|
33
|
+
for i in range(event_count):
|
|
34
|
+
outbox.enqueue(conn, Event(topic="orders", payload=str(i)))
|
|
35
|
+
|
|
36
|
+
crash_after = random.randint(1, event_count - 1)
|
|
37
|
+
publish_calls = {"count": 0}
|
|
38
|
+
real_publish = backend.publish
|
|
39
|
+
|
|
40
|
+
def flaky_publish(**kwargs):
|
|
41
|
+
if publish_calls["count"] == crash_after:
|
|
42
|
+
raise RuntimeError("simulated crash mid-batch")
|
|
43
|
+
publish_calls["count"] += 1
|
|
44
|
+
real_publish(**kwargs)
|
|
45
|
+
|
|
46
|
+
monkeypatch.setattr(backend, "publish", flaky_publish)
|
|
47
|
+
|
|
48
|
+
relay = Relay(engine, backend, batch_size=1, tracker=tracker)
|
|
49
|
+
for _ in range(event_count):
|
|
50
|
+
try:
|
|
51
|
+
relay.poll_once()
|
|
52
|
+
except RuntimeError:
|
|
53
|
+
monkeypatch.setattr(backend, "publish", real_publish)
|
|
54
|
+
|
|
55
|
+
relay.poll_once()
|
|
56
|
+
|
|
57
|
+
delivered_payloads = [m.payload for m in backend.messages]
|
|
58
|
+
assert sorted(delivered_payloads, key=int) == [str(i) for i in range(event_count)]
|
|
59
|
+
assert len(delivered_payloads) == len(set(delivered_payloads))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""A redelivered event id must never reach the backend's handler twice."""
|
|
2
|
+
|
|
3
|
+
from outboxed.dedup import DeliveryTracker
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_mark_delivered_then_is_duplicate():
|
|
7
|
+
tracker = DeliveryTracker()
|
|
8
|
+
assert not tracker.is_duplicate("evt-1")
|
|
9
|
+
|
|
10
|
+
tracker.mark_delivered("evt-1")
|
|
11
|
+
|
|
12
|
+
assert tracker.is_duplicate("evt-1")
|
|
13
|
+
assert not tracker.is_duplicate("evt-2")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Same-transaction write semantics: commit keeps the event, rollback drops it."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import create_engine, select
|
|
4
|
+
|
|
5
|
+
from outboxed.models import Event, metadata, outbox_table
|
|
6
|
+
from outboxed.outbox import Outbox
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_engine():
|
|
10
|
+
engine = create_engine("sqlite:///:memory:")
|
|
11
|
+
metadata.create_all(engine)
|
|
12
|
+
return engine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_enqueue_is_visible_after_commit():
|
|
16
|
+
engine = make_engine()
|
|
17
|
+
outbox = Outbox()
|
|
18
|
+
event = Event(topic="orders", payload='{"id": 1}')
|
|
19
|
+
|
|
20
|
+
with engine.begin() as conn:
|
|
21
|
+
outbox.enqueue(conn, event)
|
|
22
|
+
|
|
23
|
+
with engine.connect() as conn:
|
|
24
|
+
rows = conn.execute(select(outbox_table)).fetchall()
|
|
25
|
+
|
|
26
|
+
assert len(rows) == 1
|
|
27
|
+
assert rows[0].id == event.id
|
|
28
|
+
assert rows[0].delivered_at is None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_enqueue_is_rolled_back_with_the_transaction():
|
|
32
|
+
engine = make_engine()
|
|
33
|
+
outbox = Outbox()
|
|
34
|
+
event = Event(topic="orders", payload='{"id": 1}')
|
|
35
|
+
|
|
36
|
+
with engine.connect() as conn:
|
|
37
|
+
trans = conn.begin()
|
|
38
|
+
outbox.enqueue(conn, event)
|
|
39
|
+
trans.rollback()
|
|
40
|
+
|
|
41
|
+
with engine.connect() as conn:
|
|
42
|
+
rows = conn.execute(select(outbox_table)).fetchall()
|
|
43
|
+
|
|
44
|
+
assert rows == []
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Polling, ordering, and marking entries delivered."""
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import create_engine
|
|
4
|
+
|
|
5
|
+
from outboxed.backends.in_memory import InMemoryBackend
|
|
6
|
+
from outboxed.models import Event, metadata
|
|
7
|
+
from outboxed.outbox import Outbox
|
|
8
|
+
from outboxed.relay import Relay
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_engine():
|
|
12
|
+
engine = create_engine("sqlite:///:memory:")
|
|
13
|
+
metadata.create_all(engine)
|
|
14
|
+
return engine
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_poll_once_delivers_in_order():
|
|
18
|
+
engine = make_engine()
|
|
19
|
+
outbox = Outbox()
|
|
20
|
+
backend = InMemoryBackend()
|
|
21
|
+
relay = Relay(engine, backend)
|
|
22
|
+
|
|
23
|
+
with engine.begin() as conn:
|
|
24
|
+
outbox.enqueue(conn, Event(topic="orders", payload="1"))
|
|
25
|
+
outbox.enqueue(conn, Event(topic="orders", payload="2"))
|
|
26
|
+
|
|
27
|
+
delivered = relay.poll_once()
|
|
28
|
+
|
|
29
|
+
assert delivered == 2
|
|
30
|
+
assert [m.payload for m in backend.messages] == ["1", "2"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_poll_once_skips_already_delivered_entries():
|
|
34
|
+
engine = make_engine()
|
|
35
|
+
outbox = Outbox()
|
|
36
|
+
backend = InMemoryBackend()
|
|
37
|
+
relay = Relay(engine, backend)
|
|
38
|
+
|
|
39
|
+
with engine.begin() as conn:
|
|
40
|
+
outbox.enqueue(conn, Event(topic="orders", payload="1"))
|
|
41
|
+
|
|
42
|
+
relay.poll_once()
|
|
43
|
+
delivered_again = relay.poll_once()
|
|
44
|
+
|
|
45
|
+
assert delivered_again == 0
|
|
46
|
+
assert len(backend.messages) == 1
|