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.
Files changed (25) hide show
  1. {python_pq-0.9.0 → python_pq-0.9.1}/PKG-INFO +8 -2
  2. {python_pq-0.9.0 → python_pq-0.9.1}/README.md +7 -1
  3. {python_pq-0.9.0 → python_pq-0.9.1}/pyproject.toml +1 -1
  4. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/__init__.py +1 -1
  5. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/client.py +58 -0
  6. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/worker.py +44 -13
  7. {python_pq-0.9.0 → python_pq-0.9.1}/LICENSE +0 -0
  8. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/config.py +0 -0
  9. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/logging.py +0 -0
  10. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/README +0 -0
  11. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/__init__.py +0 -0
  12. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/env.py +0 -0
  13. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/script.py.mako +0 -0
  14. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
  15. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
  16. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
  17. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
  18. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
  19. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20260527T170136Z_aee3e8e7e647_add_task_max_runtime_seconds.py +0 -0
  20. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/20261007T120000Z_d4e5f6a7b8c9_add_task_requeue.py +0 -0
  21. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/migrations/versions/__init__.py +0 -0
  22. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/models.py +0 -0
  23. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/priority.py +0 -0
  24. {python_pq-0.9.0 → python_pq-0.9.1}/src/pq/registry.py +0 -0
  25. {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.0
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-pq"
3
- version = "0.9.0"
3
+ version = "0.9.1"
4
4
  description = "Postgres-backed job queue for Python"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -5,7 +5,7 @@ from pq.models import Periodic, Task, TaskStatus
5
5
  from pq.priority import Priority
6
6
  from pq.worker import PostExecuteHook, PreExecuteHook, TaskTimeoutError
7
7
 
8
- __version__ = "0.9.0"
8
+ __version__ = "0.9.1"
9
9
 
10
10
  __all__ = [
11
11
  "PQ",
@@ -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
- requested: bool = False
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.requested = False
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). Repeat signals
196
- during the drain are ignored so they can't abandon remaining children.
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.requested = True
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
- os._exit(EXIT_SUCCESS)
334
+ exit_code = EXIT_SUCCESS
313
335
  elif isinstance(error, asyncio.TimeoutError):
314
- os._exit(EXIT_TIMEOUT)
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
- os._exit(EXIT_FAILURE)
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