python-pq 0.4.1__tar.gz → 0.5.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.4.1 → python_pq-0.5.1}/PKG-INFO +30 -2
- {python_pq-0.4.1 → python_pq-0.5.1}/README.md +29 -1
- {python_pq-0.4.1 → python_pq-0.5.1}/pyproject.toml +1 -1
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/__init__.py +1 -1
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/client.py +20 -3
- python_pq-0.5.1/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +39 -0
- python_pq-0.5.1/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +38 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/models.py +8 -1
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/worker.py +31 -3
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/config.py +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/logging.py +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/README +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/__init__.py +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/env.py +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/script.py.mako +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/versions/__init__.py +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/priority.py +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/registry.py +0 -0
- {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/serialization.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: python-pq
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: Postgres-backed job queue for Python
|
|
5
5
|
Author: ricwo
|
|
6
6
|
Author-email: ricwo <r@cogram.com>
|
|
@@ -35,7 +35,7 @@ Postgres-backed job queue for Python with fork-based worker isolation.
|
|
|
35
35
|
## Installation
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
uv add pq
|
|
38
|
+
uv add python-pq
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Requires PostgreSQL and Python 3.13+.
|
|
@@ -152,6 +152,34 @@ pq.schedule(weekly_report, cron="0 9 * * 1") # Monday 9am
|
|
|
152
152
|
pq.schedule(report, run_every=timedelta(hours=1), report_type="hourly")
|
|
153
153
|
```
|
|
154
154
|
|
|
155
|
+
### Overlap Control
|
|
156
|
+
|
|
157
|
+
By default, periodic tasks don't overlap — if an instance is still running when the next tick arrives, the tick is skipped:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
# Default: max_concurrent=1, no overlap
|
|
161
|
+
pq.schedule(sync_inventory, run_every=timedelta(minutes=5))
|
|
162
|
+
|
|
163
|
+
# Opt in to unlimited concurrency (overlap allowed)
|
|
164
|
+
pq.schedule(fast_idempotent_task, run_every=timedelta(seconds=30), max_concurrent=None)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The lock auto-expires after `max_runtime` seconds (or 1 hour by default) for crash safety.
|
|
168
|
+
|
|
169
|
+
### Multiple Schedules (Key)
|
|
170
|
+
|
|
171
|
+
Use `key` to register the same function multiple times with different configurations:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
pq.schedule(sync_data, run_every=timedelta(hours=1), key="us", region="us")
|
|
175
|
+
pq.schedule(sync_data, run_every=timedelta(hours=2), key="eu", region="eu")
|
|
176
|
+
|
|
177
|
+
# Unschedule only the US schedule
|
|
178
|
+
pq.unschedule(sync_data, key="us")
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Omitting `key` defaults to `""` — backward-compatible with single-schedule usage.
|
|
182
|
+
|
|
155
183
|
### Unscheduling
|
|
156
184
|
|
|
157
185
|
```python
|
|
@@ -14,7 +14,7 @@ Postgres-backed job queue for Python with fork-based worker isolation.
|
|
|
14
14
|
## Installation
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
uv add pq
|
|
17
|
+
uv add python-pq
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
Requires PostgreSQL and Python 3.13+.
|
|
@@ -131,6 +131,34 @@ pq.schedule(weekly_report, cron="0 9 * * 1") # Monday 9am
|
|
|
131
131
|
pq.schedule(report, run_every=timedelta(hours=1), report_type="hourly")
|
|
132
132
|
```
|
|
133
133
|
|
|
134
|
+
### Overlap Control
|
|
135
|
+
|
|
136
|
+
By default, periodic tasks don't overlap — if an instance is still running when the next tick arrives, the tick is skipped:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
# Default: max_concurrent=1, no overlap
|
|
140
|
+
pq.schedule(sync_inventory, run_every=timedelta(minutes=5))
|
|
141
|
+
|
|
142
|
+
# Opt in to unlimited concurrency (overlap allowed)
|
|
143
|
+
pq.schedule(fast_idempotent_task, run_every=timedelta(seconds=30), max_concurrent=None)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The lock auto-expires after `max_runtime` seconds (or 1 hour by default) for crash safety.
|
|
147
|
+
|
|
148
|
+
### Multiple Schedules (Key)
|
|
149
|
+
|
|
150
|
+
Use `key` to register the same function multiple times with different configurations:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
pq.schedule(sync_data, run_every=timedelta(hours=1), key="us", region="us")
|
|
154
|
+
pq.schedule(sync_data, run_every=timedelta(hours=2), key="eu", region="eu")
|
|
155
|
+
|
|
156
|
+
# Unschedule only the US schedule
|
|
157
|
+
pq.unschedule(sync_data, key="us")
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Omitting `key` defaults to `""` — backward-compatible with single-schedule usage.
|
|
161
|
+
|
|
134
162
|
### Unscheduling
|
|
135
163
|
|
|
136
164
|
```python
|
|
@@ -235,6 +235,8 @@ class PQ:
|
|
|
235
235
|
cron: str | croniter | None = None,
|
|
236
236
|
priority: Priority = Priority.NORMAL,
|
|
237
237
|
client_id: str | None = None,
|
|
238
|
+
max_concurrent: int | None = 1,
|
|
239
|
+
key: str = "",
|
|
238
240
|
**kwargs: Any,
|
|
239
241
|
) -> int:
|
|
240
242
|
"""Schedule a periodic task.
|
|
@@ -249,6 +251,11 @@ class PQ:
|
|
|
249
251
|
cron: Cron expression string (e.g., "0 9 * * 1") or croniter object.
|
|
250
252
|
priority: Task priority. Higher = higher priority. Defaults to NORMAL.
|
|
251
253
|
client_id: Optional client-provided identifier. Must be unique if provided.
|
|
254
|
+
max_concurrent: Maximum concurrent executions. Default 1 (no overlap).
|
|
255
|
+
Set to None for unlimited concurrency. Values > 1 are reserved
|
|
256
|
+
for future use and raise ValueError.
|
|
257
|
+
key: Discriminator for multiple schedules of the same function.
|
|
258
|
+
Defaults to "" (empty string).
|
|
252
259
|
**kwargs: Keyword arguments to pass to the handler.
|
|
253
260
|
|
|
254
261
|
Returns:
|
|
@@ -257,6 +264,7 @@ class PQ:
|
|
|
257
264
|
Raises:
|
|
258
265
|
ValueError: If neither run_every nor cron is provided, or if both are.
|
|
259
266
|
ValueError: If cron expression is invalid.
|
|
267
|
+
ValueError: If max_concurrent is greater than 1.
|
|
260
268
|
ValueError: If task is a lambda, closure, or cannot be imported.
|
|
261
269
|
IntegrityError: If client_id already exists.
|
|
262
270
|
"""
|
|
@@ -264,6 +272,11 @@ class PQ:
|
|
|
264
272
|
raise ValueError("Either run_every or cron must be provided")
|
|
265
273
|
if run_every is not None and cron is not None:
|
|
266
274
|
raise ValueError("Only one of run_every or cron can be provided")
|
|
275
|
+
if max_concurrent is not None and max_concurrent > 1:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
f"max_concurrent must be 1 or None, got {max_concurrent} "
|
|
278
|
+
"(values > 1 reserved for future use)"
|
|
279
|
+
)
|
|
267
280
|
|
|
268
281
|
# Validate and normalize cron expression
|
|
269
282
|
cron_expr: str | None = None
|
|
@@ -295,21 +308,24 @@ class PQ:
|
|
|
295
308
|
insert(Periodic)
|
|
296
309
|
.values(
|
|
297
310
|
name=name,
|
|
311
|
+
key=key,
|
|
298
312
|
payload=payload,
|
|
299
313
|
priority=priority,
|
|
300
314
|
run_every=run_every,
|
|
301
315
|
cron=cron_expr,
|
|
302
316
|
next_run=next_run,
|
|
303
317
|
client_id=client_id,
|
|
318
|
+
max_concurrent=max_concurrent,
|
|
304
319
|
)
|
|
305
320
|
.on_conflict_do_update(
|
|
306
|
-
index_elements=["name"],
|
|
321
|
+
index_elements=["name", "key"],
|
|
307
322
|
set_={
|
|
308
323
|
"payload": payload,
|
|
309
324
|
"priority": priority,
|
|
310
325
|
"run_every": run_every,
|
|
311
326
|
"cron": cron_expr,
|
|
312
327
|
"next_run": next_run,
|
|
328
|
+
"max_concurrent": max_concurrent,
|
|
313
329
|
},
|
|
314
330
|
)
|
|
315
331
|
.returning(Periodic.id)
|
|
@@ -331,18 +347,19 @@ class PQ:
|
|
|
331
347
|
result = session.execute(stmt)
|
|
332
348
|
return result.rowcount > 0
|
|
333
349
|
|
|
334
|
-
def unschedule(self, task: Callable[..., Any]) -> bool:
|
|
350
|
+
def unschedule(self, task: Callable[..., Any], *, key: str = "") -> bool:
|
|
335
351
|
"""Remove a periodic task.
|
|
336
352
|
|
|
337
353
|
Args:
|
|
338
354
|
task: The scheduled function to remove.
|
|
355
|
+
key: Discriminator key. Defaults to "" (the default schedule).
|
|
339
356
|
|
|
340
357
|
Returns:
|
|
341
358
|
True if task was found and deleted, False otherwise.
|
|
342
359
|
"""
|
|
343
360
|
name = get_function_path(task)
|
|
344
361
|
with self.session() as session:
|
|
345
|
-
stmt = delete(Periodic).where(Periodic.name == name)
|
|
362
|
+
stmt = delete(Periodic).where(Periodic.name == name, Periodic.key == key)
|
|
346
363
|
result = session.execute(stmt)
|
|
347
364
|
return result.rowcount > 0
|
|
348
365
|
|
python_pq-0.5.1/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""add max_concurrent
|
|
2
|
+
|
|
3
|
+
Revision ID: a1b2c3d4e5f6
|
|
4
|
+
Revises: 2483bec70083
|
|
5
|
+
Create Date: 2026-02-05 12:00:00 Z
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Sequence, Union
|
|
10
|
+
|
|
11
|
+
from alembic import op
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# revision identifiers, used by Alembic.
|
|
16
|
+
revision: str = "a1b2c3d4e5f6"
|
|
17
|
+
down_revision: Union[str, Sequence[str], None] = "2483bec70083"
|
|
18
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
19
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade() -> None:
|
|
23
|
+
"""Add max_concurrent and locked_until columns to pq_periodic."""
|
|
24
|
+
op.add_column(
|
|
25
|
+
"pq_periodic",
|
|
26
|
+
sa.Column("max_concurrent", sa.SmallInteger(), nullable=True),
|
|
27
|
+
)
|
|
28
|
+
op.add_column(
|
|
29
|
+
"pq_periodic",
|
|
30
|
+
sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
|
|
31
|
+
)
|
|
32
|
+
# Backfill existing rows to default max_concurrent=1
|
|
33
|
+
op.execute("UPDATE pq_periodic SET max_concurrent = 1 WHERE max_concurrent IS NULL")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def downgrade() -> None:
|
|
37
|
+
"""Remove max_concurrent and locked_until columns from pq_periodic."""
|
|
38
|
+
op.drop_column("pq_periodic", "locked_until")
|
|
39
|
+
op.drop_column("pq_periodic", "max_concurrent")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""add periodic key
|
|
2
|
+
|
|
3
|
+
Revision ID: b7c8d9e0f1a2
|
|
4
|
+
Revises: a1b2c3d4e5f6
|
|
5
|
+
Create Date: 2026-02-05 18:00:00 Z
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Sequence, Union
|
|
10
|
+
|
|
11
|
+
from alembic import op
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# revision identifiers, used by Alembic.
|
|
16
|
+
revision: str = "b7c8d9e0f1a2"
|
|
17
|
+
down_revision: Union[str, Sequence[str], None] = "a1b2c3d4e5f6"
|
|
18
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
19
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade() -> None:
|
|
23
|
+
"""Add key column to pq_periodic and update unique constraint."""
|
|
24
|
+
op.add_column(
|
|
25
|
+
"pq_periodic",
|
|
26
|
+
sa.Column("key", sa.String(255), nullable=False, server_default=""),
|
|
27
|
+
)
|
|
28
|
+
op.drop_constraint("pq_periodic_name_key", "pq_periodic", type_="unique")
|
|
29
|
+
op.create_unique_constraint(
|
|
30
|
+
"uq_pq_periodic_name_key", "pq_periodic", ["name", "key"]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def downgrade() -> None:
|
|
35
|
+
"""Remove key column and restore original unique constraint."""
|
|
36
|
+
op.drop_constraint("uq_pq_periodic_name_key", "pq_periodic", type_="unique")
|
|
37
|
+
op.drop_column("pq_periodic", "key")
|
|
38
|
+
op.create_unique_constraint("pq_periodic_name_key", "pq_periodic", ["name"])
|
|
@@ -15,6 +15,7 @@ from sqlalchemy import (
|
|
|
15
15
|
SmallInteger,
|
|
16
16
|
String,
|
|
17
17
|
Text,
|
|
18
|
+
UniqueConstraint,
|
|
18
19
|
func,
|
|
19
20
|
)
|
|
20
21
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
@@ -76,21 +77,27 @@ class Periodic(Base):
|
|
|
76
77
|
__tablename__ = "pq_periodic"
|
|
77
78
|
__table_args__ = (
|
|
78
79
|
Index("ix_pq_periodic_priority_next_run", "priority", "next_run"),
|
|
80
|
+
UniqueConstraint("name", "key"),
|
|
79
81
|
)
|
|
80
82
|
|
|
81
83
|
id: Mapped[int] = mapped_column(BigInteger, Identity(), primary_key=True)
|
|
82
84
|
client_id: Mapped[str | None] = mapped_column(
|
|
83
85
|
String(255), nullable=True, unique=True, index=True
|
|
84
86
|
)
|
|
85
|
-
name: Mapped[str] = mapped_column(String(255), nullable=False
|
|
87
|
+
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
88
|
+
key: Mapped[str] = mapped_column(String(255), nullable=False, server_default="")
|
|
86
89
|
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
|
87
90
|
priority: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0)
|
|
88
91
|
run_every: Mapped[timedelta | None] = mapped_column(Interval, nullable=True)
|
|
89
92
|
cron: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
90
93
|
next_run: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
94
|
+
max_concurrent: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
|
91
95
|
last_run: Mapped[datetime | None] = mapped_column(
|
|
92
96
|
DateTime(timezone=True), nullable=True
|
|
93
97
|
)
|
|
98
|
+
locked_until: Mapped[datetime | None] = mapped_column(
|
|
99
|
+
DateTime(timezone=True), nullable=True
|
|
100
|
+
)
|
|
94
101
|
created_at: Mapped[datetime] = mapped_column(
|
|
95
102
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
96
103
|
)
|
|
@@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any, Protocol
|
|
|
18
18
|
|
|
19
19
|
from croniter import croniter
|
|
20
20
|
from loguru import logger
|
|
21
|
-
from sqlalchemy import func, select
|
|
21
|
+
from sqlalchemy import func, or_, select, update
|
|
22
22
|
|
|
23
23
|
from pq.models import Periodic, Task, TaskStatus
|
|
24
24
|
from pq.registry import resolve_function_path
|
|
@@ -558,7 +558,15 @@ def _process_periodic_task(
|
|
|
558
558
|
try:
|
|
559
559
|
with pq.session() as session:
|
|
560
560
|
# Claim highest priority due periodic task with FOR UPDATE SKIP LOCKED
|
|
561
|
-
|
|
561
|
+
# Filter out tasks that are locked (max_concurrent=1 and locked_until in future)
|
|
562
|
+
stmt = select(Periodic).where(
|
|
563
|
+
Periodic.next_run <= func.now(),
|
|
564
|
+
or_(
|
|
565
|
+
Periodic.max_concurrent.is_(None),
|
|
566
|
+
Periodic.locked_until.is_(None),
|
|
567
|
+
Periodic.locked_until <= func.now(),
|
|
568
|
+
),
|
|
569
|
+
)
|
|
562
570
|
if priorities:
|
|
563
571
|
stmt = stmt.where(Periodic.priority.in_([p.value for p in priorities]))
|
|
564
572
|
stmt = (
|
|
@@ -571,9 +579,16 @@ def _process_periodic_task(
|
|
|
571
579
|
if periodic is None:
|
|
572
580
|
return False
|
|
573
581
|
|
|
574
|
-
# Get task data
|
|
582
|
+
# Get task data before expunge
|
|
575
583
|
name = periodic.name
|
|
576
584
|
payload = periodic.payload
|
|
585
|
+
periodic_id = periodic.id
|
|
586
|
+
periodic_max_concurrent = periodic.max_concurrent
|
|
587
|
+
|
|
588
|
+
# Set lock before execution if concurrency is limited
|
|
589
|
+
if periodic.max_concurrent is not None:
|
|
590
|
+
lock_duration = max_runtime if max_runtime > 0 else 3600
|
|
591
|
+
periodic.locked_until = func.now() + timedelta(seconds=lock_duration)
|
|
577
592
|
|
|
578
593
|
# Advance schedule BEFORE execution
|
|
579
594
|
periodic.last_run = func.now()
|
|
@@ -623,4 +638,17 @@ def _process_periodic_task(
|
|
|
623
638
|
elapsed = time.perf_counter() - start
|
|
624
639
|
logger.error(f"Periodic task '{name}' failed after {elapsed:.3f} s: {e}")
|
|
625
640
|
|
|
641
|
+
finally:
|
|
642
|
+
# Clear lock after execution (success or failure)
|
|
643
|
+
if periodic_max_concurrent is not None:
|
|
644
|
+
try:
|
|
645
|
+
with pq.session() as session:
|
|
646
|
+
session.execute(
|
|
647
|
+
update(Periodic)
|
|
648
|
+
.where(Periodic.id == periodic_id)
|
|
649
|
+
.values(locked_until=None)
|
|
650
|
+
)
|
|
651
|
+
except Exception as e:
|
|
652
|
+
logger.error(f"Failed to clear lock for periodic task '{name}': {e}")
|
|
653
|
+
|
|
626
654
|
return True
|
|
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
|