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.
@@ -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
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .mypy_cache/
7
+ .ruff_cache/
8
+ dump.rdb
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
+ [![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
oncely-0.1.0/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # oncely
2
+
3
+ [![CI](https://github.com/tiff-xt/oncely/actions/workflows/ci.yml/badge.svg)](https://github.com/tiff-xt/oncely/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/oncely)](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
+ ]