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 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
+ [![CI](https://github.com/tiff-xt/oncely/actions/workflows/ci.yml/badge.svg)](https://github.com/tiff-xt/oncely/actions/workflows/ci.yml)
26
+ [![PyPI](https://img.shields.io/pypi/v/oncely)](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,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 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.