pgrelay 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.
- pgrelay-0.1.0/LICENSE +21 -0
- pgrelay-0.1.0/PKG-INFO +254 -0
- pgrelay-0.1.0/README.md +227 -0
- pgrelay-0.1.0/alembic.ini +37 -0
- pgrelay-0.1.0/migrations/env.py +61 -0
- pgrelay-0.1.0/migrations/versions/0001_initial_schema.py +147 -0
- pgrelay-0.1.0/pyproject.toml +85 -0
- pgrelay-0.1.0/src/pgrelay/__init__.py +3 -0
- pgrelay-0.1.0/src/pgrelay/__main__.py +12 -0
- pgrelay-0.1.0/src/pgrelay/api/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/api/app.py +98 -0
- pgrelay-0.1.0/src/pgrelay/api/dependencies.py +62 -0
- pgrelay-0.1.0/src/pgrelay/api/routers/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/api/routers/health.py +28 -0
- pgrelay-0.1.0/src/pgrelay/api/routers/jobs.py +107 -0
- pgrelay-0.1.0/src/pgrelay/api/routers/queues.py +52 -0
- pgrelay-0.1.0/src/pgrelay/api/routers/stats.py +22 -0
- pgrelay-0.1.0/src/pgrelay/api/routers/workers.py +21 -0
- pgrelay-0.1.0/src/pgrelay/cli/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/cli/app.py +18 -0
- pgrelay-0.1.0/src/pgrelay/cli/commands_api.py +15 -0
- pgrelay-0.1.0/src/pgrelay/cli/commands_doctor.py +76 -0
- pgrelay-0.1.0/src/pgrelay/cli/commands_jobs.py +106 -0
- pgrelay-0.1.0/src/pgrelay/cli/commands_migrate.py +24 -0
- pgrelay-0.1.0/src/pgrelay/cli/commands_worker.py +29 -0
- pgrelay-0.1.0/src/pgrelay/config/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/config/settings.py +111 -0
- pgrelay-0.1.0/src/pgrelay/constants.py +42 -0
- pgrelay-0.1.0/src/pgrelay/db/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/db/base.py +7 -0
- pgrelay-0.1.0/src/pgrelay/db/migrations.py +37 -0
- pgrelay-0.1.0/src/pgrelay/db/models.py +147 -0
- pgrelay-0.1.0/src/pgrelay/db/session.py +43 -0
- pgrelay-0.1.0/src/pgrelay/errors.py +70 -0
- pgrelay-0.1.0/src/pgrelay/observability/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/observability/logging.py +32 -0
- pgrelay-0.1.0/src/pgrelay/observability/metrics.py +16 -0
- pgrelay-0.1.0/src/pgrelay/py.typed +1 -0
- pgrelay-0.1.0/src/pgrelay/repositories/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/repositories/attempts.py +63 -0
- pgrelay-0.1.0/src/pgrelay/repositories/job_rows.py +125 -0
- pgrelay-0.1.0/src/pgrelay/repositories/job_sql.py +205 -0
- pgrelay-0.1.0/src/pgrelay/repositories/job_state.py +203 -0
- pgrelay-0.1.0/src/pgrelay/repositories/jobs.py +200 -0
- pgrelay-0.1.0/src/pgrelay/repositories/protocols.py +175 -0
- pgrelay-0.1.0/src/pgrelay/repositories/queues.py +111 -0
- pgrelay-0.1.0/src/pgrelay/repositories/stats.py +161 -0
- pgrelay-0.1.0/src/pgrelay/repositories/workers.py +89 -0
- pgrelay-0.1.0/src/pgrelay/schemas/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/schemas/api_errors.py +19 -0
- pgrelay-0.1.0/src/pgrelay/schemas/enqueue.py +52 -0
- pgrelay-0.1.0/src/pgrelay/schemas/jobs.py +119 -0
- pgrelay-0.1.0/src/pgrelay/schemas/queues.py +24 -0
- pgrelay-0.1.0/src/pgrelay/schemas/stats.py +20 -0
- pgrelay-0.1.0/src/pgrelay/schemas/workers.py +18 -0
- pgrelay-0.1.0/src/pgrelay/sdk/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/sdk/client.py +107 -0
- pgrelay-0.1.0/src/pgrelay/sdk/result.py +15 -0
- pgrelay-0.1.0/src/pgrelay/security/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/security/auth.py +51 -0
- pgrelay-0.1.0/src/pgrelay/services/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/services/enqueue.py +78 -0
- pgrelay-0.1.0/src/pgrelay/services/jobs.py +105 -0
- pgrelay-0.1.0/src/pgrelay/services/purge.py +56 -0
- pgrelay-0.1.0/src/pgrelay/services/queues.py +54 -0
- pgrelay-0.1.0/src/pgrelay/services/stats.py +28 -0
- pgrelay-0.1.0/src/pgrelay/services/workers.py +19 -0
- pgrelay-0.1.0/src/pgrelay/utils/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/utils/ids.py +16 -0
- pgrelay-0.1.0/src/pgrelay/utils/json.py +20 -0
- pgrelay-0.1.0/src/pgrelay/utils/redaction.py +35 -0
- pgrelay-0.1.0/src/pgrelay/utils/validation.py +85 -0
- pgrelay-0.1.0/src/pgrelay/worker/__init__.py +1 -0
- pgrelay-0.1.0/src/pgrelay/worker/backoff.py +14 -0
- pgrelay-0.1.0/src/pgrelay/worker/dispatcher.py +51 -0
- pgrelay-0.1.0/src/pgrelay/worker/handlers.py +46 -0
- pgrelay-0.1.0/src/pgrelay/worker/heartbeat.py +35 -0
- pgrelay-0.1.0/src/pgrelay/worker/http_executor.py +220 -0
- pgrelay-0.1.0/src/pgrelay/worker/python_executor.py +55 -0
- pgrelay-0.1.0/src/pgrelay/worker/recovery.py +19 -0
- pgrelay-0.1.0/src/pgrelay/worker/runner.py +316 -0
- pgrelay-0.1.0/src/pgrelay/worker/signals.py +13 -0
pgrelay-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Evgeny Balyakin
|
|
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.
|
pgrelay-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgrelay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PostgreSQL-backed transactional outbox and reliable jobs for Python backends
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: PgRelay maintainers
|
|
8
|
+
Requires-Python: >=3.12,<3.14
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Requires-Dist: alembic (>=1.16.0,<2.0.0)
|
|
14
|
+
Requires-Dist: asyncpg (>=0.30.0,<1.0.0)
|
|
15
|
+
Requires-Dist: fastapi (>=0.115.0,<1.0.0)
|
|
16
|
+
Requires-Dist: httpx (>=0.28.0,<1.0.0)
|
|
17
|
+
Requires-Dist: prometheus-client (>=0.22.0,<1.0.0)
|
|
18
|
+
Requires-Dist: psycopg[binary] (>=3.2.0,<4.0.0)
|
|
19
|
+
Requires-Dist: pydantic (>=2.11.0,<3.0.0)
|
|
20
|
+
Requires-Dist: pydantic-settings (>=2.10.0,<3.0.0)
|
|
21
|
+
Requires-Dist: sqlalchemy[asyncio] (>=2.0.41,<2.1.0)
|
|
22
|
+
Requires-Dist: structlog (>=25.4.0,<26.0.0)
|
|
23
|
+
Requires-Dist: typer (>=0.16.0,<1.0.0)
|
|
24
|
+
Requires-Dist: uvicorn[standard] (>=0.35.0,<1.0.0)
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# PgRelay
|
|
28
|
+
|
|
29
|
+
PostgreSQL-backed transactional outbox and reliable background jobs for Python services.
|
|
30
|
+
|
|
31
|
+
PgRelay is for the awkward space between "just call the webhook in the request" and "we need a separate queueing
|
|
32
|
+
platform". It stores jobs in the same PostgreSQL database your application already commits to, then runs them from a
|
|
33
|
+
small asyncio worker. The SDK writes into your existing SQLAlchemy `AsyncSession`, so a domain row and the job that
|
|
34
|
+
publishes it can commit or roll back together.
|
|
35
|
+
|
|
36
|
+
It is intentionally not an exactly-once system. PgRelay gives you at-least-once delivery, retries, leases, dead-letter
|
|
37
|
+
jobs, replay, and enough operator API to see what happened. Any external side effect still needs an idempotency key on
|
|
38
|
+
the receiving side.
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
42
|
+
The image is a concept for a possible browser admin console. PgRelay currently ships the admin API and CLI; a separate
|
|
43
|
+
browser console is not part of this release.
|
|
44
|
+
|
|
45
|
+
## What You Get
|
|
46
|
+
|
|
47
|
+
- Transactional enqueue from a caller-owned `sqlalchemy.ext.asyncio.AsyncSession`
|
|
48
|
+
- HTTP jobs and Python handler jobs
|
|
49
|
+
- PostgreSQL queues with pause/resume and per-queue concurrency limits
|
|
50
|
+
- Worker leases, lease heartbeats, expired lease recovery, retries, and dead-letter state
|
|
51
|
+
- Idempotency keys and dedupe keys
|
|
52
|
+
- Replay and cancel operations for job recovery
|
|
53
|
+
- FastAPI admin API with health, readiness, jobs, attempts, queues, stats, and worker heartbeats
|
|
54
|
+
- Typer CLI for migrations, workers, replay, drain, purge, and environment checks
|
|
55
|
+
- Worker counters and histograms built with `prometheus-client`
|
|
56
|
+
|
|
57
|
+
## When It Fits
|
|
58
|
+
|
|
59
|
+
PgRelay is a good fit when your service already uses PostgreSQL and SQLAlchemy async, and the job is part of the same
|
|
60
|
+
business fact you are committing. Typical examples are webhook delivery, search indexing, billing sync, email requests,
|
|
61
|
+
or a small internal handler that should run after a transaction is durable.
|
|
62
|
+
|
|
63
|
+
It is not trying to replace Kafka, Temporal, Celery, SQS, or a workflow engine. If you need fan-out streams,
|
|
64
|
+
long-running durable workflows, cross-language workers, hosted scheduling, or exactly-once effects in another system,
|
|
65
|
+
use the tool built for that job.
|
|
66
|
+
|
|
67
|
+
## Quick Start
|
|
68
|
+
|
|
69
|
+
Requirements:
|
|
70
|
+
|
|
71
|
+
- Python 3.12 or 3.13
|
|
72
|
+
- PostgreSQL 15+
|
|
73
|
+
- Poetry 2.x for local development
|
|
74
|
+
- Docker Compose if you want the one-command local stack
|
|
75
|
+
|
|
76
|
+
Run the local stack:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
poetry install --with dev
|
|
80
|
+
docker compose up --build
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The compose file starts PostgreSQL, runs migrations, then launches the admin API and one worker. The API listens on
|
|
84
|
+
`http://localhost:8090`.
|
|
85
|
+
|
|
86
|
+
Check it:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
curl http://localhost:8090/healthz
|
|
90
|
+
curl http://localhost:8090/readyz
|
|
91
|
+
curl -H "Authorization: Bearer dev-token-change-me" http://localhost:8090/v1/jobs
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
FastAPI's OpenAPI UI is available at:
|
|
95
|
+
|
|
96
|
+
```text
|
|
97
|
+
http://localhost:8090/docs
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The default token is for local development only. Set `PGRELAY_API_AUTH_TOKENS` before running anything that is reachable
|
|
101
|
+
outside your machine.
|
|
102
|
+
|
|
103
|
+
## Enqueue Inside Your Transaction
|
|
104
|
+
|
|
105
|
+
The important detail is ownership: PgRelay does not call `commit()` and does not close your session. Your application
|
|
106
|
+
keeps the transaction boundary.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from pgrelay.sdk.client import PgRelayClient
|
|
110
|
+
|
|
111
|
+
client = PgRelayClient.from_env()
|
|
112
|
+
|
|
113
|
+
async with session_factory() as session:
|
|
114
|
+
async with session.begin():
|
|
115
|
+
session.add(order)
|
|
116
|
+
|
|
117
|
+
await client.enqueue_http(
|
|
118
|
+
session=session,
|
|
119
|
+
name="order.webhook",
|
|
120
|
+
url="https://example.com/webhooks/orders",
|
|
121
|
+
json_body={"order_id": str(order.id)},
|
|
122
|
+
idempotency_key=f"order-webhook:{order.id}",
|
|
123
|
+
trace_id=request_id,
|
|
124
|
+
)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
If the transaction rolls back, the job rolls back with it. If the transaction commits, a worker can claim the job after
|
|
128
|
+
commit.
|
|
129
|
+
|
|
130
|
+
Python handler jobs use the same pattern:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
await client.enqueue_handler(
|
|
134
|
+
session=session,
|
|
135
|
+
name="orders.recalculate_totals",
|
|
136
|
+
payload={"order_id": str(order.id)},
|
|
137
|
+
queue_name="default",
|
|
138
|
+
idempotency_key=f"recalculate:{order.id}",
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## CLI
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
pgrelay migrate upgrade
|
|
146
|
+
pgrelay migrate downgrade REVISION
|
|
147
|
+
pgrelay api
|
|
148
|
+
pgrelay worker
|
|
149
|
+
pgrelay replay JOB_ID
|
|
150
|
+
pgrelay replay JOB_ID --force
|
|
151
|
+
pgrelay drain default --timeout-seconds 300
|
|
152
|
+
pgrelay purge
|
|
153
|
+
pgrelay doctor
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The CLI reads `PGRELAY_*` settings from the environment or `.env`.
|
|
157
|
+
|
|
158
|
+
## Admin API
|
|
159
|
+
|
|
160
|
+
Unauthenticated endpoints:
|
|
161
|
+
|
|
162
|
+
| Method | Path | Purpose |
|
|
163
|
+
| --- | --- | --- |
|
|
164
|
+
| `GET` | `/healthz` | Process liveness |
|
|
165
|
+
| `GET` | `/readyz` | Database readiness |
|
|
166
|
+
|
|
167
|
+
Authenticated endpoints require `Authorization: Bearer <token>`:
|
|
168
|
+
|
|
169
|
+
| Method | Path | Purpose |
|
|
170
|
+
| --- | --- | --- |
|
|
171
|
+
| `POST` | `/v1/jobs` | Enqueue a job through the admin API |
|
|
172
|
+
| `GET` | `/v1/jobs` | List jobs without payload fields |
|
|
173
|
+
| `GET` | `/v1/jobs/{job_id}` | Read one job, including payload |
|
|
174
|
+
| `GET` | `/v1/jobs/{job_id}/attempts` | Read attempt history |
|
|
175
|
+
| `POST` | `/v1/jobs/{job_id}/replay` | Create a fresh pending job from an existing one |
|
|
176
|
+
| `POST` | `/v1/jobs/{job_id}/cancel` | Cancel a pending job |
|
|
177
|
+
| `GET` | `/v1/queues` | List queues |
|
|
178
|
+
| `PUT` | `/v1/queues/{queue_name}` | Create or update a queue |
|
|
179
|
+
| `POST` | `/v1/queues/{queue_name}/pause` | Pause a queue |
|
|
180
|
+
| `POST` | `/v1/queues/{queue_name}/resume` | Resume a queue |
|
|
181
|
+
| `GET` | `/v1/stats` | Read queue and job stats |
|
|
182
|
+
| `GET` | `/v1/workers` | Read worker heartbeat rows |
|
|
183
|
+
|
|
184
|
+
Example:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
curl -H "Authorization: Bearer dev-token-change-me" \
|
|
188
|
+
"http://localhost:8090/v1/jobs?status=pending&limit=25"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Configuration
|
|
192
|
+
|
|
193
|
+
PgRelay uses `pydantic-settings` with the `PGRELAY_` prefix. The sample file is [.env.example](.env.example).
|
|
194
|
+
|
|
195
|
+
The settings you will usually touch first:
|
|
196
|
+
|
|
197
|
+
| Setting | Why it matters |
|
|
198
|
+
| --- | --- |
|
|
199
|
+
| `PGRELAY_DATABASE_URL` | Runtime database URL. It must use `postgresql+asyncpg://`. |
|
|
200
|
+
| `PGRELAY_API_AUTH_TOKENS` | Comma-separated bearer tokens for the admin API. Required in production. |
|
|
201
|
+
| `PGRELAY_WORKER_QUEUES` | Comma-separated queue names a worker should claim from. |
|
|
202
|
+
| `PGRELAY_WORKER_CONCURRENCY` | Maximum in-flight jobs per worker process. |
|
|
203
|
+
| `PGRELAY_WORKER_LEASE_SECONDS` | Lease duration before another worker may recover a stuck job. |
|
|
204
|
+
| `PGRELAY_HTTP_ALLOWED_HOSTS` | Allowlist for HTTP job targets. |
|
|
205
|
+
| `PGRELAY_BLOCK_PRIVATE_NETWORK_TARGETS` | Blocks HTTP jobs from reaching private network targets by default. |
|
|
206
|
+
| `PGRELAY_RETENTION_SUCCEEDED_DAYS` | How long succeeded jobs are kept before purge. |
|
|
207
|
+
| `PGRELAY_RETENTION_DEAD_LETTER_DAYS` | How long dead-letter jobs are kept before purge. |
|
|
208
|
+
|
|
209
|
+
In production, do not use the development token from `docker-compose.yml`. PgRelay refuses to start with that token when
|
|
210
|
+
`PGRELAY_ENV=prod`.
|
|
211
|
+
|
|
212
|
+
## Job Lifecycle
|
|
213
|
+
|
|
214
|
+
```text
|
|
215
|
+
pending -> leased -> succeeded
|
|
216
|
+
-> pending (retryable failure before max_attempts)
|
|
217
|
+
-> dead_letter (max attempts reached or permanent failure)
|
|
218
|
+
pending -> cancelled
|
|
219
|
+
dead_letter/cancelled -> pending (replay creates a new job id)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Workers claim pending jobs with PostgreSQL row locks and `SKIP LOCKED`, then heartbeat the lease while the job runs. If
|
|
223
|
+
a worker dies, lease recovery returns the job to `pending` or moves it to `dead_letter` when attempts are exhausted.
|
|
224
|
+
|
|
225
|
+
## Operational Notes
|
|
226
|
+
|
|
227
|
+
- Design receivers to handle duplicate delivery. At-least-once is the contract.
|
|
228
|
+
- Use stable `idempotency_key` values for effects that should not be queued twice.
|
|
229
|
+
- Keep HTTP job hosts allowlisted. The worker follows no redirects and blocks private network targets by default.
|
|
230
|
+
- Watch dead-letter jobs. They are usually either a receiver problem, a bad payload, or a missing idempotency rule.
|
|
231
|
+
- Run more worker processes for throughput, but size the database pool so each worker has room to claim, heartbeat, and
|
|
232
|
+
finish jobs.
|
|
233
|
+
- Treat payloads as operational data. Job list endpoints omit payloads, but detail endpoints return them to authorized
|
|
234
|
+
callers.
|
|
235
|
+
|
|
236
|
+
For state transitions, guarantees, and failure modes, see [Architecture](docs/architecture.md).
|
|
237
|
+
|
|
238
|
+
## Project Status
|
|
239
|
+
|
|
240
|
+
This repository is currently at `0.1.0`. The core API, worker, SDK, migrations, and local Docker stack are present, but
|
|
241
|
+
the project should still be treated as young. Pin versions, test against your own failure modes, and expect the edges to
|
|
242
|
+
be sharper than a mature hosted queue.
|
|
243
|
+
|
|
244
|
+
Planned next steps are intentionally modest: better wakeups with PostgreSQL `LISTEN/NOTIFY`, batch enqueue, and
|
|
245
|
+
OpenTelemetry integration.
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
MIT. See [LICENSE](LICENSE).
|
|
250
|
+
|
|
251
|
+
## Development
|
|
252
|
+
|
|
253
|
+
This project was developed with AI assistance and is maintained by the author.
|
|
254
|
+
|
pgrelay-0.1.0/README.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# PgRelay
|
|
2
|
+
|
|
3
|
+
PostgreSQL-backed transactional outbox and reliable background jobs for Python services.
|
|
4
|
+
|
|
5
|
+
PgRelay is for the awkward space between "just call the webhook in the request" and "we need a separate queueing
|
|
6
|
+
platform". It stores jobs in the same PostgreSQL database your application already commits to, then runs them from a
|
|
7
|
+
small asyncio worker. The SDK writes into your existing SQLAlchemy `AsyncSession`, so a domain row and the job that
|
|
8
|
+
publishes it can commit or roll back together.
|
|
9
|
+
|
|
10
|
+
It is intentionally not an exactly-once system. PgRelay gives you at-least-once delivery, retries, leases, dead-letter
|
|
11
|
+
jobs, replay, and enough operator API to see what happened. Any external side effect still needs an idempotency key on
|
|
12
|
+
the receiving side.
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
The image is a concept for a possible browser admin console. PgRelay currently ships the admin API and CLI; a separate
|
|
17
|
+
browser console is not part of this release.
|
|
18
|
+
|
|
19
|
+
## What You Get
|
|
20
|
+
|
|
21
|
+
- Transactional enqueue from a caller-owned `sqlalchemy.ext.asyncio.AsyncSession`
|
|
22
|
+
- HTTP jobs and Python handler jobs
|
|
23
|
+
- PostgreSQL queues with pause/resume and per-queue concurrency limits
|
|
24
|
+
- Worker leases, lease heartbeats, expired lease recovery, retries, and dead-letter state
|
|
25
|
+
- Idempotency keys and dedupe keys
|
|
26
|
+
- Replay and cancel operations for job recovery
|
|
27
|
+
- FastAPI admin API with health, readiness, jobs, attempts, queues, stats, and worker heartbeats
|
|
28
|
+
- Typer CLI for migrations, workers, replay, drain, purge, and environment checks
|
|
29
|
+
- Worker counters and histograms built with `prometheus-client`
|
|
30
|
+
|
|
31
|
+
## When It Fits
|
|
32
|
+
|
|
33
|
+
PgRelay is a good fit when your service already uses PostgreSQL and SQLAlchemy async, and the job is part of the same
|
|
34
|
+
business fact you are committing. Typical examples are webhook delivery, search indexing, billing sync, email requests,
|
|
35
|
+
or a small internal handler that should run after a transaction is durable.
|
|
36
|
+
|
|
37
|
+
It is not trying to replace Kafka, Temporal, Celery, SQS, or a workflow engine. If you need fan-out streams,
|
|
38
|
+
long-running durable workflows, cross-language workers, hosted scheduling, or exactly-once effects in another system,
|
|
39
|
+
use the tool built for that job.
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
Requirements:
|
|
44
|
+
|
|
45
|
+
- Python 3.12 or 3.13
|
|
46
|
+
- PostgreSQL 15+
|
|
47
|
+
- Poetry 2.x for local development
|
|
48
|
+
- Docker Compose if you want the one-command local stack
|
|
49
|
+
|
|
50
|
+
Run the local stack:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
poetry install --with dev
|
|
54
|
+
docker compose up --build
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The compose file starts PostgreSQL, runs migrations, then launches the admin API and one worker. The API listens on
|
|
58
|
+
`http://localhost:8090`.
|
|
59
|
+
|
|
60
|
+
Check it:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
curl http://localhost:8090/healthz
|
|
64
|
+
curl http://localhost:8090/readyz
|
|
65
|
+
curl -H "Authorization: Bearer dev-token-change-me" http://localhost:8090/v1/jobs
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
FastAPI's OpenAPI UI is available at:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
http://localhost:8090/docs
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The default token is for local development only. Set `PGRELAY_API_AUTH_TOKENS` before running anything that is reachable
|
|
75
|
+
outside your machine.
|
|
76
|
+
|
|
77
|
+
## Enqueue Inside Your Transaction
|
|
78
|
+
|
|
79
|
+
The important detail is ownership: PgRelay does not call `commit()` and does not close your session. Your application
|
|
80
|
+
keeps the transaction boundary.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from pgrelay.sdk.client import PgRelayClient
|
|
84
|
+
|
|
85
|
+
client = PgRelayClient.from_env()
|
|
86
|
+
|
|
87
|
+
async with session_factory() as session:
|
|
88
|
+
async with session.begin():
|
|
89
|
+
session.add(order)
|
|
90
|
+
|
|
91
|
+
await client.enqueue_http(
|
|
92
|
+
session=session,
|
|
93
|
+
name="order.webhook",
|
|
94
|
+
url="https://example.com/webhooks/orders",
|
|
95
|
+
json_body={"order_id": str(order.id)},
|
|
96
|
+
idempotency_key=f"order-webhook:{order.id}",
|
|
97
|
+
trace_id=request_id,
|
|
98
|
+
)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
If the transaction rolls back, the job rolls back with it. If the transaction commits, a worker can claim the job after
|
|
102
|
+
commit.
|
|
103
|
+
|
|
104
|
+
Python handler jobs use the same pattern:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
await client.enqueue_handler(
|
|
108
|
+
session=session,
|
|
109
|
+
name="orders.recalculate_totals",
|
|
110
|
+
payload={"order_id": str(order.id)},
|
|
111
|
+
queue_name="default",
|
|
112
|
+
idempotency_key=f"recalculate:{order.id}",
|
|
113
|
+
)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## CLI
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pgrelay migrate upgrade
|
|
120
|
+
pgrelay migrate downgrade REVISION
|
|
121
|
+
pgrelay api
|
|
122
|
+
pgrelay worker
|
|
123
|
+
pgrelay replay JOB_ID
|
|
124
|
+
pgrelay replay JOB_ID --force
|
|
125
|
+
pgrelay drain default --timeout-seconds 300
|
|
126
|
+
pgrelay purge
|
|
127
|
+
pgrelay doctor
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The CLI reads `PGRELAY_*` settings from the environment or `.env`.
|
|
131
|
+
|
|
132
|
+
## Admin API
|
|
133
|
+
|
|
134
|
+
Unauthenticated endpoints:
|
|
135
|
+
|
|
136
|
+
| Method | Path | Purpose |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| `GET` | `/healthz` | Process liveness |
|
|
139
|
+
| `GET` | `/readyz` | Database readiness |
|
|
140
|
+
|
|
141
|
+
Authenticated endpoints require `Authorization: Bearer <token>`:
|
|
142
|
+
|
|
143
|
+
| Method | Path | Purpose |
|
|
144
|
+
| --- | --- | --- |
|
|
145
|
+
| `POST` | `/v1/jobs` | Enqueue a job through the admin API |
|
|
146
|
+
| `GET` | `/v1/jobs` | List jobs without payload fields |
|
|
147
|
+
| `GET` | `/v1/jobs/{job_id}` | Read one job, including payload |
|
|
148
|
+
| `GET` | `/v1/jobs/{job_id}/attempts` | Read attempt history |
|
|
149
|
+
| `POST` | `/v1/jobs/{job_id}/replay` | Create a fresh pending job from an existing one |
|
|
150
|
+
| `POST` | `/v1/jobs/{job_id}/cancel` | Cancel a pending job |
|
|
151
|
+
| `GET` | `/v1/queues` | List queues |
|
|
152
|
+
| `PUT` | `/v1/queues/{queue_name}` | Create or update a queue |
|
|
153
|
+
| `POST` | `/v1/queues/{queue_name}/pause` | Pause a queue |
|
|
154
|
+
| `POST` | `/v1/queues/{queue_name}/resume` | Resume a queue |
|
|
155
|
+
| `GET` | `/v1/stats` | Read queue and job stats |
|
|
156
|
+
| `GET` | `/v1/workers` | Read worker heartbeat rows |
|
|
157
|
+
|
|
158
|
+
Example:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
curl -H "Authorization: Bearer dev-token-change-me" \
|
|
162
|
+
"http://localhost:8090/v1/jobs?status=pending&limit=25"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Configuration
|
|
166
|
+
|
|
167
|
+
PgRelay uses `pydantic-settings` with the `PGRELAY_` prefix. The sample file is [.env.example](.env.example).
|
|
168
|
+
|
|
169
|
+
The settings you will usually touch first:
|
|
170
|
+
|
|
171
|
+
| Setting | Why it matters |
|
|
172
|
+
| --- | --- |
|
|
173
|
+
| `PGRELAY_DATABASE_URL` | Runtime database URL. It must use `postgresql+asyncpg://`. |
|
|
174
|
+
| `PGRELAY_API_AUTH_TOKENS` | Comma-separated bearer tokens for the admin API. Required in production. |
|
|
175
|
+
| `PGRELAY_WORKER_QUEUES` | Comma-separated queue names a worker should claim from. |
|
|
176
|
+
| `PGRELAY_WORKER_CONCURRENCY` | Maximum in-flight jobs per worker process. |
|
|
177
|
+
| `PGRELAY_WORKER_LEASE_SECONDS` | Lease duration before another worker may recover a stuck job. |
|
|
178
|
+
| `PGRELAY_HTTP_ALLOWED_HOSTS` | Allowlist for HTTP job targets. |
|
|
179
|
+
| `PGRELAY_BLOCK_PRIVATE_NETWORK_TARGETS` | Blocks HTTP jobs from reaching private network targets by default. |
|
|
180
|
+
| `PGRELAY_RETENTION_SUCCEEDED_DAYS` | How long succeeded jobs are kept before purge. |
|
|
181
|
+
| `PGRELAY_RETENTION_DEAD_LETTER_DAYS` | How long dead-letter jobs are kept before purge. |
|
|
182
|
+
|
|
183
|
+
In production, do not use the development token from `docker-compose.yml`. PgRelay refuses to start with that token when
|
|
184
|
+
`PGRELAY_ENV=prod`.
|
|
185
|
+
|
|
186
|
+
## Job Lifecycle
|
|
187
|
+
|
|
188
|
+
```text
|
|
189
|
+
pending -> leased -> succeeded
|
|
190
|
+
-> pending (retryable failure before max_attempts)
|
|
191
|
+
-> dead_letter (max attempts reached or permanent failure)
|
|
192
|
+
pending -> cancelled
|
|
193
|
+
dead_letter/cancelled -> pending (replay creates a new job id)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Workers claim pending jobs with PostgreSQL row locks and `SKIP LOCKED`, then heartbeat the lease while the job runs. If
|
|
197
|
+
a worker dies, lease recovery returns the job to `pending` or moves it to `dead_letter` when attempts are exhausted.
|
|
198
|
+
|
|
199
|
+
## Operational Notes
|
|
200
|
+
|
|
201
|
+
- Design receivers to handle duplicate delivery. At-least-once is the contract.
|
|
202
|
+
- Use stable `idempotency_key` values for effects that should not be queued twice.
|
|
203
|
+
- Keep HTTP job hosts allowlisted. The worker follows no redirects and blocks private network targets by default.
|
|
204
|
+
- Watch dead-letter jobs. They are usually either a receiver problem, a bad payload, or a missing idempotency rule.
|
|
205
|
+
- Run more worker processes for throughput, but size the database pool so each worker has room to claim, heartbeat, and
|
|
206
|
+
finish jobs.
|
|
207
|
+
- Treat payloads as operational data. Job list endpoints omit payloads, but detail endpoints return them to authorized
|
|
208
|
+
callers.
|
|
209
|
+
|
|
210
|
+
For state transitions, guarantees, and failure modes, see [Architecture](docs/architecture.md).
|
|
211
|
+
|
|
212
|
+
## Project Status
|
|
213
|
+
|
|
214
|
+
This repository is currently at `0.1.0`. The core API, worker, SDK, migrations, and local Docker stack are present, but
|
|
215
|
+
the project should still be treated as young. Pin versions, test against your own failure modes, and expect the edges to
|
|
216
|
+
be sharper than a mature hosted queue.
|
|
217
|
+
|
|
218
|
+
Planned next steps are intentionally modest: better wakeups with PostgreSQL `LISTEN/NOTIFY`, batch enqueue, and
|
|
219
|
+
OpenTelemetry integration.
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
MIT. See [LICENSE](LICENSE).
|
|
224
|
+
|
|
225
|
+
## Development
|
|
226
|
+
|
|
227
|
+
This project was developed with AI assistance and is maintained by the author.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = migrations
|
|
3
|
+
version_table = pgrelay_alembic_version
|
|
4
|
+
prepend_sys_path = .
|
|
5
|
+
|
|
6
|
+
[loggers]
|
|
7
|
+
keys = root,sqlalchemy,alembic
|
|
8
|
+
|
|
9
|
+
[handlers]
|
|
10
|
+
keys = console
|
|
11
|
+
|
|
12
|
+
[formatters]
|
|
13
|
+
keys = generic
|
|
14
|
+
|
|
15
|
+
[logger_root]
|
|
16
|
+
level = WARN
|
|
17
|
+
handlers = console
|
|
18
|
+
qualname =
|
|
19
|
+
|
|
20
|
+
[logger_sqlalchemy]
|
|
21
|
+
level = WARN
|
|
22
|
+
handlers =
|
|
23
|
+
qualname = sqlalchemy.engine
|
|
24
|
+
|
|
25
|
+
[logger_alembic]
|
|
26
|
+
level = INFO
|
|
27
|
+
handlers =
|
|
28
|
+
qualname = alembic
|
|
29
|
+
|
|
30
|
+
[handler_console]
|
|
31
|
+
class = StreamHandler
|
|
32
|
+
args = (sys.stderr,)
|
|
33
|
+
level = NOTSET
|
|
34
|
+
formatter = generic
|
|
35
|
+
|
|
36
|
+
[formatter_generic]
|
|
37
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Alembic environment for PgRelay."""
|
|
2
|
+
|
|
3
|
+
from logging.config import fileConfig
|
|
4
|
+
|
|
5
|
+
from alembic import context
|
|
6
|
+
from sqlalchemy import engine_from_config, pool
|
|
7
|
+
from sqlalchemy.engine import make_url
|
|
8
|
+
|
|
9
|
+
from pgrelay.config.settings import load_settings
|
|
10
|
+
from pgrelay.db import models
|
|
11
|
+
from pgrelay.db.base import Base
|
|
12
|
+
|
|
13
|
+
config = context.config
|
|
14
|
+
|
|
15
|
+
if config.config_file_name is not None:
|
|
16
|
+
fileConfig(config.config_file_name)
|
|
17
|
+
|
|
18
|
+
database_url = config.get_main_option("sqlalchemy.url") or load_settings().database_url
|
|
19
|
+
url = make_url(database_url).set(drivername="postgresql+psycopg")
|
|
20
|
+
config.set_main_option("sqlalchemy.url", url.render_as_string(hide_password=False))
|
|
21
|
+
target_metadata = Base.metadata
|
|
22
|
+
loaded_models = (models.PgRelayAttempt, models.PgRelayJob, models.PgRelayQueue, models.PgRelayWorker)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_migrations_offline() -> None:
|
|
26
|
+
"""Run migrations without creating an engine."""
|
|
27
|
+
context.configure(
|
|
28
|
+
url=url.render_as_string(hide_password=False),
|
|
29
|
+
target_metadata=target_metadata,
|
|
30
|
+
literal_binds=True,
|
|
31
|
+
dialect_opts={"paramstyle": "named"},
|
|
32
|
+
version_table="pgrelay_alembic_version",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
with context.begin_transaction():
|
|
36
|
+
context.run_migrations()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_migrations_online() -> None:
|
|
40
|
+
"""Run migrations with a sync Alembic engine."""
|
|
41
|
+
connectable = engine_from_config(
|
|
42
|
+
config.get_section(config.config_ini_section, {}),
|
|
43
|
+
prefix="sqlalchemy.",
|
|
44
|
+
poolclass=pool.NullPool,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
with connectable.connect() as connection:
|
|
48
|
+
context.configure(
|
|
49
|
+
connection=connection,
|
|
50
|
+
target_metadata=target_metadata,
|
|
51
|
+
version_table="pgrelay_alembic_version",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
with context.begin_transaction():
|
|
55
|
+
context.run_migrations()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if context.is_offline_mode():
|
|
59
|
+
run_migrations_offline()
|
|
60
|
+
else:
|
|
61
|
+
run_migrations_online()
|