dj-queue 0.10.3__tar.gz → 0.10.4__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 (85) hide show
  1. {dj_queue-0.10.3 → dj_queue-0.10.4}/PKG-INFO +6 -7
  2. {dj_queue-0.10.3 → dj_queue-0.10.4}/README.md +5 -6
  3. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/backend.py +8 -2
  4. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/operations/_helpers.py +8 -3
  5. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/operations/concurrency.py +111 -26
  6. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/operations/jobs.py +210 -43
  7. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/base.py +9 -2
  8. {dj_queue-0.10.3 → dj_queue-0.10.4}/pyproject.toml +1 -1
  9. {dj_queue-0.10.3 → dj_queue-0.10.4}/LICENSE +0 -0
  10. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/__init__.py +0 -0
  11. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/admin.py +0 -0
  12. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/api.py +0 -0
  13. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/apps.py +0 -0
  14. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/config.py +0 -0
  15. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/contrib/__init__.py +0 -0
  16. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/contrib/asgi.py +0 -0
  17. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/contrib/gunicorn.py +0 -0
  18. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/contrib/prometheus.py +0 -0
  19. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/cron.py +0 -0
  20. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/dashboard.py +0 -0
  21. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/db.py +0 -0
  22. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/exceptions.py +0 -0
  23. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/hooks.py +0 -0
  24. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/log.py +0 -0
  25. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/management/__init__.py +0 -0
  26. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/management/commands/__init__.py +0 -0
  27. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/management/commands/dj_queue.py +0 -0
  28. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/management/commands/dj_queue_health.py +0 -0
  29. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/management/commands/dj_queue_prune.py +0 -0
  30. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/metrics.py +0 -0
  31. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0001_initial.py +0 -0
  32. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
  33. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
  34. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0004_dashboard.py +0 -0
  35. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0005_remove_recurringexecution_dj_queue_recurring_executions_task_key_run_at_unique_and_more.py +0 -0
  36. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0006_blockedexecution_dj_queue_bl_concurr_2d8393_idx_and_more.py +0 -0
  37. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
  38. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0008_remove_blockedexecution_dj_queue_bl_concurr_1ce730_idx_and_more.py +0 -0
  39. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0009_remove_process_dj_queue_processes_name_supervisor_unique_and_more.py +0 -0
  40. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/0010_remove_process_djq_pr_name_parent_uniq_and_more.py +0 -0
  41. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/migrations/__init__.py +0 -0
  42. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/models/__init__.py +0 -0
  43. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/models/jobs.py +0 -0
  44. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/models/recurring.py +0 -0
  45. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/models/runtime.py +0 -0
  46. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/observability.py +0 -0
  47. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/operations/__init__.py +0 -0
  48. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/operations/_insert.py +0 -0
  49. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/operations/cleanup.py +0 -0
  50. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/operations/queues.py +0 -0
  51. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/operations/recurring.py +0 -0
  52. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/queue_selectors.py +0 -0
  53. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/queue_state.py +0 -0
  54. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/routers.py +0 -0
  55. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/__init__.py +0 -0
  56. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/connection_budget.py +0 -0
  57. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/dispatcher.py +0 -0
  58. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/errors.py +0 -0
  59. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/interruptible.py +0 -0
  60. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/notify.py +0 -0
  61. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/pidfile.py +0 -0
  62. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/pool.py +0 -0
  63. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/procline.py +0 -0
  64. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/scheduler.py +0 -0
  65. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/supervisor.py +0 -0
  66. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/topology.py +0 -0
  67. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/runtime/worker.py +0 -0
  68. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/task_results.py +0 -0
  69. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
  70. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
  71. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
  72. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
  73. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
  74. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/_queue_controls.html +0 -0
  75. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +0 -0
  76. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/change_form.html +0 -0
  77. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
  78. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/dashboard.html +0 -0
  79. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
  80. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templates/admin/dj_queue/queue_jobs.html +0 -0
  81. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templatetags/__init__.py +0 -0
  82. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/templatetags/dj_queue_admin.py +0 -0
  83. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/urls.py +0 -0
  84. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/views.py +0 -0
  85. {dj_queue-0.10.3 → dj_queue-0.10.4}/dj_queue/wakeup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dj-queue
3
- Version: 0.10.3
3
+ Version: 0.10.4
4
4
  Summary: Database-backed task queue backend for Django’s Tasks framework.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -61,6 +61,9 @@ It has a narrow, explicit shape:
61
61
  For detailed comparisons with Celery, RQ, Procrastinate, and other alternatives,
62
62
  see [COMPARISONS.md](docs/COMPARISONS.md).
63
63
 
64
+ For benchmarks on enqueue, promotion, recurring, claiming, worker-drain, and concurrency-contention scenarios,
65
+ see [docs/benchmarks/](docs/benchmarks/) for the latest published reports.
66
+
64
67
  ## Installation
65
68
 
66
69
  `dj_queue` requires Python 3.12+ and Django 6.0+.
@@ -640,8 +643,9 @@ Run your normal application migrations on `default`, then migrate `dj_queue`
640
643
  onto the queue database:
641
644
 
642
645
  ```bash
643
- python manage.py migrate
646
+ # migrate dj_queue on its queue alias first so django doesn't mark it applied on default
644
647
  python manage.py migrate dj_queue --database queue
648
+ python manage.py migrate
645
649
  ```
646
650
 
647
651
  With this setup, `dj_queue`'s ORM queries and raw SQL helpers stay on the queue
@@ -1012,11 +1016,6 @@ Both endpoints support bearer token authentication. Set
1012
1016
  `Authorization: Bearer <token>`. Leave it unset if you protect these URLs at
1013
1017
  the network or proxy layer.
1014
1018
 
1015
- ## Benchmarks
1016
-
1017
- The repository includes a standalone benchmark harness for enqueue, promotion, recurring, worker-drain, and concurrency-contention scenarios.
1018
- See [`docs/benchmarks/`](docs/benchmarks/) for the latest published reports.
1019
-
1020
1019
  ## License
1021
1020
 
1022
1021
  MIT
@@ -35,6 +35,9 @@ It has a narrow, explicit shape:
35
35
  For detailed comparisons with Celery, RQ, Procrastinate, and other alternatives,
36
36
  see [COMPARISONS.md](docs/COMPARISONS.md).
37
37
 
38
+ For benchmarks on enqueue, promotion, recurring, claiming, worker-drain, and concurrency-contention scenarios,
39
+ see [docs/benchmarks/](docs/benchmarks/) for the latest published reports.
40
+
38
41
  ## Installation
39
42
 
40
43
  `dj_queue` requires Python 3.12+ and Django 6.0+.
@@ -614,8 +617,9 @@ Run your normal application migrations on `default`, then migrate `dj_queue`
614
617
  onto the queue database:
615
618
 
616
619
  ```bash
617
- python manage.py migrate
620
+ # migrate dj_queue on its queue alias first so django doesn't mark it applied on default
618
621
  python manage.py migrate dj_queue --database queue
622
+ python manage.py migrate
619
623
  ```
620
624
 
621
625
  With this setup, `dj_queue`'s ORM queries and raw SQL helpers stay on the queue
@@ -986,11 +990,6 @@ Both endpoints support bearer token authentication. Set
986
990
  `Authorization: Bearer <token>`. Leave it unset if you protect these URLs at
987
991
  the network or proxy layer.
988
992
 
989
- ## Benchmarks
990
-
991
- The repository includes a standalone benchmark harness for enqueue, promotion, recurring, worker-drain, and concurrency-contention scenarios.
992
- See [`docs/benchmarks/`](docs/benchmarks/) for the latest published reports.
993
-
994
993
  ## License
995
994
 
996
995
  MIT
@@ -28,7 +28,13 @@ class DjQueueBackend(BaseTaskBackend):
28
28
 
29
29
  def enqueue(self, task, args, kwargs):
30
30
  self.validate_task(task)
31
- job, dispatch_outcome = enqueue_job_with_dispatch(task, args, kwargs, backend_alias=self.alias)
31
+ job, dispatch_outcome = enqueue_job_with_dispatch(
32
+ task,
33
+ args,
34
+ kwargs,
35
+ backend_alias=self.alias,
36
+ validate=False,
37
+ )
32
38
  return task_result_from_enqueued_job(
33
39
  job,
34
40
  task,
@@ -49,7 +55,7 @@ class DjQueueBackend(BaseTaskBackend):
49
55
  self.validate_task(task)
50
56
  jobs.append((task, args, kwargs))
51
57
 
52
- created_jobs = enqueue_jobs_bulk(jobs, backend_alias=self.alias)
58
+ created_jobs = enqueue_jobs_bulk(jobs, backend_alias=self.alias, validate=False)
53
59
  return [
54
60
  task_result_from_enqueued_job(
55
61
  job,
@@ -170,8 +170,11 @@ def _scheduled_execution_row(job, *, backend_alias, scheduled_at=None, created_a
170
170
  )
171
171
 
172
172
 
173
- def _create_scheduled_execution(alias, job, *, backend_alias, scheduled_at=None):
174
- _ensure_no_other_execution_state(alias, job)
173
+ def _create_scheduled_execution(
174
+ alias, job, *, backend_alias, scheduled_at=None, check_conflicts=True
175
+ ):
176
+ if check_conflicts:
177
+ _ensure_no_other_execution_state(alias, job)
175
178
  return ScheduledExecution.objects.using(alias).create(
176
179
  **_scheduled_execution_fields(
177
180
  job,
@@ -190,8 +193,10 @@ def _create_blocked_execution(
190
193
  expires_at,
191
194
  queue_name=None,
192
195
  priority=None,
196
+ check_conflicts=True,
193
197
  ):
194
- _ensure_no_other_execution_state(alias, job)
198
+ if check_conflicts:
199
+ _ensure_no_other_execution_state(alias, job)
195
200
  return BlockedExecution.objects.using(alias).create(
196
201
  **_blocked_execution_fields(
197
202
  job,
@@ -22,7 +22,7 @@ from dj_queue.models import (
22
22
  from dj_queue.operations._helpers import (
23
23
  _consume_selected_rows,
24
24
  _create_blocked_execution,
25
- _create_ready_execution,
25
+ _create_ready_execution_locked,
26
26
  _lock_active_pauses,
27
27
  _task_option,
28
28
  )
@@ -247,20 +247,15 @@ def unblock_next_blocked_job(
247
247
  now = timezone.now()
248
248
 
249
249
  with _operation_atomic(alias):
250
- queryset = (
251
- BlockedExecution.objects.using(alias)
252
- .select_related("job")
253
- .filter(backend_alias=backend_alias, concurrency_key=key)
254
- .order_by("-priority", "id")
250
+ blocked = _consume_next_blocked_job(
251
+ alias,
252
+ backend_alias=backend_alias,
253
+ key=key,
254
+ use_skip_locked=use_skip_locked,
255
255
  )
256
- blocked = locked_queryset(queryset, use_skip_locked=use_skip_locked).first()
257
256
  if blocked is None:
258
257
  return None
259
258
 
260
- consumed = _consume_selected_rows(alias, BlockedExecution, [blocked])
261
- if not consumed:
262
- return None
263
-
264
259
  slot_acquired = False
265
260
  if release_slot:
266
261
  slot_acquired = _handoff_released_claimed_slot(
@@ -287,37 +282,125 @@ def unblock_next_blocked_job(
287
282
  backend_alias=backend_alias,
288
283
  )
289
284
 
285
+ mock_job = Job(
286
+ id=blocked["job_id"],
287
+ queue_name=blocked["queue_name"],
288
+ priority=blocked["priority"],
289
+ concurrency_key=blocked["concurrency_key"],
290
+ backend_alias=backend_alias,
291
+ )
292
+
290
293
  if not slot_acquired:
291
294
  _create_blocked_execution(
292
295
  alias,
293
- blocked.job,
296
+ mock_job,
294
297
  backend_alias=backend_alias,
295
- queue_name=blocked.queue_name,
296
- priority=blocked.priority,
297
- concurrency_key=blocked.concurrency_key,
298
- expires_at=blocked.expires_at,
298
+ queue_name=blocked["queue_name"],
299
+ priority=blocked["priority"],
300
+ concurrency_key=blocked["concurrency_key"],
301
+ expires_at=blocked["expires_at"],
302
+ check_conflicts=False,
299
303
  )
300
304
  return None
301
305
 
302
- job = blocked.job
303
- queue_name = blocked.queue_name
304
- priority = blocked.priority
305
306
  _create_ready_execution_after_blocked_consume(
306
307
  alias,
307
- job=job,
308
+ job=mock_job,
308
309
  backend_alias=backend_alias,
309
- queue_name=queue_name,
310
- priority=priority,
310
+ queue_name=blocked["queue_name"],
311
+ priority=blocked["priority"],
311
312
  ready_at=now,
312
313
  )
313
314
 
314
315
  log_event(
315
316
  "job.unblocked",
316
- job_id=str(job.id),
317
+ job_id=str(mock_job.id),
317
318
  concurrency_key=key,
318
319
  )
319
- notify_ready_queues_on_commit((job.queue_name,), backend_alias=backend_alias)
320
- return job
320
+ notify_ready_queues_on_commit((blocked["queue_name"],), backend_alias=backend_alias)
321
+ return mock_job
322
+
323
+
324
+ def _consume_next_blocked_job(alias, *, backend_alias, key, use_skip_locked):
325
+ capabilities = database_capabilities(alias)
326
+ if capabilities.backend_family == "postgresql":
327
+ return _postgres_consume_next_blocked_job(
328
+ alias,
329
+ backend_alias=backend_alias,
330
+ key=key,
331
+ use_skip_locked=use_skip_locked and capabilities.supports_skip_locked,
332
+ )
333
+
334
+ queryset = (
335
+ BlockedExecution.objects.using(alias)
336
+ .filter(backend_alias=backend_alias, concurrency_key=key)
337
+ .order_by("-priority", "id")
338
+ )
339
+ blocked = locked_queryset(queryset, use_skip_locked=use_skip_locked).first()
340
+ if blocked is None:
341
+ return None
342
+
343
+ consumed = _consume_selected_rows(alias, BlockedExecution, [blocked])
344
+ if not consumed:
345
+ return None
346
+ return _blocked_execution_values(blocked)
347
+
348
+
349
+ def _postgres_consume_next_blocked_job(alias, *, backend_alias, key, use_skip_locked):
350
+ connection = connections[alias]
351
+ quote = connection.ops.quote_name
352
+ table = quote(BlockedExecution._meta.db_table)
353
+ pk_column = quote(BlockedExecution._meta.pk.column)
354
+ job_id_column = quote(BlockedExecution._meta.get_field("job").column)
355
+ backend_alias_column = quote(BlockedExecution._meta.get_field("backend_alias").column)
356
+ queue_name_column = quote(BlockedExecution._meta.get_field("queue_name").column)
357
+ priority_column = quote(BlockedExecution._meta.get_field("priority").column)
358
+ concurrency_key_column = quote(BlockedExecution._meta.get_field("concurrency_key").column)
359
+ expires_at_column = quote(BlockedExecution._meta.get_field("expires_at").column)
360
+ skip_locked_sql = " SKIP LOCKED" if use_skip_locked else ""
361
+
362
+ with connection.cursor() as cursor:
363
+ cursor.execute(
364
+ f"""
365
+ DELETE FROM {table}
366
+ WHERE {table}.{pk_column} = (
367
+ SELECT {pk_column}
368
+ FROM {table}
369
+ WHERE {backend_alias_column} = %s AND {concurrency_key_column} = %s
370
+ ORDER BY {priority_column} DESC, {pk_column} ASC
371
+ LIMIT 1
372
+ FOR UPDATE{skip_locked_sql}
373
+ )
374
+ RETURNING
375
+ {job_id_column},
376
+ {queue_name_column},
377
+ {priority_column},
378
+ {concurrency_key_column},
379
+ {expires_at_column}
380
+ """,
381
+ [backend_alias, key],
382
+ )
383
+ row = cursor.fetchone()
384
+
385
+ if row is None:
386
+ return None
387
+ return {
388
+ "job_id": row[0],
389
+ "queue_name": row[1],
390
+ "priority": row[2],
391
+ "concurrency_key": row[3],
392
+ "expires_at": row[4],
393
+ }
394
+
395
+
396
+ def _blocked_execution_values(blocked):
397
+ return {
398
+ "job_id": blocked.job_id,
399
+ "queue_name": blocked.queue_name,
400
+ "priority": blocked.priority,
401
+ "concurrency_key": blocked.concurrency_key,
402
+ "expires_at": blocked.expires_at,
403
+ }
321
404
 
322
405
 
323
406
  def _create_ready_execution_after_blocked_consume(
@@ -455,13 +538,14 @@ def promote_expired_blocked_jobs(*, batch_size=500, backend_alias="default", use
455
538
  priority = blocked.priority
456
539
  if not uses_serialized_writes:
457
540
  blocked.delete(using=alias)
458
- _create_ready_execution(
541
+ _create_ready_execution_locked(
459
542
  alias,
460
543
  job=job,
461
544
  backend_alias=backend_alias,
462
545
  queue_name=queue_name,
463
546
  priority=priority,
464
547
  ready_at=now,
548
+ check_conflicts=False,
465
549
  )
466
550
  promoted_jobs.append(job)
467
551
  else:
@@ -475,6 +559,7 @@ def promote_expired_blocked_jobs(*, batch_size=500, backend_alias="default", use
475
559
  priority=blocked.priority,
476
560
  concurrency_key=blocked.concurrency_key,
477
561
  expires_at=expires_at,
562
+ check_conflicts=False,
478
563
  )
479
564
  else:
480
565
  blocked.expires_at = expires_at
@@ -6,7 +6,6 @@ from dataclasses import dataclass
6
6
  from datetime import timedelta
7
7
  from enum import StrEnum
8
8
 
9
- from django.core.exceptions import ObjectDoesNotExist
10
9
  from django.db import connections, transaction
11
10
  from django.db.models import Case, IntegerField, Value, When
12
11
  from django.db.utils import OperationalError
@@ -15,7 +14,7 @@ from django.utils import timezone
15
14
  from django.utils.module_loading import import_string
16
15
 
17
16
  from dj_queue.config import load_allowed_queues, load_backend_config
18
- from dj_queue.db import get_database_alias, locked_queryset
17
+ from dj_queue.db import database_capabilities, get_database_alias, locked_queryset
19
18
  from dj_queue.exceptions import EnqueueError
20
19
  from dj_queue.log import event_logging_enabled, log_event
21
20
  from dj_queue.models import (
@@ -92,9 +91,10 @@ def enqueue_job(task, args, kwargs, *, backend_alias="default"):
92
91
  return job
93
92
 
94
93
 
95
- def enqueue_job_with_dispatch(task, args, kwargs, *, backend_alias="default"):
96
- validate_queue_allowed(task.queue_name, backend_alias=backend_alias)
97
- validate_priority(task.priority)
94
+ def enqueue_job_with_dispatch(task, args, kwargs, *, backend_alias="default", validate=True):
95
+ if validate:
96
+ validate_queue_allowed(task.queue_name, backend_alias=backend_alias)
97
+ validate_priority(task.priority)
98
98
  alias = get_database_alias(backend_alias)
99
99
  payload = _normalize_payload(args, kwargs)
100
100
  concurrency_key = _resolve_concurrency_key(task, args, kwargs)
@@ -130,14 +130,15 @@ def enqueue_job_with_dispatch(task, args, kwargs, *, backend_alias="default"):
130
130
  return job, dispatch_outcome
131
131
 
132
132
 
133
- def enqueue_jobs_bulk(task_calls, *, backend_alias="default"):
133
+ def enqueue_jobs_bulk(task_calls, *, backend_alias="default", validate=True):
134
134
  alias = get_database_alias(backend_alias)
135
135
  now = timezone.now()
136
136
  prepared = []
137
137
 
138
138
  for index, (task, args, kwargs) in enumerate(task_calls):
139
- validate_queue_allowed(task.queue_name, backend_alias=backend_alias)
140
- validate_priority(task.priority)
139
+ if validate:
140
+ validate_queue_allowed(task.queue_name, backend_alias=backend_alias)
141
+ validate_priority(task.priority)
141
142
  payload = _normalize_payload(args, kwargs)
142
143
  concurrency_key = _resolve_concurrency_key(task, args, kwargs)
143
144
  created_at = now + timedelta(microseconds=index)
@@ -318,16 +319,9 @@ def _claim_ready_jobs_once(
318
319
  ):
319
320
 
320
321
  with transaction.atomic(using=alias):
322
+ claimed_insert_checks_conflicts = _claimed_insert_checks_conflicts(alias)
321
323
  queryset = (
322
- ReadyExecution.objects.using(alias)
323
- .select_related(
324
- "job",
325
- "job__scheduled_execution",
326
- "job__claimed_execution",
327
- "job__blocked_execution",
328
- "job__failed_execution",
329
- )
330
- .filter(backend_alias=backend_alias)
324
+ ReadyExecution.objects.using(alias).select_related("job").filter(backend_alias=backend_alias)
331
325
  )
332
326
  queryset = _exclude_active_pauses(queryset, alias, backend_alias)
333
327
  ready_rows = _select_ready_rows(
@@ -339,9 +333,15 @@ def _claim_ready_jobs_once(
339
333
  if not ready_rows:
340
334
  return []
341
335
 
342
- for row in ready_rows:
343
- if _ready_row_has_conflicting_state(row):
344
- raise EnqueueError(f"job {row.job_id} already has an execution-state row")
336
+ if not claimed_insert_checks_conflicts:
337
+ conflicting_job_ids = _job_ids_with_other_execution_state(
338
+ alias,
339
+ [row.job_id for row in ready_rows],
340
+ ignored_models=(ReadyExecution,),
341
+ )
342
+ if conflicting_job_ids:
343
+ conflicting_job_id = next(iter(conflicting_job_ids))
344
+ raise EnqueueError(f"job {conflicting_job_id} already has an execution-state row")
345
345
 
346
346
  paused_queue_names = _lock_active_pauses(
347
347
  alias,
@@ -361,30 +361,17 @@ def _claim_ready_jobs_once(
361
361
 
362
362
  claimed_at = timezone.now()
363
363
  worker_ids = (process.name,) if process is not None else ()
364
- _bulk_create(
364
+ _create_claimed_executions(
365
365
  alias,
366
- ClaimedExecution,
367
- [ClaimedExecution(job=job, process=process, created_at=claimed_at) for job in jobs],
366
+ jobs,
367
+ process=process,
368
+ claimed_at=claimed_at,
369
+ check_conflicts=claimed_insert_checks_conflicts,
368
370
  )
369
371
 
370
372
  return [ClaimedJob(job=job, claimed_at=claimed_at, worker_ids=worker_ids) for job in jobs]
371
373
 
372
374
 
373
- def _ready_row_has_conflicting_state(row):
374
- for relation_name in (
375
- "scheduled_execution",
376
- "claimed_execution",
377
- "blocked_execution",
378
- "failed_execution",
379
- ):
380
- try:
381
- getattr(row.job, relation_name)
382
- except ObjectDoesNotExist:
383
- continue
384
- return True
385
- return False
386
-
387
-
388
375
  def execute_claimed_job(job, *, backend_alias="default"):
389
376
  claimed_job = None
390
377
  if isinstance(job, ClaimedJob):
@@ -432,13 +419,22 @@ def _complete_claimed_job(job, return_value, *, backend_alias="default", task=No
432
419
  job = _resolve_claimed_job(job, alias=alias, backend_alias=backend_alias)
433
420
 
434
421
  with transaction.atomic(using=alias):
435
- _delete_claimed_execution(alias, job.id)
436
422
  now = timezone.now()
437
423
  config = load_backend_config(job.backend_alias)
438
424
 
439
425
  if config.preserve_finished_jobs:
440
- _finish_job_if_no_execution_state(alias, job, return_value, finished_at=now)
426
+ if database_capabilities(alias).backend_family == "postgresql":
427
+ _delete_claimed_and_finish_job_if_no_execution_state(
428
+ alias,
429
+ job,
430
+ return_value,
431
+ finished_at=now,
432
+ )
433
+ else:
434
+ _delete_claimed_execution(alias, job.id)
435
+ _finish_job_if_no_execution_state(alias, job, return_value, finished_at=now)
441
436
  else:
437
+ _delete_claimed_execution(alias, job.id)
442
438
  _ensure_no_other_execution_state(alias, job, ignored_models=(ClaimedExecution,))
443
439
  job.delete(using=alias)
444
440
 
@@ -674,7 +670,7 @@ def promote_scheduled_jobs(*, batch_size, backend_alias="default", use_skip_lock
674
670
  for job in jobs:
675
671
  if job.pk in direct_job_ids:
676
672
  continue
677
- dispatch_outcome = _dispatch_existing_job(job)
673
+ dispatch_outcome = _dispatch_existing_job(job, check_conflicts=False)
678
674
  if dispatch_outcome.should_notify:
679
675
  ready_queue_names.append(job.queue_name)
680
676
 
@@ -907,9 +903,11 @@ def discard_blocked_jobs(*, job_ids=None, batch_size=500, backend_alias="default
907
903
  )
908
904
 
909
905
 
910
- def _dispatch_existing_job(job):
906
+ def _dispatch_existing_job(job, *, check_conflicts=True):
911
907
  task = import_string(job.task_path)
912
- return _dispatch_job(job, task=task, backend_alias=job.backend_alias)
908
+ return _dispatch_job(
909
+ job, task=task, backend_alias=job.backend_alias, check_conflicts=check_conflicts
910
+ )
913
911
 
914
912
 
915
913
  def _dispatch_job(job, *, task, backend_alias, now=None, check_conflicts=True):
@@ -923,6 +921,7 @@ def _dispatch_job(job, *, task, backend_alias, now=None, check_conflicts=True):
923
921
  job=job,
924
922
  backend_alias=backend_alias,
925
923
  scheduled_at=job.scheduled_at,
924
+ check_conflicts=check_conflicts,
926
925
  )
927
926
  return DispatchOutcome.SCHEDULED
928
927
 
@@ -966,6 +965,7 @@ def _dispatch_job(job, *, task, backend_alias, now=None, check_conflicts=True):
966
965
  backend_alias=backend_alias,
967
966
  concurrency_key=job.concurrency_key,
968
967
  expires_at=now + timedelta(seconds=duration_seconds),
968
+ check_conflicts=check_conflicts,
969
969
  )
970
970
  return DispatchOutcome.BLOCKED
971
971
 
@@ -1107,6 +1107,105 @@ def _select_ready_rows(queryset, *, limit, queues, use_skip_locked):
1107
1107
  return selected_rows
1108
1108
 
1109
1109
 
1110
+ def _claimed_insert_checks_conflicts(alias):
1111
+ return database_capabilities(alias).backend_family == "postgresql"
1112
+
1113
+
1114
+ def _create_claimed_executions(alias, jobs, *, process, claimed_at, check_conflicts):
1115
+ if check_conflicts:
1116
+ return _postgres_create_claimed_executions_if_no_other_state(
1117
+ alias,
1118
+ jobs,
1119
+ process=process,
1120
+ claimed_at=claimed_at,
1121
+ )
1122
+
1123
+ return _bulk_create(
1124
+ alias,
1125
+ ClaimedExecution,
1126
+ [ClaimedExecution(job=job, process=process, created_at=claimed_at) for job in jobs],
1127
+ )
1128
+
1129
+
1130
+ def _postgres_create_claimed_executions_if_no_other_state(
1131
+ alias,
1132
+ jobs,
1133
+ *,
1134
+ process,
1135
+ claimed_at,
1136
+ ):
1137
+ connection = connections[alias]
1138
+ quote = connection.ops.quote_name
1139
+ claimed_table = quote(ClaimedExecution._meta.db_table)
1140
+ job_id_column = quote(ClaimedExecution._meta.get_field("job").column)
1141
+ process_id_column = quote(ClaimedExecution._meta.get_field("process").column)
1142
+ created_at_column = quote(ClaimedExecution._meta.get_field("created_at").column)
1143
+ values_sql = ", ".join(["(%s::uuid, %s::bigint, %s::timestamptz)"] * len(jobs))
1144
+ state_checks = " AND ".join(
1145
+ _state_absence_for_job_id_sql(
1146
+ model,
1147
+ job_id_reference="claimed_input.job_id",
1148
+ quote=quote,
1149
+ )
1150
+ for model in (
1151
+ ReadyExecution,
1152
+ ScheduledExecution,
1153
+ ClaimedExecution,
1154
+ BlockedExecution,
1155
+ FailedExecution,
1156
+ )
1157
+ )
1158
+ params = []
1159
+ process_id = process.pk if process is not None else None
1160
+ job_id_field = Job._meta.get_field("id")
1161
+ for job in jobs:
1162
+ params.extend(
1163
+ [
1164
+ job_id_field.get_db_prep_value(job.pk, connection=connection, prepared=False),
1165
+ process_id,
1166
+ claimed_at,
1167
+ ]
1168
+ )
1169
+
1170
+ with connection.cursor() as cursor:
1171
+ cursor.execute(
1172
+ f"""
1173
+ INSERT INTO {claimed_table} (
1174
+ {job_id_column},
1175
+ {process_id_column},
1176
+ {created_at_column}
1177
+ )
1178
+ SELECT
1179
+ claimed_input.job_id,
1180
+ claimed_input.process_id,
1181
+ claimed_input.created_at
1182
+ FROM (VALUES {values_sql}) AS claimed_input(job_id, process_id, created_at)
1183
+ WHERE {state_checks}
1184
+ """,
1185
+ params,
1186
+ )
1187
+ created = cursor.rowcount
1188
+
1189
+ if created != len(jobs):
1190
+ conflicting_job_ids = _job_ids_with_other_execution_state(alias, [job.pk for job in jobs])
1191
+ if conflicting_job_ids:
1192
+ conflicting_job_id = next(iter(conflicting_job_ids))
1193
+ raise EnqueueError(f"job {conflicting_job_id} already has an execution-state row")
1194
+ raise EnqueueError("could not claim selected jobs")
1195
+ return None
1196
+
1197
+
1198
+ def _state_absence_for_job_id_sql(model, *, job_id_reference, quote):
1199
+ state_table = quote(model._meta.db_table)
1200
+ state_job_id_column = quote(model._meta.get_field("job").column)
1201
+ return (
1202
+ f"NOT EXISTS ("
1203
+ f"SELECT 1 FROM {state_table} "
1204
+ f"WHERE {state_table}.{state_job_id_column} = {job_id_reference}"
1205
+ f")"
1206
+ )
1207
+
1208
+
1110
1209
  def _select_exact_selector_rows(queryset, selectors, *, limit, use_skip_locked):
1111
1210
  selected_rows = []
1112
1211
  for selector in dict.fromkeys(selectors):
@@ -1182,6 +1281,74 @@ def _delete_claimed_execution(alias, job_id):
1182
1281
  raise ClaimedExecution.DoesNotExist
1183
1282
 
1184
1283
 
1284
+ def _delete_claimed_and_finish_job_if_no_execution_state(alias, job, return_value, *, finished_at):
1285
+ connection = connections[alias]
1286
+ quote = connection.ops.quote_name
1287
+ claimed_table = quote(ClaimedExecution._meta.db_table)
1288
+ claimed_job_id_column = quote(ClaimedExecution._meta.get_field("job").column)
1289
+ jobs_table = quote(Job._meta.db_table)
1290
+ job_id_column = quote(Job._meta.get_field("id").column)
1291
+ backend_alias_column = quote(Job._meta.get_field("backend_alias").column)
1292
+ finished_at_column = quote(Job._meta.get_field("finished_at").column)
1293
+ return_value_column = quote(Job._meta.get_field("return_value").column)
1294
+ updated_at_column = quote(Job._meta.get_field("updated_at").column)
1295
+ state_checks = " AND ".join(
1296
+ _state_absence_sql(model, jobs_table=jobs_table, job_id_column=job_id_column, quote=quote)
1297
+ for model in (
1298
+ ReadyExecution,
1299
+ ScheduledExecution,
1300
+ BlockedExecution,
1301
+ FailedExecution,
1302
+ )
1303
+ )
1304
+ job_id = Job._meta.get_field("id").get_db_prep_value(
1305
+ job.pk,
1306
+ connection=connection,
1307
+ prepared=False,
1308
+ )
1309
+ prepared_return_value = Job._meta.get_field("return_value").get_db_prep_save(
1310
+ return_value,
1311
+ connection=connection,
1312
+ )
1313
+
1314
+ with connection.cursor() as cursor:
1315
+ cursor.execute(
1316
+ f"""
1317
+ WITH deleted_claim AS (
1318
+ DELETE FROM {claimed_table}
1319
+ WHERE {claimed_table}.{claimed_job_id_column} = %s
1320
+ RETURNING {claimed_job_id_column}
1321
+ ),
1322
+ updated_job AS (
1323
+ UPDATE {jobs_table}
1324
+ SET
1325
+ {finished_at_column} = %s,
1326
+ {return_value_column} = %s,
1327
+ {updated_at_column} = %s
1328
+ WHERE
1329
+ {jobs_table}.{job_id_column} = %s
1330
+ AND {jobs_table}.{backend_alias_column} = %s
1331
+ AND EXISTS (SELECT 1 FROM deleted_claim)
1332
+ AND {state_checks}
1333
+ RETURNING {job_id_column}
1334
+ )
1335
+ SELECT
1336
+ (SELECT COUNT(*) FROM deleted_claim),
1337
+ (SELECT COUNT(*) FROM updated_job)
1338
+ """,
1339
+ [job_id, finished_at, prepared_return_value, finished_at, job_id, job.backend_alias],
1340
+ )
1341
+ deleted_count, updated_count = cursor.fetchone()
1342
+
1343
+ if deleted_count != 1:
1344
+ raise ClaimedExecution.DoesNotExist
1345
+ if updated_count != 1:
1346
+ raise EnqueueError(f"job {job.id} already has an execution-state row")
1347
+ job.finished_at = finished_at
1348
+ job.return_value = return_value
1349
+ job.updated_at = finished_at
1350
+
1351
+
1185
1352
  def _finish_job_if_no_execution_state(alias, job, return_value, *, finished_at):
1186
1353
  connection = connections[alias]
1187
1354
  quote = connection.ops.quote_name
@@ -244,16 +244,23 @@ class BaseRunner:
244
244
  if interval <= 0:
245
245
  return
246
246
  self._heartbeat_stop_event.clear()
247
- self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
247
+ self._heartbeat_thread = threading.Thread(
248
+ target=self._heartbeat_loop,
249
+ name=f"dj_queue-heartbeat-{self.name}",
250
+ daemon=True,
251
+ )
248
252
  self._heartbeat_thread.start()
249
253
 
250
254
  def _stop_heartbeat_thread(self):
251
255
  thread = self._heartbeat_thread
252
256
  if thread is None:
253
- return
257
+ return True
254
258
  self._heartbeat_stop_event.set()
255
259
  thread.join(timeout=max(self._effective_heartbeat_interval(), 0.1) + 0.1)
260
+ if thread.is_alive():
261
+ return False
256
262
  self._heartbeat_thread = None
263
+ return True
257
264
 
258
265
  def _heartbeat_loop(self):
259
266
  interval = self._effective_heartbeat_interval()
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "dj-queue"
7
- version = "0.10.3"
7
+ version = "0.10.4"
8
8
  description = "Database-backed task queue backend for Django’s Tasks framework."
9
9
  readme = "README.md"
10
10
  license = "MIT"
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