oncely 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.
- oncely/__init__.py +34 -0
- oncely/idempotency.py +170 -0
- oncely/outbox.py +162 -0
- oncely/py.typed +0 -0
- oncely/streams.py +178 -0
- oncely-0.1.0.dist-info/METADATA +194 -0
- oncely-0.1.0.dist-info/RECORD +9 -0
- oncely-0.1.0.dist-info/WHEEL +4 -0
- oncely-0.1.0.dist-info/licenses/LICENSE +21 -0
oncely/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Idempotency keys and a transactional outbox for async SQLAlchemy and Postgres.
|
|
2
|
+
|
|
3
|
+
Two small tools for the same problem: a network that retries.
|
|
4
|
+
|
|
5
|
+
- `IdempotencyKeys` runs a piece of work once per key and gives every retry
|
|
6
|
+
the first answer.
|
|
7
|
+
- `Outbox` records an event in the same transaction as the change it
|
|
8
|
+
describes, and a relay publishes it afterwards, at least once.
|
|
9
|
+
|
|
10
|
+
`oncely.streams` adds a Redis Streams publisher and a consumer with
|
|
11
|
+
retries and a dead-letter stream. It needs the `redis` extra.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from oncely.idempotency import (
|
|
15
|
+
IdempotencyKeys,
|
|
16
|
+
KeyReused,
|
|
17
|
+
Outcome,
|
|
18
|
+
Refused,
|
|
19
|
+
fingerprint,
|
|
20
|
+
is_valid_key,
|
|
21
|
+
)
|
|
22
|
+
from oncely.outbox import Event, Outbox, Publisher
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Event",
|
|
26
|
+
"IdempotencyKeys",
|
|
27
|
+
"KeyReused",
|
|
28
|
+
"Outbox",
|
|
29
|
+
"Outcome",
|
|
30
|
+
"Publisher",
|
|
31
|
+
"Refused",
|
|
32
|
+
"fingerprint",
|
|
33
|
+
"is_valid_key",
|
|
34
|
+
]
|
oncely/idempotency.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Idempotency keys: a retried request gets the first response and does nothing else.
|
|
2
|
+
|
|
3
|
+
The key row, the work and the stored response all commit in one transaction,
|
|
4
|
+
so there is no state in which the work happened but the key was not recorded,
|
|
5
|
+
or the reverse.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import re
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
|
|
14
|
+
from sqlalchemy import (
|
|
15
|
+
Column,
|
|
16
|
+
Integer,
|
|
17
|
+
LargeBinary,
|
|
18
|
+
MetaData,
|
|
19
|
+
Table,
|
|
20
|
+
Text,
|
|
21
|
+
delete,
|
|
22
|
+
func,
|
|
23
|
+
select,
|
|
24
|
+
update,
|
|
25
|
+
)
|
|
26
|
+
from sqlalchemy.dialects.postgresql import TIMESTAMP
|
|
27
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
28
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
29
|
+
|
|
30
|
+
_KEY_FORMAT = re.compile(r"^[\x21-\x7e]{8,255}$") # printable ASCII, no spaces
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_valid_key(key: str) -> bool:
|
|
34
|
+
"""Whether a client-supplied key is usable: 8 to 255 printable characters."""
|
|
35
|
+
return _KEY_FORMAT.match(key) is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fingerprint(*parts: str | bytes) -> bytes:
|
|
39
|
+
"""A digest of what the request asks for, e.g. method, path and body.
|
|
40
|
+
|
|
41
|
+
Stored with the key, so the same key arriving with a different request is
|
|
42
|
+
recognised as a client bug instead of being answered as a retry.
|
|
43
|
+
"""
|
|
44
|
+
digest = hashlib.sha256()
|
|
45
|
+
for part in parts:
|
|
46
|
+
data = part.encode() if isinstance(part, str) else part
|
|
47
|
+
# Length-prefixed, so ("ab", "c") and ("a", "bc") differ.
|
|
48
|
+
digest.update(len(data).to_bytes(8, "big"))
|
|
49
|
+
digest.update(data)
|
|
50
|
+
return digest.digest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class Outcome:
|
|
55
|
+
"""The response for a key: the status and body first produced for it."""
|
|
56
|
+
|
|
57
|
+
status: int
|
|
58
|
+
body: str
|
|
59
|
+
#: True when this call did no work and is repeating an earlier answer.
|
|
60
|
+
replayed: bool
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Refused(Exception):
|
|
64
|
+
"""Raise from the work to give a final answer that is not a success.
|
|
65
|
+
|
|
66
|
+
"That seat is taken" is as final as "here is your order": the work's
|
|
67
|
+
writes are undone, the refusal is stored, and every retry gets it again.
|
|
68
|
+
Any other exception is treated as a crash: nothing is stored, and a retry
|
|
69
|
+
runs the work from scratch.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, status: int, body: str) -> None:
|
|
73
|
+
super().__init__(f"refused with status {status}")
|
|
74
|
+
self.status = status
|
|
75
|
+
self.body = body
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class KeyReused(Exception):
|
|
79
|
+
"""The key was already used for a different request."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class IdempotencyKeys:
|
|
83
|
+
"""A table of keys, attached to your metadata so your migrations create it.
|
|
84
|
+
|
|
85
|
+
`scope` separates one caller's keys from another's (a user id, a tenant, an
|
|
86
|
+
API client), so two callers choosing the same key never see each other's
|
|
87
|
+
responses.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, metadata: MetaData, *, table_name: str = "idempotency_keys") -> None:
|
|
91
|
+
self.table = Table(
|
|
92
|
+
table_name,
|
|
93
|
+
metadata,
|
|
94
|
+
Column("scope", Text, primary_key=True),
|
|
95
|
+
Column("key", Text, primary_key=True),
|
|
96
|
+
Column("request_hash", LargeBinary, nullable=False),
|
|
97
|
+
Column("response_code", Integer),
|
|
98
|
+
# Text, exactly as first sent, so a replay is byte for byte the
|
|
99
|
+
# same response (jsonb would reorder the keys).
|
|
100
|
+
Column("response_body", Text),
|
|
101
|
+
Column(
|
|
102
|
+
"created_at",
|
|
103
|
+
TIMESTAMP(timezone=True),
|
|
104
|
+
server_default=func.now(),
|
|
105
|
+
nullable=False,
|
|
106
|
+
index=True,
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
async def run(
|
|
111
|
+
self,
|
|
112
|
+
session: AsyncSession,
|
|
113
|
+
*,
|
|
114
|
+
scope: str,
|
|
115
|
+
key: str,
|
|
116
|
+
fingerprint: bytes,
|
|
117
|
+
work: Callable[[], Awaitable[tuple[int, str]]],
|
|
118
|
+
) -> Outcome:
|
|
119
|
+
"""Run `work` once per (scope, key) and return its answer on every call.
|
|
120
|
+
|
|
121
|
+
`work` returns (status, body). The caller owns the transaction: commit
|
|
122
|
+
it and the answer is permanent, roll it back and the key is free again.
|
|
123
|
+
Raises `KeyReused` if the key was first seen with another fingerprint.
|
|
124
|
+
"""
|
|
125
|
+
t = self.table
|
|
126
|
+
mine = (t.c.scope == scope) & (t.c.key == key)
|
|
127
|
+
|
|
128
|
+
# If another request with this key is in flight, this insert waits for
|
|
129
|
+
# it to finish. Then either it committed (no row comes back and its
|
|
130
|
+
# response is replayed) or it rolled back (the row is ours, and so is
|
|
131
|
+
# the work).
|
|
132
|
+
claimed = await session.execute(
|
|
133
|
+
pg_insert(t)
|
|
134
|
+
.values(scope=scope, key=key, request_hash=fingerprint)
|
|
135
|
+
.on_conflict_do_nothing()
|
|
136
|
+
.returning(t.c.key)
|
|
137
|
+
)
|
|
138
|
+
if claimed.first() is None:
|
|
139
|
+
stored = (
|
|
140
|
+
await session.execute(
|
|
141
|
+
select(t.c.request_hash, t.c.response_code, t.c.response_body).where(mine)
|
|
142
|
+
)
|
|
143
|
+
).one()
|
|
144
|
+
if bytes(stored.request_hash) != fingerprint:
|
|
145
|
+
raise KeyReused(key)
|
|
146
|
+
if stored.response_code is None or stored.response_body is None:
|
|
147
|
+
# Cannot happen: the row and its response commit together.
|
|
148
|
+
raise RuntimeError("idempotency key stored without a response")
|
|
149
|
+
return Outcome(stored.response_code, stored.response_body, replayed=True)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
# A savepoint: if the work is refused, its partial writes are
|
|
153
|
+
# undone but the key row survives to record the refusal.
|
|
154
|
+
async with session.begin_nested():
|
|
155
|
+
status, body = await work()
|
|
156
|
+
except Refused as refusal:
|
|
157
|
+
status, body = refusal.status, refusal.body
|
|
158
|
+
|
|
159
|
+
await session.execute(
|
|
160
|
+
update(t).where(mine).values(response_code=status, response_body=body)
|
|
161
|
+
)
|
|
162
|
+
return Outcome(status, body, replayed=False)
|
|
163
|
+
|
|
164
|
+
async def prune(self, session: AsyncSession, *, older_than: timedelta) -> int:
|
|
165
|
+
"""Delete keys past the retry window. Returns how many were removed."""
|
|
166
|
+
t = self.table
|
|
167
|
+
deleted = await session.execute(
|
|
168
|
+
delete(t).where(t.c.created_at < func.now() - older_than).returning(t.c.key)
|
|
169
|
+
)
|
|
170
|
+
return len(deleted.all())
|
oncely/outbox.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Transactional outbox.
|
|
2
|
+
|
|
3
|
+
An event is written to a table in the same transaction as the change it
|
|
4
|
+
describes, so it exists if and only if the change happened. A relay then
|
|
5
|
+
hands events to a publisher. Delivery is at least once: every consumer must
|
|
6
|
+
be safe to run twice.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from sqlalchemy import (
|
|
19
|
+
Column,
|
|
20
|
+
Index,
|
|
21
|
+
MetaData,
|
|
22
|
+
Table,
|
|
23
|
+
Text,
|
|
24
|
+
Uuid,
|
|
25
|
+
delete,
|
|
26
|
+
func,
|
|
27
|
+
select,
|
|
28
|
+
text,
|
|
29
|
+
update,
|
|
30
|
+
)
|
|
31
|
+
from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP
|
|
32
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
33
|
+
|
|
34
|
+
_last_id = 0
|
|
35
|
+
_last_id_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def uuid7() -> uuid.UUID:
|
|
39
|
+
"""A time-ordered UUID (RFC 9562), so id order is creation order.
|
|
40
|
+
|
|
41
|
+
Two ids made in the same millisecond would otherwise be ordered by their
|
|
42
|
+
random bits, so within a process each id is also made greater than the
|
|
43
|
+
one before it. Across processes, order is to the millisecond.
|
|
44
|
+
"""
|
|
45
|
+
global _last_id
|
|
46
|
+
millis = time.time_ns() // 1_000_000
|
|
47
|
+
rand = int.from_bytes(os.urandom(10))
|
|
48
|
+
value = (millis & ((1 << 48) - 1)) << 80
|
|
49
|
+
value |= 0x7 << 76
|
|
50
|
+
value |= (rand >> 68) << 64
|
|
51
|
+
value |= 0b10 << 62
|
|
52
|
+
value |= rand & ((1 << 62) - 1)
|
|
53
|
+
with _last_id_lock:
|
|
54
|
+
if value <= _last_id:
|
|
55
|
+
value = _last_id + 1
|
|
56
|
+
_last_id = value
|
|
57
|
+
return uuid.UUID(int=value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class Event:
|
|
62
|
+
id: uuid.UUID
|
|
63
|
+
topic: str
|
|
64
|
+
payload: dict[str, Any]
|
|
65
|
+
#: Context that travels with the event and is not part of its meaning:
|
|
66
|
+
#: a trace id, a tenant, a schema version.
|
|
67
|
+
headers: dict[str, str] | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
#: Sends a batch somewhere durable: a stream, a queue, a webhook. If it
|
|
71
|
+
#: raises, nothing is marked published and the batch is offered again.
|
|
72
|
+
Publisher = Callable[[Sequence[Event]], Awaitable[None]]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Outbox:
|
|
76
|
+
"""A table of events, attached to your metadata so your migrations create it."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, metadata: MetaData, *, table_name: str = "outbox") -> None:
|
|
79
|
+
self.table = Table(
|
|
80
|
+
table_name,
|
|
81
|
+
metadata,
|
|
82
|
+
Column("id", Uuid, primary_key=True),
|
|
83
|
+
Column("topic", Text, nullable=False),
|
|
84
|
+
Column("payload", JSONB, nullable=False),
|
|
85
|
+
Column("headers", JSONB),
|
|
86
|
+
Column(
|
|
87
|
+
"created_at", TIMESTAMP(timezone=True), server_default=func.now(), nullable=False
|
|
88
|
+
),
|
|
89
|
+
Column("published_at", TIMESTAMP(timezone=True)),
|
|
90
|
+
# The relay's only query: oldest unpublished first. Partial, so it
|
|
91
|
+
# stays tiny however many events have already been published.
|
|
92
|
+
Index(f"{table_name}_pending_idx", "id", postgresql_where=text("published_at IS NULL")),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
async def emit(
|
|
96
|
+
self,
|
|
97
|
+
session: AsyncSession,
|
|
98
|
+
topic: str,
|
|
99
|
+
payload: dict[str, Any],
|
|
100
|
+
*,
|
|
101
|
+
headers: dict[str, str] | None = None,
|
|
102
|
+
) -> uuid.UUID:
|
|
103
|
+
"""Record an event. It is published only if the caller's transaction commits."""
|
|
104
|
+
event_id = uuid7()
|
|
105
|
+
await session.execute(
|
|
106
|
+
self.table.insert().values(id=event_id, topic=topic, payload=payload, headers=headers)
|
|
107
|
+
)
|
|
108
|
+
return event_id
|
|
109
|
+
|
|
110
|
+
async def relay_once(
|
|
111
|
+
self,
|
|
112
|
+
sessionmaker: async_sessionmaker[AsyncSession],
|
|
113
|
+
publish: Publisher,
|
|
114
|
+
*,
|
|
115
|
+
batch: int = 100,
|
|
116
|
+
) -> list[Event]:
|
|
117
|
+
"""Publish one batch of pending events, oldest first. Returns them.
|
|
118
|
+
|
|
119
|
+
If the process dies after publishing but before the commit that marks
|
|
120
|
+
the rows, they are published again next time. That is the "at least
|
|
121
|
+
once".
|
|
122
|
+
"""
|
|
123
|
+
t = self.table
|
|
124
|
+
async with sessionmaker.begin() as session:
|
|
125
|
+
rows = (
|
|
126
|
+
await session.execute(
|
|
127
|
+
select(t.c.id, t.c.topic, t.c.payload, t.c.headers)
|
|
128
|
+
.where(t.c.published_at.is_(None))
|
|
129
|
+
.order_by(t.c.id)
|
|
130
|
+
.limit(batch)
|
|
131
|
+
# Several relays can run: each takes rows the others have not.
|
|
132
|
+
.with_for_update(skip_locked=True)
|
|
133
|
+
)
|
|
134
|
+
).all()
|
|
135
|
+
if not rows:
|
|
136
|
+
return []
|
|
137
|
+
events = [Event(row.id, row.topic, row.payload, row.headers) for row in rows]
|
|
138
|
+
await publish(events)
|
|
139
|
+
await session.execute(
|
|
140
|
+
update(t)
|
|
141
|
+
.where(t.c.id.in_([event.id for event in events]))
|
|
142
|
+
.values(published_at=func.now())
|
|
143
|
+
)
|
|
144
|
+
return events
|
|
145
|
+
|
|
146
|
+
async def lag_seconds(self, session: AsyncSession) -> float:
|
|
147
|
+
"""Age of the oldest unpublished event. The outbox's key health number."""
|
|
148
|
+
t = self.table
|
|
149
|
+
oldest = await session.scalar(
|
|
150
|
+
select(func.extract("epoch", func.now() - func.min(t.c.created_at))).where(
|
|
151
|
+
t.c.published_at.is_(None)
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return float(oldest or 0)
|
|
155
|
+
|
|
156
|
+
async def prune(self, session: AsyncSession, *, older_than: timedelta) -> int:
|
|
157
|
+
"""Delete events published longer ago than this. Returns how many."""
|
|
158
|
+
t = self.table
|
|
159
|
+
deleted = await session.execute(
|
|
160
|
+
delete(t).where(t.c.published_at < func.now() - older_than).returning(t.c.id)
|
|
161
|
+
)
|
|
162
|
+
return len(deleted.all())
|
oncely/py.typed
ADDED
|
File without changes
|
oncely/streams.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Redis Streams: a publisher for the outbox relay, and a consumer.
|
|
2
|
+
|
|
3
|
+
The consumer uses a consumer group: each event goes to one worker, stays
|
|
4
|
+
"pending" until that worker acknowledges it, and can be taken over by another
|
|
5
|
+
worker if the first one dies. An event whose handlers keep failing is moved
|
|
6
|
+
to a dead-letter stream after a fixed number of attempts, so one poison
|
|
7
|
+
message cannot block the rest or retry forever.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
from redis.asyncio import Redis
|
|
18
|
+
from redis.exceptions import ResponseError
|
|
19
|
+
|
|
20
|
+
from oncely.outbox import Event, Publisher
|
|
21
|
+
|
|
22
|
+
Handler = Callable[[Event], Awaitable[None]]
|
|
23
|
+
#: Wraps the handling of one event: open a span, bind log context, time it.
|
|
24
|
+
Around = Callable[[Event, Callable[[], Awaitable[None]]], Awaitable[None]]
|
|
25
|
+
Result = Literal["ok", "retry", "dead"]
|
|
26
|
+
#: Told how each attempt ended and how long it took, for metrics and logs.
|
|
27
|
+
#: `error` is the exception behind a "retry" or "dead".
|
|
28
|
+
OnResult = Callable[[Event, Result, float, BaseException | None], None]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def stream_publisher(redis: Redis, stream: str) -> Publisher:
|
|
32
|
+
"""A publisher that appends each event to a Redis stream."""
|
|
33
|
+
|
|
34
|
+
async def publish(events: Sequence[Event]) -> None:
|
|
35
|
+
pipeline = redis.pipeline(transaction=False)
|
|
36
|
+
for event in events:
|
|
37
|
+
pipeline.xadd(stream, _encode(event)) # type: ignore[arg-type]
|
|
38
|
+
await pipeline.execute()
|
|
39
|
+
|
|
40
|
+
return publish
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _encode(event: Event) -> dict[str, str]:
|
|
44
|
+
fields = {"id": str(event.id), "topic": event.topic, "payload": json.dumps(event.payload)}
|
|
45
|
+
if event.headers:
|
|
46
|
+
fields["headers"] = json.dumps(event.headers)
|
|
47
|
+
return fields
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _decode(fields: dict[str, str]) -> Event:
|
|
51
|
+
return Event(
|
|
52
|
+
id=uuid.UUID(fields["id"]),
|
|
53
|
+
topic=fields["topic"],
|
|
54
|
+
payload=json.loads(fields["payload"]),
|
|
55
|
+
headers=json.loads(fields["headers"]) if "headers" in fields else None,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class StreamConsumer:
|
|
60
|
+
"""One worker in a consumer group. Run as many as you like.
|
|
61
|
+
|
|
62
|
+
Handlers must be safe to run twice: an event is delivered again if a
|
|
63
|
+
handler raised, if the worker died before acknowledging it, or if the
|
|
64
|
+
relay published it twice.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
redis: Redis,
|
|
70
|
+
*,
|
|
71
|
+
stream: str,
|
|
72
|
+
group: str = "workers",
|
|
73
|
+
consumer: str | None = None,
|
|
74
|
+
retry_after_ms: int = 30_000,
|
|
75
|
+
max_attempts: int = 5,
|
|
76
|
+
concurrency: int = 16,
|
|
77
|
+
dead_letter_stream: str | None = None,
|
|
78
|
+
around: Around | None = None,
|
|
79
|
+
on_result: OnResult | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
self.redis = redis
|
|
82
|
+
self.stream = stream
|
|
83
|
+
self.group = group
|
|
84
|
+
self.consumer = consumer or f"worker-{uuid.uuid4().hex[:8]}"
|
|
85
|
+
self.retry_after_ms = retry_after_ms
|
|
86
|
+
self.max_attempts = max_attempts
|
|
87
|
+
self.concurrency = concurrency
|
|
88
|
+
self.dead_letter_stream = dead_letter_stream or f"{stream}:dead"
|
|
89
|
+
self._attempts = f"{stream}:attempts"
|
|
90
|
+
self._around = around
|
|
91
|
+
self._on_result = on_result
|
|
92
|
+
self._handlers: dict[str, list[Handler]] = {}
|
|
93
|
+
|
|
94
|
+
def subscribe(self, topic: str, handler: Handler) -> None:
|
|
95
|
+
handlers = self._handlers.setdefault(topic, [])
|
|
96
|
+
if handler not in handlers:
|
|
97
|
+
handlers.append(handler)
|
|
98
|
+
|
|
99
|
+
async def _ensure_group(self) -> None:
|
|
100
|
+
try:
|
|
101
|
+
await self.redis.xgroup_create(self.stream, self.group, id="0", mkstream=True)
|
|
102
|
+
except ResponseError as exc:
|
|
103
|
+
if "BUSYGROUP" not in str(exc): # already exists: fine
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
async def run_once(self, *, count: int = 50, block_ms: int | None = None) -> int:
|
|
107
|
+
"""Handle one batch: first events whose earlier attempt failed or whose
|
|
108
|
+
worker died, then new ones. Returns how many were settled (handled, or
|
|
109
|
+
given up on and dead-lettered)."""
|
|
110
|
+
await self._ensure_group()
|
|
111
|
+
|
|
112
|
+
# Take over anything delivered but not acknowledged for long enough.
|
|
113
|
+
claimed: Any = await self.redis.xautoclaim(
|
|
114
|
+
self.stream,
|
|
115
|
+
self.group,
|
|
116
|
+
self.consumer,
|
|
117
|
+
min_idle_time=self.retry_after_ms,
|
|
118
|
+
start_id="0-0",
|
|
119
|
+
count=count,
|
|
120
|
+
)
|
|
121
|
+
fresh: Any = await self.redis.xreadgroup(
|
|
122
|
+
self.group, self.consumer, {self.stream: ">"}, count=count, block=block_ms
|
|
123
|
+
)
|
|
124
|
+
messages: list[tuple[str, dict[str, str]]] = list(claimed[1])
|
|
125
|
+
messages += [message for _, batch in fresh for message in batch]
|
|
126
|
+
|
|
127
|
+
# Handlers mostly wait on the network, so a batch is worked on
|
|
128
|
+
# together, not one event at a time.
|
|
129
|
+
gate = asyncio.Semaphore(self.concurrency)
|
|
130
|
+
|
|
131
|
+
async def one(message_id: str, fields: dict[str, str]) -> bool:
|
|
132
|
+
async with gate:
|
|
133
|
+
if not fields: # the entry was trimmed from the stream
|
|
134
|
+
await self.redis.xack(self.stream, self.group, message_id)
|
|
135
|
+
return False
|
|
136
|
+
return await self._handle(message_id, fields)
|
|
137
|
+
|
|
138
|
+
return sum(await asyncio.gather(*(one(*message) for message in messages)))
|
|
139
|
+
|
|
140
|
+
async def _handle(self, message_id: str, fields: dict[str, str]) -> bool:
|
|
141
|
+
event = _decode(fields)
|
|
142
|
+
started = time.perf_counter()
|
|
143
|
+
|
|
144
|
+
async def handlers() -> None:
|
|
145
|
+
for handler in self._handlers.get(event.topic, []):
|
|
146
|
+
await handler(event)
|
|
147
|
+
|
|
148
|
+
def report(result: Result, error: BaseException | None = None) -> None:
|
|
149
|
+
if self._on_result:
|
|
150
|
+
self._on_result(event, result, time.perf_counter() - started, error)
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
if self._around:
|
|
154
|
+
await self._around(event, handlers)
|
|
155
|
+
else:
|
|
156
|
+
await handlers()
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
attempts = await self.redis.hincrby(self._attempts, message_id, 1)
|
|
159
|
+
if attempts < self.max_attempts:
|
|
160
|
+
# Leave it pending. It is retried once it has been idle long enough.
|
|
161
|
+
report("retry", exc)
|
|
162
|
+
return False
|
|
163
|
+
dead: dict[Any, Any] = {**fields, "attempts": str(attempts)}
|
|
164
|
+
await self.redis.xadd(self.dead_letter_stream, dead)
|
|
165
|
+
report("dead", exc)
|
|
166
|
+
else:
|
|
167
|
+
report("ok")
|
|
168
|
+
|
|
169
|
+
# Reached on success, and after dead-lettering.
|
|
170
|
+
pipeline = self.redis.pipeline(transaction=False)
|
|
171
|
+
pipeline.xack(self.stream, self.group, message_id)
|
|
172
|
+
pipeline.hdel(self._attempts, message_id)
|
|
173
|
+
await pipeline.execute()
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
async def trim(self, *, keep: int = 100_000) -> None:
|
|
177
|
+
"""Bound the stream's size. Acknowledged entries are not needed again."""
|
|
178
|
+
await self.redis.xtrim(self.stream, maxlen=keep, approximate=True)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: oncely
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Idempotency keys and a transactional outbox for async SQLAlchemy and Postgres.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tiff-xt/oncely
|
|
6
|
+
Project-URL: Issues, https://github.com/tiff-xt/oncely/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: asyncio,idempotency,outbox,postgres,redis,sqlalchemy
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Database
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.36
|
|
19
|
+
Provides-Extra: redis
|
|
20
|
+
Requires-Dist: redis>=5.2; extra == 'redis'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# oncely
|
|
24
|
+
|
|
25
|
+
[](https://github.com/tiff-xt/oncely/actions/workflows/ci.yml)
|
|
26
|
+
[](https://pypi.org/project/oncely/)
|
|
27
|
+
|
|
28
|
+
Idempotency keys and a transactional outbox for async SQLAlchemy and Postgres.
|
|
29
|
+
|
|
30
|
+
Two small tools for the same problem: a network that retries.
|
|
31
|
+
|
|
32
|
+
- **Idempotency keys.** A client that times out sends the request again. The
|
|
33
|
+
work runs once, and every retry gets the first answer, byte for byte.
|
|
34
|
+
- **Transactional outbox.** "Save the order, then publish an event" can do one
|
|
35
|
+
without the other. Here the event is a row written in the same transaction
|
|
36
|
+
as the change, and a relay publishes it afterwards.
|
|
37
|
+
|
|
38
|
+
About 400 lines, typed, no framework required. It was extracted from
|
|
39
|
+
[lobbyd](https://github.com/tiff-xt/lobbyd), a ticketing backend, where it sits under every
|
|
40
|
+
purchase and every payment.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install "oncely[redis]"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The `redis` extra adds a Redis Streams publisher and consumer. Without it the
|
|
49
|
+
package needs only SQLAlchemy 2 and a Postgres driver such as asyncpg.
|
|
50
|
+
|
|
51
|
+
## Idempotency keys
|
|
52
|
+
|
|
53
|
+
Attach the table to your metadata, so your own migrations create it:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from oncely import IdempotencyKeys, Refused, fingerprint
|
|
57
|
+
|
|
58
|
+
keys = IdempotencyKeys(Base.metadata)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Then wrap the work. This is a FastAPI route, but nothing here depends on it:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
@router.post("/orders", status_code=201)
|
|
65
|
+
async def create_order(
|
|
66
|
+
body: NewOrder,
|
|
67
|
+
request: Request,
|
|
68
|
+
user: CurrentUser,
|
|
69
|
+
session: Session,
|
|
70
|
+
key: str = Header(alias="Idempotency-Key"),
|
|
71
|
+
):
|
|
72
|
+
async def work() -> tuple[int, str]:
|
|
73
|
+
seat = await claim_seat(session, body.seat_id)
|
|
74
|
+
if seat is None:
|
|
75
|
+
raise Refused(409, '{"code": "seat_taken"}')
|
|
76
|
+
order = await place_order(session, user, seat)
|
|
77
|
+
return 201, order.model_dump_json()
|
|
78
|
+
|
|
79
|
+
outcome = await keys.run(
|
|
80
|
+
session,
|
|
81
|
+
scope=str(user.id),
|
|
82
|
+
key=key,
|
|
83
|
+
fingerprint=fingerprint(request.method, request.url.path, body.model_dump_json()),
|
|
84
|
+
work=work,
|
|
85
|
+
)
|
|
86
|
+
return Response(outcome.body, outcome.status, media_type="application/json")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
What you get:
|
|
90
|
+
|
|
91
|
+
| Situation | Result |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| First request | `work` runs. Its answer is stored in the same transaction as its writes. |
|
|
94
|
+
| Retry after success | The stored answer. `work` does not run. `outcome.replayed` is true. |
|
|
95
|
+
| Retry while the first is still running | Waits for the first to finish, then gets its answer. |
|
|
96
|
+
| `work` raises `Refused` | Its writes are undone, the refusal is stored, and retries get it again. |
|
|
97
|
+
| `work` raises anything else | Nothing is stored. The retry starts from scratch. |
|
|
98
|
+
| Same key, different request | `KeyReused` is raised. That is a client bug, not a retry. |
|
|
99
|
+
|
|
100
|
+
The caller owns the transaction. Commit, and the answer is permanent. Roll
|
|
101
|
+
back, and the key is free again. There is no state in which the work happened
|
|
102
|
+
but the key was not recorded.
|
|
103
|
+
|
|
104
|
+
`scope` keeps callers apart: two users who pick the same key never see each
|
|
105
|
+
other's responses. Run `keys.prune(session, older_than=timedelta(hours=24))`
|
|
106
|
+
on a timer to drop keys past your retry window.
|
|
107
|
+
|
|
108
|
+
## Transactional outbox
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from oncely import Outbox
|
|
112
|
+
|
|
113
|
+
outbox = Outbox(Base.metadata)
|
|
114
|
+
|
|
115
|
+
async with sessionmaker.begin() as session:
|
|
116
|
+
order.status = "paid"
|
|
117
|
+
await outbox.emit(session, "order.paid", {"order_id": str(order.id)})
|
|
118
|
+
# Committed together, or not at all.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
A relay moves events out. It takes any async function as the publisher:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from oncely.streams import stream_publisher
|
|
125
|
+
|
|
126
|
+
publish = stream_publisher(redis, "shop:events")
|
|
127
|
+
|
|
128
|
+
while True:
|
|
129
|
+
if not await outbox.relay_once(sessionmaker, publish):
|
|
130
|
+
await asyncio.sleep(0.2)
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- Events leave oldest first.
|
|
134
|
+
- Run as many relays as you like. Rows are taken with `FOR UPDATE SKIP LOCKED`,
|
|
135
|
+
so two relays never publish the same batch.
|
|
136
|
+
- If the publisher raises, nothing is marked and the batch is offered again.
|
|
137
|
+
- If the process dies between publishing and committing, the batch is published
|
|
138
|
+
twice. Delivery is **at least once**, so handlers must be safe to repeat.
|
|
139
|
+
- `outbox.lag_seconds(session)` is the age of the oldest unpublished event. It
|
|
140
|
+
is the one number worth an alert.
|
|
141
|
+
|
|
142
|
+
`headers` on `emit` carries context that is not part of the event's meaning,
|
|
143
|
+
such as a trace id, so a handler can continue the trace of the request that
|
|
144
|
+
caused it.
|
|
145
|
+
|
|
146
|
+
## Consuming from Redis Streams
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from oncely.streams import StreamConsumer
|
|
150
|
+
|
|
151
|
+
worker = StreamConsumer(redis, stream="shop:events", max_attempts=5)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def send_receipt(event):
|
|
155
|
+
await mail.send(event.payload["order_id"])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
worker.subscribe("order.paid", send_receipt)
|
|
159
|
+
|
|
160
|
+
while True:
|
|
161
|
+
await worker.run_once(block_ms=1000)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Each event goes to one worker in the group. If a handler raises, the event is
|
|
165
|
+
retried after `retry_after_ms`. If its worker dies, another takes it over.
|
|
166
|
+
After `max_attempts` it moves to `shop:events:dead`, so one bad message cannot
|
|
167
|
+
block the rest. `around` wraps each event's handling (a span, log context) and
|
|
168
|
+
`on_result` reports how each attempt ended (metrics).
|
|
169
|
+
|
|
170
|
+
## What it does not do
|
|
171
|
+
|
|
172
|
+
- **Exactly-once delivery.** Nothing does, across a network. The name refers to
|
|
173
|
+
the work behind a key, which does run once.
|
|
174
|
+
- **Other databases.** It uses Postgres features on purpose: `ON CONFLICT`,
|
|
175
|
+
`SKIP LOCKED`, partial indexes.
|
|
176
|
+
- **Migrations.** The tables join your metadata, and Alembic's autogenerate
|
|
177
|
+
picks them up like any other.
|
|
178
|
+
|
|
179
|
+
## Tests
|
|
180
|
+
|
|
181
|
+
The suite runs against a real Postgres and Redis, including the races: twelve
|
|
182
|
+
retries of one key at once, and two relays competing for the same events.
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
ONCELY_TEST_DATABASE_URL=postgresql+asyncpg://user:pass@localhost/db \
|
|
186
|
+
ONCELY_TEST_REDIS_URL=redis://localhost:6379/14 \
|
|
187
|
+
pytest
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
It works inside its own Postgres schema (`oncely_test`) and drops it when done.
|
|
191
|
+
|
|
192
|
+
## License
|
|
193
|
+
|
|
194
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
oncely/__init__.py,sha256=aWOcAqjM6Q5K5YVf4EVZl0F2FulvVotNTIpqJwq9fmc,863
|
|
2
|
+
oncely/idempotency.py,sha256=Sq_zdaU9N2Nvjje4GKiObzJvLWJKLH64Lmwe8_ciJJs,6101
|
|
3
|
+
oncely/outbox.py,sha256=UuUIwaou2OqhcGWso4uGxYppJRhgR3eGTCbSwfTUwnQ,5500
|
|
4
|
+
oncely/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
oncely/streams.py,sha256=1xCp7keC--1-uKynlu4xYwJid44W_C7k1GY5iIj9djI,6851
|
|
6
|
+
oncely-0.1.0.dist-info/METADATA,sha256=sA1rETczjmke6PjsHPVgmlfqTb0vAexLFRTLJIrGB-M,6727
|
|
7
|
+
oncely-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
8
|
+
oncely-0.1.0.dist-info/licenses/LICENSE,sha256=VnTfSF6NFjMEFwxncha8sClcZ9gAjCoR-WB8C9x8Tnc,1065
|
|
9
|
+
oncely-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tiff! :)
|
|
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.
|