dj-queue 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {dj_queue-0.2.2 → dj_queue-0.2.4}/PKG-INFO +28 -10
  2. {dj_queue-0.2.2 → dj_queue-0.2.4}/README.md +27 -9
  3. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/admin.py +65 -9
  4. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/dashboard.py +115 -16
  5. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/operations/jobs.py +17 -0
  6. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/templates/admin/dj_queue/change_form.html +1 -1
  7. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/templates/admin/dj_queue/dashboard.html +134 -2
  8. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/templates/admin/dj_queue/queue_jobs.html +10 -2
  9. {dj_queue-0.2.2 → dj_queue-0.2.4}/pyproject.toml +1 -1
  10. {dj_queue-0.2.2 → dj_queue-0.2.4}/LICENSE +0 -0
  11. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/__init__.py +0 -0
  12. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/api.py +0 -0
  13. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/apps.py +0 -0
  14. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/backend.py +0 -0
  15. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/config.py +0 -0
  16. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/contrib/__init__.py +0 -0
  17. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/contrib/asgi.py +0 -0
  18. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/contrib/gunicorn.py +0 -0
  19. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/db.py +0 -0
  20. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/exceptions.py +0 -0
  21. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/hooks.py +0 -0
  22. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/log.py +0 -0
  23. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/management/__init__.py +0 -0
  24. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/management/commands/__init__.py +0 -0
  25. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/management/commands/dj_queue.py +0 -0
  26. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/management/commands/dj_queue_health.py +0 -0
  27. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/management/commands/dj_queue_prune.py +0 -0
  28. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/migrations/0001_initial.py +0 -0
  29. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
  30. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
  31. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/migrations/0004_dashboard.py +0 -0
  32. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/migrations/__init__.py +0 -0
  33. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/models/__init__.py +0 -0
  34. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/models/jobs.py +0 -0
  35. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/models/recurring.py +0 -0
  36. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/models/runtime.py +0 -0
  37. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/operations/__init__.py +0 -0
  38. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/operations/cleanup.py +0 -0
  39. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/operations/concurrency.py +0 -0
  40. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/operations/recurring.py +0 -0
  41. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/routers.py +0 -0
  42. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/__init__.py +0 -0
  43. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/base.py +0 -0
  44. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/dispatcher.py +0 -0
  45. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/errors.py +0 -0
  46. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/interruptible.py +0 -0
  47. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/notify.py +0 -0
  48. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/pidfile.py +0 -0
  49. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/pool.py +0 -0
  50. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/procline.py +0 -0
  51. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/scheduler.py +0 -0
  52. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/supervisor.py +0 -0
  53. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/runtime/worker.py +0 -0
  54. {dj_queue-0.2.2 → dj_queue-0.2.4}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dj-queue
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Database-backed task queue backend for Django's django.tasks framework
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -27,11 +27,11 @@ Description-Content-Type: text/markdown
27
27
  # dj_queue
28
28
 
29
29
  [![CI](https://github.com/coriocactus/dj_queue/actions/workflows/ci.yml/badge.svg)](https://github.com/coriocactus/dj_queue/actions/workflows/ci.yml)
30
- ![PyPI](https://img.shields.io/pypi/v/dj-queue.svg)
30
+ [![PyPI](https://img.shields.io/pypi/v/dj-queue.svg)](https://pypi.org/project/dj-queue/)
31
31
  [![Latest on Django Packages](https://img.shields.io/badge/pypi/dj-queue-tags.svg)](https://djangopackages.org/packages/p/dj-queue/)
32
32
  ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/dj-queue.svg)
33
33
  ![PyPI - Status](https://img.shields.io/pypi/status/dj-queue.svg)
34
- ![PyPI - License](https://img.shields.io/pypi/l/dj-queue.svg)
34
+ [![PyPI - License](https://img.shields.io/pypi/l/dj-queue.svg)](https://github.com/coriocactus/dj_queue/blob/main/LICENSE)
35
35
 
36
36
  `dj_queue` is a database-backed task queue backend for the `django.tasks` framework.
37
37
 
@@ -59,7 +59,7 @@ It has a narrow, explicit shape:
59
59
  - polling remains the correctness path on every supported database
60
60
 
61
61
  For detailed comparisons with Celery, RQ, Procrastinate, and other alternatives,
62
- see [COMPARISONS.md](COMPARISONS.md).
62
+ see [COMPARISONS.md](docs/COMPARISONS.md).
63
63
 
64
64
  ## Installation
65
65
 
@@ -156,6 +156,27 @@ print(fresh_result.return_value)
156
156
 
157
157
  When the worker has executed the job, `fresh_result.return_value` will be `10`.
158
158
 
159
+ ## Admin Integration
160
+
161
+ If Django admin is installed, `dj_queue` adds an operator dashboard at
162
+ `/admin/dj_queue/dashboard/`.
163
+
164
+ - queue, process, recurring-task, and semaphore overview
165
+ - backend-aware dashboard and raw changelists
166
+ - queue controls: pause, resume, clear ready
167
+ - job detail action: enqueue a fresh copy of any stored job
168
+ - pause detail action: resume the paused queue from the raw pause row
169
+ - failed-job actions: retry and discard from list and detail views
170
+ - queue drill-down pages for state-specific inspection
171
+
172
+ **Dashboard overview**
173
+
174
+ ![dj_queue admin dashboard](docs/dashboard.png)
175
+
176
+ **Queue drill-down**
177
+
178
+ ![dj_queue admin dashboard - queue](docs/dashboard-queue.png)
179
+
159
180
  ## Common Patterns
160
181
 
161
182
  ### Scheduled jobs
@@ -406,17 +427,14 @@ python manage.py dj_queue_prune --older-than 86400
406
427
  python manage.py dj_queue_prune --task-path myapp.tasks.cleanup
407
428
  ```
408
429
 
409
- If Django admin is installed, `dj_queue` also registers the main operational
410
- models there, including jobs, failed executions, processes, recurring tasks,
411
- pauses, and semaphores.
412
-
413
430
  ## Failed Jobs
414
431
 
415
432
  When a task raises, `dj_queue` keeps the job and its failed execution row in the
416
433
  queue database, including the exception class, message, and traceback.
417
434
 
418
- You can retry and discard failed jobs through Django admin, or call the same
419
- operations directly through the operations layer:
435
+ You can retry and discard failed jobs through Django admin, and any raw job
436
+ detail page can enqueue a fresh copy of that stored job. The failed-job actions
437
+ also stay available directly through the operations layer:
420
438
 
421
439
  ```python
422
440
  from dj_queue.operations.jobs import discard_failed_job, retry_failed_job
@@ -1,11 +1,11 @@
1
1
  # dj_queue
2
2
 
3
3
  [![CI](https://github.com/coriocactus/dj_queue/actions/workflows/ci.yml/badge.svg)](https://github.com/coriocactus/dj_queue/actions/workflows/ci.yml)
4
- ![PyPI](https://img.shields.io/pypi/v/dj-queue.svg)
4
+ [![PyPI](https://img.shields.io/pypi/v/dj-queue.svg)](https://pypi.org/project/dj-queue/)
5
5
  [![Latest on Django Packages](https://img.shields.io/badge/pypi/dj-queue-tags.svg)](https://djangopackages.org/packages/p/dj-queue/)
6
6
  ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/dj-queue.svg)
7
7
  ![PyPI - Status](https://img.shields.io/pypi/status/dj-queue.svg)
8
- ![PyPI - License](https://img.shields.io/pypi/l/dj-queue.svg)
8
+ [![PyPI - License](https://img.shields.io/pypi/l/dj-queue.svg)](https://github.com/coriocactus/dj_queue/blob/main/LICENSE)
9
9
 
10
10
  `dj_queue` is a database-backed task queue backend for the `django.tasks` framework.
11
11
 
@@ -33,7 +33,7 @@ It has a narrow, explicit shape:
33
33
  - polling remains the correctness path on every supported database
34
34
 
35
35
  For detailed comparisons with Celery, RQ, Procrastinate, and other alternatives,
36
- see [COMPARISONS.md](COMPARISONS.md).
36
+ see [COMPARISONS.md](docs/COMPARISONS.md).
37
37
 
38
38
  ## Installation
39
39
 
@@ -130,6 +130,27 @@ print(fresh_result.return_value)
130
130
 
131
131
  When the worker has executed the job, `fresh_result.return_value` will be `10`.
132
132
 
133
+ ## Admin Integration
134
+
135
+ If Django admin is installed, `dj_queue` adds an operator dashboard at
136
+ `/admin/dj_queue/dashboard/`.
137
+
138
+ - queue, process, recurring-task, and semaphore overview
139
+ - backend-aware dashboard and raw changelists
140
+ - queue controls: pause, resume, clear ready
141
+ - job detail action: enqueue a fresh copy of any stored job
142
+ - pause detail action: resume the paused queue from the raw pause row
143
+ - failed-job actions: retry and discard from list and detail views
144
+ - queue drill-down pages for state-specific inspection
145
+
146
+ **Dashboard overview**
147
+
148
+ ![dj_queue admin dashboard](docs/dashboard.png)
149
+
150
+ **Queue drill-down**
151
+
152
+ ![dj_queue admin dashboard - queue](docs/dashboard-queue.png)
153
+
133
154
  ## Common Patterns
134
155
 
135
156
  ### Scheduled jobs
@@ -380,17 +401,14 @@ python manage.py dj_queue_prune --older-than 86400
380
401
  python manage.py dj_queue_prune --task-path myapp.tasks.cleanup
381
402
  ```
382
403
 
383
- If Django admin is installed, `dj_queue` also registers the main operational
384
- models there, including jobs, failed executions, processes, recurring tasks,
385
- pauses, and semaphores.
386
-
387
404
  ## Failed Jobs
388
405
 
389
406
  When a task raises, `dj_queue` keeps the job and its failed execution row in the
390
407
  queue database, including the exception class, message, and traceback.
391
408
 
392
- You can retry and discard failed jobs through Django admin, or call the same
393
- operations directly through the operations layer:
409
+ You can retry and discard failed jobs through Django admin, and any raw job
410
+ detail page can enqueue a fresh copy of that stored job. The failed-job actions
411
+ also stay available directly through the operations layer:
394
412
 
395
413
  ```python
396
414
  from dj_queue.operations.jobs import discard_failed_job, retry_failed_job
@@ -15,6 +15,7 @@ from django.utils import timezone
15
15
 
16
16
  from dj_queue.config import load_backend_config
17
17
  from dj_queue import dashboard
18
+ from dj_queue.api import QueueInfo
18
19
  from dj_queue.db import get_database_alias
19
20
  from dj_queue.models import (
20
21
  BlockedExecution,
@@ -26,6 +27,7 @@ from dj_queue.models import (
26
27
  RecurringTask,
27
28
  Semaphore,
28
29
  )
30
+ from dj_queue.operations.jobs import enqueue_job_again
29
31
 
30
32
 
31
33
  class DjQueueFirstAdminSite(admin.AdminSite):
@@ -214,6 +216,7 @@ class HiddenSidebarAdminMixin:
214
216
  extra_context = {
215
217
  **(extra_context or {}),
216
218
  "dashboard_url": self._dashboard_url(request),
219
+ "changelist_url": self._changelist_url(backend_alias=self._backend_alias(request)),
217
220
  "change_actions": self.get_change_actions(request, obj),
218
221
  }
219
222
  return super().changeform_view(
@@ -474,18 +477,49 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
474
477
  return format_html('<a href="{}">{}</a>', url, obj.queue_name)
475
478
 
476
479
  def get_change_actions(self, request, obj):
477
- if obj is None or obj.status != "failed":
480
+ if obj is None:
478
481
  return ()
479
- return (
480
- {"name": "retry", "label": "Retry failed job", "css_class": "djq-object-action-retry"},
481
- {
482
- "name": "discard",
483
- "label": "Discard failed job",
484
- "css_class": "djq-object-action-discard",
485
- },
486
- )
482
+ actions = [{"name": "enqueue", "label": "Enqueue job", "css_class": "djq-object-action-retry"}]
483
+ if obj.status == "failed":
484
+ actions.extend(
485
+ (
486
+ {
487
+ "name": "retry",
488
+ "label": "Retry failed job",
489
+ "css_class": "djq-object-action-retry",
490
+ },
491
+ {
492
+ "name": "discard",
493
+ "label": "Discard failed job",
494
+ "css_class": "djq-object-action-discard",
495
+ },
496
+ )
497
+ )
498
+ return tuple(actions)
487
499
 
488
500
  def handle_change_action(self, request, obj, action):
501
+ if action == "enqueue":
502
+ try:
503
+ new_job = enqueue_job_again(obj.pk, backend_alias=obj.backend_name)
504
+ except Exception as exc:
505
+ self.message_user(request, f"Could not enqueue job: {exc}", level=messages.ERROR)
506
+ return HttpResponseRedirect(
507
+ self._change_url(object_id=obj.pk, backend_alias=obj.backend_name)
508
+ )
509
+
510
+ self.message_user(
511
+ request,
512
+ format_html(
513
+ 'Enqueued job <a href="{}">{}</a>.',
514
+ self._change_url(object_id=new_job.pk, backend_alias=new_job.backend_name),
515
+ new_job.pk,
516
+ ),
517
+ level=messages.SUCCESS,
518
+ )
519
+ return HttpResponseRedirect(
520
+ self._change_url(object_id=obj.pk, backend_alias=obj.backend_name)
521
+ )
522
+
489
523
  if obj.status != "failed":
490
524
  self.message_user(request, "This job is not failed", level=messages.ERROR)
491
525
  return HttpResponseRedirect(
@@ -633,6 +667,28 @@ class PauseAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
633
667
  readonly_fields = ("queue_name", "created_at")
634
668
  search_fields = ("queue_name",)
635
669
 
670
+ def get_change_actions(self, request, obj):
671
+ if obj is None:
672
+ return ()
673
+ return ({"name": "resume", "label": "Resume queue", "css_class": "djq-object-action-retry"},)
674
+
675
+ def handle_change_action(self, request, obj, action):
676
+ backend_alias = self._backend_alias(request)
677
+ if action == "resume":
678
+ QueueInfo(obj.queue_name, backend_alias=backend_alias).resume()
679
+ self.message_user(
680
+ request,
681
+ format_html(
682
+ 'Resumed queue <a href="{}">{}</a>',
683
+ f"{reverse('admin:dj_queue_dashboard_queue', args=[obj.queue_name])}?{urlencode({'backend': backend_alias})}",
684
+ obj.queue_name,
685
+ ),
686
+ level=messages.SUCCESS,
687
+ )
688
+ return HttpResponseRedirect(self._changelist_url(backend_alias=backend_alias))
689
+
690
+ return HttpResponseRedirect(self._change_url(object_id=obj.pk, backend_alias=backend_alias))
691
+
636
692
 
637
693
  @admin.register(Semaphore)
638
694
  class SemaphoreAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
@@ -15,7 +15,7 @@ from django.utils import timezone
15
15
 
16
16
  from dj_queue.api import QueueInfo
17
17
  from dj_queue.config import load_backend_config
18
- from dj_queue.db import get_database_alias
18
+ from dj_queue.db import database_capabilities, get_database_alias
19
19
  from dj_queue.models import (
20
20
  BlockedExecution,
21
21
  ClaimedExecution,
@@ -44,12 +44,14 @@ QUEUE_STATE_LABELS = dict(QUEUE_STATES)
44
44
  PAGE_SIZE = 100
45
45
  OVERVIEW_PAGE_SIZES = {
46
46
  "queues": 18,
47
+ "shared_queues": 5,
47
48
  "processes": 10,
48
49
  "recurring": 12,
49
50
  "semaphores": 12,
50
51
  }
51
52
  OVERVIEW_COUNT_LABELS = {
52
53
  "queues": ("queue", "queues"),
54
+ "shared_queues": ("shared queue", "shared queues"),
53
55
  "processes": ("process", "processes"),
54
56
  "recurring": ("recurring task", "recurring tasks"),
55
57
  "semaphores": ("semaphore", "semaphores"),
@@ -80,6 +82,19 @@ OVERVIEW_SORTS = {
80
82
  },
81
83
  },
82
84
  },
85
+ "shared_queues": {
86
+ "default": "name",
87
+ "fields": {
88
+ "name": {"label": "name", "key": "name", "default_desc": False, "css_class": "djq-col-name"},
89
+ "shared_via": {
90
+ "label": "shared via",
91
+ "key": "shared_source_labels",
92
+ "default_desc": False,
93
+ "css_class": "djq-col-shared-via",
94
+ },
95
+ "paused": {"label": "paused", "key": "paused", "default_desc": True},
96
+ },
97
+ },
83
98
  "processes": {
84
99
  "default": "status",
85
100
  "fields": {
@@ -272,6 +287,7 @@ def dashboard_context(*, backend_alias, query_params=None):
272
287
  query_params = {}
273
288
 
274
289
  queue_rows = _queue_rows(backend_alias=backend_alias, now=now, process_cutoff=process_cutoff)
290
+ backend_queue_rows, shared_queue_rows = _split_queue_rows(queue_rows)
275
291
  process_rows = _process_rows(
276
292
  backend_alias=backend_alias,
277
293
  now=now,
@@ -287,7 +303,7 @@ def dashboard_context(*, backend_alias, query_params=None):
287
303
  "queue_database_alias": queue_database_alias,
288
304
  "summary_cards": _summary_cards(
289
305
  backend_alias=backend_alias,
290
- queue_rows=queue_rows,
306
+ queue_rows=backend_queue_rows,
291
307
  process_rows=process_rows,
292
308
  recurring_rows=recurring_rows,
293
309
  semaphore_rows=semaphore_rows,
@@ -300,13 +316,22 @@ def dashboard_context(*, backend_alias, query_params=None):
300
316
  ),
301
317
  "queue_section": _overview_section(
302
318
  section="queues",
303
- rows=queue_rows,
319
+ rows=backend_queue_rows,
304
320
  page_param="queues_page",
305
321
  page_size=OVERVIEW_PAGE_SIZES["queues"],
306
322
  sort_param="queues_sort",
307
323
  query_params=query_params,
308
324
  anchor="queue-summary",
309
325
  ),
326
+ "shared_queue_section": _overview_section(
327
+ section="shared_queues",
328
+ rows=shared_queue_rows,
329
+ page_param="shared_queues_page",
330
+ page_size=OVERVIEW_PAGE_SIZES["shared_queues"],
331
+ sort_param="shared_queues_sort",
332
+ query_params=query_params,
333
+ anchor="shared-queue-summary",
334
+ ),
310
335
  "process_section": _overview_section(
311
336
  section="processes",
312
337
  rows=process_rows,
@@ -360,7 +385,14 @@ def queue_page_context(*, backend_alias, queue_name, state, page_number, query_p
360
385
  paginator = Paginator(jobs, PAGE_SIZE)
361
386
  page_obj = paginator.get_page(query_params.get("page", page_number))
362
387
  queue_info = QueueInfo(queue_name, backend_alias=backend_alias)
388
+ queue_paused = queue_info.paused
389
+ queue_latency_seconds = None if queue_paused else queue_info.latency
363
390
  state_counts = _queue_state_counts(backend_alias=backend_alias, queue_name=queue_name)
391
+ live_workers = [
392
+ process
393
+ for process in Process.objects.using(alias).filter(kind="Worker")
394
+ if process.last_heartbeat_at >= process_cutoff
395
+ ]
364
396
  state_tabs = [
365
397
  {
366
398
  "name": state_name,
@@ -374,7 +406,7 @@ def queue_page_context(*, backend_alias, queue_name, state, page_number, query_p
374
406
  if sum(state_counts.values()):
375
407
  raw_links.append(
376
408
  {
377
- "label": "raw jobs",
409
+ "label": "Raw jobs",
378
410
  "url": _job_changelist_url(
379
411
  backend_alias,
380
412
  queue_name=queue_name,
@@ -385,7 +417,7 @@ def queue_page_context(*, backend_alias, queue_name, state, page_number, query_p
385
417
  if state_counts["failed"]:
386
418
  raw_links.append(
387
419
  {
388
- "label": "failed executions",
420
+ "label": "Failed executions",
389
421
  "url": _failed_execution_changelist_url(
390
422
  backend_alias,
391
423
  job__queue_name=queue_name,
@@ -400,7 +432,13 @@ def queue_page_context(*, backend_alias, queue_name, state, page_number, query_p
400
432
  "queue_database_alias": alias,
401
433
  "queue_name": queue_name,
402
434
  "queue_info": queue_info,
403
- "queue_paused": queue_info.paused,
435
+ "queue_paused": queue_paused,
436
+ "queue_latency_seconds": queue_latency_seconds,
437
+ "queue_worker_count": sum(
438
+ 1
439
+ for worker in live_workers
440
+ if _queue_matches_selectors(queue_name, worker.metadata.get("queues", []))
441
+ ),
404
442
  "state": state,
405
443
  "state_label": QUEUE_STATE_LABELS[state],
406
444
  "state_tabs": state_tabs,
@@ -448,6 +486,9 @@ def apply_queue_action(*, backend_alias, queue_name, action):
448
486
 
449
487
 
450
488
  def apply_job_action(*, backend_alias, queue_name, state, action, job_ids):
489
+ if not action:
490
+ raise ValueError("No action selected.")
491
+
451
492
  if not job_ids:
452
493
  raise ValueError("select at least one job")
453
494
 
@@ -557,17 +598,42 @@ def _summary_cards(*, backend_alias, queue_rows, process_rows, recurring_rows, s
557
598
  )
558
599
 
559
600
 
601
+ def _split_queue_rows(queue_rows):
602
+ backend_queue_rows = []
603
+ shared_queue_rows = []
604
+ for row in queue_rows:
605
+ if row["has_backend_jobs"]:
606
+ backend_queue_rows.append(row)
607
+ continue
608
+ shared_queue_rows.append(row)
609
+ return backend_queue_rows, shared_queue_rows
610
+
611
+
560
612
  def _backend_facts(*, config, queue_database_alias, recurring_count, semaphore_count):
561
613
  retention = "disabled"
562
614
  if config.clear_finished_jobs_after is not None:
563
615
  retention = f"{config.clear_finished_jobs_after}s"
564
616
 
617
+ capabilities = database_capabilities(queue_database_alias)
618
+
565
619
  return (
566
620
  {"label": "mode", "value": config.mode},
567
621
  {"label": "queue db", "value": queue_database_alias},
568
622
  {"label": "scheduler", "value": "enabled" if config.has_scheduler_work else "disabled"},
569
- {"label": "notify", "value": "on" if config.listen_notify else "off"},
570
- {"label": "skip locked", "value": "on" if config.use_skip_locked else "off"},
623
+ {
624
+ "label": "notify",
625
+ "value": _capability_fact_value(
626
+ enabled=config.listen_notify,
627
+ supported=capabilities.supports_listen_notify,
628
+ ),
629
+ },
630
+ {
631
+ "label": "skip locked",
632
+ "value": _capability_fact_value(
633
+ enabled=config.use_skip_locked,
634
+ supported=capabilities.supports_skip_locked,
635
+ ),
636
+ },
571
637
  {"label": "heartbeat", "value": f"{config.process_alive_threshold}s"},
572
638
  {"label": "retention", "value": retention},
573
639
  {"label": "recurring", "value": str(recurring_count)},
@@ -575,6 +641,14 @@ def _backend_facts(*, config, queue_database_alias, recurring_count, semaphore_c
575
641
  )
576
642
 
577
643
 
644
+ def _capability_fact_value(*, enabled, supported):
645
+ if not supported:
646
+ return "unsupported"
647
+ if enabled:
648
+ return "on"
649
+ return "off"
650
+
651
+
578
652
  def _overview_section(*, section, rows, page_param, page_size, sort_param, query_params, anchor):
579
653
  raw_sort = query_params.get(sort_param)
580
654
  sort, explicit_sort = _resolve_overview_sort(section=section, raw_sort=raw_sort)
@@ -1163,24 +1237,49 @@ def _queue_rows(*, backend_alias, now, process_cutoff):
1163
1237
 
1164
1238
  rows = []
1165
1239
  for queue_name in sorted(queue_names):
1240
+ paused = queue_name in paused_queues
1166
1241
  oldest_ready_at = oldest_ready.get(queue_name)
1167
1242
  latency_seconds = None
1168
- if oldest_ready_at is not None:
1243
+ if oldest_ready_at is not None and paused is False:
1169
1244
  latency_seconds = max((now - oldest_ready_at).total_seconds(), 0.0)
1170
1245
 
1246
+ ready_count = ready_counts.get(queue_name, 0)
1247
+ claimed_count = claimed_counts.get(queue_name, 0)
1248
+ scheduled_count = scheduled_counts.get(queue_name, 0)
1249
+ blocked_count = blocked_counts.get(queue_name, 0)
1250
+ failed_count = failed_counts.get(queue_name, 0)
1251
+ finished_count = finished_counts.get(queue_name, 0)
1252
+ shared_sources = []
1253
+ if paused:
1254
+ shared_sources.append("pause")
1255
+ if queue_name in recurring_queues:
1256
+ shared_sources.append("recurring task")
1257
+
1171
1258
  rows.append(
1172
1259
  {
1173
1260
  "name": queue_name,
1174
- "ready_count": ready_counts.get(queue_name, 0),
1175
- "claimed_count": claimed_counts.get(queue_name, 0),
1176
- "scheduled_count": scheduled_counts.get(queue_name, 0),
1177
- "blocked_count": blocked_counts.get(queue_name, 0),
1178
- "failed_count": failed_counts.get(queue_name, 0),
1179
- "finished_count": finished_counts.get(queue_name, 0),
1180
- "paused": queue_name in paused_queues,
1261
+ "ready_count": ready_count,
1262
+ "claimed_count": claimed_count,
1263
+ "scheduled_count": scheduled_count,
1264
+ "blocked_count": blocked_count,
1265
+ "failed_count": failed_count,
1266
+ "finished_count": finished_count,
1267
+ "paused": paused,
1181
1268
  "latency_seconds": latency_seconds,
1182
1269
  "oldest_scheduled_at": oldest_scheduled.get(queue_name),
1183
1270
  "oldest_blocked_at": oldest_blocked.get(queue_name),
1271
+ "has_backend_jobs": any(
1272
+ (
1273
+ ready_count,
1274
+ claimed_count,
1275
+ scheduled_count,
1276
+ blocked_count,
1277
+ failed_count,
1278
+ finished_count,
1279
+ )
1280
+ ),
1281
+ "shared_sources": tuple(shared_sources),
1282
+ "shared_source_labels": ", ".join(shared_sources),
1184
1283
  "live_worker_count": sum(
1185
1284
  1
1186
1285
  for worker in live_workers
@@ -354,6 +354,23 @@ def retry_failed_job(job_id, *, backend_alias="default"):
354
354
  return job
355
355
 
356
356
 
357
+ def enqueue_job_again(job_id, *, backend_alias="default"):
358
+ alias = get_database_alias(backend_alias)
359
+ source_job = Job.objects.using(alias).get(pk=job_id)
360
+ task = import_string(source_job.task_path)
361
+ if hasattr(task, "using"):
362
+ task = task.using(
363
+ priority=source_job.priority,
364
+ queue_name=source_job.queue_name,
365
+ run_after=source_job.scheduled_at,
366
+ backend=source_job.backend_name,
367
+ )
368
+ args = list(source_job.payload.get("args", []))
369
+ kwargs = dict(source_job.payload.get("kwargs", {}))
370
+ job, _ = enqueue_job_with_dispatch(task, args, kwargs, backend_alias=source_job.backend_name)
371
+ return job
372
+
373
+
357
374
  def discard_failed_job(job_id, *, backend_alias="default"):
358
375
  alias = get_database_alias(backend_alias)
359
376
  queryset = Job.objects.using(alias).filter(pk=job_id, failed_execution__isnull=False)
@@ -46,7 +46,7 @@
46
46
  <div class="breadcrumbs">
47
47
  <a href='{% url "admin:index" %}'>{% translate "Home" %}</a>
48
48
  › <a href="{{ dashboard_url }}">dj_queue</a>
49
- › {% if has_view_permission %}<a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
49
+ › {% if has_view_permission %}<a href="{{ changelist_url }}">{{ opts.verbose_name_plural|capfirst }}</a>{% else %}{{ opts.verbose_name_plural|capfirst }}{% endif %}
50
50
  › {% if add %}{% blocktranslate with name=opts.verbose_name %}Add {{ name }}{% endblocktranslate %}{% else %}{{ original|truncatewords:"18" }}{% endif %}
51
51
  </div>
52
52
  {% endblock breadcrumbs %}
@@ -332,6 +332,27 @@
332
332
  min-width: 48rem;
333
333
  }
334
334
 
335
+ .djq-table-shared-queues {
336
+ min-width: 56rem;
337
+ table-layout: fixed;
338
+ }
339
+
340
+ .djq-table-shared-queues col.djq-shared-col-name {
341
+ width: 20%;
342
+ }
343
+
344
+ .djq-table-shared-queues col.djq-shared-col-via {
345
+ width: 20%;
346
+ }
347
+
348
+ .djq-table-shared-queues col.djq-shared-col-paused {
349
+ width: 20%;
350
+ }
351
+
352
+ .djq-table-shared-queues col.djq-shared-col-controls {
353
+ width: 20%;
354
+ }
355
+
335
356
  .djq-table th,
336
357
  .djq-table td {
337
358
  white-space: nowrap;
@@ -367,6 +388,15 @@
367
388
  white-space: normal;
368
389
  }
369
390
 
391
+ .djq-col-shared-via {
392
+ min-width: 16rem;
393
+ }
394
+
395
+ .djq-table-shared-queues .djq-col-controls,
396
+ .djq-table-shared-queues td.djq-col-controls {
397
+ min-width: 13rem;
398
+ }
399
+
370
400
  .djq-process-row-group {
371
401
  background: var(--selected-bg);
372
402
  }
@@ -435,6 +465,26 @@
435
465
  line-height: 1.45;
436
466
  }
437
467
 
468
+ .djq-reason-list {
469
+ display: flex;
470
+ gap: 0.45rem;
471
+ flex-wrap: wrap;
472
+ }
473
+
474
+ .djq-reason-pill {
475
+ display: inline-flex;
476
+ align-items: center;
477
+ min-height: 1.6rem;
478
+ padding: 0 0.6rem;
479
+ border: 1px solid var(--hairline-color);
480
+ border-radius: 999px;
481
+ background: var(--body-bg);
482
+ color: var(--body-quiet-color);
483
+ font-size: 0.76rem;
484
+ font-weight: 600;
485
+ line-height: 1;
486
+ }
487
+
438
488
  @media (max-width: 900px) {
439
489
  .djq-summary-grid {
440
490
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -475,7 +525,7 @@
475
525
  <form method="get" action="{% url 'admin:dj_queue_dashboard_changelist' %}">
476
526
  <select id="id_backend" name="backend" onchange="this.form.submit()">
477
527
  {% for choice in backend_choices %}
478
- <option value="{{ choice.alias }}"{% if choice.alias == backend_alias %} selected{% endif %}>{{ choice.alias }} (db {{ choice.database_alias }})</option>
528
+ <option value="{{ choice.alias }}"{% if choice.alias == backend_alias %} selected{% endif %}>{{ choice.alias }}</option>
479
529
  {% endfor %}
480
530
  </select>
481
531
  <noscript><button type="submit" class="button">Switch</button></noscript>
@@ -521,7 +571,7 @@
521
571
 
522
572
  <section class="module djq-section" id="queue-summary">
523
573
  <div class="djq-section-banner"><h2>queues</h2></div>
524
- <p class="djq-section-copy">Live queue pressure, pause state, and first-line controls for this backend.</p>
574
+ <p class="djq-section-copy">Live queue pressure, pause state, and first-line controls for this backend.{% if shared_queue_section.total_count %} Shared pause or recurring-only queues are available in a separate section below.{% endif %}</p>
525
575
  {% if queue_section.rows %}
526
576
  <div class="djq-table-wrap">
527
577
  <table class="djq-table djq-table-queues" id="result_list">
@@ -800,6 +850,88 @@
800
850
  {% endif %}
801
851
  </section>
802
852
 
853
+ {% if shared_queue_section.total_count %}
854
+ <section class="module djq-section" id="shared-queue-summary">
855
+ <div class="djq-section-banner"><h2>shared queues</h2></div>
856
+ <p class="djq-section-copy">These queues have no jobs for backend <strong>{{ backend_alias }}</strong> and are shown separately because pause and recurring-task rows are shared on queue database <strong>{{ queue_database_alias }}</strong>.</p>
857
+ <div class="djq-table-wrap">
858
+ <table class="djq-table djq-table-shared-queues">
859
+ <colgroup>
860
+ <col class="djq-shared-col-name">
861
+ <col class="djq-shared-col-via">
862
+ <col class="djq-shared-col-paused">
863
+ <col class="djq-shared-col-controls">
864
+ </colgroup>
865
+ <thead>
866
+ <tr>
867
+ {% for header in shared_queue_section.headers %}
868
+ <th scope="col"{{ header.class_attrib|safe }}>
869
+ {% if header.sortable and header.sorted %}
870
+ <div class="sortoptions">
871
+ <a class="sortremove" href="{{ header.url_remove }}" title="Remove from sorting"></a>
872
+ {% if shared_queue_section.num_sorted_fields > 1 %}<span class="sortpriority" title="Sorting priority: {{ header.sort_priority }}">{{ header.sort_priority }}</span>{% endif %}
873
+ <a href="{{ header.url_toggle }}" class="toggle {{ header.ascending|yesno:'ascending,descending' }}" title="Toggle sorting"></a>
874
+ </div>
875
+ {% endif %}
876
+ <div class="text"><a role="button" href="{{ header.url_primary }}">{{ header.text|capfirst }}</a></div>
877
+ <div class="clear"></div>
878
+ </th>
879
+ {% endfor %}
880
+ <th scope="col" class="djq-col-controls"><div class="text">Controls</div></th>
881
+ </tr>
882
+ </thead>
883
+ <tbody>
884
+ {% for queue in shared_queue_section.rows %}
885
+ <tr class="{% cycle 'row1' 'row2' %}">
886
+ <th class="djq-col-name"><a href="{% url 'admin:dj_queue_dashboard_queue' queue.name %}?backend={{ backend_alias }}&state=ready">{{ queue.name }}</a></th>
887
+ <td>
888
+ <div class="djq-reason-list">
889
+ {% for source in queue.shared_sources %}
890
+ <span class="djq-reason-pill">{{ source }}</span>
891
+ {% endfor %}
892
+ </div>
893
+ </td>
894
+ <td>{{ queue.paused|yesno:"yes,no" }}</td>
895
+ <td class="djq-col-controls">
896
+ <div class="djq-queue-controls">
897
+ <form method="post" action="{% url 'admin:dj_queue_dashboard_queue_action' queue.name %}">
898
+ {% csrf_token %}
899
+ <input type="hidden" name="backend" value="{{ backend_alias }}">
900
+ <input type="hidden" name="next" value="{{ request.get_full_path }}">
901
+ <input type="hidden" name="action" value="{% if queue.paused %}resume{% else %}pause{% endif %}">
902
+ <button type="submit" class="button {% if queue.paused %}djq-button-resume{% else %}djq-button-pause{% endif %}">{% if queue.paused %}resume{% else %}pause{% endif %}</button>
903
+ </form>
904
+ </div>
905
+ </td>
906
+ </tr>
907
+ {% endfor %}
908
+ </tbody>
909
+ </table>
910
+ </div>
911
+ <div class="djq-table-footer">
912
+ <nav class="paginator" aria-labelledby="pagination-shared-queues">
913
+ <h2 id="pagination-shared-queues" class="visually-hidden">Pagination shared queues</h2>
914
+ {% if shared_queue_section.pagination_required %}
915
+ <ul>
916
+ {% for link in shared_queue_section.page_links %}
917
+ <li>
918
+ {% if link.is_ellipsis %}
919
+ {{ link.label }}
920
+ {% elif link.is_current %}
921
+ <span aria-current="page">{{ link.number }}</span>
922
+ {% else %}
923
+ <a role="button" href="{{ link.url }}">{{ link.number }}</a>
924
+ {% endif %}
925
+ </li>
926
+ {% endfor %}
927
+ </ul>
928
+ {% endif %}
929
+ {{ shared_queue_section.result_count_text }}
930
+ </nav>
931
+ </div>
932
+ </section>
933
+ {% endif %}
934
+
803
935
  <section class="module djq-section" id="raw-summary">
804
936
  <div class="djq-section-banner"><h2>inspect raw rows</h2></div>
805
937
  <p class="djq-section-copy">Use the model changelists for exhaustive filtering, lower-level debugging, and record-by-record inspection.</p>
@@ -55,6 +55,10 @@
55
55
  margin: 0;
56
56
  }
57
57
 
58
+ #toolbar .queue-toolbar-meta strong {
59
+ text-transform: uppercase;
60
+ }
61
+
58
62
  #toolbar .queue-toolbar-actions button {
59
63
  border: none;
60
64
  border-radius: 15px;
@@ -210,8 +214,11 @@
210
214
  <div id="toolbar">
211
215
  <div class="queue-toolbar-row">
212
216
  <div class="queue-toolbar-meta">
213
- <span><strong>backend:</strong> {{ backend_alias }} (db {{ queue_database_alias }})</span>
214
- <span><strong>paused:</strong> {{ queue_paused|yesno:"yes,no" }}</span>
217
+ <span><strong>Backend:</strong> {{ backend_alias }}</span>
218
+ <span><strong>Database:</strong> {{ queue_database_alias }}</span>
219
+ <span><strong>Workers:</strong> {{ queue_worker_count }}</span>
220
+ <span><strong>Latency:</strong> {% if queue_latency_seconds != None %}{{ queue_latency_seconds|floatformat:1 }}s{% else %}-{% endif %}</span>
221
+ <span><strong>Paused:</strong> {{ queue_paused|yesno:"yes,no" }}</span>
215
222
  </div>
216
223
  <div class="queue-toolbar-actions">
217
224
  <form method="post" action="{% url 'admin:dj_queue_dashboard_queue_action' queue_name %}">
@@ -265,6 +272,7 @@
265
272
  <div class="actions">
266
273
  <label for="action">Action:
267
274
  <select name="action" id="action">
275
+ <option value="" selected>---------</option>
268
276
  {% for action in job_actions %}
269
277
  <option value="{{ action.name }}">{{ action.label }}</option>
270
278
  {% endfor %}
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "dj-queue"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Database-backed task queue backend for Django's django.tasks framework"
9
9
  readme = "README.md"
10
10
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes