dj-queue 0.10.4__tar.gz → 0.10.6__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 (87) hide show
  1. {dj_queue-0.10.4 → dj_queue-0.10.6}/PKG-INFO +1 -1
  2. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/admin.py +13 -2
  3. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/api.py +14 -20
  4. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/backend.py +5 -0
  5. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/config.py +63 -23
  6. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/contrib/asgi.py +11 -4
  7. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/contrib/gunicorn.py +56 -14
  8. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/contrib/prometheus.py +6 -3
  9. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/dashboard.py +6 -6
  10. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/metrics.py +17 -7
  11. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/observability.py +112 -164
  12. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/operations/_helpers.py +71 -13
  13. dj_queue-0.10.6/dj_queue/operations/_insert.py +36 -0
  14. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/operations/cleanup.py +11 -5
  15. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/operations/concurrency.py +52 -27
  16. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/operations/jobs.py +99 -69
  17. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/operations/recurring.py +82 -32
  18. dj_queue-0.10.6/dj_queue/queue_state.py +270 -0
  19. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/base.py +28 -3
  20. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/errors.py +0 -1
  21. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/notify.py +2 -1
  22. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/pool.py +18 -1
  23. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/supervisor.py +40 -17
  24. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/worker.py +44 -5
  25. {dj_queue-0.10.4 → dj_queue-0.10.6}/pyproject.toml +1 -1
  26. dj_queue-0.10.4/dj_queue/operations/_insert.py +0 -24
  27. dj_queue-0.10.4/dj_queue/queue_state.py +0 -138
  28. {dj_queue-0.10.4 → dj_queue-0.10.6}/LICENSE +0 -0
  29. {dj_queue-0.10.4 → dj_queue-0.10.6}/README.md +0 -0
  30. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/__init__.py +0 -0
  31. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/apps.py +0 -0
  32. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/contrib/__init__.py +0 -0
  33. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/cron.py +0 -0
  34. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/db.py +0 -0
  35. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/exceptions.py +0 -0
  36. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/hooks.py +0 -0
  37. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/log.py +0 -0
  38. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/management/__init__.py +0 -0
  39. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/management/commands/__init__.py +0 -0
  40. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/management/commands/dj_queue.py +0 -0
  41. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/management/commands/dj_queue_health.py +0 -0
  42. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/management/commands/dj_queue_prune.py +0 -0
  43. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0001_initial.py +0 -0
  44. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
  45. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
  46. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0004_dashboard.py +0 -0
  47. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0005_remove_recurringexecution_dj_queue_recurring_executions_task_key_run_at_unique_and_more.py +0 -0
  48. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0006_blockedexecution_dj_queue_bl_concurr_2d8393_idx_and_more.py +0 -0
  49. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
  50. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0008_remove_blockedexecution_dj_queue_bl_concurr_1ce730_idx_and_more.py +0 -0
  51. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0009_remove_process_dj_queue_processes_name_supervisor_unique_and_more.py +0 -0
  52. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/0010_remove_process_djq_pr_name_parent_uniq_and_more.py +0 -0
  53. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/migrations/__init__.py +0 -0
  54. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/models/__init__.py +0 -0
  55. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/models/jobs.py +0 -0
  56. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/models/recurring.py +0 -0
  57. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/models/runtime.py +0 -0
  58. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/operations/__init__.py +0 -0
  59. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/operations/queues.py +0 -0
  60. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/queue_selectors.py +0 -0
  61. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/routers.py +0 -0
  62. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/__init__.py +0 -0
  63. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/connection_budget.py +0 -0
  64. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/dispatcher.py +0 -0
  65. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/interruptible.py +0 -0
  66. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/pidfile.py +0 -0
  67. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/procline.py +0 -0
  68. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/scheduler.py +0 -0
  69. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/runtime/topology.py +0 -0
  70. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/task_results.py +0 -0
  71. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
  72. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
  73. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
  74. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
  75. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
  76. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/_queue_controls.html +0 -0
  77. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +0 -0
  78. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/change_form.html +0 -0
  79. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
  80. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/dashboard.html +0 -0
  81. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
  82. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templates/admin/dj_queue/queue_jobs.html +0 -0
  83. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templatetags/__init__.py +0 -0
  84. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/templatetags/dj_queue_admin.py +0 -0
  85. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/urls.py +0 -0
  86. {dj_queue-0.10.4 → dj_queue-0.10.6}/dj_queue/views.py +0 -0
  87. {dj_queue-0.10.4 → dj_queue-0.10.6}/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.6
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
@@ -149,12 +149,14 @@ def load_backend_config(
149
149
  if tasks_settings is None:
150
150
  tasks_settings = getattr(settings, "TASKS", {})
151
151
 
152
+ ensure_dj_queue_backend_alias(tasks_settings, backend_alias)
153
+ backend_block = _backend_block(tasks_settings, backend_alias)
152
154
  env_values = {key: env.get(key) for key in CONFIG_ENV_KEYS if env.get(key) is not None}
153
155
  cache_key = (
154
156
  backend_alias,
155
157
  _cache_key(cli_overrides),
156
158
  _cache_key(env_values),
157
- _cache_key(tasks_settings),
159
+ _cache_key(backend_block),
158
160
  )
159
161
  if cache_key not in _BACKEND_CONFIG_CACHE:
160
162
  _BACKEND_CONFIG_CACHE[cache_key] = _load_backend_config_uncached(
@@ -248,7 +250,9 @@ def _load_backend_config_uncached(
248
250
  resolved_options["process_alive_threshold"], "process_alive_threshold"
249
251
  ),
250
252
  shutdown_timeout=_nonnegative_float(resolved_options["shutdown_timeout"], "shutdown_timeout"),
251
- supervisor_pidfile=resolved_options["supervisor_pidfile"],
253
+ supervisor_pidfile=_optional_string_option(
254
+ resolved_options["supervisor_pidfile"], "supervisor_pidfile"
255
+ ),
252
256
  preserve_finished_jobs=preserve_finished_jobs,
253
257
  clear_finished_jobs_after=_optional_nonnegative_int(
254
258
  resolved_options["clear_finished_jobs_after"], "clear_finished_jobs_after"
@@ -264,7 +268,7 @@ def _load_backend_config_uncached(
264
268
  resolved_options["default_concurrency_duration"],
265
269
  "default_concurrency_duration",
266
270
  ),
267
- database_alias=str(resolved_options["database_alias"]),
271
+ database_alias=_string_option(resolved_options["database_alias"], "database_alias"),
268
272
  use_skip_locked=_bool_option(resolved_options["use_skip_locked"], "use_skip_locked"),
269
273
  listen_notify=_bool_option(resolved_options["listen_notify"], "listen_notify"),
270
274
  silence_polling=_bool_option(resolved_options["silence_polling"], "silence_polling"),
@@ -428,7 +432,7 @@ def _validated_callback_path(callback_path: Any) -> str | None:
428
432
  if callback_path in (None, ""):
429
433
  return None
430
434
 
431
- callback_path = str(callback_path)
435
+ callback_path = _string_option(callback_path, "on_thread_error")
432
436
  try:
433
437
  import_string(callback_path)
434
438
  except ImportError as exc:
@@ -546,24 +550,31 @@ def _build_recurring_config(
546
550
 
547
551
  recurring: dict[str, RecurringTaskConfig] = {}
548
552
  for key, raw_entry in raw_recurring.items():
553
+ key = _string_option(key, "recurring task key")
549
554
  if not isinstance(raw_entry, Mapping):
550
555
  raise ImproperlyConfigured("recurring entries must be mappings")
551
556
 
552
- task_path = raw_entry.get("task_path")
553
- schedule = raw_entry.get("schedule")
554
- if not task_path or not schedule:
557
+ raw_task_path = raw_entry.get("task_path")
558
+ raw_schedule = raw_entry.get("schedule")
559
+ if raw_task_path in (None, "") or raw_schedule in (None, ""):
555
560
  raise ImproperlyConfigured(f"recurring task {key!r} requires task_path and schedule")
556
- if not is_valid_cron(str(schedule)):
561
+ task_path = _string_option(raw_task_path, f"recurring task {key!r} task_path")
562
+ schedule = _string_option(raw_schedule, f"recurring task {key!r} schedule")
563
+ if not is_valid_cron(schedule):
557
564
  raise ImproperlyConfigured(f"recurring task {key!r} has an invalid cron schedule")
558
565
 
559
- queue_name = str(raw_entry.get("queue_name", "default"))
566
+ queue_name = _string_option(
567
+ raw_entry.get("queue_name", "default"), f"recurring task {key!r} queue_name"
568
+ )
560
569
  priority = _priority_int(raw_entry.get("priority", 0), f"recurring task {key!r} priority")
561
570
  if allowed_queues and queue_name not in allowed_queues:
562
571
  raise ImproperlyConfigured(
563
572
  f"recurring task {key!r} is invalid: queue {queue_name!r} is not allowed for backend {backend_alias!r}"
564
573
  )
574
+ args = _tuple_option(raw_entry.get("args", []), f"recurring task {key!r} args")
575
+ kwargs = _dict_option(raw_entry.get("kwargs", {}), f"recurring task {key!r} kwargs")
565
576
  try:
566
- task = import_string(str(task_path))
577
+ task = import_string(task_path)
567
578
  except ImportError as exc:
568
579
  raise ImproperlyConfigured(f"recurring task {key!r} is invalid: {exc}") from exc
569
580
  if not hasattr(task, "using"):
@@ -571,15 +582,17 @@ def _build_recurring_config(
571
582
  f"recurring task {key!r} is invalid: task_path must reference a Django task"
572
583
  )
573
584
 
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", {})),
585
+ recurring[key] = RecurringTaskConfig(
586
+ key=key,
587
+ task_path=task_path,
588
+ schedule=schedule,
589
+ args=args,
590
+ kwargs=kwargs,
580
591
  queue_name=queue_name,
581
592
  priority=priority,
582
- description=str(raw_entry.get("description", "")),
593
+ description=_string_option(
594
+ raw_entry.get("description", ""), f"recurring task {key!r} description"
595
+ ),
583
596
  )
584
597
  return recurring
585
598
 
@@ -612,9 +625,36 @@ def _as_string_tuple(value: Any) -> tuple[str, ...]:
612
625
  return ()
613
626
  if isinstance(value, str):
614
627
  return (value,)
615
- if not isinstance(value, Sequence):
628
+ if not isinstance(value, Sequence) or isinstance(value, (bytes, bytearray)):
629
+ raise ImproperlyConfigured("expected a string or a sequence of strings")
630
+ values = tuple(value)
631
+ if not all(isinstance(item, str) for item in values):
616
632
  raise ImproperlyConfigured("expected a string or a sequence of strings")
617
- return tuple(str(item) for item in value)
633
+ return values
634
+
635
+
636
+ def _string_option(value: Any, setting_name: str) -> str:
637
+ if not isinstance(value, str):
638
+ raise ImproperlyConfigured(f"dj_queue {setting_name} must be a string")
639
+ return value
640
+
641
+
642
+ def _optional_string_option(value: Any, setting_name: str) -> str | None:
643
+ if value is None:
644
+ return None
645
+ return _string_option(value, setting_name)
646
+
647
+
648
+ def _tuple_option(value: Any, setting_name: str) -> tuple[Any, ...]:
649
+ if isinstance(value, str) or not isinstance(value, Sequence):
650
+ raise ImproperlyConfigured(f"dj_queue {setting_name} must be a sequence")
651
+ return tuple(value)
652
+
653
+
654
+ def _dict_option(value: Any, setting_name: str) -> dict[str, Any]:
655
+ if not isinstance(value, Mapping):
656
+ raise ImproperlyConfigured(f"dj_queue {setting_name} must be a mapping")
657
+ return dict(value)
618
658
 
619
659
 
620
660
  def _optional_nonnegative_int(value: Any, setting_name: str) -> int | None:
@@ -24,13 +24,18 @@ class DjQueueLifespan:
24
24
  self.supervisor is not None and self._poll_stop is not None and not self._poll_stop.is_set()
25
25
  ):
26
26
  try:
27
- await asyncio.to_thread(self.supervisor.poll_once)
27
+ poll_once = getattr(self.supervisor, "poll_once_if_running", self.supervisor.poll_once)
28
+ keep_polling = await asyncio.to_thread(poll_once)
28
29
  except Exception as error:
29
30
  handle_thread_error(
30
31
  error,
31
32
  context="supervisor.run",
32
33
  backend_alias=self.supervisor.backend_alias,
33
34
  )
35
+ keep_polling = True
36
+
37
+ if keep_polling is False:
38
+ return
34
39
 
35
40
  if self.supervisor is None or self._poll_stop is None or self._poll_stop.is_set():
36
41
  return
@@ -104,8 +109,10 @@ class DjQueueLifespan:
104
109
 
105
110
  receive_queue = asyncio.Queue()
106
111
  send_queue = asyncio.Queue()
107
- app_task = await self._start_wrapped_lifespan_app(scope, receive_queue.get, send_queue.put)
108
112
  wrapped_app_supports_lifespan = self.forward_wrapped_lifespan
113
+ app_task = None
114
+ if wrapped_app_supports_lifespan:
115
+ app_task = await self._start_wrapped_lifespan_app(scope, receive_queue.get, send_queue.put)
109
116
 
110
117
  try:
111
118
  while True:
@@ -137,12 +144,12 @@ class DjQueueLifespan:
137
144
  if response is None:
138
145
  response = {"type": "lifespan.shutdown.complete"}
139
146
  await send(response)
140
- if wrapped_app_supports_lifespan:
147
+ if wrapped_app_supports_lifespan and app_task is not None:
141
148
  await app_task
142
149
  return
143
150
  finally:
144
151
  await self._stop_supervisor()
145
- if not app_task.done():
152
+ if app_task is not None and not app_task.done():
146
153
  app_task.cancel()
147
154
  with suppress(asyncio.CancelledError):
148
155
  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,11 +21,17 @@ 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
+ if getattr(worker, "_dj_queue_supervisor_exiting", False):
25
+ return None
26
+
27
+ lock_file = _try_acquire_supervisor_lock(backend_alias=backend_alias)
24
28
  if lock_file is None:
25
29
  return None
26
30
 
27
31
  try:
32
+ if getattr(worker, "_dj_queue_supervisor_exiting", False):
33
+ _release_supervisor_lock(lock_file)
34
+ return None
28
35
  supervisor = build_supervisor(backend_alias=backend_alias)
29
36
  poll_stop = threading.Event()
30
37
  _set_supervisor_state(
@@ -34,6 +41,17 @@ def _start_embedded_supervisor(worker, *, backend_alias="default"):
34
41
  supervisor_poll_stop=poll_stop,
35
42
  )
36
43
  supervisor.start()
44
+ if getattr(worker, "_dj_queue_supervisor_exiting", False):
45
+ supervisor.stop()
46
+ _release_supervisor_lock(lock_file)
47
+ _set_supervisor_state(
48
+ worker,
49
+ supervisor_lock=None,
50
+ supervisor=None,
51
+ supervisor_poll_stop=None,
52
+ supervisor_poll_thread=None,
53
+ )
54
+ return None
37
55
  except Exception:
38
56
  _release_supervisor_lock(lock_file)
39
57
  _set_supervisor_state(
@@ -49,7 +67,9 @@ def _start_embedded_supervisor(worker, *, backend_alias="default"):
49
67
  stop_event = worker._dj_queue_supervisor_poll_stop
50
68
  while stop_event.wait(supervisor.polling_interval) is False:
51
69
  try:
52
- supervisor.poll_once()
70
+ poll_once = getattr(supervisor, "poll_once_if_running", supervisor.poll_once)
71
+ if poll_once() is False:
72
+ return
53
73
  except Exception as error:
54
74
  handle_thread_error(
55
75
  error,
@@ -70,7 +90,12 @@ def _start_lock_retry_loop(worker, *, backend_alias="default"):
70
90
  while retry_stop.wait(LOCK_RETRY_INTERVAL) is False:
71
91
  if getattr(worker, "_dj_queue_supervisor", None) is not None:
72
92
  return
73
- if _start_embedded_supervisor(worker, backend_alias=backend_alias) is not None:
93
+ try:
94
+ supervisor = _start_embedded_supervisor(worker, backend_alias=backend_alias)
95
+ except Exception as error:
96
+ handle_thread_error(error, context="gunicorn.supervisor", backend_alias=backend_alias)
97
+ continue
98
+ if supervisor is not None:
74
99
  retry_stop.set()
75
100
  return
76
101
 
@@ -81,7 +106,8 @@ def _start_lock_retry_loop(worker, *, backend_alias="default"):
81
106
  retry_thread.start()
82
107
 
83
108
 
84
- def post_fork(_server, worker):
109
+ def post_fork(server, worker):
110
+ backend_alias = _backend_alias(server, worker)
85
111
  _set_supervisor_state(
86
112
  worker,
87
113
  supervisor=None,
@@ -90,20 +116,18 @@ def post_fork(_server, worker):
90
116
  supervisor_poll_thread=None,
91
117
  supervisor_retry_stop=None,
92
118
  supervisor_retry_thread=None,
119
+ supervisor_exiting=False,
93
120
  )
94
- supervisor = _start_embedded_supervisor(worker)
121
+ supervisor = _start_embedded_supervisor(worker, backend_alias=backend_alias)
95
122
  if supervisor is not None:
96
123
  return supervisor
97
124
 
98
- _start_lock_retry_loop(worker)
125
+ _start_lock_retry_loop(worker, backend_alias=backend_alias)
99
126
  return None
100
127
 
101
128
 
102
129
  def worker_exit(_server, worker):
103
- supervisor = getattr(worker, "_dj_queue_supervisor", None)
104
- lock_file = getattr(worker, "_dj_queue_supervisor_lock", None)
105
- stop_event = getattr(worker, "_dj_queue_supervisor_poll_stop", None)
106
- poll_thread = getattr(worker, "_dj_queue_supervisor_poll_thread", None)
130
+ worker._dj_queue_supervisor_exiting = True
107
131
  retry_stop = getattr(worker, "_dj_queue_supervisor_retry_stop", None)
108
132
  retry_thread = getattr(worker, "_dj_queue_supervisor_retry_thread", None)
109
133
 
@@ -114,6 +138,11 @@ def worker_exit(_server, worker):
114
138
  worker._dj_queue_supervisor_retry_thread = None
115
139
  worker._dj_queue_supervisor_retry_stop = None
116
140
 
141
+ supervisor = getattr(worker, "_dj_queue_supervisor", None)
142
+ lock_file = getattr(worker, "_dj_queue_supervisor_lock", None)
143
+ stop_event = getattr(worker, "_dj_queue_supervisor_poll_stop", None)
144
+ poll_thread = getattr(worker, "_dj_queue_supervisor_poll_thread", None)
145
+
117
146
  if stop_event is not None:
118
147
  stop_event.set()
119
148
  if poll_thread is not None:
@@ -133,9 +162,22 @@ def worker_exit(_server, worker):
133
162
  return None
134
163
 
135
164
 
136
- def _try_acquire_supervisor_lock():
137
- LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
138
- lock_file = LOCK_PATH.open("a+")
165
+ def _backend_alias(server, worker):
166
+ return getattr(worker, "dj_queue_backend_alias", None) or getattr(
167
+ server, "dj_queue_backend_alias", "default"
168
+ )
169
+
170
+
171
+ def _supervisor_lock_path(*, backend_alias):
172
+ lock_scope = f"{Path.cwd()}:{backend_alias}"
173
+ digest = hashlib.sha256(lock_scope.encode()).hexdigest()[:12]
174
+ return Path(tempfile.gettempdir()) / f"{LOCK_PATH_PREFIX}_{backend_alias}_{digest}.lock"
175
+
176
+
177
+ def _try_acquire_supervisor_lock(*, backend_alias="default"):
178
+ lock_path = _supervisor_lock_path(backend_alias=backend_alias)
179
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
180
+ lock_file = lock_path.open("a+")
139
181
  try:
140
182
  fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
141
183
  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())
@@ -268,8 +268,8 @@ def dashboard_context(*, backend_alias, query_params=None):
268
268
  query_params = {}
269
269
 
270
270
  snapshot = observability.backend_snapshot(backend_alias=backend_alias)
271
- queue_rows = snapshot["queue_rows"]
272
- process_rows = snapshot["process_rows"]
271
+ queue_rows = snapshot.queue_rows
272
+ process_rows = snapshot.process_rows
273
273
  recurring_rows = [
274
274
  {
275
275
  **row,
@@ -278,7 +278,7 @@ def dashboard_context(*, backend_alias, query_params=None):
278
278
  recurring_task_key=row["key"],
279
279
  ),
280
280
  }
281
- for row in snapshot["recurring_rows"]
281
+ for row in snapshot.recurring_rows
282
282
  ]
283
283
  semaphore_rows = [
284
284
  {
@@ -288,14 +288,14 @@ def dashboard_context(*, backend_alias, query_params=None):
288
288
  concurrency_key=row["key"],
289
289
  ),
290
290
  }
291
- for row in snapshot["semaphore_rows"]
291
+ for row in snapshot.semaphore_rows
292
292
  ]
293
293
 
294
294
  return {
295
295
  "backend_alias": backend_alias,
296
296
  "backend_choices": backend_choices(),
297
297
  "config": config,
298
- "queue_database_alias": snapshot["queue_database_alias"],
298
+ "queue_database_alias": snapshot.queue_database_alias,
299
299
  "summary_cards": _summary_cards(
300
300
  backend_alias=backend_alias,
301
301
  queue_rows=queue_rows,
@@ -305,7 +305,7 @@ def dashboard_context(*, backend_alias, query_params=None):
305
305
  ),
306
306
  "backend_facts": _backend_facts(
307
307
  config=config,
308
- queue_database_alias=snapshot["queue_database_alias"],
308
+ queue_database_alias=snapshot.queue_database_alias,
309
309
  recurring_count=len(recurring_rows),
310
310
  semaphore_count=len(semaphore_rows),
311
311
  ),
@@ -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
 
@@ -34,11 +35,11 @@ def metric_families(*, snapshots=None):
34
35
  seen_queue_databases = set()
35
36
 
36
37
  for snapshot in snapshots:
37
- backend_alias = snapshot["backend_alias"]
38
- queue_database_alias = snapshot["queue_database_alias"]
39
- runner_metrics = snapshot["runner_metrics"]
38
+ backend_alias = snapshot.backend_alias
39
+ queue_database_alias = snapshot.queue_database_alias
40
+ runner_metrics = snapshot.runner_metrics
40
41
 
41
- for queue in snapshot["queue_rows"]:
42
+ for queue in snapshot.queue_rows:
42
43
  for definition in QUEUE_STATE_DEFINITIONS:
43
44
  queue_jobs.append(
44
45
  MetricSample(
@@ -85,13 +86,13 @@ def metric_families(*, snapshots=None):
85
86
  recurring_tasks.append(
86
87
  MetricSample(
87
88
  labels=(backend_alias,),
88
- value=len(snapshot["recurring_rows"]),
89
+ value=len(snapshot.recurring_rows),
89
90
  )
90
91
  )
91
92
  process_rows.append(
92
93
  MetricSample(
93
94
  labels=(backend_alias,),
94
- value=len(snapshot["process_rows"]),
95
+ value=len(snapshot.process_rows),
95
96
  )
96
97
  )
97
98
 
@@ -101,7 +102,7 @@ def metric_families(*, snapshots=None):
101
102
  semaphores.append(
102
103
  MetricSample(
103
104
  labels=(queue_database_alias,),
104
- value=len(snapshot["semaphore_rows"]),
105
+ value=len(snapshot.semaphore_rows),
105
106
  )
106
107
  )
107
108
 
@@ -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
  ),