dj-queue 0.10.3__tar.gz → 0.10.5__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.5}/PKG-INFO +6 -7
  2. {dj_queue-0.10.3 → dj_queue-0.10.5}/README.md +5 -6
  3. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/admin.py +13 -2
  4. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/api.py +14 -20
  5. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/backend.py +13 -2
  6. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/config.py +60 -22
  7. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/contrib/asgi.py +5 -3
  8. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/contrib/gunicorn.py +29 -9
  9. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/contrib/prometheus.py +6 -3
  10. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/metrics.py +10 -0
  11. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/observability.py +64 -34
  12. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/operations/_helpers.py +17 -6
  13. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/operations/concurrency.py +111 -26
  14. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/operations/jobs.py +232 -48
  15. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/operations/recurring.py +82 -32
  16. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/base.py +30 -4
  17. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/pool.py +18 -1
  18. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/supervisor.py +35 -14
  19. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/worker.py +44 -5
  20. {dj_queue-0.10.3 → dj_queue-0.10.5}/pyproject.toml +1 -1
  21. {dj_queue-0.10.3 → dj_queue-0.10.5}/LICENSE +0 -0
  22. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/__init__.py +0 -0
  23. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/apps.py +0 -0
  24. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/contrib/__init__.py +0 -0
  25. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/cron.py +0 -0
  26. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/dashboard.py +0 -0
  27. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/db.py +0 -0
  28. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/exceptions.py +0 -0
  29. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/hooks.py +0 -0
  30. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/log.py +0 -0
  31. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/management/__init__.py +0 -0
  32. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/management/commands/__init__.py +0 -0
  33. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/management/commands/dj_queue.py +0 -0
  34. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/management/commands/dj_queue_health.py +0 -0
  35. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/management/commands/dj_queue_prune.py +0 -0
  36. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0001_initial.py +0 -0
  37. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
  38. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
  39. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0004_dashboard.py +0 -0
  40. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0005_remove_recurringexecution_dj_queue_recurring_executions_task_key_run_at_unique_and_more.py +0 -0
  41. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0006_blockedexecution_dj_queue_bl_concurr_2d8393_idx_and_more.py +0 -0
  42. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
  43. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0008_remove_blockedexecution_dj_queue_bl_concurr_1ce730_idx_and_more.py +0 -0
  44. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0009_remove_process_dj_queue_processes_name_supervisor_unique_and_more.py +0 -0
  45. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/0010_remove_process_djq_pr_name_parent_uniq_and_more.py +0 -0
  46. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/migrations/__init__.py +0 -0
  47. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/models/__init__.py +0 -0
  48. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/models/jobs.py +0 -0
  49. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/models/recurring.py +0 -0
  50. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/models/runtime.py +0 -0
  51. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/operations/__init__.py +0 -0
  52. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/operations/_insert.py +0 -0
  53. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/operations/cleanup.py +0 -0
  54. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/operations/queues.py +0 -0
  55. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/queue_selectors.py +0 -0
  56. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/queue_state.py +0 -0
  57. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/routers.py +0 -0
  58. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/__init__.py +0 -0
  59. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/connection_budget.py +0 -0
  60. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/dispatcher.py +0 -0
  61. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/errors.py +0 -0
  62. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/interruptible.py +0 -0
  63. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/notify.py +0 -0
  64. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/pidfile.py +0 -0
  65. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/procline.py +0 -0
  66. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/scheduler.py +0 -0
  67. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/runtime/topology.py +0 -0
  68. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/task_results.py +0 -0
  69. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
  70. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
  71. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
  72. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
  73. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
  74. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_queue_controls.html +0 -0
  75. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +0 -0
  76. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/change_form.html +0 -0
  77. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
  78. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/dashboard.html +0 -0
  79. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
  80. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/queue_jobs.html +0 -0
  81. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templatetags/__init__.py +0 -0
  82. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/templatetags/dj_queue_admin.py +0 -0
  83. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/urls.py +0 -0
  84. {dj_queue-0.10.3 → dj_queue-0.10.5}/dj_queue/views.py +0 -0
  85. {dj_queue-0.10.3 → dj_queue-0.10.5}/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.5
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
@@ -44,7 +44,7 @@ from dj_queue.queue_state import (
44
44
  )
45
45
 
46
46
 
47
- class DjQueueFirstAdminSite(admin.AdminSite):
47
+ class DjQueueAdminSiteMixin:
48
48
  def _dashboard_app_url(self):
49
49
  return reverse("admin:dj_queue_dashboard_changelist", current_app=self.name)
50
50
 
@@ -65,7 +65,18 @@ class DjQueueFirstAdminSite(admin.AdminSite):
65
65
  return super().app_index(request, app_label, extra_context=extra_context)
66
66
 
67
67
 
68
- admin.site.__class__ = DjQueueFirstAdminSite
68
+ def _install_dj_queue_admin_site(site):
69
+ if isinstance(site, DjQueueAdminSiteMixin):
70
+ return
71
+
72
+ site.__class__ = type(
73
+ f"DjQueue{site.__class__.__name__}",
74
+ (DjQueueAdminSiteMixin, site.__class__),
75
+ {"__module__": __name__},
76
+ )
77
+
78
+
79
+ _install_dj_queue_admin_site(admin.site)
69
80
 
70
81
 
71
82
  def _format_admin_datetime(value):
@@ -1,14 +1,13 @@
1
1
  from datetime import timedelta
2
2
  from functools import partial
3
3
 
4
- from django.db.models.functions import Coalesce
5
4
  from django.db import transaction
6
5
  from django.utils import timezone
7
6
 
8
7
  from dj_queue import observability
9
8
  from dj_queue.config import load_backend_config
10
9
  from dj_queue.db import get_database_alias
11
- from dj_queue.models import Pause, ReadyExecution
10
+ from dj_queue.models import ReadyExecution
12
11
  from dj_queue.operations.jobs import (
13
12
  ClaimedJob,
14
13
  claim_ready_jobs,
@@ -53,30 +52,25 @@ class QueueInfo:
53
52
 
54
53
  @property
55
54
  def latency(self):
56
- if self.paused:
55
+ paused = observability.queue_is_paused(
56
+ backend_alias=self.backend_alias,
57
+ queue_name=self.queue_name,
58
+ )
59
+ if paused:
57
60
  return None
58
61
 
59
- oldest = (
60
- self._ready_queryset()
61
- .annotate(latency_at=Coalesce("latency_started_at", "created_at"))
62
- .order_by("latency_at", "created_at")
63
- .values_list("latency_at", flat=True)
64
- .first()
62
+ latency = observability.queue_latency_seconds(
63
+ backend_alias=self.backend_alias,
64
+ queue_name=self.queue_name,
65
+ paused=False,
65
66
  )
66
- if oldest is None:
67
- return 0.0
68
- return max((timezone.now() - oldest).total_seconds(), 0.0)
67
+ return 0.0 if latency is None else latency
69
68
 
70
69
  @property
71
70
  def paused(self):
72
- alias = get_database_alias(self.backend_alias)
73
- return (
74
- Pause.objects.using(alias)
75
- .filter(
76
- backend_alias=self.backend_alias,
77
- queue_name=self.queue_name,
78
- )
79
- .exists()
71
+ return observability.queue_is_paused(
72
+ backend_alias=self.backend_alias,
73
+ queue_name=self.queue_name,
80
74
  )
81
75
 
82
76
  def pause(self):
@@ -21,6 +21,11 @@ class DjQueueBackend(BaseTaskBackend):
21
21
  supports_get_result = True
22
22
  supports_priority = True
23
23
 
24
+ def __init__(self, alias, params):
25
+ if not params.get("QUEUES"):
26
+ params = {**params, "QUEUES": []}
27
+ super().__init__(alias, params)
28
+
24
29
  def validate_task(self, task):
25
30
  validate_queue_allowed(task.queue_name, backend_alias=self.alias)
26
31
  validate_priority(task.priority)
@@ -28,7 +33,13 @@ class DjQueueBackend(BaseTaskBackend):
28
33
 
29
34
  def enqueue(self, task, args, kwargs):
30
35
  self.validate_task(task)
31
- job, dispatch_outcome = enqueue_job_with_dispatch(task, args, kwargs, backend_alias=self.alias)
36
+ job, dispatch_outcome = enqueue_job_with_dispatch(
37
+ task,
38
+ args,
39
+ kwargs,
40
+ backend_alias=self.alias,
41
+ validate=False,
42
+ )
32
43
  return task_result_from_enqueued_job(
33
44
  job,
34
45
  task,
@@ -49,7 +60,7 @@ class DjQueueBackend(BaseTaskBackend):
49
60
  self.validate_task(task)
50
61
  jobs.append((task, args, kwargs))
51
62
 
52
- created_jobs = enqueue_jobs_bulk(jobs, backend_alias=self.alias)
63
+ created_jobs = enqueue_jobs_bulk(jobs, backend_alias=self.alias, validate=False)
53
64
  return [
54
65
  task_result_from_enqueued_job(
55
66
  job,
@@ -82,7 +82,7 @@ class DispatcherConfig(ConfigValue):
82
82
  batch_size: int = 500
83
83
  polling_interval: float = 1
84
84
  concurrency_maintenance: bool = True
85
- concurrency_maintenance_interval: int = 600
85
+ concurrency_maintenance_interval: float = 600
86
86
 
87
87
 
88
88
  @dataclass(frozen=True, slots=True)
@@ -112,9 +112,9 @@ class BackendConfig(ConfigValue):
112
112
  dispatchers: tuple[DispatcherConfig, ...] = (DispatcherConfig(),)
113
113
  scheduler: SchedulerConfig | None = field(default_factory=SchedulerConfig)
114
114
  recurring: dict[str, RecurringTaskConfig] = field(default_factory=dict)
115
- process_heartbeat_interval: int = 60
116
- process_alive_threshold: int = 300
117
- shutdown_timeout: int = 5
115
+ process_heartbeat_interval: float = 60
116
+ process_alive_threshold: float = 300
117
+ shutdown_timeout: float = 5
118
118
  supervisor_pidfile: str | None = None
119
119
  preserve_finished_jobs: bool = True
120
120
  clear_finished_jobs_after: int | None = 86400
@@ -248,7 +248,9 @@ def _load_backend_config_uncached(
248
248
  resolved_options["process_alive_threshold"], "process_alive_threshold"
249
249
  ),
250
250
  shutdown_timeout=_nonnegative_float(resolved_options["shutdown_timeout"], "shutdown_timeout"),
251
- supervisor_pidfile=resolved_options["supervisor_pidfile"],
251
+ supervisor_pidfile=_optional_string_option(
252
+ resolved_options["supervisor_pidfile"], "supervisor_pidfile"
253
+ ),
252
254
  preserve_finished_jobs=preserve_finished_jobs,
253
255
  clear_finished_jobs_after=_optional_nonnegative_int(
254
256
  resolved_options["clear_finished_jobs_after"], "clear_finished_jobs_after"
@@ -264,7 +266,7 @@ def _load_backend_config_uncached(
264
266
  resolved_options["default_concurrency_duration"],
265
267
  "default_concurrency_duration",
266
268
  ),
267
- database_alias=str(resolved_options["database_alias"]),
269
+ database_alias=_string_option(resolved_options["database_alias"], "database_alias"),
268
270
  use_skip_locked=_bool_option(resolved_options["use_skip_locked"], "use_skip_locked"),
269
271
  listen_notify=_bool_option(resolved_options["listen_notify"], "listen_notify"),
270
272
  silence_polling=_bool_option(resolved_options["silence_polling"], "silence_polling"),
@@ -428,7 +430,7 @@ def _validated_callback_path(callback_path: Any) -> str | None:
428
430
  if callback_path in (None, ""):
429
431
  return None
430
432
 
431
- callback_path = str(callback_path)
433
+ callback_path = _string_option(callback_path, "on_thread_error")
432
434
  try:
433
435
  import_string(callback_path)
434
436
  except ImportError as exc:
@@ -546,24 +548,31 @@ def _build_recurring_config(
546
548
 
547
549
  recurring: dict[str, RecurringTaskConfig] = {}
548
550
  for key, raw_entry in raw_recurring.items():
551
+ key = _string_option(key, "recurring task key")
549
552
  if not isinstance(raw_entry, Mapping):
550
553
  raise ImproperlyConfigured("recurring entries must be mappings")
551
554
 
552
- task_path = raw_entry.get("task_path")
553
- schedule = raw_entry.get("schedule")
554
- if not task_path or not schedule:
555
+ raw_task_path = raw_entry.get("task_path")
556
+ raw_schedule = raw_entry.get("schedule")
557
+ if raw_task_path in (None, "") or raw_schedule in (None, ""):
555
558
  raise ImproperlyConfigured(f"recurring task {key!r} requires task_path and schedule")
556
- if not is_valid_cron(str(schedule)):
559
+ task_path = _string_option(raw_task_path, f"recurring task {key!r} task_path")
560
+ schedule = _string_option(raw_schedule, f"recurring task {key!r} schedule")
561
+ if not is_valid_cron(schedule):
557
562
  raise ImproperlyConfigured(f"recurring task {key!r} has an invalid cron schedule")
558
563
 
559
- queue_name = str(raw_entry.get("queue_name", "default"))
564
+ queue_name = _string_option(
565
+ raw_entry.get("queue_name", "default"), f"recurring task {key!r} queue_name"
566
+ )
560
567
  priority = _priority_int(raw_entry.get("priority", 0), f"recurring task {key!r} priority")
561
568
  if allowed_queues and queue_name not in allowed_queues:
562
569
  raise ImproperlyConfigured(
563
570
  f"recurring task {key!r} is invalid: queue {queue_name!r} is not allowed for backend {backend_alias!r}"
564
571
  )
572
+ args = _tuple_option(raw_entry.get("args", []), f"recurring task {key!r} args")
573
+ kwargs = _dict_option(raw_entry.get("kwargs", {}), f"recurring task {key!r} kwargs")
565
574
  try:
566
- task = import_string(str(task_path))
575
+ task = import_string(task_path)
567
576
  except ImportError as exc:
568
577
  raise ImproperlyConfigured(f"recurring task {key!r} is invalid: {exc}") from exc
569
578
  if not hasattr(task, "using"):
@@ -571,15 +580,17 @@ def _build_recurring_config(
571
580
  f"recurring task {key!r} is invalid: task_path must reference a Django task"
572
581
  )
573
582
 
574
- recurring[str(key)] = RecurringTaskConfig(
575
- key=str(key),
576
- task_path=str(task_path),
577
- schedule=str(schedule),
578
- args=tuple(raw_entry.get("args", [])),
579
- kwargs=dict(raw_entry.get("kwargs", {})),
583
+ recurring[key] = RecurringTaskConfig(
584
+ key=key,
585
+ task_path=task_path,
586
+ schedule=schedule,
587
+ args=args,
588
+ kwargs=kwargs,
580
589
  queue_name=queue_name,
581
590
  priority=priority,
582
- description=str(raw_entry.get("description", "")),
591
+ description=_string_option(
592
+ raw_entry.get("description", ""), f"recurring task {key!r} description"
593
+ ),
583
594
  )
584
595
  return recurring
585
596
 
@@ -612,9 +623,36 @@ def _as_string_tuple(value: Any) -> tuple[str, ...]:
612
623
  return ()
613
624
  if isinstance(value, str):
614
625
  return (value,)
615
- if not isinstance(value, Sequence):
626
+ if not isinstance(value, Sequence) or isinstance(value, (bytes, bytearray)):
627
+ raise ImproperlyConfigured("expected a string or a sequence of strings")
628
+ values = tuple(value)
629
+ if not all(isinstance(item, str) for item in values):
616
630
  raise ImproperlyConfigured("expected a string or a sequence of strings")
617
- return tuple(str(item) for item in value)
631
+ return values
632
+
633
+
634
+ def _string_option(value: Any, setting_name: str) -> str:
635
+ if not isinstance(value, str):
636
+ raise ImproperlyConfigured(f"dj_queue {setting_name} must be a string")
637
+ return value
638
+
639
+
640
+ def _optional_string_option(value: Any, setting_name: str) -> str | None:
641
+ if value is None:
642
+ return None
643
+ return _string_option(value, setting_name)
644
+
645
+
646
+ def _tuple_option(value: Any, setting_name: str) -> tuple[Any, ...]:
647
+ if isinstance(value, str) or not isinstance(value, Sequence):
648
+ raise ImproperlyConfigured(f"dj_queue {setting_name} must be a sequence")
649
+ return tuple(value)
650
+
651
+
652
+ def _dict_option(value: Any, setting_name: str) -> dict[str, Any]:
653
+ if not isinstance(value, Mapping):
654
+ raise ImproperlyConfigured(f"dj_queue {setting_name} must be a mapping")
655
+ return dict(value)
618
656
 
619
657
 
620
658
  def _optional_nonnegative_int(value: Any, setting_name: str) -> int | None:
@@ -104,8 +104,10 @@ class DjQueueLifespan:
104
104
 
105
105
  receive_queue = asyncio.Queue()
106
106
  send_queue = asyncio.Queue()
107
- app_task = await self._start_wrapped_lifespan_app(scope, receive_queue.get, send_queue.put)
108
107
  wrapped_app_supports_lifespan = self.forward_wrapped_lifespan
108
+ app_task = None
109
+ if wrapped_app_supports_lifespan:
110
+ app_task = await self._start_wrapped_lifespan_app(scope, receive_queue.get, send_queue.put)
109
111
 
110
112
  try:
111
113
  while True:
@@ -137,12 +139,12 @@ class DjQueueLifespan:
137
139
  if response is None:
138
140
  response = {"type": "lifespan.shutdown.complete"}
139
141
  await send(response)
140
- if wrapped_app_supports_lifespan:
142
+ if wrapped_app_supports_lifespan and app_task is not None:
141
143
  await app_task
142
144
  return
143
145
  finally:
144
146
  await self._stop_supervisor()
145
- if not app_task.done():
147
+ if app_task is not None and not app_task.done():
146
148
  app_task.cancel()
147
149
  with suppress(asyncio.CancelledError):
148
150
  await app_task
@@ -1,4 +1,5 @@
1
1
  import fcntl
2
+ import hashlib
2
3
  import tempfile
3
4
  import threading
4
5
  from pathlib import Path
@@ -6,7 +7,7 @@ from pathlib import Path
6
7
  from dj_queue.runtime.errors import handle_thread_error
7
8
  from dj_queue.runtime.supervisor import AsyncSupervisor
8
9
 
9
- LOCK_PATH = Path(tempfile.gettempdir()) / "dj_queue_gunicorn_supervisor.lock"
10
+ LOCK_PATH_PREFIX = "dj_queue_gunicorn_supervisor"
10
11
  LOCK_RETRY_INTERVAL = 1.0
11
12
 
12
13
 
@@ -20,7 +21,7 @@ def _set_supervisor_state(worker, **state):
20
21
 
21
22
 
22
23
  def _start_embedded_supervisor(worker, *, backend_alias="default"):
23
- lock_file = _try_acquire_supervisor_lock()
24
+ lock_file = _try_acquire_supervisor_lock(backend_alias=backend_alias)
24
25
  if lock_file is None:
25
26
  return None
26
27
 
@@ -70,7 +71,12 @@ def _start_lock_retry_loop(worker, *, backend_alias="default"):
70
71
  while retry_stop.wait(LOCK_RETRY_INTERVAL) is False:
71
72
  if getattr(worker, "_dj_queue_supervisor", None) is not None:
72
73
  return
73
- if _start_embedded_supervisor(worker, backend_alias=backend_alias) is not None:
74
+ try:
75
+ supervisor = _start_embedded_supervisor(worker, backend_alias=backend_alias)
76
+ except Exception as error:
77
+ handle_thread_error(error, context="gunicorn.supervisor", backend_alias=backend_alias)
78
+ continue
79
+ if supervisor is not None:
74
80
  retry_stop.set()
75
81
  return
76
82
 
@@ -81,7 +87,8 @@ def _start_lock_retry_loop(worker, *, backend_alias="default"):
81
87
  retry_thread.start()
82
88
 
83
89
 
84
- def post_fork(_server, worker):
90
+ def post_fork(server, worker):
91
+ backend_alias = _backend_alias(server, worker)
85
92
  _set_supervisor_state(
86
93
  worker,
87
94
  supervisor=None,
@@ -91,11 +98,11 @@ def post_fork(_server, worker):
91
98
  supervisor_retry_stop=None,
92
99
  supervisor_retry_thread=None,
93
100
  )
94
- supervisor = _start_embedded_supervisor(worker)
101
+ supervisor = _start_embedded_supervisor(worker, backend_alias=backend_alias)
95
102
  if supervisor is not None:
96
103
  return supervisor
97
104
 
98
- _start_lock_retry_loop(worker)
105
+ _start_lock_retry_loop(worker, backend_alias=backend_alias)
99
106
  return None
100
107
 
101
108
 
@@ -133,9 +140,22 @@ def worker_exit(_server, worker):
133
140
  return None
134
141
 
135
142
 
136
- def _try_acquire_supervisor_lock():
137
- LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
138
- lock_file = LOCK_PATH.open("a+")
143
+ def _backend_alias(server, worker):
144
+ return getattr(worker, "dj_queue_backend_alias", None) or getattr(
145
+ server, "dj_queue_backend_alias", "default"
146
+ )
147
+
148
+
149
+ def _supervisor_lock_path(*, backend_alias):
150
+ lock_scope = f"{Path.cwd()}:{backend_alias}"
151
+ digest = hashlib.sha256(lock_scope.encode()).hexdigest()[:12]
152
+ return Path(tempfile.gettempdir()) / f"{LOCK_PATH_PREFIX}_{backend_alias}_{digest}.lock"
153
+
154
+
155
+ def _try_acquire_supervisor_lock(*, backend_alias="default"):
156
+ lock_path = _supervisor_lock_path(backend_alias=backend_alias)
157
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
158
+ lock_file = lock_path.open("a+")
139
159
  try:
140
160
  fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
141
161
  except BlockingIOError:
@@ -8,19 +8,22 @@ except ImportError:
8
8
  else:
9
9
  from dj_queue.metrics import metric_families
10
10
 
11
+ METRIC_TYPES = {"gauge": GaugeMetricFamily}
12
+
11
13
  class DjQueueCollector:
12
14
  """Prometheus collector that exposes dj_queue metrics from the shared observability snapshot."""
13
15
 
14
16
  def collect(self):
15
17
  for family in metric_families():
16
- gauge = GaugeMetricFamily(
18
+ metric_class = METRIC_TYPES[family.metric_type]
19
+ metric = metric_class(
17
20
  family.name,
18
21
  family.help_text,
19
22
  labels=list(family.labels),
20
23
  )
21
24
  for sample in family.samples:
22
- gauge.add_metric(list(sample.labels), sample.value)
23
- yield gauge
25
+ metric.add_metric(list(sample.labels), sample.value)
26
+ yield metric
24
27
 
25
28
  registry = CollectorRegistry(auto_describe=False)
26
29
  registry.register(DjQueueCollector())
@@ -14,6 +14,7 @@ class MetricSample:
14
14
  class MetricFamily:
15
15
  name: str
16
16
  help_text: str
17
+ metric_type: str
17
18
  labels: tuple[str, ...]
18
19
  samples: tuple[MetricSample, ...]
19
20
 
@@ -109,54 +110,63 @@ def metric_families(*, snapshots=None):
109
110
  MetricFamily(
110
111
  name="dj_queue_queue_jobs",
111
112
  help_text="Current job count by backend, queue, and state",
113
+ metric_type="gauge",
112
114
  labels=("backend", "queue", "state"),
113
115
  samples=tuple(queue_jobs),
114
116
  ),
115
117
  MetricFamily(
116
118
  name="dj_queue_queue_paused",
117
119
  help_text="Whether a queue is paused for a backend",
120
+ metric_type="gauge",
118
121
  labels=("backend", "queue"),
119
122
  samples=tuple(queue_paused),
120
123
  ),
121
124
  MetricFamily(
122
125
  name="dj_queue_queue_latency_seconds",
123
126
  help_text="Latency of the oldest ready job in a backend queue",
127
+ metric_type="gauge",
124
128
  labels=("backend", "queue"),
125
129
  samples=tuple(queue_latency),
126
130
  ),
127
131
  MetricFamily(
128
132
  name="dj_queue_queue_live_workers",
129
133
  help_text="Live workers that can service a backend queue",
134
+ metric_type="gauge",
130
135
  labels=("backend", "queue"),
131
136
  samples=tuple(queue_workers),
132
137
  ),
133
138
  MetricFamily(
134
139
  name="dj_queue_runner_processes",
135
140
  help_text="Current runner process count by backend and liveness",
141
+ metric_type="gauge",
136
142
  labels=("backend", "status"),
137
143
  samples=tuple(runner_processes),
138
144
  ),
139
145
  MetricFamily(
140
146
  name="dj_queue_runner_processes_by_kind",
141
147
  help_text="Current runner process count by backend, kind, and liveness",
148
+ metric_type="gauge",
142
149
  labels=("backend", "kind", "status"),
143
150
  samples=tuple(runner_processes_by_kind),
144
151
  ),
145
152
  MetricFamily(
146
153
  name="dj_queue_recurring_tasks",
147
154
  help_text="Current recurring task count by backend",
155
+ metric_type="gauge",
148
156
  labels=("backend",),
149
157
  samples=tuple(recurring_tasks),
150
158
  ),
151
159
  MetricFamily(
152
160
  name="dj_queue_semaphores",
153
161
  help_text="Current semaphore count by queue database",
162
+ metric_type="gauge",
154
163
  labels=("queue_database",),
155
164
  samples=tuple(semaphores),
156
165
  ),
157
166
  MetricFamily(
158
167
  name="dj_queue_process_rows",
159
168
  help_text="Current process row count by backend",
169
+ metric_type="gauge",
160
170
  labels=("backend",),
161
171
  samples=tuple(process_rows),
162
172
  ),