txoutbox 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.
txoutbox/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """txoutbox: a storage-agnostic Transactional Outbox relay for asyncio.
2
+
3
+ Bring your own table, bring your own broker. Implement :class:`Storage` over your
4
+ database and :class:`Publisher` over your transport; :class:`Relay` does the rest.
5
+ """
6
+
7
+ from .backoff import Backoff
8
+ from .message import MessageId, OutboxMessage
9
+ from .poller import AdaptivePoller
10
+ from .protocols import Hooks, Publisher, Storage
11
+ from .relay import Relay, RelayConfig, RoundResult, default_worker_id
12
+
13
+ __all__ = [
14
+ "AdaptivePoller",
15
+ "Backoff",
16
+ "Hooks",
17
+ "MessageId",
18
+ "OutboxMessage",
19
+ "Publisher",
20
+ "Relay",
21
+ "RelayConfig",
22
+ "RoundResult",
23
+ "Storage",
24
+ "default_worker_id",
25
+ ]
26
+
27
+ __version__ = "0.1.0"
@@ -0,0 +1,7 @@
1
+ """Built-in storage and publisher adapters.
2
+
3
+ * :mod:`txoutbox.adapters.memory` - in-process, for tests and demos.
4
+ * :mod:`txoutbox.adapters.sqlite` - stdlib ``sqlite3``, for small services and examples.
5
+ * :mod:`txoutbox.adapters.postgres` - ``asyncpg`` with ``FOR UPDATE SKIP LOCKED``
6
+ (install extra ``postgres``).
7
+ """
@@ -0,0 +1,162 @@
1
+ """In-memory storage and publisher. Deterministic, dependency-free, test-friendly."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import itertools
7
+ from collections.abc import Callable, Mapping, Sequence
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime, timedelta
10
+ from enum import StrEnum
11
+
12
+ from ..message import MessageId, OutboxMessage
13
+
14
+
15
+ class Status(StrEnum):
16
+ PENDING = "pending"
17
+ DONE = "done"
18
+ DEAD = "dead"
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class Record:
23
+ message: OutboxMessage
24
+ status: Status = Status.PENDING
25
+ attempts: int = 0
26
+ retry_at: datetime | None = None
27
+ leased_by: str | None = None
28
+ lease_until: datetime | None = None
29
+ last_error: str | None = None
30
+
31
+
32
+ class MemoryStorage:
33
+ """A dict-backed outbox. Rows are inserted with :meth:`add` (your "transaction").
34
+
35
+ With ``strict_ordering`` (default), a message whose key has an earlier message that
36
+ is pending but not claimable right now (leased elsewhere, or waiting for a retry)
37
+ is held back, so retries never reorder a key.
38
+ """
39
+
40
+ def __init__(
41
+ self, *, clock: Callable[[], datetime] | None = None, strict_ordering: bool = True
42
+ ) -> None:
43
+ self.strict_ordering = strict_ordering
44
+ self._records: dict[MessageId, Record] = {}
45
+ self._ids = itertools.count(1)
46
+ self._clock = clock or (lambda: datetime.now(UTC))
47
+ self._lock = asyncio.Lock()
48
+
49
+ # -- producer side -----------------------------------------------------------------
50
+
51
+ def add(
52
+ self,
53
+ topic: str,
54
+ payload: bytes,
55
+ *,
56
+ key: str | None = None,
57
+ headers: Mapping[str, str] | None = None,
58
+ ) -> MessageId:
59
+ message_id = next(self._ids)
60
+ self._records[message_id] = Record(
61
+ OutboxMessage(
62
+ id=message_id,
63
+ topic=topic,
64
+ payload=payload,
65
+ key=key,
66
+ headers=dict(headers or {}),
67
+ created_at=self._clock(),
68
+ )
69
+ )
70
+ return message_id
71
+
72
+ # -- inspection --------------------------------------------------------------------
73
+
74
+ def get(self, message_id: MessageId) -> Record:
75
+ return self._records[message_id]
76
+
77
+ def records(self, status: Status | None = None) -> list[Record]:
78
+ return [r for r in self._records.values() if status is None or r.status == status]
79
+
80
+ def __len__(self) -> int:
81
+ return len(self._records)
82
+
83
+ # -- Storage protocol --------------------------------------------------------------
84
+
85
+ async def claim(
86
+ self, *, batch_size: int, lease: timedelta, worker_id: str
87
+ ) -> Sequence[OutboxMessage]:
88
+ async with self._lock:
89
+ now = self._clock()
90
+ claimed: list[OutboxMessage] = []
91
+ blocked_keys: set[str] = set()
92
+ for record in self._records.values():
93
+ if len(claimed) >= batch_size:
94
+ break
95
+ if record.status is not Status.PENDING:
96
+ continue
97
+ key = record.message.key
98
+ not_due = record.retry_at is not None and record.retry_at > now
99
+ leased = record.lease_until is not None and record.lease_until > now
100
+ if not_due or leased or (key is not None and key in blocked_keys):
101
+ if key is not None and self.strict_ordering:
102
+ blocked_keys.add(key)
103
+ continue
104
+ record.attempts += 1
105
+ record.leased_by = worker_id
106
+ record.lease_until = now + lease
107
+ record.retry_at = None
108
+ claimed.append(_with_attempts(record.message, record.attempts))
109
+ return claimed
110
+
111
+ async def ack(self, ids: Sequence[MessageId]) -> None:
112
+ async with self._lock:
113
+ for message_id in ids:
114
+ record = self._records[message_id]
115
+ record.status = Status.DONE
116
+ record.leased_by = record.lease_until = None
117
+
118
+ async def nack(self, message_id: MessageId, *, error: str, retry_at: datetime) -> None:
119
+ async with self._lock:
120
+ record = self._records[message_id]
121
+ record.last_error = error
122
+ record.retry_at = retry_at
123
+ record.leased_by = record.lease_until = None
124
+
125
+ async def dead_letter(self, message_id: MessageId, *, error: str) -> None:
126
+ async with self._lock:
127
+ record = self._records[message_id]
128
+ record.status = Status.DEAD
129
+ record.last_error = error
130
+ record.leased_by = record.lease_until = None
131
+
132
+
133
+ def _with_attempts(message: OutboxMessage, attempts: int) -> OutboxMessage:
134
+ return OutboxMessage(
135
+ id=message.id,
136
+ topic=message.topic,
137
+ payload=message.payload,
138
+ key=message.key,
139
+ headers=message.headers,
140
+ attempts=attempts,
141
+ created_at=message.created_at,
142
+ )
143
+
144
+
145
+ @dataclass(slots=True)
146
+ class MemoryPublisher:
147
+ """Collects published messages. ``fail`` decides which publishes raise."""
148
+
149
+ published: list[OutboxMessage] = field(default_factory=list)
150
+ fail: Callable[[OutboxMessage], BaseException | None] | None = None
151
+ delay: float = 0.0
152
+
153
+ async def publish(self, message: OutboxMessage) -> None:
154
+ if self.delay:
155
+ await asyncio.sleep(self.delay)
156
+ if self.fail is not None and (error := self.fail(message)) is not None:
157
+ raise error
158
+ self.published.append(message)
159
+
160
+ @property
161
+ def topics(self) -> list[str]:
162
+ return [m.topic for m in self.published]
@@ -0,0 +1,201 @@
1
+ """Outbox storage on PostgreSQL via ``asyncpg``.
2
+
3
+ Claims use ``FOR UPDATE SKIP LOCKED`` so any number of relay workers can share one
4
+ table without stepping on each other. Install with ``pip install txoutbox[postgres]``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections.abc import Mapping, Sequence
11
+ from datetime import datetime, timedelta
12
+ from typing import Any, Self
13
+
14
+ from ..message import MessageId, OutboxMessage
15
+
16
+ try:
17
+ import asyncpg
18
+ except ImportError as _exc: # pragma: no cover - exercised only without the extra
19
+ raise ImportError(
20
+ "txoutbox.adapters.postgres needs asyncpg: pip install 'txoutbox[postgres]'"
21
+ ) from _exc
22
+
23
+ SCHEMA_SQL = """
24
+ CREATE TABLE IF NOT EXISTS {table} (
25
+ id BIGSERIAL PRIMARY KEY,
26
+ topic TEXT NOT NULL,
27
+ key TEXT,
28
+ payload BYTEA NOT NULL,
29
+ headers JSONB NOT NULL DEFAULT '{{}}',
30
+ status TEXT NOT NULL DEFAULT 'pending',
31
+ attempts INT NOT NULL DEFAULT 0,
32
+ retry_at TIMESTAMPTZ,
33
+ leased_by TEXT,
34
+ lease_until TIMESTAMPTZ,
35
+ last_error TEXT,
36
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
37
+ );
38
+ CREATE INDEX IF NOT EXISTS {index} ON {table} (id) WHERE status = 'pending';
39
+ """
40
+
41
+
42
+ class PostgresStorage:
43
+ """Outbox table in PostgreSQL, driven by an :class:`asyncpg.Pool`.
44
+
45
+ Pass your application's pool, or build a private one with :meth:`connect`.
46
+ ``strict_ordering`` (default) holds back a message while an earlier pending message
47
+ with the same key is not claimable (leased elsewhere, or waiting for its retry).
48
+ ``delete_on_ack`` removes delivered rows instead of marking them ``done``.
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ pool: asyncpg.Pool,
54
+ *,
55
+ table: str = "outbox",
56
+ schema: str | None = None,
57
+ strict_ordering: bool = True,
58
+ delete_on_ack: bool = False,
59
+ ) -> None:
60
+ if not table.isidentifier():
61
+ raise ValueError("table must be a plain identifier")
62
+ if schema is not None and not schema.isidentifier():
63
+ raise ValueError("schema must be a plain identifier")
64
+ self.pool = pool
65
+ self.table = table
66
+ self.schema = schema
67
+ self.strict_ordering = strict_ordering
68
+ self.delete_on_ack = delete_on_ack
69
+ self._owns_pool = False
70
+ self._sql = _Sql(self.qualified_table, f"{table}_pending_idx", strict_ordering)
71
+
72
+ @classmethod
73
+ async def connect(cls, dsn: str, **kwargs: Any) -> Self:
74
+ """Create a storage with its own pool. ``kwargs`` go to the constructor."""
75
+ pool_kwargs = {k: kwargs.pop(k) for k in list(kwargs) if k not in _INIT_KWARGS}
76
+ pool = await asyncpg.create_pool(dsn, **pool_kwargs)
77
+ self = cls(pool, **kwargs)
78
+ self._owns_pool = True
79
+ return self
80
+
81
+ async def close(self) -> None:
82
+ """Close the pool, but only if :meth:`connect` created it."""
83
+ if self._owns_pool:
84
+ await self.pool.close()
85
+ self._owns_pool = False
86
+
87
+ @property
88
+ def qualified_table(self) -> str:
89
+ return f"{self.schema}.{self.table}" if self.schema else self.table
90
+
91
+ # -- setup -------------------------------------------------------------------------
92
+
93
+ def schema_sql(self) -> str:
94
+ """DDL for the outbox table. Paste it into your migration tool."""
95
+ return SCHEMA_SQL.format(table=self.qualified_table, index=f"{self.table}_pending_idx")
96
+
97
+ async def create_schema(self) -> None:
98
+ await self.pool.execute(self.schema_sql())
99
+
100
+ # -- producer side -----------------------------------------------------------------
101
+
102
+ async def insert(
103
+ self,
104
+ conn: asyncpg.Connection,
105
+ topic: str,
106
+ payload: bytes,
107
+ *,
108
+ key: str | None = None,
109
+ headers: Mapping[str, str] | None = None,
110
+ ) -> int:
111
+ """Insert a row using *your* connection, inside *your* transaction."""
112
+ row_id: int = await conn.fetchval(
113
+ self._sql.insert, topic, key, payload, json.dumps(dict(headers or {}))
114
+ )
115
+ return row_id
116
+
117
+ async def add(
118
+ self,
119
+ topic: str,
120
+ payload: bytes,
121
+ *,
122
+ key: str | None = None,
123
+ headers: Mapping[str, str] | None = None,
124
+ ) -> int:
125
+ """Insert a row on a pooled connection. Handy for demos and tests."""
126
+ async with self.pool.acquire() as conn:
127
+ return await self.insert(conn, topic, payload, key=key, headers=headers)
128
+
129
+ # -- Storage protocol --------------------------------------------------------------
130
+
131
+ async def claim(
132
+ self, *, batch_size: int, lease: timedelta, worker_id: str
133
+ ) -> Sequence[OutboxMessage]:
134
+ rows = await self.pool.fetch(self._sql.claim, worker_id, lease, batch_size)
135
+ return [_row_to_message(row) for row in rows]
136
+
137
+ async def ack(self, ids: Sequence[MessageId]) -> None:
138
+ if not ids:
139
+ return
140
+ sql = self._sql.delete if self.delete_on_ack else self._sql.ack
141
+ await self.pool.execute(sql, list(ids))
142
+
143
+ async def nack(self, message_id: MessageId, *, error: str, retry_at: datetime) -> None:
144
+ await self.pool.execute(self._sql.nack, retry_at, error, message_id)
145
+
146
+ async def dead_letter(self, message_id: MessageId, *, error: str) -> None:
147
+ await self.pool.execute(self._sql.dead_letter, error, message_id)
148
+
149
+
150
+ _INIT_KWARGS = frozenset({"table", "schema", "strict_ordering", "delete_on_ack"})
151
+
152
+
153
+ class _Sql:
154
+ """Statements pre-rendered for one table."""
155
+
156
+ def __init__(self, table: str, index: str, strict_ordering: bool) -> None:
157
+ blocked = (
158
+ " AND (o.key IS NULL OR NOT EXISTS ("
159
+ f"SELECT 1 FROM {table} p WHERE p.key = o.key AND p.id < o.id"
160
+ " AND p.status = 'pending' AND (p.retry_at > now() OR p.lease_until > now())))"
161
+ if strict_ordering
162
+ else ""
163
+ )
164
+ self.insert = (
165
+ f"INSERT INTO {table} (topic, key, payload, headers)"
166
+ " VALUES ($1, $2, $3, $4::jsonb) RETURNING id"
167
+ )
168
+ self.claim = (
169
+ f"UPDATE {table} SET attempts = attempts + 1, leased_by = $1,"
170
+ " lease_until = now() + $2, retry_at = NULL"
171
+ f" WHERE id IN (SELECT o.id FROM {table} o WHERE o.status = 'pending'"
172
+ " AND (o.retry_at IS NULL OR o.retry_at <= now())"
173
+ " AND (o.lease_until IS NULL OR o.lease_until <= now())"
174
+ f"{blocked} ORDER BY o.id LIMIT $3 FOR UPDATE SKIP LOCKED)"
175
+ " RETURNING id, topic, key, payload, headers::text AS headers, attempts, created_at"
176
+ )
177
+ self.ack = (
178
+ f"UPDATE {table} SET status = 'done', leased_by = NULL, lease_until = NULL"
179
+ " WHERE id = ANY($1::bigint[])"
180
+ )
181
+ self.delete = f"DELETE FROM {table} WHERE id = ANY($1::bigint[])"
182
+ self.nack = (
183
+ f"UPDATE {table} SET retry_at = $1, last_error = $2, leased_by = NULL,"
184
+ " lease_until = NULL WHERE id = $3"
185
+ )
186
+ self.dead_letter = (
187
+ f"UPDATE {table} SET status = 'dead', last_error = $1, leased_by = NULL,"
188
+ " lease_until = NULL WHERE id = $2"
189
+ )
190
+
191
+
192
+ def _row_to_message(row: asyncpg.Record) -> OutboxMessage:
193
+ return OutboxMessage(
194
+ id=row["id"],
195
+ topic=row["topic"],
196
+ payload=bytes(row["payload"]),
197
+ key=row["key"],
198
+ headers=json.loads(row["headers"]),
199
+ attempts=row["attempts"],
200
+ created_at=row["created_at"],
201
+ )
@@ -0,0 +1,216 @@
1
+ """Outbox storage on the standard library's ``sqlite3``.
2
+
3
+ Good for small services, examples and integration tests. Calls run in a worker
4
+ thread so the event loop is never blocked; a process-wide lock serialises them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import sqlite3
12
+ import threading
13
+ from collections.abc import Mapping, Sequence
14
+ from datetime import UTC, datetime, timedelta
15
+ from typing import Any
16
+
17
+ from ..message import MessageId, OutboxMessage
18
+
19
+ SCHEMA = """
20
+ CREATE TABLE IF NOT EXISTS {table} (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ topic TEXT NOT NULL,
23
+ key TEXT,
24
+ payload BLOB NOT NULL,
25
+ headers TEXT NOT NULL DEFAULT '{{}}',
26
+ status TEXT NOT NULL DEFAULT 'pending',
27
+ attempts INTEGER NOT NULL DEFAULT 0,
28
+ retry_at REAL,
29
+ leased_by TEXT,
30
+ lease_until REAL,
31
+ last_error TEXT,
32
+ created_at REAL NOT NULL
33
+ );
34
+ CREATE INDEX IF NOT EXISTS {table}_pending_idx ON {table} (status, id);
35
+ """
36
+
37
+
38
+ class SqliteStorage:
39
+ """Outbox table in a SQLite database.
40
+
41
+ ``strict_ordering`` (default) holds back a message while an earlier pending message
42
+ with the same key is not claimable (leased elsewhere, or waiting for its retry).
43
+ ``delete_on_ack`` removes delivered rows instead of marking them ``done``.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ path: str,
49
+ *,
50
+ table: str = "outbox",
51
+ strict_ordering: bool = True,
52
+ delete_on_ack: bool = False,
53
+ ) -> None:
54
+ if not table.isidentifier():
55
+ raise ValueError("table must be a plain identifier")
56
+ self.path = path
57
+ self.table = table
58
+ self.strict_ordering = strict_ordering
59
+ self.delete_on_ack = delete_on_ack
60
+ self._lock = threading.Lock()
61
+ self._conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
62
+ self._conn.execute("PRAGMA journal_mode=WAL")
63
+ self._conn.execute("PRAGMA busy_timeout=5000")
64
+
65
+ # -- setup -------------------------------------------------------------------------
66
+
67
+ def create_schema(self) -> None:
68
+ with self._lock:
69
+ self._conn.executescript(SCHEMA.format(table=self.table))
70
+
71
+ def close(self) -> None:
72
+ with self._lock:
73
+ self._conn.close()
74
+
75
+ # -- producer side -----------------------------------------------------------------
76
+
77
+ def insert(
78
+ self,
79
+ conn: sqlite3.Connection,
80
+ topic: str,
81
+ payload: bytes,
82
+ *,
83
+ key: str | None = None,
84
+ headers: Mapping[str, str] | None = None,
85
+ ) -> MessageId:
86
+ """Insert a row using *your* connection, inside *your* transaction."""
87
+ cur = conn.execute(
88
+ f"INSERT INTO {self.table} (topic, key, payload, headers, created_at)"
89
+ " VALUES (?, ?, ?, ?, ?)",
90
+ (topic, key, payload, json.dumps(dict(headers or {})), _now()),
91
+ )
92
+ return cur.lastrowid
93
+
94
+ async def add(
95
+ self,
96
+ topic: str,
97
+ payload: bytes,
98
+ *,
99
+ key: str | None = None,
100
+ headers: Mapping[str, str] | None = None,
101
+ ) -> MessageId:
102
+ """Insert a row on the storage's own connection. Handy for demos and tests."""
103
+
104
+ def _add() -> MessageId:
105
+ with self._lock:
106
+ return self.insert(self._conn, topic, payload, key=key, headers=headers)
107
+
108
+ return await asyncio.to_thread(_add)
109
+
110
+ # -- Storage protocol --------------------------------------------------------------
111
+
112
+ async def claim(
113
+ self, *, batch_size: int, lease: timedelta, worker_id: str
114
+ ) -> Sequence[OutboxMessage]:
115
+ return await asyncio.to_thread(self._claim, batch_size, lease, worker_id)
116
+
117
+ async def ack(self, ids: Sequence[MessageId]) -> None:
118
+ if not ids:
119
+ return
120
+ if self.delete_on_ack:
121
+ sql = f"DELETE FROM {self.table} WHERE id = ?"
122
+ else:
123
+ sql = (
124
+ f"UPDATE {self.table} SET status = 'done', leased_by = NULL, lease_until = NULL"
125
+ " WHERE id = ?"
126
+ )
127
+ await asyncio.to_thread(self._executemany, sql, [(i,) for i in ids])
128
+
129
+ async def nack(self, message_id: MessageId, *, error: str, retry_at: datetime) -> None:
130
+ await asyncio.to_thread(
131
+ self._execute,
132
+ f"UPDATE {self.table} SET retry_at = ?, last_error = ?, leased_by = NULL,"
133
+ " lease_until = NULL WHERE id = ?",
134
+ (retry_at.timestamp(), error, message_id),
135
+ )
136
+
137
+ async def dead_letter(self, message_id: MessageId, *, error: str) -> None:
138
+ await asyncio.to_thread(
139
+ self._execute,
140
+ f"UPDATE {self.table} SET status = 'dead', last_error = ?, leased_by = NULL,"
141
+ " lease_until = NULL WHERE id = ?",
142
+ (error, message_id),
143
+ )
144
+
145
+ # -- internals ---------------------------------------------------------------------
146
+
147
+ def _claim(self, batch_size: int, lease: timedelta, worker_id: str) -> list[OutboxMessage]:
148
+ t = self.table
149
+ now = _now()
150
+ blocked = (
151
+ f" AND (o.key IS NULL OR NOT EXISTS (SELECT 1 FROM {t} p WHERE p.key = o.key"
152
+ " AND p.id < o.id AND p.status = 'pending'"
153
+ " AND (p.retry_at > :now OR p.lease_until > :now)))"
154
+ if self.strict_ordering
155
+ else ""
156
+ )
157
+ select = (
158
+ f"SELECT o.id FROM {t} o WHERE o.status = 'pending'"
159
+ " AND (o.retry_at IS NULL OR o.retry_at <= :now)"
160
+ " AND (o.lease_until IS NULL OR o.lease_until <= :now)"
161
+ f"{blocked} ORDER BY o.id LIMIT :limit"
162
+ )
163
+ with self._lock:
164
+ self._conn.execute("BEGIN IMMEDIATE")
165
+ try:
166
+ ids = [r[0] for r in self._conn.execute(select, {"now": now, "limit": batch_size})]
167
+ if not ids:
168
+ self._conn.execute("COMMIT")
169
+ return []
170
+ marks = ",".join("?" * len(ids))
171
+ self._conn.execute(
172
+ f"UPDATE {t} SET attempts = attempts + 1, leased_by = ?, lease_until = ?,"
173
+ f" retry_at = NULL WHERE id IN ({marks})",
174
+ (worker_id, now + lease.total_seconds(), *ids),
175
+ )
176
+ rows = self._conn.execute(
177
+ f"SELECT id, topic, key, payload, headers, attempts, created_at FROM {t}"
178
+ f" WHERE id IN ({marks}) ORDER BY id",
179
+ ids,
180
+ ).fetchall()
181
+ self._conn.execute("COMMIT")
182
+ except BaseException:
183
+ self._conn.execute("ROLLBACK")
184
+ raise
185
+ return [_row_to_message(row) for row in rows]
186
+
187
+ def _execute(self, sql: str, params: tuple[Any, ...]) -> None:
188
+ with self._lock:
189
+ self._conn.execute(sql, params)
190
+
191
+ def _executemany(self, sql: str, params: list[tuple[Any, ...]]) -> None:
192
+ with self._lock:
193
+ self._conn.execute("BEGIN IMMEDIATE")
194
+ try:
195
+ self._conn.executemany(sql, params)
196
+ self._conn.execute("COMMIT")
197
+ except BaseException:
198
+ self._conn.execute("ROLLBACK")
199
+ raise
200
+
201
+
202
+ def _now() -> float:
203
+ return datetime.now(UTC).timestamp()
204
+
205
+
206
+ def _row_to_message(row: tuple[Any, ...]) -> OutboxMessage:
207
+ message_id, topic, key, payload, headers, attempts, created_at = row
208
+ return OutboxMessage(
209
+ id=message_id,
210
+ topic=topic,
211
+ payload=bytes(payload),
212
+ key=key,
213
+ headers=json.loads(headers),
214
+ attempts=attempts,
215
+ created_at=datetime.fromtimestamp(created_at, tz=UTC),
216
+ )
txoutbox/backoff.py ADDED
@@ -0,0 +1,38 @@
1
+ """Retry delay policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from dataclasses import dataclass
7
+ from datetime import timedelta
8
+
9
+
10
+ @dataclass(frozen=True, slots=True, kw_only=True)
11
+ class Backoff:
12
+ """Exponential backoff with full jitter.
13
+
14
+ Delay for attempt ``n`` (1-based) is ``min(base * factor ** (n - 1), maximum)``,
15
+ multiplied by a random factor in ``[1 - jitter, 1 + jitter]``.
16
+ """
17
+
18
+ base: timedelta = timedelta(seconds=1)
19
+ factor: float = 2.0
20
+ maximum: timedelta = timedelta(minutes=5)
21
+ jitter: float = 0.25
22
+
23
+ def __post_init__(self) -> None:
24
+ if self.factor < 1:
25
+ raise ValueError("factor must be >= 1")
26
+ if not 0 <= self.jitter <= 1:
27
+ raise ValueError("jitter must be within [0, 1]")
28
+ if self.base <= timedelta(0):
29
+ raise ValueError("base must be positive")
30
+
31
+ def delay(self, attempt: int) -> timedelta:
32
+ if attempt < 1:
33
+ raise ValueError("attempt is 1-based")
34
+ raw = self.base.total_seconds() * self.factor ** (attempt - 1)
35
+ capped = min(raw, self.maximum.total_seconds())
36
+ if self.jitter:
37
+ capped *= random.uniform(1 - self.jitter, 1 + self.jitter)
38
+ return timedelta(seconds=capped)
txoutbox/message.py ADDED
@@ -0,0 +1,33 @@
1
+ """The message record that flows from storage to publisher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime
8
+ from types import MappingProxyType
9
+ from typing import Any
10
+
11
+ #: Identifier of an outbox row. Opaque to the relay: it only passes ids back to storage.
12
+ MessageId = Any
13
+
14
+ _EMPTY_HEADERS: Mapping[str, str] = MappingProxyType({})
15
+
16
+
17
+ @dataclass(frozen=True, slots=True, kw_only=True)
18
+ class OutboxMessage:
19
+ """One row of the outbox table, as seen by the relay.
20
+
21
+ Storage adapters build these from their rows; publishers receive them.
22
+ Everything except ``id``, ``topic`` and ``payload`` is optional.
23
+ """
24
+
25
+ id: MessageId
26
+ topic: str
27
+ payload: bytes
28
+ #: Ordering key. Messages sharing a key are published sequentially, in claim order.
29
+ key: str | None = None
30
+ headers: Mapping[str, str] = field(default_factory=lambda: _EMPTY_HEADERS)
31
+ #: How many times this message has been claimed, including the current claim.
32
+ attempts: int = 1
33
+ created_at: datetime | None = None