python-pq 0.6.3__tar.gz → 0.7.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 (24) hide show
  1. {python_pq-0.6.3 → python_pq-0.7.1}/PKG-INFO +1 -1
  2. {python_pq-0.6.3 → python_pq-0.7.1}/pyproject.toml +1 -1
  3. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/__init__.py +1 -1
  4. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/client.py +50 -1
  5. python_pq-0.7.1/src/pq/worker.py +1181 -0
  6. python_pq-0.6.3/src/pq/worker.py +0 -655
  7. {python_pq-0.6.3 → python_pq-0.7.1}/LICENSE +0 -0
  8. {python_pq-0.6.3 → python_pq-0.7.1}/README.md +0 -0
  9. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/config.py +0 -0
  10. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/logging.py +0 -0
  11. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/README +0 -0
  12. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/__init__.py +0 -0
  13. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/env.py +0 -0
  14. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/script.py.mako +0 -0
  15. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
  16. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
  17. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +0 -0
  18. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +0 -0
  19. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/versions/20260217T120000Z_c3d4e5f6a7b8_add_periodic_active.py +0 -0
  20. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/migrations/versions/__init__.py +0 -0
  21. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/models.py +0 -0
  22. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/priority.py +0 -0
  23. {python_pq-0.6.3 → python_pq-0.7.1}/src/pq/registry.py +0 -0
  24. {python_pq-0.6.3 → python_pq-0.7.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.6.3
3
+ Version: 0.7.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-pq"
3
- version = "0.6.3"
3
+ version = "0.7.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.6.3"
8
+ __version__ = "0.7.1"
9
9
 
10
10
  __all__ = [
11
11
  "PQ",
@@ -9,7 +9,8 @@ from typing import Any, Self
9
9
 
10
10
  from croniter import croniter
11
11
  from croniter.croniter import CroniterBadCronError
12
- from sqlalchemy import create_engine, delete, func, select
12
+ from loguru import logger
13
+ from sqlalchemy import create_engine, delete, func, select, update
13
14
  from sqlalchemy.dialects.postgresql import insert
14
15
  from sqlalchemy.engine import Engine
15
16
  from sqlalchemy.orm import sessionmaker
@@ -507,9 +508,54 @@ class PQ:
507
508
  result = session.execute(stmt)
508
509
  return result.rowcount
509
510
 
511
+ def reap_stale_tasks(self, threshold: timedelta) -> int:
512
+ """Mark stale RUNNING tasks as FAILED.
513
+
514
+ When a worker dies mid-execution (e.g. pod restart, OOM on the worker
515
+ process), in-flight tasks stay RUNNING forever because no parent
516
+ process remains to update their status. This method detects those
517
+ orphaned rows and transitions them to FAILED.
518
+
519
+ Args:
520
+ threshold: A task is considered stale when
521
+ ``started_at + threshold < now()``. Should be at least
522
+ 2x ``max_runtime`` to avoid reaping legitimately running
523
+ tasks, e.g. ``timedelta(seconds=max_runtime * 2)``.
524
+
525
+ Returns:
526
+ Number of tasks reaped.
527
+ """
528
+ now = datetime.now(UTC)
529
+ cutoff = now - threshold
530
+ with self.session() as session:
531
+ stmt = (
532
+ update(Task)
533
+ .where(
534
+ Task.status == TaskStatus.RUNNING,
535
+ Task.started_at < cutoff,
536
+ )
537
+ .values(
538
+ status=TaskStatus.FAILED,
539
+ completed_at=now,
540
+ error=(
541
+ f"Reaped: task still RUNNING after {threshold} "
542
+ f"(cutoff={cutoff.isoformat()}). Worker likely died."
543
+ ),
544
+ )
545
+ .returning(Task.id, Task.name, Task.started_at)
546
+ )
547
+ reaped = list(session.execute(stmt).all())
548
+ for task_id, name, started_at in reaped:
549
+ logger.warning(
550
+ f"Reaped stale task '{name}' (id={task_id},"
551
+ f" started_at={started_at})"
552
+ )
553
+ return len(reaped)
554
+
510
555
  def run_worker(
511
556
  self,
512
557
  *,
558
+ concurrency: int = 1,
513
559
  poll_interval: float = 1.0,
514
560
  max_runtime: float = 30 * 60,
515
561
  priorities: Set[Priority] | None = None,
@@ -519,6 +565,8 @@ class PQ:
519
565
  Each task executes in a forked child process for memory isolation.
520
566
 
521
567
  Args:
568
+ concurrency: Maximum number of tasks to process simultaneously.
569
+ Default: 1 (sequential processing).
522
570
  poll_interval: Seconds to sleep between polls when idle.
523
571
  max_runtime: Maximum execution time per task in seconds. Default: 30 min.
524
572
  priorities: If set, only process tasks with these priority levels.
@@ -528,6 +576,7 @@ class PQ:
528
576
 
529
577
  run_worker(
530
578
  self,
579
+ concurrency=concurrency,
531
580
  poll_interval=poll_interval,
532
581
  max_runtime=max_runtime,
533
582
  priorities=priorities,