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 ADDED
@@ -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)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.