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.
Files changed (25) hide show
  1. python_pq-0.6.0/LICENSE +21 -0
  2. python_pq-0.6.0/PKG-INFO +342 -0
  3. python_pq-0.6.0/README.md +306 -0
  4. {python_pq-0.5.1 → python_pq-0.6.0}/pyproject.toml +20 -1
  5. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/__init__.py +1 -1
  6. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/client.py +5 -0
  7. python_pq-0.6.0/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +32 -0
  8. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/models.py +2 -0
  9. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/worker.py +1 -0
  10. python_pq-0.5.1/PKG-INFO +0 -337
  11. python_pq-0.5.1/README.md +0 -316
  12. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/config.py +0 -0
  13. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/logging.py +0 -0
  14. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/README +0 -0
  15. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/__init__.py +0 -0
  16. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/env.py +0 -0
  17. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/script.py.mako +0 -0
  18. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
  19. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
  20. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
  21. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
  22. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/migrations/versions/__init__.py +0 -0
  23. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/priority.py +0 -0
  24. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/registry.py +0 -0
  25. {python_pq-0.5.1 → python_pq-0.6.0}/src/pq/serialization.py +0 -0
@@ -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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/python-pq)](https://pypi.org/project/python-pq/)
40
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
41
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/python-pq)](https://pypi.org/project/python-pq/)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![Python 3.13+](https://img.shields.io/badge/python-3.13+-blue.svg)](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.5.1"
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