python-pq 0.5.1__tar.gz → 0.6.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.
- python_pq-0.6.0/LICENSE +21 -0
- python_pq-0.6.0/PKG-INFO +342 -0
- python_pq-0.6.0/README.md +306 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/pyproject.toml +20 -1
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/__init__.py +1 -1
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/client.py +5 -0
- python_pq-0.6.0/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +32 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/models.py +2 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/worker.py +1 -0
- python_pq-0.5.1/PKG-INFO +0 -337
- python_pq-0.5.1/README.md +0 -316
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/config.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/logging.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/README +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/__init__.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/env.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/script.py.mako +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/__init__.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/priority.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/registry.py +0 -0
- {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/serialization.py +0 -0
python_pq-0.6.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ricwo
|
|
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.
|
python_pq-0.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-pq
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Postgres-backed job queue for Python
|
|
5
|
+
Keywords: postgres,job-queue,task-queue,background-jobs,worker
|
|
6
|
+
Author: ricwo
|
|
7
|
+
Author-email: ricwo <r@cogram.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Dist: alembic>=1.17.2
|
|
19
|
+
Requires-Dist: click>=8.3.1
|
|
20
|
+
Requires-Dist: croniter>=6.0.0
|
|
21
|
+
Requires-Dist: dill>=0.4.0
|
|
22
|
+
Requires-Dist: loguru>=0.7.3
|
|
23
|
+
Requires-Dist: psycopg2-binary>=2.9.11
|
|
24
|
+
Requires-Dist: pydantic>=2.12.5
|
|
25
|
+
Requires-Dist: pydantic-settings>=2.12.0
|
|
26
|
+
Requires-Dist: sqlalchemy>=2.0.45
|
|
27
|
+
Requires-Dist: mkdocs-material>=9.7.1 ; extra == 'docs'
|
|
28
|
+
Requires-Dist: mkdocstrings[python]>=1.0.0 ; extra == 'docs'
|
|
29
|
+
Requires-Python: >=3.13, <3.15
|
|
30
|
+
Project-URL: Documentation, https://ricwo.github.io/pq/
|
|
31
|
+
Project-URL: Homepage, https://github.com/ricwo/pq
|
|
32
|
+
Project-URL: Issues, https://github.com/ricwo/pq/issues
|
|
33
|
+
Project-URL: Repository, https://github.com/ricwo/pq
|
|
34
|
+
Provides-Extra: docs
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# pq
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/python-pq/)
|
|
40
|
+
[](LICENSE)
|
|
41
|
+
[](https://www.python.org/downloads/)
|
|
42
|
+
|
|
43
|
+
Postgres-backed job queue for Python with fork-based worker isolation.
|
|
44
|
+
|
|
45
|
+
If you already run Postgres, you don't need Redis or RabbitMQ to process background jobs. pq uses `SELECT ... FOR UPDATE SKIP LOCKED` to turn your existing database into a reliable task queue. Enqueue in the same transaction as your writes, and process tasks in isolated child processes that can't crash your worker.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from pq import PQ
|
|
49
|
+
|
|
50
|
+
pq = PQ("postgresql://localhost/mydb")
|
|
51
|
+
pq.run_db_migrations()
|
|
52
|
+
|
|
53
|
+
def send_email(to: str, subject: str) -> None:
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
pq.enqueue(send_email, to="user@example.com", subject="Hello")
|
|
57
|
+
pq.run_worker()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Install
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
uv add python-pq
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or with pip:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install python-pq
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Requires PostgreSQL and Python 3.13+.
|
|
73
|
+
|
|
74
|
+
## Features
|
|
75
|
+
|
|
76
|
+
- **Fork isolation** -- Each task runs in a forked child process. If it OOMs, segfaults, or crashes, the worker keeps running.
|
|
77
|
+
- **No extra infrastructure** -- Uses your existing Postgres. No broker to deploy, monitor, or lose data.
|
|
78
|
+
- **Transactional enqueueing** -- Enqueue tasks in the same database transaction as your writes. If the transaction rolls back, the task is never created.
|
|
79
|
+
- **Periodic tasks** -- Schedule with intervals (`timedelta`) or cron expressions. Control overlap, pause/resume without deleting.
|
|
80
|
+
- **Priority queues** -- Five levels from `BATCH` (0) to `CRITICAL` (100). Dedicate workers to specific priority tiers.
|
|
81
|
+
- **Lifecycle hooks** -- Run `pre_execute` / `post_execute` code in the forked child, safe for fork-unsafe libraries like OpenTelemetry.
|
|
82
|
+
|
|
83
|
+
## Tasks
|
|
84
|
+
|
|
85
|
+
### Enqueueing
|
|
86
|
+
|
|
87
|
+
Pass any importable function with its arguments:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
def greet(name: str) -> None:
|
|
91
|
+
print(f"Hello, {name}!")
|
|
92
|
+
|
|
93
|
+
pq.enqueue(greet, name="World")
|
|
94
|
+
pq.enqueue(greet, "World") # Positional args work too
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Delayed execution
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from datetime import datetime, timedelta, UTC
|
|
101
|
+
|
|
102
|
+
pq.enqueue(greet, "World", run_at=datetime.now(UTC) + timedelta(hours=1))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Priority
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from pq import Priority
|
|
109
|
+
|
|
110
|
+
pq.enqueue(task, priority=Priority.CRITICAL) # 100 - runs first
|
|
111
|
+
pq.enqueue(task, priority=Priority.HIGH) # 75
|
|
112
|
+
pq.enqueue(task, priority=Priority.NORMAL) # 50 (default)
|
|
113
|
+
pq.enqueue(task, priority=Priority.LOW) # 25
|
|
114
|
+
pq.enqueue(task, priority=Priority.BATCH) # 0 - runs last
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Cancellation
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
task_id = pq.enqueue(my_task)
|
|
121
|
+
pq.cancel(task_id)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Client IDs
|
|
125
|
+
|
|
126
|
+
Use `client_id` for idempotency and lookups:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
pq.enqueue(process_order, order_id=123, client_id="order-123")
|
|
130
|
+
|
|
131
|
+
task = pq.get_task_by_client_id("order-123")
|
|
132
|
+
# Duplicate client_id raises IntegrityError
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Upsert
|
|
136
|
+
|
|
137
|
+
Insert or update a task by `client_id`. Useful for debouncing -- only the latest version runs:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
pq.upsert(send_email, to="a@b.com", client_id="welcome-email")
|
|
141
|
+
|
|
142
|
+
# Second call updates the existing task (resets to PENDING)
|
|
143
|
+
pq.upsert(send_email, to="new@b.com", client_id="welcome-email")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Periodic Tasks
|
|
147
|
+
|
|
148
|
+
### Intervals
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from datetime import timedelta
|
|
152
|
+
|
|
153
|
+
def heartbeat() -> None:
|
|
154
|
+
print("alive")
|
|
155
|
+
|
|
156
|
+
pq.schedule(heartbeat, run_every=timedelta(minutes=5))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Cron expressions
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
pq.schedule(weekly_report, cron="0 9 * * 1") # Monday 9am
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### With arguments
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
pq.schedule(report, run_every=timedelta(hours=1), report_type="hourly")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Overlap control
|
|
172
|
+
|
|
173
|
+
By default, periodic tasks don't overlap -- if an instance is still running when the next tick arrives, the tick is skipped:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
# Default: max_concurrent=1, no overlap
|
|
177
|
+
pq.schedule(sync_inventory, run_every=timedelta(minutes=5))
|
|
178
|
+
|
|
179
|
+
# Allow unlimited concurrency
|
|
180
|
+
pq.schedule(fast_task, run_every=timedelta(seconds=30), max_concurrent=None)
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Pausing and resuming
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
# Pause -- task stays in the database but won't run
|
|
187
|
+
pq.schedule(sync_inventory, run_every=timedelta(minutes=5), active=False)
|
|
188
|
+
|
|
189
|
+
# Resume
|
|
190
|
+
pq.schedule(sync_inventory, run_every=timedelta(minutes=5), active=True)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Multiple schedules
|
|
194
|
+
|
|
195
|
+
Use `key` to register the same function with different configurations:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
pq.schedule(sync_data, run_every=timedelta(hours=1), key="us", region="us")
|
|
199
|
+
pq.schedule(sync_data, run_every=timedelta(hours=2), key="eu", region="eu")
|
|
200
|
+
|
|
201
|
+
pq.unschedule(sync_data, key="us")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Workers
|
|
205
|
+
|
|
206
|
+
### Running
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
pq.run_worker(poll_interval=1.0) # Run forever
|
|
210
|
+
processed = pq.run_worker_once() # Process single task (for testing)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Timeout
|
|
214
|
+
|
|
215
|
+
Kill tasks that run too long:
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
pq.run_worker(max_runtime=300) # 5 minute timeout per task
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Priority-dedicated workers
|
|
222
|
+
|
|
223
|
+
Reserve workers for high-priority tasks:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from pq import Priority
|
|
227
|
+
|
|
228
|
+
# This worker only processes CRITICAL and HIGH
|
|
229
|
+
pq.run_worker(priorities={Priority.CRITICAL, Priority.HIGH})
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Lifecycle hooks
|
|
233
|
+
|
|
234
|
+
Run code before/after each task in the forked child process:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from pq import PQ, Task, Periodic
|
|
238
|
+
|
|
239
|
+
def setup_tracing(task: Task | Periodic) -> None:
|
|
240
|
+
print(f"Starting: {task.name}")
|
|
241
|
+
|
|
242
|
+
def flush_tracing(task: Task | Periodic, error: Exception | None) -> None:
|
|
243
|
+
if error:
|
|
244
|
+
print(f"Failed: {error}")
|
|
245
|
+
|
|
246
|
+
pq.run_worker(pre_execute=setup_tracing, post_execute=flush_tracing)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Hooks run in the forked child, making them safe for fork-unsafe resources like OpenTelemetry.
|
|
250
|
+
|
|
251
|
+
## Serialization
|
|
252
|
+
|
|
253
|
+
Arguments are serialized automatically:
|
|
254
|
+
|
|
255
|
+
| Type | Method |
|
|
256
|
+
|------|--------|
|
|
257
|
+
| JSON-serializable (str, int, list, dict) | JSON |
|
|
258
|
+
| Pydantic models | `model_dump()` → JSON |
|
|
259
|
+
| Custom objects, lambdas | dill (pickle) |
|
|
260
|
+
|
|
261
|
+
## Async tasks
|
|
262
|
+
|
|
263
|
+
Async handlers work without any changes:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
import httpx
|
|
267
|
+
|
|
268
|
+
async def fetch(url: str) -> None:
|
|
269
|
+
async with httpx.AsyncClient() as client:
|
|
270
|
+
response = await client.get(url)
|
|
271
|
+
print(response.status_code)
|
|
272
|
+
|
|
273
|
+
pq.enqueue(fetch, "https://example.com")
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Error handling
|
|
277
|
+
|
|
278
|
+
Failed tasks are marked with status `FAILED` and the error is stored:
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
for task in pq.list_failed():
|
|
282
|
+
print(f"{task.name}: {task.error}")
|
|
283
|
+
|
|
284
|
+
pq.clear_failed(before=datetime.now(UTC) - timedelta(days=7))
|
|
285
|
+
pq.clear_completed(before=datetime.now(UTC) - timedelta(days=1))
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
## How it works
|
|
289
|
+
|
|
290
|
+
Every task runs in a forked child process:
|
|
291
|
+
|
|
292
|
+
```
|
|
293
|
+
Worker (parent)
|
|
294
|
+
|
|
|
295
|
+
+-- fork() -> Child executes task -> exits
|
|
296
|
+
| (OOM/crash only affects child)
|
|
297
|
+
|
|
|
298
|
+
+-- Continues processing next task
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
The parent monitors via `os.wait4()` and detects timeout, OOM (SIGKILL), and signal-based crashes. The child process exits after every task, giving you true memory isolation.
|
|
302
|
+
|
|
303
|
+
Multiple workers can run in parallel. Tasks are claimed atomically with PostgreSQL's `FOR UPDATE SKIP LOCKED`, so each task runs exactly once.
|
|
304
|
+
|
|
305
|
+
## Alternatives
|
|
306
|
+
|
|
307
|
+
There are good options in this space. pq makes different tradeoffs:
|
|
308
|
+
|
|
309
|
+
| | Broker | Isolation | Use case |
|
|
310
|
+
|---|---|---|---|
|
|
311
|
+
| **pq** | Postgres | Fork (process-per-task) | Teams already on Postgres who want fewer moving parts |
|
|
312
|
+
| **Celery** | Redis/RabbitMQ | Per-worker process | Large-scale, multi-language, established teams |
|
|
313
|
+
| **RQ** | Redis | Per-worker process | Simple Redis-based queues |
|
|
314
|
+
| **Dramatiq** | Redis/RabbitMQ | Per-worker process/thread | Celery alternative with better defaults |
|
|
315
|
+
| **ARQ** | Redis | Async (single process) | Async-first applications |
|
|
316
|
+
| **Procrastinate** | Postgres | Async (single process) | Async-first, Postgres-backed, Django integration |
|
|
317
|
+
|
|
318
|
+
pq is a good fit when:
|
|
319
|
+
- You already run Postgres and don't want to add Redis or RabbitMQ
|
|
320
|
+
- You want transactional enqueueing (enqueue atomically with your writes)
|
|
321
|
+
- You need true process isolation per task (OOM/crash safety)
|
|
322
|
+
- You want periodic tasks with overlap control, pause/resume, and cron
|
|
323
|
+
|
|
324
|
+
pq is not the right choice when:
|
|
325
|
+
- You need very high throughput (10,000+ jobs/second) -- use a dedicated broker
|
|
326
|
+
- You need cross-language workers -- Celery or a dedicated queue service is better
|
|
327
|
+
- You need complex workflows (DAGs, chaining, fan-out) -- look at Temporal or Prefect
|
|
328
|
+
|
|
329
|
+
## Documentation
|
|
330
|
+
|
|
331
|
+
Full docs at [ricwo.github.io/pq](https://ricwo.github.io/pq/).
|
|
332
|
+
|
|
333
|
+
## Development
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
make dev # Start Postgres
|
|
337
|
+
uv run pytest # Run tests
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## License
|
|
341
|
+
|
|
342
|
+
MIT
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# pq
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/python-pq/)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
|
|
7
|
+
Postgres-backed job queue for Python with fork-based worker isolation.
|
|
8
|
+
|
|
9
|
+
If you already run Postgres, you don't need Redis or RabbitMQ to process background jobs. pq uses `SELECT ... FOR UPDATE SKIP LOCKED` to turn your existing database into a reliable task queue. Enqueue in the same transaction as your writes, and process tasks in isolated child processes that can't crash your worker.
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
from pq import PQ
|
|
13
|
+
|
|
14
|
+
pq = PQ("postgresql://localhost/mydb")
|
|
15
|
+
pq.run_db_migrations()
|
|
16
|
+
|
|
17
|
+
def send_email(to: str, subject: str) -> None:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
pq.enqueue(send_email, to="user@example.com", subject="Hello")
|
|
21
|
+
pq.run_worker()
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add python-pq
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or with pip:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install python-pq
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires PostgreSQL and Python 3.13+.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **Fork isolation** -- Each task runs in a forked child process. If it OOMs, segfaults, or crashes, the worker keeps running.
|
|
41
|
+
- **No extra infrastructure** -- Uses your existing Postgres. No broker to deploy, monitor, or lose data.
|
|
42
|
+
- **Transactional enqueueing** -- Enqueue tasks in the same database transaction as your writes. If the transaction rolls back, the task is never created.
|
|
43
|
+
- **Periodic tasks** -- Schedule with intervals (`timedelta`) or cron expressions. Control overlap, pause/resume without deleting.
|
|
44
|
+
- **Priority queues** -- Five levels from `BATCH` (0) to `CRITICAL` (100). Dedicate workers to specific priority tiers.
|
|
45
|
+
- **Lifecycle hooks** -- Run `pre_execute` / `post_execute` code in the forked child, safe for fork-unsafe libraries like OpenTelemetry.
|
|
46
|
+
|
|
47
|
+
## Tasks
|
|
48
|
+
|
|
49
|
+
### Enqueueing
|
|
50
|
+
|
|
51
|
+
Pass any importable function with its arguments:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
def greet(name: str) -> None:
|
|
55
|
+
print(f"Hello, {name}!")
|
|
56
|
+
|
|
57
|
+
pq.enqueue(greet, name="World")
|
|
58
|
+
pq.enqueue(greet, "World") # Positional args work too
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Delayed execution
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from datetime import datetime, timedelta, UTC
|
|
65
|
+
|
|
66
|
+
pq.enqueue(greet, "World", run_at=datetime.now(UTC) + timedelta(hours=1))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Priority
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from pq import Priority
|
|
73
|
+
|
|
74
|
+
pq.enqueue(task, priority=Priority.CRITICAL) # 100 - runs first
|
|
75
|
+
pq.enqueue(task, priority=Priority.HIGH) # 75
|
|
76
|
+
pq.enqueue(task, priority=Priority.NORMAL) # 50 (default)
|
|
77
|
+
pq.enqueue(task, priority=Priority.LOW) # 25
|
|
78
|
+
pq.enqueue(task, priority=Priority.BATCH) # 0 - runs last
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Cancellation
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
task_id = pq.enqueue(my_task)
|
|
85
|
+
pq.cancel(task_id)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Client IDs
|
|
89
|
+
|
|
90
|
+
Use `client_id` for idempotency and lookups:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
pq.enqueue(process_order, order_id=123, client_id="order-123")
|
|
94
|
+
|
|
95
|
+
task = pq.get_task_by_client_id("order-123")
|
|
96
|
+
# Duplicate client_id raises IntegrityError
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Upsert
|
|
100
|
+
|
|
101
|
+
Insert or update a task by `client_id`. Useful for debouncing -- only the latest version runs:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
pq.upsert(send_email, to="a@b.com", client_id="welcome-email")
|
|
105
|
+
|
|
106
|
+
# Second call updates the existing task (resets to PENDING)
|
|
107
|
+
pq.upsert(send_email, to="new@b.com", client_id="welcome-email")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Periodic Tasks
|
|
111
|
+
|
|
112
|
+
### Intervals
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from datetime import timedelta
|
|
116
|
+
|
|
117
|
+
def heartbeat() -> None:
|
|
118
|
+
print("alive")
|
|
119
|
+
|
|
120
|
+
pq.schedule(heartbeat, run_every=timedelta(minutes=5))
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Cron expressions
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
pq.schedule(weekly_report, cron="0 9 * * 1") # Monday 9am
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### With arguments
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
pq.schedule(report, run_every=timedelta(hours=1), report_type="hourly")
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Overlap control
|
|
136
|
+
|
|
137
|
+
By default, periodic tasks don't overlap -- if an instance is still running when the next tick arrives, the tick is skipped:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
# Default: max_concurrent=1, no overlap
|
|
141
|
+
pq.schedule(sync_inventory, run_every=timedelta(minutes=5))
|
|
142
|
+
|
|
143
|
+
# Allow unlimited concurrency
|
|
144
|
+
pq.schedule(fast_task, run_every=timedelta(seconds=30), max_concurrent=None)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Pausing and resuming
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
# Pause -- task stays in the database but won't run
|
|
151
|
+
pq.schedule(sync_inventory, run_every=timedelta(minutes=5), active=False)
|
|
152
|
+
|
|
153
|
+
# Resume
|
|
154
|
+
pq.schedule(sync_inventory, run_every=timedelta(minutes=5), active=True)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Multiple schedules
|
|
158
|
+
|
|
159
|
+
Use `key` to register the same function with different configurations:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
pq.schedule(sync_data, run_every=timedelta(hours=1), key="us", region="us")
|
|
163
|
+
pq.schedule(sync_data, run_every=timedelta(hours=2), key="eu", region="eu")
|
|
164
|
+
|
|
165
|
+
pq.unschedule(sync_data, key="us")
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Workers
|
|
169
|
+
|
|
170
|
+
### Running
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
pq.run_worker(poll_interval=1.0) # Run forever
|
|
174
|
+
processed = pq.run_worker_once() # Process single task (for testing)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Timeout
|
|
178
|
+
|
|
179
|
+
Kill tasks that run too long:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
pq.run_worker(max_runtime=300) # 5 minute timeout per task
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### Priority-dedicated workers
|
|
186
|
+
|
|
187
|
+
Reserve workers for high-priority tasks:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from pq import Priority
|
|
191
|
+
|
|
192
|
+
# This worker only processes CRITICAL and HIGH
|
|
193
|
+
pq.run_worker(priorities={Priority.CRITICAL, Priority.HIGH})
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Lifecycle hooks
|
|
197
|
+
|
|
198
|
+
Run code before/after each task in the forked child process:
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from pq import PQ, Task, Periodic
|
|
202
|
+
|
|
203
|
+
def setup_tracing(task: Task | Periodic) -> None:
|
|
204
|
+
print(f"Starting: {task.name}")
|
|
205
|
+
|
|
206
|
+
def flush_tracing(task: Task | Periodic, error: Exception | None) -> None:
|
|
207
|
+
if error:
|
|
208
|
+
print(f"Failed: {error}")
|
|
209
|
+
|
|
210
|
+
pq.run_worker(pre_execute=setup_tracing, post_execute=flush_tracing)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Hooks run in the forked child, making them safe for fork-unsafe resources like OpenTelemetry.
|
|
214
|
+
|
|
215
|
+
## Serialization
|
|
216
|
+
|
|
217
|
+
Arguments are serialized automatically:
|
|
218
|
+
|
|
219
|
+
| Type | Method |
|
|
220
|
+
|------|--------|
|
|
221
|
+
| JSON-serializable (str, int, list, dict) | JSON |
|
|
222
|
+
| Pydantic models | `model_dump()` → JSON |
|
|
223
|
+
| Custom objects, lambdas | dill (pickle) |
|
|
224
|
+
|
|
225
|
+
## Async tasks
|
|
226
|
+
|
|
227
|
+
Async handlers work without any changes:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
import httpx
|
|
231
|
+
|
|
232
|
+
async def fetch(url: str) -> None:
|
|
233
|
+
async with httpx.AsyncClient() as client:
|
|
234
|
+
response = await client.get(url)
|
|
235
|
+
print(response.status_code)
|
|
236
|
+
|
|
237
|
+
pq.enqueue(fetch, "https://example.com")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Error handling
|
|
241
|
+
|
|
242
|
+
Failed tasks are marked with status `FAILED` and the error is stored:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
for task in pq.list_failed():
|
|
246
|
+
print(f"{task.name}: {task.error}")
|
|
247
|
+
|
|
248
|
+
pq.clear_failed(before=datetime.now(UTC) - timedelta(days=7))
|
|
249
|
+
pq.clear_completed(before=datetime.now(UTC) - timedelta(days=1))
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## How it works
|
|
253
|
+
|
|
254
|
+
Every task runs in a forked child process:
|
|
255
|
+
|
|
256
|
+
```
|
|
257
|
+
Worker (parent)
|
|
258
|
+
|
|
|
259
|
+
+-- fork() -> Child executes task -> exits
|
|
260
|
+
| (OOM/crash only affects child)
|
|
261
|
+
|
|
|
262
|
+
+-- Continues processing next task
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The parent monitors via `os.wait4()` and detects timeout, OOM (SIGKILL), and signal-based crashes. The child process exits after every task, giving you true memory isolation.
|
|
266
|
+
|
|
267
|
+
Multiple workers can run in parallel. Tasks are claimed atomically with PostgreSQL's `FOR UPDATE SKIP LOCKED`, so each task runs exactly once.
|
|
268
|
+
|
|
269
|
+
## Alternatives
|
|
270
|
+
|
|
271
|
+
There are good options in this space. pq makes different tradeoffs:
|
|
272
|
+
|
|
273
|
+
| | Broker | Isolation | Use case |
|
|
274
|
+
|---|---|---|---|
|
|
275
|
+
| **pq** | Postgres | Fork (process-per-task) | Teams already on Postgres who want fewer moving parts |
|
|
276
|
+
| **Celery** | Redis/RabbitMQ | Per-worker process | Large-scale, multi-language, established teams |
|
|
277
|
+
| **RQ** | Redis | Per-worker process | Simple Redis-based queues |
|
|
278
|
+
| **Dramatiq** | Redis/RabbitMQ | Per-worker process/thread | Celery alternative with better defaults |
|
|
279
|
+
| **ARQ** | Redis | Async (single process) | Async-first applications |
|
|
280
|
+
| **Procrastinate** | Postgres | Async (single process) | Async-first, Postgres-backed, Django integration |
|
|
281
|
+
|
|
282
|
+
pq is a good fit when:
|
|
283
|
+
- You already run Postgres and don't want to add Redis or RabbitMQ
|
|
284
|
+
- You want transactional enqueueing (enqueue atomically with your writes)
|
|
285
|
+
- You need true process isolation per task (OOM/crash safety)
|
|
286
|
+
- You want periodic tasks with overlap control, pause/resume, and cron
|
|
287
|
+
|
|
288
|
+
pq is not the right choice when:
|
|
289
|
+
- You need very high throughput (10,000+ jobs/second) -- use a dedicated broker
|
|
290
|
+
- You need cross-language workers -- Celery or a dedicated queue service is better
|
|
291
|
+
- You need complex workflows (DAGs, chaining, fan-out) -- look at Temporal or Prefect
|
|
292
|
+
|
|
293
|
+
## Documentation
|
|
294
|
+
|
|
295
|
+
Full docs at [ricwo.github.io/pq](https://ricwo.github.io/pq/).
|
|
296
|
+
|
|
297
|
+
## Development
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
make dev # Start Postgres
|
|
301
|
+
uv run pytest # Run tests
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## License
|
|
305
|
+
|
|
306
|
+
MIT
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "python-pq"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
4
4
|
description = "Postgres-backed job queue for Python"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "ricwo", email = "r@cogram.com" }
|
|
8
8
|
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
keywords = ["postgres", "job-queue", "task-queue", "background-jobs", "worker"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3.13",
|
|
17
|
+
"Programming Language :: Python :: 3.14",
|
|
18
|
+
"Topic :: Software Development :: Libraries",
|
|
19
|
+
"Topic :: System :: Distributed Computing",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
9
22
|
requires-python = ">=3.13,<3.15"
|
|
10
23
|
dependencies = [
|
|
11
24
|
"alembic>=1.17.2",
|
|
@@ -25,6 +38,12 @@ docs = [
|
|
|
25
38
|
"mkdocstrings[python]>=1.0.0",
|
|
26
39
|
]
|
|
27
40
|
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/ricwo/pq"
|
|
43
|
+
Documentation = "https://ricwo.github.io/pq/"
|
|
44
|
+
Repository = "https://github.com/ricwo/pq"
|
|
45
|
+
Issues = "https://github.com/ricwo/pq/issues"
|
|
46
|
+
|
|
28
47
|
[project.scripts]
|
|
29
48
|
pq = "pq.cli:main"
|
|
30
49
|
|