dj-queue 0.11.0__tar.gz → 0.12.0__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 (96) hide show
  1. {dj_queue-0.11.0 → dj_queue-0.12.0}/PKG-INFO +4 -2
  2. {dj_queue-0.11.0 → dj_queue-0.12.0}/README.md +3 -1
  3. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/admin.py +13 -4
  4. dj_queue-0.12.0/dj_queue/apps.py +17 -0
  5. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/backend.py +29 -4
  6. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/config.py +18 -0
  7. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/dashboard.py +299 -71
  8. dj_queue-0.12.0/dj_queue/migrations/0012_job_djq_jobs_b_queue_id_idx_job_djq_jobs_b_conc_idx_and_more.py +33 -0
  9. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/models/jobs.py +2 -0
  10. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/models/runtime.py +4 -0
  11. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/observability.py +36 -7
  12. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/_helpers.py +31 -32
  13. dj_queue-0.12.0/dj_queue/operations/_insert.py +32 -0
  14. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/concurrency.py +293 -358
  15. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/jobs.py +406 -273
  16. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/queues.py +18 -21
  17. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/recurring.py +90 -22
  18. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/queue_state.py +132 -7
  19. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/base.py +24 -0
  20. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/connection_budget.py +2 -7
  21. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/notify.py +10 -7
  22. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/scheduler.py +34 -6
  23. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/worker.py +13 -1
  24. dj_queue-0.12.0/dj_queue/sql/__init__.py +12 -0
  25. dj_queue-0.12.0/dj_queue/sql/common.py +174 -0
  26. dj_queue-0.12.0/dj_queue/sql/mysql.py +81 -0
  27. dj_queue-0.12.0/dj_queue/sql/postgres.py +392 -0
  28. dj_queue-0.12.0/dj_queue/sql/sqlite.py +8 -0
  29. dj_queue-0.12.0/dj_queue/sql/state.py +37 -0
  30. {dj_queue-0.11.0 → dj_queue-0.12.0}/pyproject.toml +1 -1
  31. dj_queue-0.11.0/dj_queue/apps.py +0 -6
  32. dj_queue-0.11.0/dj_queue/operations/_insert.py +0 -43
  33. {dj_queue-0.11.0 → dj_queue-0.12.0}/LICENSE +0 -0
  34. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/__init__.py +0 -0
  35. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/api.py +0 -0
  36. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/contrib/__init__.py +0 -0
  37. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/contrib/asgi.py +0 -0
  38. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/contrib/gunicorn.py +0 -0
  39. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/contrib/prometheus.py +0 -0
  40. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/cron.py +0 -0
  41. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/dashboard_actions.py +0 -0
  42. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/db.py +0 -0
  43. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/exceptions.py +0 -0
  44. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/hooks.py +0 -0
  45. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/log.py +0 -0
  46. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/__init__.py +0 -0
  47. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/commands/__init__.py +0 -0
  48. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/commands/dj_queue.py +0 -0
  49. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/commands/dj_queue_health.py +0 -0
  50. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/management/commands/dj_queue_prune.py +0 -0
  51. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/metrics.py +0 -0
  52. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0001_initial.py +0 -0
  53. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
  54. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
  55. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0004_dashboard.py +0 -0
  56. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0005_remove_recurringexecution_dj_queue_recurring_executions_task_key_run_at_unique_and_more.py +0 -0
  57. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0006_blockedexecution_dj_queue_bl_concurr_2d8393_idx_and_more.py +0 -0
  58. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
  59. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0008_remove_blockedexecution_dj_queue_bl_concurr_1ce730_idx_and_more.py +0 -0
  60. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0009_remove_process_dj_queue_processes_name_supervisor_unique_and_more.py +0 -0
  61. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0010_remove_process_djq_pr_name_parent_uniq_and_more.py +0 -0
  62. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/0011_remove_blockedexecution_djq_bl_b_conc_idx_and_more.py +0 -0
  63. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/migrations/__init__.py +0 -0
  64. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/models/__init__.py +0 -0
  65. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/models/recurring.py +0 -0
  66. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/__init__.py +0 -0
  67. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/operations/cleanup.py +0 -0
  68. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/queue_selectors.py +0 -0
  69. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/routers.py +0 -0
  70. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/__init__.py +0 -0
  71. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/dispatcher.py +0 -0
  72. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/errors.py +0 -0
  73. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/interruptible.py +0 -0
  74. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/pidfile.py +0 -0
  75. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/pool.py +0 -0
  76. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/procline.py +0 -0
  77. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/supervisor.py +0 -0
  78. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/runtime/topology.py +0 -0
  79. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/task_results.py +0 -0
  80. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
  81. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
  82. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
  83. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
  84. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
  85. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_queue_controls.html +0 -0
  86. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +0 -0
  87. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/change_form.html +0 -0
  88. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
  89. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/dashboard.html +0 -0
  90. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
  91. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/queue_jobs.html +0 -0
  92. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templatetags/__init__.py +0 -0
  93. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/templatetags/dj_queue_admin.py +0 -0
  94. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/urls.py +0 -0
  95. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/views.py +0 -0
  96. {dj_queue-0.11.0 → dj_queue-0.12.0}/dj_queue/wakeup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dj-queue
3
- Version: 0.11.0
3
+ Version: 0.12.0
4
4
  Summary: Database-backed task queue backend for Django’s Tasks framework.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -812,7 +812,7 @@ Global options:
812
812
  | Option | Default | Meaning |
813
813
  |---|---|---|
814
814
  | `mode` | `"fork"` | standalone runtime mode, either `"fork"` or `"async"` |
815
- | `workers` | `[{"queues": "*", "threads": 3, "processes": 1, "polling_interval": 0.1}]` | worker topology and queue selectors |
815
+ | `workers` | `[{"queues": "*", "threads": 3, "processes": 1, "polling_interval": 0.1, "prefetch_multiplier": 2}]` | worker topology and queue selectors |
816
816
  | `dispatchers` | `[{"batch_size": 500, "polling_interval": 1, "concurrency_maintenance": true, "concurrency_maintenance_interval": 600}]` | scheduled promotion and concurrency maintenance |
817
817
  | `scheduler` | `{"dynamic_tasks_enabled": false, "polling_interval": 5}` | dynamic recurring polling; the scheduler only starts when recurring or cleanup work exists |
818
818
  | `recurring` | `{}` | static recurring task definitions keyed by name |
@@ -839,6 +839,7 @@ Worker entry options:
839
839
  | `threads` | `3` | worker threads per worker process |
840
840
  | `processes` | `1` | worker processes in `fork` mode; normalized to `1` in `async` mode |
841
841
  | `polling_interval` | `0.1` | seconds between worker polls |
842
+ | `prefetch_multiplier` | `2` | max claimed running/queued jobs per worker thread |
842
843
 
843
844
  Dispatcher entry options:
844
845
 
@@ -914,6 +915,7 @@ queues = ["default", "email*"]
914
915
  threads = 8
915
916
  processes = 1
916
917
  polling_interval = 0.1
918
+ prefetch_multiplier = 2
917
919
 
918
920
  [[dispatchers]]
919
921
  batch_size = 500
@@ -786,7 +786,7 @@ Global options:
786
786
  | Option | Default | Meaning |
787
787
  |---|---|---|
788
788
  | `mode` | `"fork"` | standalone runtime mode, either `"fork"` or `"async"` |
789
- | `workers` | `[{"queues": "*", "threads": 3, "processes": 1, "polling_interval": 0.1}]` | worker topology and queue selectors |
789
+ | `workers` | `[{"queues": "*", "threads": 3, "processes": 1, "polling_interval": 0.1, "prefetch_multiplier": 2}]` | worker topology and queue selectors |
790
790
  | `dispatchers` | `[{"batch_size": 500, "polling_interval": 1, "concurrency_maintenance": true, "concurrency_maintenance_interval": 600}]` | scheduled promotion and concurrency maintenance |
791
791
  | `scheduler` | `{"dynamic_tasks_enabled": false, "polling_interval": 5}` | dynamic recurring polling; the scheduler only starts when recurring or cleanup work exists |
792
792
  | `recurring` | `{}` | static recurring task definitions keyed by name |
@@ -813,6 +813,7 @@ Worker entry options:
813
813
  | `threads` | `3` | worker threads per worker process |
814
814
  | `processes` | `1` | worker processes in `fork` mode; normalized to `1` in `async` mode |
815
815
  | `polling_interval` | `0.1` | seconds between worker polls |
816
+ | `prefetch_multiplier` | `2` | max claimed running/queued jobs per worker thread |
816
817
 
817
818
  Dispatcher entry options:
818
819
 
@@ -888,6 +889,7 @@ queues = ["default", "email*"]
888
889
  threads = 8
889
890
  processes = 1
890
891
  polling_interval = 0.1
892
+ prefetch_multiplier = 2
891
893
 
892
894
  [[dispatchers]]
893
895
  batch_size = 500
@@ -82,9 +82,6 @@ def _install_dj_queue_admin_site(site):
82
82
  site._dj_queue_dashboard_installed = True
83
83
 
84
84
 
85
- _install_dj_queue_admin_site(admin.site)
86
-
87
-
88
85
  def _format_admin_datetime(value):
89
86
  if value is None:
90
87
  return None
@@ -530,7 +527,19 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
530
527
 
531
528
  def get_queryset(self, request):
532
529
  queryset = super().get_queryset(request)
533
- return queryset.annotate(status_rank=status_rank_expression())
530
+ if self._orders_by_status(request):
531
+ return queryset.annotate(status_rank=status_rank_expression())
532
+ return queryset
533
+
534
+ def _orders_by_status(self, request):
535
+ ordering = request.GET.get("o")
536
+ if not ordering:
537
+ return False
538
+ try:
539
+ status_index = self.get_list_display(request).index("display_status") + 1
540
+ except ValueError:
541
+ return False
542
+ return any(part.lstrip("-") == str(status_index) for part in ordering.split("."))
534
543
 
535
544
  @admin.display(description="status", ordering="status_rank")
536
545
  def display_status(self, obj):
@@ -0,0 +1,17 @@
1
+ from django.apps import AppConfig
2
+ from django.apps import apps
3
+
4
+
5
+ class DjQueueConfig(AppConfig):
6
+ name = "dj_queue"
7
+ verbose_name = "dj_queue"
8
+
9
+ def ready(self):
10
+ if not apps.is_installed("django.contrib.admin"):
11
+ return
12
+
13
+ from django.contrib import admin
14
+
15
+ from dj_queue.admin import _install_dj_queue_admin_site
16
+
17
+ _install_dj_queue_admin_site(admin.site)
@@ -3,6 +3,7 @@ from django.db import close_old_connections, connections
3
3
  from django.tasks.backends.base import BaseTaskBackend
4
4
  from django.tasks.exceptions import TaskResultDoesNotExist
5
5
 
6
+ from dj_queue.config import load_backend_config
6
7
  from dj_queue.db import get_database_alias
7
8
  from dj_queue.models import Job
8
9
  from dj_queue.operations.jobs import (
@@ -47,8 +48,13 @@ class DjQueueBackend(BaseTaskBackend):
47
48
  )
48
49
 
49
50
  async def aenqueue(self, task, args, kwargs):
50
- return await sync_to_async(_async_backend_call, thread_sensitive=True)(
51
+ config = load_backend_config(self.alias)
52
+ return await sync_to_async(
53
+ _async_backend_call,
54
+ thread_sensitive=config.async_thread_sensitive,
55
+ )(
51
56
  self.enqueue,
57
+ close_connections=config.async_close_connections,
52
58
  task=task,
53
59
  args=args,
54
60
  kwargs=kwargs,
@@ -70,6 +76,17 @@ class DjQueueBackend(BaseTaskBackend):
70
76
  for job, task, dispatch_outcome in created_jobs
71
77
  ]
72
78
 
79
+ async def aenqueue_all(self, task_calls):
80
+ config = load_backend_config(self.alias)
81
+ return await sync_to_async(
82
+ _async_backend_call,
83
+ thread_sensitive=config.async_thread_sensitive,
84
+ )(
85
+ self.enqueue_all,
86
+ close_connections=config.async_close_connections,
87
+ task_calls=task_calls,
88
+ )
89
+
73
90
  def get_result(self, result_id):
74
91
  alias = get_database_alias(self.alias)
75
92
  try:
@@ -90,15 +107,23 @@ class DjQueueBackend(BaseTaskBackend):
90
107
  return task_result_from_job(job)
91
108
 
92
109
  async def aget_result(self, result_id):
93
- return await sync_to_async(_async_backend_call, thread_sensitive=True)(
110
+ config = load_backend_config(self.alias)
111
+ return await sync_to_async(
112
+ _async_backend_call,
113
+ thread_sensitive=config.async_thread_sensitive,
114
+ )(
94
115
  self.get_result,
116
+ close_connections=config.async_close_connections,
95
117
  result_id=result_id,
96
118
  )
97
119
 
98
120
 
99
- def _async_backend_call(method, /, **kwargs):
121
+ def _async_backend_call(method, /, *, close_connections=False, **kwargs):
100
122
  close_old_connections()
101
123
  try:
102
124
  return method(**kwargs)
103
125
  finally:
104
- connections.close_all()
126
+ if close_connections:
127
+ connections.close_all()
128
+ else:
129
+ close_old_connections()
@@ -19,6 +19,7 @@ DEFAULT_WORKER = {
19
19
  "threads": 3,
20
20
  "processes": 1,
21
21
  "polling_interval": 0.1,
22
+ "prefetch_multiplier": 2,
22
23
  }
23
24
 
24
25
  DEFAULT_DISPATCHER = {
@@ -52,6 +53,8 @@ DEFAULT_OPTIONS = {
52
53
  "use_skip_locked": True,
53
54
  "listen_notify": True,
54
55
  "silence_polling": True,
56
+ "async_thread_sensitive": False,
57
+ "async_close_connections": False,
55
58
  "on_thread_error": None,
56
59
  }
57
60
 
@@ -75,6 +78,7 @@ class WorkerConfig(ConfigValue):
75
78
  threads: int = 3
76
79
  processes: int = 1
77
80
  polling_interval: float = 0.1
81
+ prefetch_multiplier: int = 2
78
82
 
79
83
 
80
84
  @dataclass(frozen=True, slots=True)
@@ -125,6 +129,8 @@ class BackendConfig(ConfigValue):
125
129
  use_skip_locked: bool = True
126
130
  listen_notify: bool = True
127
131
  silence_polling: bool = True
132
+ async_thread_sensitive: bool = False
133
+ async_close_connections: bool = False
128
134
  on_thread_error: str | None = None
129
135
  skip_recurring: bool = False
130
136
  only_work: bool = False
@@ -272,6 +278,12 @@ def _load_backend_config_uncached(
272
278
  use_skip_locked=_bool_option(resolved_options["use_skip_locked"], "use_skip_locked"),
273
279
  listen_notify=_bool_option(resolved_options["listen_notify"], "listen_notify"),
274
280
  silence_polling=_bool_option(resolved_options["silence_polling"], "silence_polling"),
281
+ async_thread_sensitive=_bool_option(
282
+ resolved_options["async_thread_sensitive"], "async_thread_sensitive"
283
+ ),
284
+ async_close_connections=_bool_option(
285
+ resolved_options["async_close_connections"], "async_close_connections"
286
+ ),
275
287
  on_thread_error=on_thread_error,
276
288
  skip_recurring=skip_recurring,
277
289
  only_work=only_work,
@@ -370,6 +382,8 @@ def _load_toml_options(config_path: Any, *, backend_alias: str) -> dict[str, Any
370
382
 
371
383
  try:
372
384
  config_payload = tomllib.loads(Path(config_path).read_text(encoding="utf-8"))
385
+ except OSError as exc:
386
+ raise ImproperlyConfigured(f"DJ_QUEUE_CONFIG could not be read: {exc}") from exc
373
387
  except tomllib.TOMLDecodeError as exc:
374
388
  raise ImproperlyConfigured(f"DJ_QUEUE_CONFIG TOML is invalid: {exc}") from exc
375
389
  if not isinstance(config_payload, dict):
@@ -475,6 +489,10 @@ def _build_worker_configs(raw_workers: Any, mode: str) -> tuple[WorkerConfig, ..
475
489
  raw_worker.get("polling_interval", DEFAULT_WORKER["polling_interval"]),
476
490
  f"workers[{index}].polling_interval",
477
491
  ),
492
+ prefetch_multiplier=_positive_int(
493
+ raw_worker.get("prefetch_multiplier", DEFAULT_WORKER["prefetch_multiplier"]),
494
+ f"workers[{index}].prefetch_multiplier",
495
+ ),
478
496
  )
479
497
 
480
498
  if mode == "async" and worker.processes > 1:
@@ -4,7 +4,7 @@ from urllib.parse import urlencode
4
4
  from uuid import UUID
5
5
 
6
6
  from django.core.paginator import Paginator
7
- from django.db.models import F
7
+ from django.db.models import DateTimeField, F, Max, OuterRef, Subquery
8
8
  from django.http import Http404
9
9
  from django.urls import reverse
10
10
  from django.utils import timezone
@@ -20,6 +20,7 @@ from dj_queue.queue_state import (
20
20
  queue_state_count_key,
21
21
  queue_state_queryset,
22
22
  )
23
+ from dj_queue.models import RecurringExecution, RecurringTask, Semaphore
23
24
 
24
25
 
25
26
  PAGE_SIZE = 100
@@ -256,84 +257,74 @@ def dashboard_context(*, backend_alias, query_params=None):
256
257
  if query_params is None:
257
258
  query_params = {}
258
259
 
259
- snapshot = observability.backend_snapshot(backend_alias=backend_alias)
260
- queue_rows = snapshot.queue_rows
261
- process_rows = snapshot.process_rows
262
- recurring_rows = [
263
- {
264
- **row,
265
- "jobs_url": _job_changelist_url(
266
- backend_alias=backend_alias,
267
- recurring_task_key=row["key"],
268
- ),
269
- }
270
- for row in snapshot.recurring_rows
271
- ]
272
- semaphore_rows = [
273
- {
274
- **row,
275
- "jobs_url": _job_changelist_url(
276
- backend_alias=backend_alias,
277
- concurrency_key=row["key"],
278
- ),
279
- }
280
- for row in snapshot.semaphore_rows
281
- ]
260
+ now = timezone.now()
261
+ queue_database_alias = get_database_alias(backend_alias)
262
+ process_cutoff = observability.process_cutoff_for_backend(
263
+ backend_alias,
264
+ now=now,
265
+ max_age=config.process_alive_threshold,
266
+ )
267
+ queue_rows = observability.queue_rows(
268
+ backend_alias=backend_alias,
269
+ now=now,
270
+ process_cutoff=process_cutoff,
271
+ )
272
+ process_rows = observability.process_rows(
273
+ backend_alias=backend_alias,
274
+ now=now,
275
+ process_cutoff=process_cutoff,
276
+ scope="backend",
277
+ )
278
+ queue_section = _overview_section(
279
+ section="queues",
280
+ rows=queue_rows,
281
+ page_param="queues_page",
282
+ page_size=OVERVIEW_PAGE_SIZES["queues"],
283
+ sort_param="queues_sort",
284
+ query_params=query_params,
285
+ anchor="queue-summary",
286
+ )
287
+ process_section = _overview_section(
288
+ section="processes",
289
+ rows=process_rows,
290
+ page_param="processes_page",
291
+ page_size=OVERVIEW_PAGE_SIZES["processes"],
292
+ sort_param="processes_sort",
293
+ query_params=query_params,
294
+ anchor="process-summary",
295
+ )
296
+ recurring_section = _recurring_overview_section(
297
+ backend_alias=backend_alias,
298
+ now=now,
299
+ query_params=query_params,
300
+ )
301
+ semaphore_section = _semaphore_overview_section(
302
+ backend_alias=backend_alias,
303
+ query_params=query_params,
304
+ )
282
305
 
283
306
  return {
284
307
  "backend_alias": backend_alias,
285
308
  "backend_choices": backend_choices(),
286
309
  "config": config,
287
- "queue_database_alias": snapshot.queue_database_alias,
310
+ "queue_database_alias": queue_database_alias,
288
311
  "summary_cards": _summary_cards(
289
312
  backend_alias=backend_alias,
290
313
  queue_rows=queue_rows,
291
314
  process_rows=process_rows,
292
- recurring_rows=recurring_rows,
293
- semaphore_rows=semaphore_rows,
315
+ recurring_count=recurring_section["total_count"],
316
+ semaphore_count=semaphore_section["total_count"],
294
317
  ),
295
318
  "backend_facts": _backend_facts(
296
319
  config=config,
297
- queue_database_alias=snapshot.queue_database_alias,
298
- recurring_count=len(recurring_rows),
299
- semaphore_count=len(semaphore_rows),
300
- ),
301
- "queue_section": _overview_section(
302
- section="queues",
303
- rows=queue_rows,
304
- page_param="queues_page",
305
- page_size=OVERVIEW_PAGE_SIZES["queues"],
306
- sort_param="queues_sort",
307
- query_params=query_params,
308
- anchor="queue-summary",
309
- ),
310
- "process_section": _overview_section(
311
- section="processes",
312
- rows=process_rows,
313
- page_param="processes_page",
314
- page_size=OVERVIEW_PAGE_SIZES["processes"],
315
- sort_param="processes_sort",
316
- query_params=query_params,
317
- anchor="process-summary",
318
- ),
319
- "recurring_section": _overview_section(
320
- section="recurring",
321
- rows=recurring_rows,
322
- page_param="recurring_page",
323
- page_size=OVERVIEW_PAGE_SIZES["recurring"],
324
- sort_param="recurring_sort",
325
- query_params=query_params,
326
- anchor="recurring-summary",
327
- ),
328
- "semaphore_section": _overview_section(
329
- section="semaphores",
330
- rows=semaphore_rows,
331
- page_param="semaphores_page",
332
- page_size=OVERVIEW_PAGE_SIZES["semaphores"],
333
- sort_param="semaphores_sort",
334
- query_params=query_params,
335
- anchor="semaphore-summary",
320
+ queue_database_alias=queue_database_alias,
321
+ recurring_count=recurring_section["total_count"],
322
+ semaphore_count=semaphore_section["total_count"],
336
323
  ),
324
+ "queue_section": queue_section,
325
+ "process_section": process_section,
326
+ "recurring_section": recurring_section,
327
+ "semaphore_section": semaphore_section,
337
328
  }
338
329
 
339
330
 
@@ -449,7 +440,7 @@ def queue_page_context(*, backend_alias, queue_name, state, page_number, query_p
449
440
  }
450
441
 
451
442
 
452
- def _summary_cards(*, backend_alias, queue_rows, process_rows, recurring_rows, semaphore_rows):
443
+ def _summary_cards(*, backend_alias, queue_rows, process_rows, recurring_count, semaphore_count):
453
444
  paused_count = sum(1 for row in queue_rows if row["paused"])
454
445
  ready_count = sum(row[queue_state_count_key("ready")] for row in queue_rows)
455
446
  scheduled_count = sum(row[queue_state_count_key("scheduled")] for row in queue_rows)
@@ -497,8 +488,8 @@ def _summary_cards(*, backend_alias, queue_rows, process_rows, recurring_rows, s
497
488
  },
498
489
  {
499
490
  "label": "control-plane",
500
- "value": len(recurring_rows) + len(semaphore_rows),
501
- "detail": f"{len(recurring_rows)} recurring and {len(semaphore_rows)} semaphores",
491
+ "value": recurring_count + semaphore_count,
492
+ "detail": f"{recurring_count} recurring and {semaphore_count} semaphores",
502
493
  },
503
494
  )
504
495
 
@@ -547,7 +538,6 @@ def _overview_section(*, section, rows, page_param, page_size, sort_param, query
547
538
  raw_sort = query_params.get(sort_param)
548
539
  sort, explicit_sort = _resolve_overview_sort(section=section, raw_sort=raw_sort)
549
540
  rows = _sort_overview_rows(rows=rows, section=section, sort=sort)
550
- sort_value = sort if explicit_sort else None
551
541
 
552
542
  if section == "processes":
553
543
  page = _paginate_process_rows(
@@ -562,6 +552,72 @@ def _overview_section(*, section, rows, page_param, page_size, sort_param, query
562
552
  page_number=query_params.get(page_param, 1),
563
553
  )
564
554
 
555
+ return _section_payload_from_page(
556
+ section=section,
557
+ page=page,
558
+ query_params=query_params,
559
+ page_param=page_param,
560
+ sort_param=sort_param,
561
+ sort=sort,
562
+ explicit_sort=explicit_sort,
563
+ anchor=anchor,
564
+ )
565
+
566
+
567
+ def _recurring_overview_section(*, backend_alias, now, query_params):
568
+ section = "recurring"
569
+ page_param = "recurring_page"
570
+ sort_param = "recurring_sort"
571
+ anchor = "recurring-summary"
572
+ raw_sort = query_params.get(sort_param)
573
+ sort, explicit_sort = _resolve_overview_sort(section=section, raw_sort=raw_sort)
574
+ page = _recurring_overview_page(
575
+ backend_alias=backend_alias,
576
+ now=now,
577
+ page_size=OVERVIEW_PAGE_SIZES[section],
578
+ page_number=query_params.get(page_param, 1),
579
+ sort=sort,
580
+ )
581
+ return _section_payload_from_page(
582
+ section=section,
583
+ page=page,
584
+ query_params=query_params,
585
+ page_param=page_param,
586
+ sort_param=sort_param,
587
+ sort=sort,
588
+ explicit_sort=explicit_sort,
589
+ anchor=anchor,
590
+ )
591
+
592
+
593
+ def _semaphore_overview_section(*, backend_alias, query_params):
594
+ section = "semaphores"
595
+ page_param = "semaphores_page"
596
+ sort_param = "semaphores_sort"
597
+ anchor = "semaphore-summary"
598
+ raw_sort = query_params.get(sort_param)
599
+ sort, explicit_sort = _resolve_overview_sort(section=section, raw_sort=raw_sort)
600
+ page = _semaphore_overview_page(
601
+ backend_alias=backend_alias,
602
+ page_size=OVERVIEW_PAGE_SIZES[section],
603
+ page_number=query_params.get(page_param, 1),
604
+ sort=sort,
605
+ )
606
+ return _section_payload_from_page(
607
+ section=section,
608
+ page=page,
609
+ query_params=query_params,
610
+ page_param=page_param,
611
+ sort_param=sort_param,
612
+ sort=sort,
613
+ explicit_sort=explicit_sort,
614
+ anchor=anchor,
615
+ )
616
+
617
+
618
+ def _section_payload_from_page(
619
+ *, section, page, query_params, page_param, sort_param, sort, explicit_sort, anchor
620
+ ):
565
621
  return _section_payload(
566
622
  section=section,
567
623
  rows=page["rows"],
@@ -574,7 +630,7 @@ def _overview_section(*, section, rows, page_param, page_size, sort_param, query
574
630
  page_param=page_param,
575
631
  sort_param=sort_param,
576
632
  sort=sort,
577
- sort_value=sort_value,
633
+ sort_value=sort if explicit_sort else None,
578
634
  explicit_sort=explicit_sort,
579
635
  anchor=anchor,
580
636
  )
@@ -645,6 +701,178 @@ def _paginate_standard_rows(*, rows, page_size, page_number):
645
701
  }
646
702
 
647
703
 
704
+ def _recurring_overview_page(*, backend_alias, now, page_size, page_number, sort):
705
+ if _recurring_sort_requires_python(sort):
706
+ rows = [
707
+ _recurring_row_with_jobs_url(row, backend_alias=backend_alias)
708
+ for row in observability.recurring_rows_for_backend(backend_alias=backend_alias, now=now)
709
+ ]
710
+ rows = _sort_overview_rows(rows=rows, section="recurring", sort=sort)
711
+ return _paginate_standard_rows(rows=rows, page_size=page_size, page_number=page_number)
712
+
713
+ alias = get_database_alias(backend_alias)
714
+ last_run_at = (
715
+ RecurringExecution.objects.using(alias)
716
+ .filter(backend_alias=backend_alias, task_key=OuterRef("key"))
717
+ .values("task_key")
718
+ .annotate(value=Max("run_at"))
719
+ .values("value")[:1]
720
+ )
721
+ queryset = (
722
+ RecurringTask.objects.using(alias)
723
+ .filter(backend_alias=backend_alias)
724
+ .annotate(last_run_at=Subquery(last_run_at, output_field=DateTimeField()))
725
+ .order_by(*_overview_queryset_ordering(section="recurring", sort=sort, tie_breaker="key"))
726
+ )
727
+ paginator = Paginator(queryset, page_size)
728
+ page_obj = paginator.get_page(page_number)
729
+ total_count = paginator.count
730
+ return {
731
+ "rows": _recurring_task_rows(
732
+ page_obj.object_list,
733
+ backend_alias=backend_alias,
734
+ now=now,
735
+ ),
736
+ "number": page_obj.number,
737
+ "total_pages": paginator.num_pages,
738
+ "total_count": total_count,
739
+ "start_index": page_obj.start_index() if total_count else 0,
740
+ "end_index": page_obj.end_index() if total_count else 0,
741
+ }
742
+
743
+
744
+ def _semaphore_overview_page(*, backend_alias, page_size, page_number, sort):
745
+ alias = get_database_alias(backend_alias)
746
+ if _semaphore_sort_requires_python(sort):
747
+ rows = [
748
+ _semaphore_row_with_jobs_url(row, backend_alias=backend_alias)
749
+ for row in observability.semaphore_rows_for_backend(backend_alias=backend_alias)
750
+ ]
751
+ rows = _sort_overview_rows(
752
+ rows=rows,
753
+ section="semaphores",
754
+ sort=_sort_with_tie_breaker(sort, "key"),
755
+ )
756
+ return _paginate_standard_rows(rows=rows, page_size=page_size, page_number=page_number)
757
+
758
+ queryset = (
759
+ Semaphore.objects.using(alias)
760
+ .annotate(blocked_waiters=observability.semaphore_blocked_waiter_count_expression(alias))
761
+ .order_by(
762
+ *_overview_queryset_ordering(
763
+ section="semaphores",
764
+ sort=sort,
765
+ field_map={"available_slots": "value"},
766
+ tie_breaker="key",
767
+ )
768
+ )
769
+ )
770
+ paginator = Paginator(queryset, page_size)
771
+ page_obj = paginator.get_page(page_number)
772
+ total_count = paginator.count
773
+ return {
774
+ "rows": _semaphore_rows(page_obj.object_list, backend_alias=backend_alias, alias=alias),
775
+ "number": page_obj.number,
776
+ "total_pages": paginator.num_pages,
777
+ "total_count": total_count,
778
+ "start_index": page_obj.start_index() if total_count else 0,
779
+ "end_index": page_obj.end_index() if total_count else 0,
780
+ }
781
+
782
+
783
+ def _recurring_sort_requires_python(sort):
784
+ return any(part.removeprefix("-") == "next_run" for part in _parse_sort_fields(sort))
785
+
786
+
787
+ def _semaphore_sort_requires_python(sort):
788
+ return any(part.removeprefix("-") == "blocked_waiters" for part in _parse_sort_fields(sort))
789
+
790
+
791
+ def _sort_with_tie_breaker(sort, tie_breaker):
792
+ field_names = {part.removeprefix("-") for part in _parse_sort_fields(sort)}
793
+ if tie_breaker in field_names:
794
+ return sort
795
+ return f"{sort}.{tie_breaker}"
796
+
797
+
798
+ def _recurring_task_rows(tasks, *, backend_alias, now):
799
+ return [
800
+ _recurring_row_with_jobs_url(
801
+ {
802
+ "key": task.key,
803
+ "task_path": task.task_path,
804
+ "queue_name": task.queue_name,
805
+ "schedule": task.schedule,
806
+ "static": task.static,
807
+ "last_run_at": task.last_run_at,
808
+ "next_run_at": task.next_run_at or _next_run_at(task.schedule, now),
809
+ },
810
+ backend_alias=backend_alias,
811
+ )
812
+ for task in tasks
813
+ ]
814
+
815
+
816
+ def _recurring_row_with_jobs_url(row, *, backend_alias):
817
+ return {
818
+ **row,
819
+ "jobs_url": _job_changelist_url(
820
+ backend_alias=backend_alias,
821
+ recurring_task_key=row["key"],
822
+ ),
823
+ }
824
+
825
+
826
+ def _semaphore_rows(semaphores, *, backend_alias, alias):
827
+ return [
828
+ _semaphore_row_with_jobs_url(
829
+ {
830
+ "scope": "queue_database",
831
+ "queue_database_alias": alias,
832
+ "key": semaphore.key,
833
+ "available_slots": semaphore.value,
834
+ "limit": semaphore.limit,
835
+ "blocked_waiters": semaphore.blocked_waiters,
836
+ "expires_at": semaphore.expires_at,
837
+ },
838
+ backend_alias=backend_alias,
839
+ )
840
+ for semaphore in semaphores
841
+ ]
842
+
843
+
844
+ def _semaphore_row_with_jobs_url(row, *, backend_alias):
845
+ return {
846
+ **row,
847
+ "jobs_url": _job_changelist_url(
848
+ backend_alias=backend_alias,
849
+ concurrency_key=row["key"],
850
+ ),
851
+ }
852
+
853
+
854
+ def _overview_queryset_ordering(*, section, sort, field_map=None, tie_breaker=None):
855
+ if field_map is None:
856
+ field_map = {}
857
+
858
+ order_by = []
859
+ ordered_fields = set()
860
+ for part in _parse_sort_fields(sort):
861
+ field_name = part.removeprefix("-")
862
+ key_name = OVERVIEW_SORTS[section]["fields"][field_name]["key"]
863
+ query_field = field_map.get(key_name, key_name)
864
+ descending = part.startswith("-")
865
+ expression = F(query_field)
866
+ order_by.append(
867
+ expression.desc(nulls_last=True) if descending else expression.asc(nulls_last=True)
868
+ )
869
+ ordered_fields.add(query_field)
870
+
871
+ if tie_breaker and tie_breaker not in ordered_fields:
872
+ order_by.append(F(tie_breaker).asc())
873
+ return order_by
874
+
875
+
648
876
  def _overview_query(*, query_params, page_param, page_number, sort_param=None, sort=None):
649
877
  params = query_params.copy()
650
878
  if str(page_number) == "1":