oncely 0.1.0__tar.gz
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-0.1.0/.github/workflows/ci.yml +41 -0
- oncely-0.1.0/.github/workflows/release.yml +20 -0
- oncely-0.1.0/.gitignore +8 -0
- oncely-0.1.0/LICENSE +21 -0
- oncely-0.1.0/PKG-INFO +194 -0
- oncely-0.1.0/README.md +172 -0
- oncely-0.1.0/pyproject.toml +64 -0
- oncely-0.1.0/src/oncely/__init__.py +34 -0
- oncely-0.1.0/src/oncely/idempotency.py +170 -0
- oncely-0.1.0/src/oncely/outbox.py +162 -0
- oncely-0.1.0/src/oncely/py.typed +0 -0
- oncely-0.1.0/src/oncely/streams.py +178 -0
- oncely-0.1.0/tests/__init__.py +0 -0
- oncely-0.1.0/tests/conftest.py +62 -0
- oncely-0.1.0/tests/test_idempotency.py +151 -0
- oncely-0.1.0/tests/test_outbox.py +133 -0
- oncely-0.1.0/tests/test_streams.py +126 -0
- oncely-0.1.0/uv.lock +607 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
check:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python: ["3.12", "3.13"]
|
|
14
|
+
services:
|
|
15
|
+
postgres:
|
|
16
|
+
image: postgres:17-alpine
|
|
17
|
+
env:
|
|
18
|
+
POSTGRES_USER: oncely
|
|
19
|
+
POSTGRES_PASSWORD: oncely
|
|
20
|
+
POSTGRES_DB: oncely_test
|
|
21
|
+
ports: ["5432:5432"]
|
|
22
|
+
options: >-
|
|
23
|
+
--health-cmd "pg_isready -U oncely"
|
|
24
|
+
--health-interval 5s --health-timeout 5s --health-retries 10
|
|
25
|
+
redis:
|
|
26
|
+
image: redis:7-alpine
|
|
27
|
+
ports: ["6379:6379"]
|
|
28
|
+
options: >-
|
|
29
|
+
--health-cmd "redis-cli ping"
|
|
30
|
+
--health-interval 5s --health-timeout 5s --health-retries 10
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@v4
|
|
33
|
+
- uses: astral-sh/setup-uv@v6
|
|
34
|
+
with:
|
|
35
|
+
python-version: ${{ matrix.python }}
|
|
36
|
+
- run: uv sync --locked --all-extras
|
|
37
|
+
- run: uv run ruff check .
|
|
38
|
+
- run: uv run ruff format --check .
|
|
39
|
+
- run: uv run mypy src tests
|
|
40
|
+
- run: uv run pytest
|
|
41
|
+
- run: uv build
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
# Publishing a GitHub release builds the package and uploads it to PyPI.
|
|
4
|
+
# No token is stored anywhere: PyPI trusts this workflow in this repository
|
|
5
|
+
# (trusted publishing), and checks that on every upload.
|
|
6
|
+
on:
|
|
7
|
+
release:
|
|
8
|
+
types: [published]
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
permissions:
|
|
15
|
+
id-token: write
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
- uses: astral-sh/setup-uv@v6
|
|
19
|
+
- run: uv build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
oncely-0.1.0/.gitignore
ADDED
oncely-0.1.0/LICENSE
ADDED
|
@@ -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.
|
oncely-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
oncely-0.1.0/README.md
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# oncely
|
|
2
|
+
|
|
3
|
+
[](https://github.com/tiff-xt/oncely/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/oncely/)
|
|
5
|
+
|
|
6
|
+
Idempotency keys and a transactional outbox for async SQLAlchemy and Postgres.
|
|
7
|
+
|
|
8
|
+
Two small tools for the same problem: a network that retries.
|
|
9
|
+
|
|
10
|
+
- **Idempotency keys.** A client that times out sends the request again. The
|
|
11
|
+
work runs once, and every retry gets the first answer, byte for byte.
|
|
12
|
+
- **Transactional outbox.** "Save the order, then publish an event" can do one
|
|
13
|
+
without the other. Here the event is a row written in the same transaction
|
|
14
|
+
as the change, and a relay publishes it afterwards.
|
|
15
|
+
|
|
16
|
+
About 400 lines, typed, no framework required. It was extracted from
|
|
17
|
+
[lobbyd](https://github.com/tiff-xt/lobbyd), a ticketing backend, where it sits under every
|
|
18
|
+
purchase and every payment.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install "oncely[redis]"
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The `redis` extra adds a Redis Streams publisher and consumer. Without it the
|
|
27
|
+
package needs only SQLAlchemy 2 and a Postgres driver such as asyncpg.
|
|
28
|
+
|
|
29
|
+
## Idempotency keys
|
|
30
|
+
|
|
31
|
+
Attach the table to your metadata, so your own migrations create it:
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from oncely import IdempotencyKeys, Refused, fingerprint
|
|
35
|
+
|
|
36
|
+
keys = IdempotencyKeys(Base.metadata)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then wrap the work. This is a FastAPI route, but nothing here depends on it:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
@router.post("/orders", status_code=201)
|
|
43
|
+
async def create_order(
|
|
44
|
+
body: NewOrder,
|
|
45
|
+
request: Request,
|
|
46
|
+
user: CurrentUser,
|
|
47
|
+
session: Session,
|
|
48
|
+
key: str = Header(alias="Idempotency-Key"),
|
|
49
|
+
):
|
|
50
|
+
async def work() -> tuple[int, str]:
|
|
51
|
+
seat = await claim_seat(session, body.seat_id)
|
|
52
|
+
if seat is None:
|
|
53
|
+
raise Refused(409, '{"code": "seat_taken"}')
|
|
54
|
+
order = await place_order(session, user, seat)
|
|
55
|
+
return 201, order.model_dump_json()
|
|
56
|
+
|
|
57
|
+
outcome = await keys.run(
|
|
58
|
+
session,
|
|
59
|
+
scope=str(user.id),
|
|
60
|
+
key=key,
|
|
61
|
+
fingerprint=fingerprint(request.method, request.url.path, body.model_dump_json()),
|
|
62
|
+
work=work,
|
|
63
|
+
)
|
|
64
|
+
return Response(outcome.body, outcome.status, media_type="application/json")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
What you get:
|
|
68
|
+
|
|
69
|
+
| Situation | Result |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| First request | `work` runs. Its answer is stored in the same transaction as its writes. |
|
|
72
|
+
| Retry after success | The stored answer. `work` does not run. `outcome.replayed` is true. |
|
|
73
|
+
| Retry while the first is still running | Waits for the first to finish, then gets its answer. |
|
|
74
|
+
| `work` raises `Refused` | Its writes are undone, the refusal is stored, and retries get it again. |
|
|
75
|
+
| `work` raises anything else | Nothing is stored. The retry starts from scratch. |
|
|
76
|
+
| Same key, different request | `KeyReused` is raised. That is a client bug, not a retry. |
|
|
77
|
+
|
|
78
|
+
The caller owns the transaction. Commit, and the answer is permanent. Roll
|
|
79
|
+
back, and the key is free again. There is no state in which the work happened
|
|
80
|
+
but the key was not recorded.
|
|
81
|
+
|
|
82
|
+
`scope` keeps callers apart: two users who pick the same key never see each
|
|
83
|
+
other's responses. Run `keys.prune(session, older_than=timedelta(hours=24))`
|
|
84
|
+
on a timer to drop keys past your retry window.
|
|
85
|
+
|
|
86
|
+
## Transactional outbox
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from oncely import Outbox
|
|
90
|
+
|
|
91
|
+
outbox = Outbox(Base.metadata)
|
|
92
|
+
|
|
93
|
+
async with sessionmaker.begin() as session:
|
|
94
|
+
order.status = "paid"
|
|
95
|
+
await outbox.emit(session, "order.paid", {"order_id": str(order.id)})
|
|
96
|
+
# Committed together, or not at all.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
A relay moves events out. It takes any async function as the publisher:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
from oncely.streams import stream_publisher
|
|
103
|
+
|
|
104
|
+
publish = stream_publisher(redis, "shop:events")
|
|
105
|
+
|
|
106
|
+
while True:
|
|
107
|
+
if not await outbox.relay_once(sessionmaker, publish):
|
|
108
|
+
await asyncio.sleep(0.2)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- Events leave oldest first.
|
|
112
|
+
- Run as many relays as you like. Rows are taken with `FOR UPDATE SKIP LOCKED`,
|
|
113
|
+
so two relays never publish the same batch.
|
|
114
|
+
- If the publisher raises, nothing is marked and the batch is offered again.
|
|
115
|
+
- If the process dies between publishing and committing, the batch is published
|
|
116
|
+
twice. Delivery is **at least once**, so handlers must be safe to repeat.
|
|
117
|
+
- `outbox.lag_seconds(session)` is the age of the oldest unpublished event. It
|
|
118
|
+
is the one number worth an alert.
|
|
119
|
+
|
|
120
|
+
`headers` on `emit` carries context that is not part of the event's meaning,
|
|
121
|
+
such as a trace id, so a handler can continue the trace of the request that
|
|
122
|
+
caused it.
|
|
123
|
+
|
|
124
|
+
## Consuming from Redis Streams
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from oncely.streams import StreamConsumer
|
|
128
|
+
|
|
129
|
+
worker = StreamConsumer(redis, stream="shop:events", max_attempts=5)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def send_receipt(event):
|
|
133
|
+
await mail.send(event.payload["order_id"])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
worker.subscribe("order.paid", send_receipt)
|
|
137
|
+
|
|
138
|
+
while True:
|
|
139
|
+
await worker.run_once(block_ms=1000)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Each event goes to one worker in the group. If a handler raises, the event is
|
|
143
|
+
retried after `retry_after_ms`. If its worker dies, another takes it over.
|
|
144
|
+
After `max_attempts` it moves to `shop:events:dead`, so one bad message cannot
|
|
145
|
+
block the rest. `around` wraps each event's handling (a span, log context) and
|
|
146
|
+
`on_result` reports how each attempt ended (metrics).
|
|
147
|
+
|
|
148
|
+
## What it does not do
|
|
149
|
+
|
|
150
|
+
- **Exactly-once delivery.** Nothing does, across a network. The name refers to
|
|
151
|
+
the work behind a key, which does run once.
|
|
152
|
+
- **Other databases.** It uses Postgres features on purpose: `ON CONFLICT`,
|
|
153
|
+
`SKIP LOCKED`, partial indexes.
|
|
154
|
+
- **Migrations.** The tables join your metadata, and Alembic's autogenerate
|
|
155
|
+
picks them up like any other.
|
|
156
|
+
|
|
157
|
+
## Tests
|
|
158
|
+
|
|
159
|
+
The suite runs against a real Postgres and Redis, including the races: twelve
|
|
160
|
+
retries of one key at once, and two relays competing for the same events.
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
ONCELY_TEST_DATABASE_URL=postgresql+asyncpg://user:pass@localhost/db \
|
|
164
|
+
ONCELY_TEST_REDIS_URL=redis://localhost:6379/14 \
|
|
165
|
+
pytest
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
It works inside its own Postgres schema (`oncely_test`) and drops it when done.
|
|
169
|
+
|
|
170
|
+
## License
|
|
171
|
+
|
|
172
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "oncely"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Idempotency keys and a transactional outbox for async SQLAlchemy and Postgres."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
keywords = ["idempotency", "outbox", "sqlalchemy", "postgres", "redis", "asyncio"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Framework :: AsyncIO",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Topic :: Database",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["sqlalchemy[asyncio]>=2.0.36"]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/tiff-xt/oncely"
|
|
22
|
+
Issues = "https://github.com/tiff-xt/oncely/issues"
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
# The outbox publishes through any async function. This extra adds the
|
|
26
|
+
# ready-made Redis Streams publisher and consumer.
|
|
27
|
+
redis = ["redis>=5.2"]
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"asyncpg>=0.30",
|
|
32
|
+
"redis>=5.2",
|
|
33
|
+
"pytest>=8.3",
|
|
34
|
+
"pytest-asyncio>=0.25",
|
|
35
|
+
"ruff>=0.9",
|
|
36
|
+
"mypy>=1.14",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/oncely"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
asyncio_default_fixture_loop_scope = "session"
|
|
50
|
+
asyncio_default_test_loop_scope = "session"
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 100
|
|
54
|
+
target-version = "py312"
|
|
55
|
+
|
|
56
|
+
[tool.ruff.lint]
|
|
57
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC", "S", "RUF", "PT", "T20"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint.per-file-ignores]
|
|
60
|
+
"tests/**" = ["S101", "S608"]
|
|
61
|
+
|
|
62
|
+
[tool.mypy]
|
|
63
|
+
python_version = "3.12"
|
|
64
|
+
strict = true
|
|
@@ -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
|
+
]
|