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.
Files changed (82) hide show
  1. pgrelay-0.1.0/LICENSE +21 -0
  2. pgrelay-0.1.0/PKG-INFO +254 -0
  3. pgrelay-0.1.0/README.md +227 -0
  4. pgrelay-0.1.0/alembic.ini +37 -0
  5. pgrelay-0.1.0/migrations/env.py +61 -0
  6. pgrelay-0.1.0/migrations/versions/0001_initial_schema.py +147 -0
  7. pgrelay-0.1.0/pyproject.toml +85 -0
  8. pgrelay-0.1.0/src/pgrelay/__init__.py +3 -0
  9. pgrelay-0.1.0/src/pgrelay/__main__.py +12 -0
  10. pgrelay-0.1.0/src/pgrelay/api/__init__.py +1 -0
  11. pgrelay-0.1.0/src/pgrelay/api/app.py +98 -0
  12. pgrelay-0.1.0/src/pgrelay/api/dependencies.py +62 -0
  13. pgrelay-0.1.0/src/pgrelay/api/routers/__init__.py +1 -0
  14. pgrelay-0.1.0/src/pgrelay/api/routers/health.py +28 -0
  15. pgrelay-0.1.0/src/pgrelay/api/routers/jobs.py +107 -0
  16. pgrelay-0.1.0/src/pgrelay/api/routers/queues.py +52 -0
  17. pgrelay-0.1.0/src/pgrelay/api/routers/stats.py +22 -0
  18. pgrelay-0.1.0/src/pgrelay/api/routers/workers.py +21 -0
  19. pgrelay-0.1.0/src/pgrelay/cli/__init__.py +1 -0
  20. pgrelay-0.1.0/src/pgrelay/cli/app.py +18 -0
  21. pgrelay-0.1.0/src/pgrelay/cli/commands_api.py +15 -0
  22. pgrelay-0.1.0/src/pgrelay/cli/commands_doctor.py +76 -0
  23. pgrelay-0.1.0/src/pgrelay/cli/commands_jobs.py +106 -0
  24. pgrelay-0.1.0/src/pgrelay/cli/commands_migrate.py +24 -0
  25. pgrelay-0.1.0/src/pgrelay/cli/commands_worker.py +29 -0
  26. pgrelay-0.1.0/src/pgrelay/config/__init__.py +1 -0
  27. pgrelay-0.1.0/src/pgrelay/config/settings.py +111 -0
  28. pgrelay-0.1.0/src/pgrelay/constants.py +42 -0
  29. pgrelay-0.1.0/src/pgrelay/db/__init__.py +1 -0
  30. pgrelay-0.1.0/src/pgrelay/db/base.py +7 -0
  31. pgrelay-0.1.0/src/pgrelay/db/migrations.py +37 -0
  32. pgrelay-0.1.0/src/pgrelay/db/models.py +147 -0
  33. pgrelay-0.1.0/src/pgrelay/db/session.py +43 -0
  34. pgrelay-0.1.0/src/pgrelay/errors.py +70 -0
  35. pgrelay-0.1.0/src/pgrelay/observability/__init__.py +1 -0
  36. pgrelay-0.1.0/src/pgrelay/observability/logging.py +32 -0
  37. pgrelay-0.1.0/src/pgrelay/observability/metrics.py +16 -0
  38. pgrelay-0.1.0/src/pgrelay/py.typed +1 -0
  39. pgrelay-0.1.0/src/pgrelay/repositories/__init__.py +1 -0
  40. pgrelay-0.1.0/src/pgrelay/repositories/attempts.py +63 -0
  41. pgrelay-0.1.0/src/pgrelay/repositories/job_rows.py +125 -0
  42. pgrelay-0.1.0/src/pgrelay/repositories/job_sql.py +205 -0
  43. pgrelay-0.1.0/src/pgrelay/repositories/job_state.py +203 -0
  44. pgrelay-0.1.0/src/pgrelay/repositories/jobs.py +200 -0
  45. pgrelay-0.1.0/src/pgrelay/repositories/protocols.py +175 -0
  46. pgrelay-0.1.0/src/pgrelay/repositories/queues.py +111 -0
  47. pgrelay-0.1.0/src/pgrelay/repositories/stats.py +161 -0
  48. pgrelay-0.1.0/src/pgrelay/repositories/workers.py +89 -0
  49. pgrelay-0.1.0/src/pgrelay/schemas/__init__.py +1 -0
  50. pgrelay-0.1.0/src/pgrelay/schemas/api_errors.py +19 -0
  51. pgrelay-0.1.0/src/pgrelay/schemas/enqueue.py +52 -0
  52. pgrelay-0.1.0/src/pgrelay/schemas/jobs.py +119 -0
  53. pgrelay-0.1.0/src/pgrelay/schemas/queues.py +24 -0
  54. pgrelay-0.1.0/src/pgrelay/schemas/stats.py +20 -0
  55. pgrelay-0.1.0/src/pgrelay/schemas/workers.py +18 -0
  56. pgrelay-0.1.0/src/pgrelay/sdk/__init__.py +1 -0
  57. pgrelay-0.1.0/src/pgrelay/sdk/client.py +107 -0
  58. pgrelay-0.1.0/src/pgrelay/sdk/result.py +15 -0
  59. pgrelay-0.1.0/src/pgrelay/security/__init__.py +1 -0
  60. pgrelay-0.1.0/src/pgrelay/security/auth.py +51 -0
  61. pgrelay-0.1.0/src/pgrelay/services/__init__.py +1 -0
  62. pgrelay-0.1.0/src/pgrelay/services/enqueue.py +78 -0
  63. pgrelay-0.1.0/src/pgrelay/services/jobs.py +105 -0
  64. pgrelay-0.1.0/src/pgrelay/services/purge.py +56 -0
  65. pgrelay-0.1.0/src/pgrelay/services/queues.py +54 -0
  66. pgrelay-0.1.0/src/pgrelay/services/stats.py +28 -0
  67. pgrelay-0.1.0/src/pgrelay/services/workers.py +19 -0
  68. pgrelay-0.1.0/src/pgrelay/utils/__init__.py +1 -0
  69. pgrelay-0.1.0/src/pgrelay/utils/ids.py +16 -0
  70. pgrelay-0.1.0/src/pgrelay/utils/json.py +20 -0
  71. pgrelay-0.1.0/src/pgrelay/utils/redaction.py +35 -0
  72. pgrelay-0.1.0/src/pgrelay/utils/validation.py +85 -0
  73. pgrelay-0.1.0/src/pgrelay/worker/__init__.py +1 -0
  74. pgrelay-0.1.0/src/pgrelay/worker/backoff.py +14 -0
  75. pgrelay-0.1.0/src/pgrelay/worker/dispatcher.py +51 -0
  76. pgrelay-0.1.0/src/pgrelay/worker/handlers.py +46 -0
  77. pgrelay-0.1.0/src/pgrelay/worker/heartbeat.py +35 -0
  78. pgrelay-0.1.0/src/pgrelay/worker/http_executor.py +220 -0
  79. pgrelay-0.1.0/src/pgrelay/worker/python_executor.py +55 -0
  80. pgrelay-0.1.0/src/pgrelay/worker/recovery.py +19 -0
  81. pgrelay-0.1.0/src/pgrelay/worker/runner.py +316 -0
  82. 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
+ ![PgRelay admin console concept](docs/screenshot-placeholder.svg)
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
+
@@ -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
+ ![PgRelay admin console concept](docs/screenshot-placeholder.svg)
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()