dj-queue 0.10.4__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.4 → dj_queue-0.10.5}/PKG-INFO +1 -1
  2. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/admin.py +13 -2
  3. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/api.py +14 -20
  4. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/backend.py +5 -0
  5. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/config.py +60 -22
  6. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/contrib/asgi.py +5 -3
  7. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/contrib/gunicorn.py +29 -9
  8. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/contrib/prometheus.py +6 -3
  9. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/metrics.py +10 -0
  10. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/observability.py +64 -34
  11. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/operations/_helpers.py +9 -3
  12. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/operations/concurrency.py +3 -3
  13. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/operations/jobs.py +30 -13
  14. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/operations/recurring.py +82 -32
  15. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/base.py +21 -2
  16. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/pool.py +18 -1
  17. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/supervisor.py +35 -14
  18. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/worker.py +44 -5
  19. {dj_queue-0.10.4 → dj_queue-0.10.5}/pyproject.toml +1 -1
  20. {dj_queue-0.10.4 → dj_queue-0.10.5}/LICENSE +0 -0
  21. {dj_queue-0.10.4 → dj_queue-0.10.5}/README.md +0 -0
  22. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/__init__.py +0 -0
  23. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/apps.py +0 -0
  24. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/contrib/__init__.py +0 -0
  25. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/cron.py +0 -0
  26. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/dashboard.py +0 -0
  27. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/db.py +0 -0
  28. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/exceptions.py +0 -0
  29. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/hooks.py +0 -0
  30. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/log.py +0 -0
  31. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/management/__init__.py +0 -0
  32. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/management/commands/__init__.py +0 -0
  33. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/management/commands/dj_queue.py +0 -0
  34. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/management/commands/dj_queue_health.py +0 -0
  35. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/management/commands/dj_queue_prune.py +0 -0
  36. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/migrations/0001_initial.py +0 -0
  37. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
  38. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
  39. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/migrations/0004_dashboard.py +0 -0
  40. {dj_queue-0.10.4 → 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.4 → 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.4 → dj_queue-0.10.5}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
  43. {dj_queue-0.10.4 → 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.4 → 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.4 → 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.4 → dj_queue-0.10.5}/dj_queue/migrations/__init__.py +0 -0
  47. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/models/__init__.py +0 -0
  48. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/models/jobs.py +0 -0
  49. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/models/recurring.py +0 -0
  50. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/models/runtime.py +0 -0
  51. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/operations/__init__.py +0 -0
  52. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/operations/_insert.py +0 -0
  53. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/operations/cleanup.py +0 -0
  54. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/operations/queues.py +0 -0
  55. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/queue_selectors.py +0 -0
  56. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/queue_state.py +0 -0
  57. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/routers.py +0 -0
  58. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/__init__.py +0 -0
  59. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/connection_budget.py +0 -0
  60. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/dispatcher.py +0 -0
  61. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/errors.py +0 -0
  62. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/interruptible.py +0 -0
  63. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/notify.py +0 -0
  64. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/pidfile.py +0 -0
  65. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/procline.py +0 -0
  66. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/scheduler.py +0 -0
  67. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/runtime/topology.py +0 -0
  68. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/task_results.py +0 -0
  69. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
  70. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
  71. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
  72. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
  73. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
  74. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_queue_controls.html +0 -0
  75. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +0 -0
  76. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/change_form.html +0 -0
  77. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
  78. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/dashboard.html +0 -0
  79. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
  80. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templates/admin/dj_queue/queue_jobs.html +0 -0
  81. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templatetags/__init__.py +0 -0
  82. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/templatetags/dj_queue_admin.py +0 -0
  83. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/urls.py +0 -0
  84. {dj_queue-0.10.4 → dj_queue-0.10.5}/dj_queue/views.py +0 -0
  85. {dj_queue-0.10.4 → 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.4
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
@@ -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)
@@ -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
  ),
@@ -29,6 +29,9 @@ from dj_queue.queue_selectors import queue_matches_selectors
29
29
  from dj_queue.queue_state import queue_state_count_fields, queue_state_counts
30
30
 
31
31
 
32
+ _NOT_PROVIDED = object()
33
+
34
+
32
35
  @dataclass(frozen=True, slots=True)
33
36
  class BackendChoice:
34
37
  alias: str
@@ -207,7 +210,6 @@ def queue_rows(*, backend_alias, now, process_cutoff):
207
210
  failed_count=failed_counts.get(queue_name, 0),
208
211
  finished_count=finished_counts.get(queue_name, 0),
209
212
  paused=queue_name in paused_queues,
210
- recurring=queue_name in recurring_queues,
211
213
  oldest_ready_at=oldest_ready.get(queue_name),
212
214
  oldest_scheduled_at=oldest_scheduled.get(queue_name),
213
215
  oldest_blocked_at=oldest_blocked.get(queue_name),
@@ -229,11 +231,10 @@ def queue_snapshot(
229
231
  blocked_count=None,
230
232
  failed_count=None,
231
233
  finished_count=None,
232
- paused=None,
233
- recurring=None,
234
- oldest_ready_at=None,
235
- oldest_scheduled_at=None,
236
- oldest_blocked_at=None,
234
+ paused=_NOT_PROVIDED,
235
+ oldest_ready_at=_NOT_PROVIDED,
236
+ oldest_scheduled_at=_NOT_PROVIDED,
237
+ oldest_blocked_at=_NOT_PROVIDED,
237
238
  live_workers=None,
238
239
  ):
239
240
  alias = get_database_alias(backend_alias)
@@ -245,37 +246,20 @@ def queue_snapshot(
245
246
  blocked_count = state_counts["blocked"]
246
247
  failed_count = state_counts["failed"]
247
248
  finished_count = state_counts["finished"]
248
- if paused is None:
249
- paused = (
250
- Pause.objects.using(alias)
251
- .filter(
252
- backend_alias=backend_alias,
253
- queue_name=queue_name,
254
- )
255
- .exists()
256
- )
257
- if recurring is None:
258
- recurring = (
259
- RecurringTask.objects.using(alias)
260
- .filter(
261
- backend_alias=backend_alias,
262
- queue_name=queue_name,
263
- )
264
- .exists()
265
- )
266
- if oldest_ready_at is None:
267
- oldest_ready_at = (
268
- ReadyExecution.objects.using(alias)
269
- .filter(backend_alias=backend_alias, queue_name=queue_name)
270
- .aggregate(oldest=Min(Coalesce("latency_started_at", "created_at")))["oldest"]
249
+ if paused is _NOT_PROVIDED:
250
+ paused = queue_is_paused(backend_alias=backend_alias, queue_name=queue_name)
251
+ if oldest_ready_at is _NOT_PROVIDED:
252
+ oldest_ready_at = oldest_ready_at_for_queue(
253
+ backend_alias=backend_alias,
254
+ queue_name=queue_name,
271
255
  )
272
- if oldest_scheduled_at is None:
256
+ if oldest_scheduled_at is _NOT_PROVIDED:
273
257
  oldest_scheduled_at = (
274
258
  ScheduledExecution.objects.using(alias)
275
259
  .filter(backend_alias=backend_alias, queue_name=queue_name)
276
260
  .aggregate(oldest=Min("scheduled_at"))["oldest"]
277
261
  )
278
- if oldest_blocked_at is None:
262
+ if oldest_blocked_at is _NOT_PROVIDED:
279
263
  oldest_blocked_at = (
280
264
  BlockedExecution.objects.using(alias)
281
265
  .filter(backend_alias=backend_alias, queue_name=queue_name)
@@ -291,9 +275,13 @@ def queue_snapshot(
291
275
  )
292
276
  )
293
277
 
294
- latency_seconds = None
295
- if oldest_ready_at is not None and paused is False:
296
- latency_seconds = max((now - oldest_ready_at).total_seconds(), 0.0)
278
+ latency_seconds = queue_latency_seconds(
279
+ backend_alias=backend_alias,
280
+ queue_name=queue_name,
281
+ now=now,
282
+ paused=paused,
283
+ oldest_ready_at=oldest_ready_at,
284
+ )
297
285
 
298
286
  state_count_fields = queue_state_count_fields(
299
287
  {
@@ -321,6 +309,46 @@ def queue_snapshot(
321
309
  }
322
310
 
323
311
 
312
+ def queue_is_paused(*, backend_alias, queue_name):
313
+ alias = get_database_alias(backend_alias)
314
+ return (
315
+ Pause.objects.using(alias)
316
+ .filter(
317
+ backend_alias=backend_alias,
318
+ queue_name=queue_name,
319
+ )
320
+ .exists()
321
+ )
322
+
323
+
324
+ def queue_latency_seconds(
325
+ *, backend_alias, queue_name, now=None, paused=None, oldest_ready_at=_NOT_PROVIDED
326
+ ):
327
+ if now is None:
328
+ now = timezone.now()
329
+ if paused is None:
330
+ paused = queue_is_paused(backend_alias=backend_alias, queue_name=queue_name)
331
+ if paused:
332
+ return None
333
+ if oldest_ready_at is _NOT_PROVIDED:
334
+ oldest_ready_at = oldest_ready_at_for_queue(
335
+ backend_alias=backend_alias,
336
+ queue_name=queue_name,
337
+ )
338
+ if oldest_ready_at is None:
339
+ return None
340
+ return max((now - oldest_ready_at).total_seconds(), 0.0)
341
+
342
+
343
+ def oldest_ready_at_for_queue(*, backend_alias, queue_name):
344
+ alias = get_database_alias(backend_alias)
345
+ return (
346
+ ReadyExecution.objects.using(alias)
347
+ .filter(backend_alias=backend_alias, queue_name=queue_name)
348
+ .aggregate(oldest=Min(Coalesce("latency_started_at", "created_at")))["oldest"]
349
+ )
350
+
351
+
324
352
  def process_rows(*, backend_alias, now, process_cutoff, scope):
325
353
  alias = get_database_alias(backend_alias)
326
354
  queryset = Process.objects.using(alias).select_related("supervisor")
@@ -412,6 +440,8 @@ def semaphore_rows_for_backend(*, backend_alias):
412
440
  )
413
441
  return [
414
442
  {
443
+ "scope": "queue_database",
444
+ "queue_database_alias": alias,
415
445
  "key": semaphore.key,
416
446
  "available_slots": semaphore.value,
417
447
  "limit": semaphore.limit,
@@ -220,7 +220,7 @@ def _ready_execution_fields(
220
220
  ):
221
221
  fields = {
222
222
  "job": job,
223
- "backend_alias": backend_alias,
223
+ "backend_alias": _execution_backend_alias(job, backend_alias),
224
224
  "queue_name": _execution_queue_name(job, queue_name),
225
225
  "priority": _execution_priority(job, priority),
226
226
  "latency_started_at": ready_at,
@@ -233,7 +233,7 @@ def _ready_execution_fields(
233
233
  def _scheduled_execution_fields(job, *, backend_alias, scheduled_at=None, created_at=None):
234
234
  fields = {
235
235
  "job": job,
236
- "backend_alias": backend_alias,
236
+ "backend_alias": _execution_backend_alias(job, backend_alias),
237
237
  "queue_name": job.queue_name,
238
238
  "priority": job.priority,
239
239
  "scheduled_at": scheduled_at if scheduled_at is not None else job.scheduled_at,
@@ -254,7 +254,7 @@ def _blocked_execution_fields(
254
254
  ):
255
255
  return {
256
256
  "job": job,
257
- "backend_alias": backend_alias,
257
+ "backend_alias": _execution_backend_alias(job, backend_alias),
258
258
  "queue_name": _execution_queue_name(job, queue_name),
259
259
  "priority": _execution_priority(job, priority),
260
260
  "concurrency_key": concurrency_key if concurrency_key is not None else job.concurrency_key,
@@ -262,6 +262,12 @@ def _blocked_execution_fields(
262
262
  }
263
263
 
264
264
 
265
+ def _execution_backend_alias(job, backend_alias):
266
+ if job.backend_alias != backend_alias:
267
+ raise EnqueueError(f"job {job.id} belongs to backend {job.backend_alias!r}")
268
+ return job.backend_alias
269
+
270
+
265
271
  def _execution_queue_name(job, queue_name):
266
272
  return job.queue_name if queue_name is None else queue_name
267
273