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 +27 -0
- txoutbox/adapters/__init__.py +7 -0
- txoutbox/adapters/memory.py +162 -0
- txoutbox/adapters/postgres.py +201 -0
- txoutbox/adapters/sqlite.py +216 -0
- txoutbox/backoff.py +38 -0
- txoutbox/message.py +33 -0
- txoutbox/poller.py +55 -0
- txoutbox/protocols.py +69 -0
- txoutbox/py.typed +0 -0
- txoutbox/relay.py +291 -0
- txoutbox/testing.py +143 -0
- txoutbox-0.1.0.dist-info/METADATA +216 -0
- txoutbox-0.1.0.dist-info/RECORD +16 -0
- txoutbox-0.1.0.dist-info/WHEEL +4 -0
- txoutbox-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|