outboxed 0.1.0__py3-none-any.whl
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/__init__.py +9 -0
- outboxed/backends/__init__.py +6 -0
- outboxed/backends/base.py +13 -0
- outboxed/backends/in_memory.py +22 -0
- outboxed/backends/kafka.py +21 -0
- outboxed/backends/redis_streams.py +17 -0
- outboxed/backends/sqs.py +24 -0
- outboxed/dedup.py +20 -0
- outboxed/models.py +31 -0
- outboxed/outbox.py +25 -0
- outboxed/py.typed +0 -0
- outboxed/relay.py +71 -0
- outboxed-0.1.0.dist-info/METADATA +93 -0
- outboxed-0.1.0.dist-info/RECORD +16 -0
- outboxed-0.1.0.dist-info/WHEEL +4 -0
- outboxed-0.1.0.dist-info/licenses/LICENSE +21 -0
outboxed/__init__.py
ADDED
|
@@ -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})
|
outboxed/backends/sqs.py
ADDED
|
@@ -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)
|
outboxed/dedup.py
ADDED
|
@@ -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)
|
outboxed/models.py
ADDED
|
@@ -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()))
|
outboxed/outbox.py
ADDED
|
@@ -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
|
+
)
|
outboxed/py.typed
ADDED
|
File without changes
|
outboxed/relay.py
ADDED
|
@@ -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,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,16 @@
|
|
|
1
|
+
outboxed/__init__.py,sha256=3Whv1AuH2EOHB8i241BiI12GiM2_OBV2ZNGCR6UMSDA,236
|
|
2
|
+
outboxed/dedup.py,sha256=aGlwjM3qJGAue1uI8G8YkUul-sR_Hsi8ktDtLj-1Q9M,619
|
|
3
|
+
outboxed/models.py,sha256=sJZH6F9M_bUYBGx7StnbJ-aG8cfe-Y76d1XGsUAstkQ,914
|
|
4
|
+
outboxed/outbox.py,sha256=SS-CDaivkrmbmEcUxag-Wh3Ooeir5qB00zC1xjw6oow,752
|
|
5
|
+
outboxed/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
outboxed/relay.py,sha256=GATinkrp6dxMp5IFGzvyD4QUGtk0g7LthJMEDTzofy4,2207
|
|
7
|
+
outboxed/backends/__init__.py,sha256=L9erj3BlMotOTIcpoJmdw4SmK6_3_XqqJKaamvwXEnA,199
|
|
8
|
+
outboxed/backends/base.py,sha256=EXqG4vZg2Zjmz1eQiIL8mxJAvWTH1GASSusGv8S4pss,377
|
|
9
|
+
outboxed/backends/in_memory.py,sha256=-C-KoKgBVyOwsPcQeRUZJhdlHpxrDEEyDvser7bFX_A,594
|
|
10
|
+
outboxed/backends/kafka.py,sha256=9sm3cOFyy7A6_3Lo2nEYeNoIP9nM8TDntR2-Wi6noM0,658
|
|
11
|
+
outboxed/backends/redis_streams.py,sha256=87klQw-hquLFKKGl-6e8WgOhNfFipwcLXU5Ak0oRlDc,570
|
|
12
|
+
outboxed/backends/sqs.py,sha256=iQwt3DEF1x_1eW9cUOpxrv1Yy6N1hhYnk5_o2ipEDfg,780
|
|
13
|
+
outboxed-0.1.0.dist-info/METADATA,sha256=P-gKVInnoLR5E3k8-MyDB8tR6QEi1Vt9S2evCeu5IAk,2903
|
|
14
|
+
outboxed-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
15
|
+
outboxed-0.1.0.dist-info/licenses/LICENSE,sha256=191kzGOjX1TzmWu8p4TT2jdQz0BnqmFybQKbzCaEL9I,1070
|
|
16
|
+
outboxed-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|