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.
Files changed (21) hide show
  1. {python_pq-0.4.1 → python_pq-0.5.1}/PKG-INFO +30 -2
  2. {python_pq-0.4.1 → python_pq-0.5.1}/README.md +29 -1
  3. {python_pq-0.4.1 → python_pq-0.5.1}/pyproject.toml +1 -1
  4. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/__init__.py +1 -1
  5. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/client.py +20 -3
  6. python_pq-0.5.1/src/pq/migrations/versions/20260205T120000Z_a1b2c3d4e5f6_add_max_concurrent.py +39 -0
  7. python_pq-0.5.1/src/pq/migrations/versions/20260205T180000Z_b7c8d9e0f1a2_add_periodic_key.py +38 -0
  8. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/models.py +8 -1
  9. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/worker.py +31 -3
  10. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/config.py +0 -0
  11. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/logging.py +0 -0
  12. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/README +0 -0
  13. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/__init__.py +0 -0
  14. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/env.py +0 -0
  15. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/script.py.mako +0 -0
  16. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/versions/20260109T055839Z_476683af098d_initial_schema.py +0 -0
  17. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/versions/20260109T063747Z_2483bec70083_add_client_id.py +0 -0
  18. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/migrations/versions/__init__.py +0 -0
  19. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/priority.py +0 -0
  20. {python_pq-0.4.1 → python_pq-0.5.1}/src/pq/registry.py +0 -0
  21. {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.4.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-pq"
3
- version = "0.4.1"
3
+ version = "0.5.1"
4
4
  description = "Postgres-backed job queue for Python"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -7,7 +7,7 @@ from pq.models import Periodic, Task, TaskStatus
7
7
  from pq.priority import Priority
8
8
  from pq.worker import PostExecuteHook, PreExecuteHook, TaskTimeoutError
9
9
 
10
- __version__ = "0.1.0"
10
+ __version__ = "0.5.1"
11
11
 
12
12
  __all__ = [
13
13
  "PQ",
@@ -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
 
@@ -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, unique=True)
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
- stmt = select(Periodic).where(Periodic.next_run <= func.now())
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