python-pq 0.9.0__tar.gz → 0.9.1__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.9.0 → python_pq-0.9.1}/PKG-INFO +8 -2
- {python_pq-0.9.0 → python_pq-0.9.1}/README.md +7 -1
- {python_pq-0.9.0 → python_pq-0.9.1}/pyproject.toml +1 -1
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/__init__.py +1 -1
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/client.py +58 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/worker.py +44 -13
- {python_pq-0.9.0 → python_pq-0.9.1}/LICENSE +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/config.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/logging.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/README +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/__init__.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/env.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/script.py.mako +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20261007T120000Z_d4e5f6a7b8c9_add_task_requeue.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/__init__.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/models.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/priority.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/registry.py +0 -0
- {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/serialization.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-pq
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Postgres-backed job queue for Python
|
|
5
5
|
Keywords: postgres,job-queue,task-queue,background-jobs,worker
|
|
6
6
|
Author: ricwo
|
|
@@ -73,7 +73,7 @@ Requires PostgreSQL and Python 3.13+.
|
|
|
73
73
|
|
|
74
74
|
## Features
|
|
75
75
|
|
|
76
|
-
- **Fork isolation** -- Each task runs in a forked child process. If it OOMs, segfaults, or crashes, the worker keeps running.
|
|
76
|
+
- **Fork isolation** -- Each task runs in a forked child process. If it OOMs, segfaults, or crashes, the worker keeps running. Each child gets a fresh connection pool and opens its own database connections.
|
|
77
77
|
- **No extra infrastructure** -- Uses your existing Postgres. No broker to deploy, monitor, or lose data.
|
|
78
78
|
- **Transactional enqueueing** -- Enqueue tasks in the same database transaction as your writes. If the transaction rolls back, the task is never created.
|
|
79
79
|
- **Periodic tasks** -- Schedule with intervals (`timedelta`) or cron expressions. Control overlap, pause/resume without deleting.
|
|
@@ -261,6 +261,12 @@ pq.run_worker(pre_execute=setup_tracing, post_execute=flush_tracing)
|
|
|
261
261
|
|
|
262
262
|
Hooks run in the forked child, making them safe for fork-unsafe resources like OpenTelemetry.
|
|
263
263
|
|
|
264
|
+
### Calling pq from a task
|
|
265
|
+
|
|
266
|
+
A task can use your application's `PQ` instance, for example to enqueue a follow-up task. Each forked child gets a fresh connection pool, opens its own connections, and closes them when the task ends. A child killed by its timeout or by a signal exits without closing them.
|
|
267
|
+
|
|
268
|
+
If your own code forks (with `os.fork()` or `multiprocessing`), fork outside an open `pq.session()`. A connection checked out at fork time stays shared between the two processes.
|
|
269
|
+
|
|
264
270
|
## Logging
|
|
265
271
|
|
|
266
272
|
pq uses [loguru](https://github.com/Delgan/loguru) internally and does **not** configure the logger on import. This means it inherits whatever logging setup the host application has already established.
|
|
@@ -37,7 +37,7 @@ Requires PostgreSQL and Python 3.13+.
|
|
|
37
37
|
|
|
38
38
|
## Features
|
|
39
39
|
|
|
40
|
-
- **Fork isolation** -- Each task runs in a forked child process. If it OOMs, segfaults, or crashes, the worker keeps running.
|
|
40
|
+
- **Fork isolation** -- Each task runs in a forked child process. If it OOMs, segfaults, or crashes, the worker keeps running. Each child gets a fresh connection pool and opens its own database connections.
|
|
41
41
|
- **No extra infrastructure** -- Uses your existing Postgres. No broker to deploy, monitor, or lose data.
|
|
42
42
|
- **Transactional enqueueing** -- Enqueue tasks in the same database transaction as your writes. If the transaction rolls back, the task is never created.
|
|
43
43
|
- **Periodic tasks** -- Schedule with intervals (`timedelta`) or cron expressions. Control overlap, pause/resume without deleting.
|
|
@@ -225,6 +225,12 @@ pq.run_worker(pre_execute=setup_tracing, post_execute=flush_tracing)
|
|
|
225
225
|
|
|
226
226
|
Hooks run in the forked child, making them safe for fork-unsafe resources like OpenTelemetry.
|
|
227
227
|
|
|
228
|
+
### Calling pq from a task
|
|
229
|
+
|
|
230
|
+
A task can use your application's `PQ` instance, for example to enqueue a follow-up task. Each forked child gets a fresh connection pool, opens its own connections, and closes them when the task ends. A child killed by its timeout or by a signal exits without closing them.
|
|
231
|
+
|
|
232
|
+
If your own code forks (with `os.fork()` or `multiprocessing`), fork outside an open `pq.session()`. A connection checked out at fork time stays shared between the two processes.
|
|
233
|
+
|
|
228
234
|
## Logging
|
|
229
235
|
|
|
230
236
|
pq uses [loguru](https://github.com/Delgan/loguru) internally and does **not** configure the logger on import. This means it inherits whatever logging setup the host application has already established.
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import importlib.resources
|
|
4
4
|
import math
|
|
5
|
+
import os
|
|
6
|
+
import weakref
|
|
5
7
|
from collections.abc import Callable, Set
|
|
6
8
|
from contextlib import contextmanager
|
|
7
9
|
from datetime import UTC, datetime, timedelta
|
|
@@ -62,6 +64,61 @@ def _validate_max_runtime(max_runtime_seconds: float | None) -> None:
|
|
|
62
64
|
)
|
|
63
65
|
|
|
64
66
|
|
|
67
|
+
_live_instances: "weakref.WeakSet[PQ]" = weakref.WeakSet()
|
|
68
|
+
"""Every ``PQ`` instance, so a forked child can reset their pools. Weak
|
|
69
|
+
references: the set never keeps an instance alive. ``close()`` does not remove
|
|
70
|
+
an instance: its engine reconnects if it is used again."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _reset_pools_after_fork() -> None:
|
|
74
|
+
"""Give each ``PQ`` instance an empty pool in a forked child.
|
|
75
|
+
|
|
76
|
+
A child inherits the parent's pooled connections, sockets and TLS state
|
|
77
|
+
included. Querying on one makes two processes speak on one Postgres
|
|
78
|
+
session: over TLS the parent's next record fails with ``bad record mac``,
|
|
79
|
+
over a plain socket the two processes read each other's results.
|
|
80
|
+
|
|
81
|
+
``close=False`` drops the inherited pool without closing its connections.
|
|
82
|
+
Closing them would send a terminate message on the parent's sockets and
|
|
83
|
+
break the parent's sessions.
|
|
84
|
+
|
|
85
|
+
Connections checked out at fork time are not covered: if the child returns
|
|
86
|
+
one (for example by leaving a ``with pq.session()`` block it was forked
|
|
87
|
+
in), the commit and rollback still go out on the parent's socket. Do not
|
|
88
|
+
fork inside an open ``pq.session()``.
|
|
89
|
+
"""
|
|
90
|
+
_dispose_pools(
|
|
91
|
+
close=False, failure="Failed to reset a PQ connection pool after fork"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _close_pools_before_exit() -> None:
|
|
96
|
+
"""Close the connections a forked child opened, before it calls
|
|
97
|
+
``os._exit()``.
|
|
98
|
+
|
|
99
|
+
``os._exit()`` skips interpreter cleanup, so without this Postgres sees
|
|
100
|
+
each connection drop without a terminate message: it counts the session
|
|
101
|
+
as abandoned, and over TLS logs ``unexpected eof while reading``. The
|
|
102
|
+
connections inherited from the parent are not in these pools any more
|
|
103
|
+
(``_reset_pools_after_fork`` dropped them), so this closes only the
|
|
104
|
+
child's own. Connections still checked out are left alone.
|
|
105
|
+
"""
|
|
106
|
+
_dispose_pools(close=True, failure="Failed to close a PQ connection pool")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _dispose_pools(*, close: bool, failure: str) -> None:
|
|
110
|
+
for instance in list(_live_instances):
|
|
111
|
+
try:
|
|
112
|
+
instance._engine.dispose(close=close)
|
|
113
|
+
except Exception:
|
|
114
|
+
# One failing instance must not stop the others.
|
|
115
|
+
logger.exception(failure)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if hasattr(os, "register_at_fork"):
|
|
119
|
+
os.register_at_fork(after_in_child=_reset_pools_after_fork)
|
|
120
|
+
|
|
121
|
+
|
|
65
122
|
class PQ:
|
|
66
123
|
"""Postgres-backed task queue client."""
|
|
67
124
|
|
|
@@ -79,6 +136,7 @@ class PQ:
|
|
|
79
136
|
pool_recycle=1800,
|
|
80
137
|
)
|
|
81
138
|
self._session_factory = sessionmaker(bind=self._engine)
|
|
139
|
+
_live_instances.add(self)
|
|
82
140
|
|
|
83
141
|
@contextmanager
|
|
84
142
|
def session(self) -> Any:
|
|
@@ -26,6 +26,7 @@ from croniter import croniter
|
|
|
26
26
|
from loguru import logger
|
|
27
27
|
from sqlalchemy import func, or_, select, update
|
|
28
28
|
|
|
29
|
+
from pq.client import _close_pools_before_exit
|
|
29
30
|
from pq.models import Periodic, Task, TaskStatus
|
|
30
31
|
from pq.registry import resolve_function_path
|
|
31
32
|
from pq.serialization import deserialize
|
|
@@ -169,14 +170,20 @@ class _ShutdownState:
|
|
|
169
170
|
``run_worker`` resets it on entry.
|
|
170
171
|
"""
|
|
171
172
|
|
|
172
|
-
|
|
173
|
+
signum: int | None = None # signal that requested the shutdown
|
|
173
174
|
deadline: float | None = None # time.monotonic() deadline, set on signal
|
|
174
175
|
drain_timeout: float = DEFAULT_DRAIN_TIMEOUT
|
|
176
|
+
logged: bool = False # log_request() already ran for this shutdown
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def requested(self) -> bool:
|
|
180
|
+
return self.signum is not None
|
|
175
181
|
|
|
176
182
|
def reset(self, drain_timeout: float) -> None:
|
|
177
|
-
self.
|
|
183
|
+
self.signum = None
|
|
178
184
|
self.deadline = None
|
|
179
185
|
self.drain_timeout = drain_timeout
|
|
186
|
+
self.logged = False
|
|
180
187
|
|
|
181
188
|
def deadline_passed(self) -> bool:
|
|
182
189
|
"""True once shutdown was requested and the drain budget is spent."""
|
|
@@ -184,25 +191,40 @@ class _ShutdownState:
|
|
|
184
191
|
return False
|
|
185
192
|
return self.deadline is None or time.monotonic() >= self.deadline
|
|
186
193
|
|
|
194
|
+
def log_request(self) -> None:
|
|
195
|
+
"""Log the shutdown request once, from the worker loop.
|
|
196
|
+
|
|
197
|
+
The signal handler can't log it itself (see
|
|
198
|
+
``_handle_shutdown_signal``), so the loops call this when they see
|
|
199
|
+
``requested``.
|
|
200
|
+
"""
|
|
201
|
+
if not self.requested or self.logged:
|
|
202
|
+
return
|
|
203
|
+
self.logged = True
|
|
204
|
+
logger.info(
|
|
205
|
+
f"Received signal {self.signum}, draining"
|
|
206
|
+
f" (drain_timeout={self.drain_timeout}s)..."
|
|
207
|
+
)
|
|
208
|
+
|
|
187
209
|
|
|
188
210
|
_shutdown = _ShutdownState()
|
|
189
211
|
|
|
190
212
|
|
|
191
213
|
def _handle_shutdown_signal(signum: int, frame: FrameType | None) -> None:
|
|
192
|
-
"""SIGTERM/SIGINT handler: request a drain, never raise.
|
|
214
|
+
"""SIGTERM/SIGINT handler: request a drain, never raise, never log.
|
|
193
215
|
|
|
194
216
|
Raising from here would land at arbitrary bytecode and could orphan a
|
|
195
|
-
task (e.g. between claim-commit and child registration).
|
|
196
|
-
|
|
217
|
+
task (e.g. between claim-commit and child registration). Logging from
|
|
218
|
+
here can deadlock: the handler runs on the main thread wherever it was
|
|
219
|
+
interrupted, and loguru holds its handler locks for the whole of
|
|
220
|
+
``os.fork()``. The worker loops log the request instead
|
|
221
|
+
(``_ShutdownState.log_request``). Repeat signals during the drain are
|
|
222
|
+
ignored so they can't abandon remaining children.
|
|
197
223
|
"""
|
|
198
224
|
if _shutdown.requested:
|
|
199
225
|
return
|
|
200
226
|
_shutdown.deadline = time.monotonic() + _shutdown.drain_timeout
|
|
201
|
-
_shutdown.
|
|
202
|
-
logger.info(
|
|
203
|
-
f"Received signal {signum}, draining"
|
|
204
|
-
f" (drain_timeout={_shutdown.drain_timeout}s)..."
|
|
205
|
-
)
|
|
227
|
+
_shutdown.signum = signum
|
|
206
228
|
|
|
207
229
|
|
|
208
230
|
def _install_shutdown_handlers() -> dict[int, _SignalHandler] | None:
|
|
@@ -309,17 +331,23 @@ def _run_in_child(
|
|
|
309
331
|
pass # Don't let hook errors mask task errors
|
|
310
332
|
|
|
311
333
|
if error is None:
|
|
312
|
-
|
|
334
|
+
exit_code = EXIT_SUCCESS
|
|
313
335
|
elif isinstance(error, asyncio.TimeoutError):
|
|
314
|
-
|
|
336
|
+
exit_code = EXIT_TIMEOUT
|
|
315
337
|
else:
|
|
338
|
+
exit_code = EXIT_FAILURE
|
|
316
339
|
# Send error message to parent via pipe
|
|
317
340
|
try:
|
|
318
341
|
error_msg = f"{type(error).__name__}: {error}\n{error_tb}"
|
|
319
342
|
os.write(error_write_fd, error_msg.encode("utf-8", errors="replace"))
|
|
320
343
|
except Exception:
|
|
321
344
|
pass # Best effort
|
|
322
|
-
|
|
345
|
+
|
|
346
|
+
# Hang up cleanly on any connection the task opened through pq. Not done
|
|
347
|
+
# in _child_timeout_handler: a signal handler can interrupt pool code
|
|
348
|
+
# that holds the pool's lock.
|
|
349
|
+
_close_pools_before_exit()
|
|
350
|
+
os._exit(exit_code)
|
|
323
351
|
|
|
324
352
|
|
|
325
353
|
def _fork_child(
|
|
@@ -385,6 +413,7 @@ def _wait_for_child(child_pid: int, read_fd: int) -> _ChildResult:
|
|
|
385
413
|
pid, raw_status, rusage = os.wait4(child_pid, os.WNOHANG)
|
|
386
414
|
if pid != 0:
|
|
387
415
|
break
|
|
416
|
+
_shutdown.log_request()
|
|
388
417
|
if not killed_by_drain and _shutdown.deadline_passed():
|
|
389
418
|
_kill_child_group(child_pid)
|
|
390
419
|
killed_by_drain = True
|
|
@@ -715,6 +744,7 @@ def run_worker(
|
|
|
715
744
|
except KeyboardInterrupt:
|
|
716
745
|
# Fallback for non-main-thread workers (no handlers installed).
|
|
717
746
|
pass
|
|
747
|
+
_shutdown.log_request()
|
|
718
748
|
logger.info("Worker stopped.")
|
|
719
749
|
finally:
|
|
720
750
|
_restore_shutdown_handlers(previous_handlers)
|
|
@@ -1488,6 +1518,7 @@ def _run_concurrent(
|
|
|
1488
1518
|
# drain below still runs, just without a deadline.
|
|
1489
1519
|
pass
|
|
1490
1520
|
|
|
1521
|
+
_shutdown.log_request()
|
|
1491
1522
|
if children:
|
|
1492
1523
|
logger.info(f"Shutting down, draining {len(children)} in-flight task(s)...")
|
|
1493
1524
|
# _wait_for_child inside _reap_and_update enforces the drain
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|