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.
Files changed (36) hide show
  1. outboxed-0.1.0/.github/workflows/ci.yml +33 -0
  2. outboxed-0.1.0/.github/workflows/publish.yml +29 -0
  3. outboxed-0.1.0/.gitignore +14 -0
  4. outboxed-0.1.0/CHANGELOG.md +18 -0
  5. outboxed-0.1.0/CONTRIBUTING.md +31 -0
  6. outboxed-0.1.0/LICENSE +21 -0
  7. outboxed-0.1.0/PKG-INFO +93 -0
  8. outboxed-0.1.0/README.md +68 -0
  9. outboxed-0.1.0/docs/backends.md +42 -0
  10. outboxed-0.1.0/docs/dual-write-problem.md +50 -0
  11. outboxed-0.1.0/docs/guarantees.md +37 -0
  12. outboxed-0.1.0/docs/index.md +45 -0
  13. outboxed-0.1.0/examples/docker-compose.yml +23 -0
  14. outboxed-0.1.0/examples/fastapi_order_service/main.py +62 -0
  15. outboxed-0.1.0/examples/fastapi_order_service/relay.py +26 -0
  16. outboxed-0.1.0/pyproject.toml +48 -0
  17. outboxed-0.1.0/src/outboxed/__init__.py +9 -0
  18. outboxed-0.1.0/src/outboxed/backends/__init__.py +6 -0
  19. outboxed-0.1.0/src/outboxed/backends/base.py +13 -0
  20. outboxed-0.1.0/src/outboxed/backends/in_memory.py +22 -0
  21. outboxed-0.1.0/src/outboxed/backends/kafka.py +21 -0
  22. outboxed-0.1.0/src/outboxed/backends/redis_streams.py +17 -0
  23. outboxed-0.1.0/src/outboxed/backends/sqs.py +24 -0
  24. outboxed-0.1.0/src/outboxed/dedup.py +20 -0
  25. outboxed-0.1.0/src/outboxed/models.py +31 -0
  26. outboxed-0.1.0/src/outboxed/outbox.py +25 -0
  27. outboxed-0.1.0/src/outboxed/py.typed +0 -0
  28. outboxed-0.1.0/src/outboxed/relay.py +71 -0
  29. outboxed-0.1.0/tests/backends/test_in_memory.py +15 -0
  30. outboxed-0.1.0/tests/backends/test_kafka.py +14 -0
  31. outboxed-0.1.0/tests/backends/test_redis_streams.py +40 -0
  32. outboxed-0.1.0/tests/backends/test_sqs.py +45 -0
  33. outboxed-0.1.0/tests/test_chaos_crash_recovery.py +59 -0
  34. outboxed-0.1.0/tests/test_dedup.py +13 -0
  35. outboxed-0.1.0/tests/test_outbox_enqueue.py +44 -0
  36. 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,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .pytest_cache/
12
+ .coverage
13
+ htmlcov/
14
+ .DS_Store
@@ -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.
@@ -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).
@@ -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,9 @@
1
+ """outboxed: the transactional outbox pattern for Python backends."""
2
+
3
+ from outboxed.models import Event
4
+ from outboxed.outbox import Outbox
5
+ from outboxed.relay import Relay
6
+
7
+ __all__ = ["Event", "Outbox", "Relay"]
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Broker adapters implementing the Backend protocol."""
2
+
3
+ from outboxed.backends.base import Backend
4
+ from outboxed.backends.in_memory import InMemoryBackend
5
+
6
+ __all__ = ["Backend", "InMemoryBackend"]
@@ -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