dj-queue 0.10.6__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 (97) hide show
  1. {dj_queue-0.10.6 → dj_queue-0.12.0}/PKG-INFO +13 -3
  2. {dj_queue-0.10.6 → dj_queue-0.12.0}/README.md +12 -2
  3. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/admin.py +155 -94
  4. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/api.py +27 -28
  5. dj_queue-0.12.0/dj_queue/apps.py +17 -0
  6. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/backend.py +29 -4
  7. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/config.py +43 -7
  8. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/contrib/gunicorn.py +17 -5
  9. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/cron.py +52 -29
  10. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/dashboard.py +366 -237
  11. dj_queue-0.12.0/dj_queue/dashboard_actions.py +141 -0
  12. dj_queue-0.12.0/dj_queue/management/commands/dj_queue_health.py +28 -0
  13. dj_queue-0.12.0/dj_queue/migrations/0011_remove_blockedexecution_djq_bl_b_conc_idx_and_more.py +32 -0
  14. 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
  15. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/models/jobs.py +80 -26
  16. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/models/runtime.py +4 -0
  17. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/observability.py +219 -14
  18. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/operations/_helpers.py +67 -40
  19. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/operations/_insert.py +8 -12
  20. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/operations/cleanup.py +24 -17
  21. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/operations/concurrency.py +360 -179
  22. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/operations/jobs.py +458 -315
  23. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/operations/queues.py +18 -21
  24. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/operations/recurring.py +135 -25
  25. dj_queue-0.12.0/dj_queue/queue_state.py +382 -0
  26. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/base.py +34 -6
  27. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/connection_budget.py +2 -7
  28. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/notify.py +66 -21
  29. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/pool.py +10 -6
  30. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/scheduler.py +49 -31
  31. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/supervisor.py +154 -24
  32. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/worker.py +37 -2
  33. dj_queue-0.12.0/dj_queue/sql/__init__.py +12 -0
  34. dj_queue-0.12.0/dj_queue/sql/common.py +174 -0
  35. dj_queue-0.12.0/dj_queue/sql/mysql.py +81 -0
  36. dj_queue-0.12.0/dj_queue/sql/postgres.py +392 -0
  37. dj_queue-0.12.0/dj_queue/sql/sqlite.py +8 -0
  38. dj_queue-0.12.0/dj_queue/sql/state.py +37 -0
  39. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/task_results.py +16 -1
  40. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +7 -1
  41. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/dashboard.html +1 -0
  42. {dj_queue-0.10.6 → dj_queue-0.12.0}/pyproject.toml +1 -1
  43. dj_queue-0.10.6/dj_queue/apps.py +0 -6
  44. dj_queue-0.10.6/dj_queue/management/commands/dj_queue_health.py +0 -39
  45. dj_queue-0.10.6/dj_queue/queue_state.py +0 -270
  46. {dj_queue-0.10.6 → dj_queue-0.12.0}/LICENSE +0 -0
  47. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/__init__.py +0 -0
  48. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/contrib/__init__.py +0 -0
  49. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/contrib/asgi.py +0 -0
  50. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/contrib/prometheus.py +0 -0
  51. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/db.py +0 -0
  52. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/exceptions.py +0 -0
  53. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/hooks.py +0 -0
  54. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/log.py +0 -0
  55. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/management/__init__.py +0 -0
  56. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/management/commands/__init__.py +0 -0
  57. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/management/commands/dj_queue.py +0 -0
  58. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/management/commands/dj_queue_prune.py +0 -0
  59. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/metrics.py +0 -0
  60. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/0001_initial.py +0 -0
  61. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
  62. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
  63. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/0004_dashboard.py +0 -0
  64. {dj_queue-0.10.6 → 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
  65. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/0006_blockedexecution_dj_queue_bl_concurr_2d8393_idx_and_more.py +0 -0
  66. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
  67. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/0008_remove_blockedexecution_dj_queue_bl_concurr_1ce730_idx_and_more.py +0 -0
  68. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/0009_remove_process_dj_queue_processes_name_supervisor_unique_and_more.py +0 -0
  69. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/0010_remove_process_djq_pr_name_parent_uniq_and_more.py +0 -0
  70. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/migrations/__init__.py +0 -0
  71. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/models/__init__.py +0 -0
  72. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/models/recurring.py +0 -0
  73. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/operations/__init__.py +0 -0
  74. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/queue_selectors.py +0 -0
  75. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/routers.py +0 -0
  76. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/__init__.py +0 -0
  77. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/dispatcher.py +0 -0
  78. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/errors.py +0 -0
  79. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/interruptible.py +0 -0
  80. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/pidfile.py +0 -0
  81. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/procline.py +0 -0
  82. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/runtime/topology.py +0 -0
  83. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
  84. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
  85. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
  86. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
  87. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
  88. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/_queue_controls.html +0 -0
  89. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/change_form.html +0 -0
  90. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
  91. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
  92. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templates/admin/dj_queue/queue_jobs.html +0 -0
  93. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templatetags/__init__.py +0 -0
  94. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/templatetags/dj_queue_admin.py +0 -0
  95. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/urls.py +0 -0
  96. {dj_queue-0.10.6 → dj_queue-0.12.0}/dj_queue/views.py +0 -0
  97. {dj_queue-0.10.6 → 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.10.6
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
@@ -323,7 +323,7 @@ across queues. Prefer one primary scheduling mechanism per worker when you can.
323
323
  In standalone mode, both `fork` and `async` `python manage.py dj_queue` supervisors own runtime signal handling:
324
324
 
325
325
  - `SIGTERM` and `SIGINT` request graceful shutdown
326
- - `SIGQUIT` takes the immediate hard-exit path
326
+ - `SIGQUIT` requests immediate process exit through normal Python cleanup
327
327
  - `shutdown_timeout` controls how long the runtime waits for in-flight work to drain
328
328
  - `supervisor_pidfile` can prevent duplicate standalone supervisors on one host
329
329
 
@@ -397,6 +397,9 @@ TASKS = {
397
397
  }
398
398
  ```
399
399
 
400
+ Static recurring definitions are synced when the scheduler runner starts, then
401
+ normal polling uses the persisted recurring rows and their `next_run_at` cursor.
402
+
400
403
  ### Dynamic recurring tasks
401
404
 
402
405
  Create, update, and remove recurring tasks at runtime:
@@ -428,6 +431,7 @@ Notes:
428
431
  - cron supports five fields or an optional leading seconds field, presets like `@daily`, optional timezone suffixes, wraparound ranges, `L`/`last` and negative monthdays, weekday `#` and `%` extensions, and normal day-of-month/day-of-week OR semantics
429
432
  - add `&` to either day field to require day-of-month and day-of-week to both match
430
433
  - natural-language schedules support `every`, `at`, `on`, and `from` forms such as `every day at noon`, `every weekday at five`, `every 5 minutes`, `every month on day 2 at 10:00`, `from monday to friday at 9`, and `at minute 5`
434
+ - natural interval counts must fit inside the cron field they map to; misleading forms such as `every 90 seconds`, `every 90 minutes`, and `every 24 hours` are rejected
431
435
  - natural-language schedules that expand to multiple cron expressions, such as `every day at 16:15 and 18:30`, are treated as the union of those schedules
432
436
  - recurring task keys are scoped per backend alias
433
437
  - only dynamic tasks can be unscheduled at runtime; unscheduling a static task returns `0`
@@ -490,6 +494,10 @@ if claimed_jobs:
490
494
  execute_claimed_job(claimed_jobs[0])
491
495
  ```
492
496
 
497
+ `QueueInfo.all()` discovers queues through the same read model as the dashboard
498
+ and reuses that snapshot for `size`, `latency`, and `paused` until a queue
499
+ mutation invalidates it.
500
+
493
501
  Notes:
494
502
 
495
503
  - pausing a queue stops future claims, not enqueueing or already-claimed work
@@ -804,7 +812,7 @@ Global options:
804
812
  | Option | Default | Meaning |
805
813
  |---|---|---|
806
814
  | `mode` | `"fork"` | standalone runtime mode, either `"fork"` or `"async"` |
807
- | `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 |
808
816
  | `dispatchers` | `[{"batch_size": 500, "polling_interval": 1, "concurrency_maintenance": true, "concurrency_maintenance_interval": 600}]` | scheduled promotion and concurrency maintenance |
809
817
  | `scheduler` | `{"dynamic_tasks_enabled": false, "polling_interval": 5}` | dynamic recurring polling; the scheduler only starts when recurring or cleanup work exists |
810
818
  | `recurring` | `{}` | static recurring task definitions keyed by name |
@@ -831,6 +839,7 @@ Worker entry options:
831
839
  | `threads` | `3` | worker threads per worker process |
832
840
  | `processes` | `1` | worker processes in `fork` mode; normalized to `1` in `async` mode |
833
841
  | `polling_interval` | `0.1` | seconds between worker polls |
842
+ | `prefetch_multiplier` | `2` | max claimed running/queued jobs per worker thread |
834
843
 
835
844
  Dispatcher entry options:
836
845
 
@@ -906,6 +915,7 @@ queues = ["default", "email*"]
906
915
  threads = 8
907
916
  processes = 1
908
917
  polling_interval = 0.1
918
+ prefetch_multiplier = 2
909
919
 
910
920
  [[dispatchers]]
911
921
  batch_size = 500
@@ -297,7 +297,7 @@ across queues. Prefer one primary scheduling mechanism per worker when you can.
297
297
  In standalone mode, both `fork` and `async` `python manage.py dj_queue` supervisors own runtime signal handling:
298
298
 
299
299
  - `SIGTERM` and `SIGINT` request graceful shutdown
300
- - `SIGQUIT` takes the immediate hard-exit path
300
+ - `SIGQUIT` requests immediate process exit through normal Python cleanup
301
301
  - `shutdown_timeout` controls how long the runtime waits for in-flight work to drain
302
302
  - `supervisor_pidfile` can prevent duplicate standalone supervisors on one host
303
303
 
@@ -371,6 +371,9 @@ TASKS = {
371
371
  }
372
372
  ```
373
373
 
374
+ Static recurring definitions are synced when the scheduler runner starts, then
375
+ normal polling uses the persisted recurring rows and their `next_run_at` cursor.
376
+
374
377
  ### Dynamic recurring tasks
375
378
 
376
379
  Create, update, and remove recurring tasks at runtime:
@@ -402,6 +405,7 @@ Notes:
402
405
  - cron supports five fields or an optional leading seconds field, presets like `@daily`, optional timezone suffixes, wraparound ranges, `L`/`last` and negative monthdays, weekday `#` and `%` extensions, and normal day-of-month/day-of-week OR semantics
403
406
  - add `&` to either day field to require day-of-month and day-of-week to both match
404
407
  - natural-language schedules support `every`, `at`, `on`, and `from` forms such as `every day at noon`, `every weekday at five`, `every 5 minutes`, `every month on day 2 at 10:00`, `from monday to friday at 9`, and `at minute 5`
408
+ - natural interval counts must fit inside the cron field they map to; misleading forms such as `every 90 seconds`, `every 90 minutes`, and `every 24 hours` are rejected
405
409
  - natural-language schedules that expand to multiple cron expressions, such as `every day at 16:15 and 18:30`, are treated as the union of those schedules
406
410
  - recurring task keys are scoped per backend alias
407
411
  - only dynamic tasks can be unscheduled at runtime; unscheduling a static task returns `0`
@@ -464,6 +468,10 @@ if claimed_jobs:
464
468
  execute_claimed_job(claimed_jobs[0])
465
469
  ```
466
470
 
471
+ `QueueInfo.all()` discovers queues through the same read model as the dashboard
472
+ and reuses that snapshot for `size`, `latency`, and `paused` until a queue
473
+ mutation invalidates it.
474
+
467
475
  Notes:
468
476
 
469
477
  - pausing a queue stops future claims, not enqueueing or already-claimed work
@@ -778,7 +786,7 @@ Global options:
778
786
  | Option | Default | Meaning |
779
787
  |---|---|---|
780
788
  | `mode` | `"fork"` | standalone runtime mode, either `"fork"` or `"async"` |
781
- | `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 |
782
790
  | `dispatchers` | `[{"batch_size": 500, "polling_interval": 1, "concurrency_maintenance": true, "concurrency_maintenance_interval": 600}]` | scheduled promotion and concurrency maintenance |
783
791
  | `scheduler` | `{"dynamic_tasks_enabled": false, "polling_interval": 5}` | dynamic recurring polling; the scheduler only starts when recurring or cleanup work exists |
784
792
  | `recurring` | `{}` | static recurring task definitions keyed by name |
@@ -805,6 +813,7 @@ Worker entry options:
805
813
  | `threads` | `3` | worker threads per worker process |
806
814
  | `processes` | `1` | worker processes in `fork` mode; normalized to `1` in `async` mode |
807
815
  | `polling_interval` | `0.1` | seconds between worker polls |
816
+ | `prefetch_multiplier` | `2` | max claimed running/queued jobs per worker thread |
808
817
 
809
818
  Dispatcher entry options:
810
819
 
@@ -880,6 +889,7 @@ queues = ["default", "email*"]
880
889
  threads = 8
881
890
  processes = 1
882
891
  polling_interval = 0.1
892
+ prefetch_multiplier = 2
883
893
 
884
894
  [[dispatchers]]
885
895
  batch_size = 500
@@ -1,11 +1,8 @@
1
1
  import json
2
- from datetime import timedelta
3
2
  from functools import wraps
4
3
  from urllib.parse import parse_qsl, urlencode
5
4
 
6
5
  from django.contrib import admin, messages
7
- from django.db.models import Case, Count, IntegerField, OuterRef, Subquery, Value, When
8
- from django.db.models.functions import Coalesce
9
6
  from django.http import HttpResponseNotAllowed, HttpResponseRedirect
10
7
  from django.template.response import TemplateResponse
11
8
  from django.urls import path, reverse
@@ -13,13 +10,13 @@ from django.utils.html import format_html
13
10
  from django.utils.http import url_has_allowed_host_and_scheme
14
11
  from django.utils import timezone
15
12
 
16
- from dj_queue.config import load_backend_config
17
13
  from dj_queue import dashboard
14
+ from dj_queue import dashboard_actions
15
+ from dj_queue import observability
18
16
  from dj_queue.api import QueueInfo, unschedule_recurring_task
19
17
  from dj_queue.db import get_database_alias
20
18
  from dj_queue.exceptions import EnqueueError
21
19
  from dj_queue.models import (
22
- BlockedExecution,
23
20
  Dashboard,
24
21
  FailedExecution,
25
22
  Job,
@@ -43,40 +40,46 @@ from dj_queue.queue_state import (
43
40
  status_rank_expression,
44
41
  )
45
42
 
43
+ ADMIN_ACTION_ERRORS = (
44
+ EnqueueError,
45
+ ImportError,
46
+ AttributeError,
47
+ Job.DoesNotExist,
48
+ FailedExecution.DoesNotExist,
49
+ RecurringTask.DoesNotExist,
50
+ )
51
+ ADMIN_POST_ACTION_ERRORS = (ValueError, *ADMIN_ACTION_ERRORS)
52
+
46
53
 
47
- class DjQueueAdminSiteMixin:
48
- def _dashboard_app_url(self):
49
- return reverse("admin:dj_queue_dashboard_changelist", current_app=self.name)
54
+ def _install_dj_queue_admin_site(site):
55
+ if getattr(site, "_dj_queue_dashboard_installed", False):
56
+ return
50
57
 
51
- def get_app_list(self, request, app_label=None):
52
- app_list = super().get_app_list(request, app_label=app_label)
58
+ original_get_app_list = site.get_app_list
59
+ original_app_index = site.app_index
60
+
61
+ def dashboard_app_url():
62
+ return reverse("admin:dj_queue_dashboard_changelist", current_app=site.name)
63
+
64
+ def get_app_list(request, app_label=None):
65
+ app_list = original_get_app_list(request, app_label=app_label)
53
66
  for app in app_list:
54
67
  if app["app_label"] == "dj_queue":
55
- app["app_url"] = self._dashboard_app_url()
68
+ app["app_url"] = dashboard_app_url()
56
69
  return sorted(app_list, key=lambda app: app["app_label"] != "dj_queue")
57
70
 
58
- def app_index(self, request, app_label, extra_context=None):
71
+ def app_index(request, app_label, extra_context=None):
59
72
  if app_label == "dj_queue":
60
- url = self._dashboard_app_url()
73
+ url = dashboard_app_url()
61
74
  query = request.GET.urlencode()
62
75
  if query:
63
76
  url = f"{url}?{query}"
64
77
  return HttpResponseRedirect(url)
65
- return super().app_index(request, app_label, extra_context=extra_context)
66
-
78
+ return original_app_index(request, app_label, extra_context=extra_context)
67
79
 
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)
80
+ site.get_app_list = get_app_list
81
+ site.app_index = app_index
82
+ site._dj_queue_dashboard_installed = True
80
83
 
81
84
 
82
85
  def _format_admin_datetime(value):
@@ -128,16 +131,20 @@ class DashboardAdmin(admin.ModelAdmin):
128
131
  return wrapper
129
132
 
130
133
  return [
131
- path("queue/<str:queue_name>/", wrap(self.queue_view), name="dj_queue_dashboard_queue"),
132
134
  path(
133
- "queue/<str:queue_name>/action/",
135
+ "queue/<path:queue_name>/jobs/action/",
136
+ wrap(self.job_action_view),
137
+ name="dj_queue_dashboard_job_action",
138
+ ),
139
+ path(
140
+ "queue/<path:queue_name>/action/",
134
141
  wrap(self.queue_action_view),
135
142
  name="dj_queue_dashboard_queue_action",
136
143
  ),
137
144
  path(
138
- "queue/<str:queue_name>/jobs/action/",
139
- wrap(self.job_action_view),
140
- name="dj_queue_dashboard_job_action",
145
+ "queue/<path:queue_name>/",
146
+ wrap(self.queue_view),
147
+ name="dj_queue_dashboard_queue",
141
148
  ),
142
149
  ] + super().get_urls()
143
150
 
@@ -155,7 +162,7 @@ class DashboardAdmin(admin.ModelAdmin):
155
162
  context = {
156
163
  **self.admin_site.each_context(request),
157
164
  **queue_context,
158
- "job_actions": dashboard.job_actions_for_state(state),
165
+ "job_actions": dashboard_actions.job_actions_for_state(state),
159
166
  "title": "dj_queue",
160
167
  "subtitle": queue_name,
161
168
  }
@@ -165,7 +172,7 @@ class DashboardAdmin(admin.ModelAdmin):
165
172
  backend_alias = dashboard.resolve_backend_alias(request.POST.get("backend"))
166
173
  return self._post_action_response(
167
174
  request,
168
- operation=lambda: dashboard.apply_queue_action(
175
+ operation=lambda: dashboard_actions.apply_queue_action(
169
176
  backend_alias=backend_alias,
170
177
  queue_name=queue_name,
171
178
  action=request.POST.get("action"),
@@ -178,7 +185,7 @@ class DashboardAdmin(admin.ModelAdmin):
178
185
  state = request.POST.get("state", "ready")
179
186
  return self._post_action_response(
180
187
  request,
181
- operation=lambda: dashboard.apply_job_action(
188
+ operation=lambda: dashboard_actions.apply_job_action(
182
189
  backend_alias=backend_alias,
183
190
  queue_name=queue_name,
184
191
  state=state,
@@ -195,7 +202,7 @@ class DashboardAdmin(admin.ModelAdmin):
195
202
  return HttpResponseNotAllowed(["POST"])
196
203
  try:
197
204
  message = operation()
198
- except ValueError as exc:
205
+ except ADMIN_POST_ACTION_ERRORS as exc:
199
206
  self.message_user(request, str(exc), level=messages.ERROR)
200
207
  else:
201
208
  self.message_user(request, message, level=messages.SUCCESS)
@@ -322,6 +329,26 @@ class HiddenSidebarAdminMixin:
322
329
  def handle_change_action(self, request, obj, action):
323
330
  return HttpResponseRedirect(request.get_full_path())
324
331
 
332
+ def _run_change_operation(
333
+ self,
334
+ request,
335
+ *,
336
+ operation,
337
+ success_message,
338
+ error_message,
339
+ success_redirect,
340
+ error_redirect,
341
+ ):
342
+ try:
343
+ result = operation()
344
+ except ADMIN_ACTION_ERRORS as exc:
345
+ self.message_user(request, f"{error_message}: {exc}", level=messages.ERROR)
346
+ return error_redirect()
347
+
348
+ message = success_message(result) if callable(success_message) else success_message
349
+ self.message_user(request, message, level=messages.SUCCESS)
350
+ return success_redirect(result)
351
+
325
352
  def _change_redirect(self, *, object_id, backend_alias):
326
353
  return HttpResponseRedirect(self._change_url(object_id=object_id, backend_alias=backend_alias))
327
354
 
@@ -428,16 +455,10 @@ class ProcessStatusListFilter(admin.SimpleListFilter):
428
455
  value = self.value()
429
456
  if not value:
430
457
  return queryset
431
- cutoff = timezone.now() - timedelta(
432
- seconds=load_backend_config(
433
- dashboard.resolve_backend_alias(request.GET.get("backend"))
434
- ).process_alive_threshold
458
+ cutoff = observability.process_cutoff_for_backend(
459
+ dashboard.resolve_backend_alias(request.GET.get("backend"))
435
460
  )
436
- if value == "live":
437
- return queryset.filter(last_heartbeat_at__gte=cutoff)
438
- if value == "stale":
439
- return queryset.filter(last_heartbeat_at__lt=cutoff)
440
- return queryset
461
+ return observability.filter_process_status(queryset, value, process_cutoff=cutoff)
441
462
 
442
463
 
443
464
  @admin.register(Job)
@@ -506,7 +527,19 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
506
527
 
507
528
  def get_queryset(self, request):
508
529
  queryset = super().get_queryset(request)
509
- 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("."))
510
543
 
511
544
  @admin.display(description="status", ordering="status_rank")
512
545
  def display_status(self, obj):
@@ -515,8 +548,9 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
515
548
  @admin.display(description="queue name", ordering="queue_name")
516
549
  def queue_name_link(self, obj):
517
550
  query = {"backend": obj.backend_alias}
518
- if obj.status is not None:
519
- query["state"] = obj.status
551
+ status = obj.status
552
+ if is_queue_state(status):
553
+ query["state"] = status
520
554
  url = f"{reverse('admin:dj_queue_dashboard_queue', args=[obj.queue_name])}?{urlencode(query)}"
521
555
  return format_html('<a href="{}">{}</a>', url, obj.queue_name)
522
556
 
@@ -595,7 +629,7 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
595
629
  _job, dispatch_outcome = dispatch_scheduled_job_now(
596
630
  obj.pk, backend_alias=obj.backend_alias
597
631
  )
598
- except (EnqueueError, ImportError, AttributeError) as exc:
632
+ except ADMIN_ACTION_ERRORS as exc:
599
633
  self.message_user(request, f"Could not dispatch job now: {exc}", level=messages.ERROR)
600
634
  return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
601
635
 
@@ -610,7 +644,7 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
610
644
  if action == "enqueue_copy_now":
611
645
  try:
612
646
  new_job = enqueue_job_again(obj.pk, backend_alias=obj.backend_alias, run_after=None)
613
- except (EnqueueError, ImportError, AttributeError) as exc:
647
+ except ADMIN_ACTION_ERRORS as exc:
614
648
  self.message_user(request, f"Could not enqueue job: {exc}", level=messages.ERROR)
615
649
  return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
616
650
 
@@ -628,7 +662,7 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
628
662
  if action == "enqueue":
629
663
  try:
630
664
  new_job = enqueue_job_again(obj.pk, backend_alias=obj.backend_alias)
631
- except (EnqueueError, ImportError, AttributeError) as exc:
665
+ except ADMIN_ACTION_ERRORS as exc:
632
666
  self.message_user(request, f"Could not enqueue job: {exc}", level=messages.ERROR)
633
667
  return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
634
668
 
@@ -648,14 +682,32 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
648
682
  return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
649
683
 
650
684
  if action == "retry":
651
- retry_failed_job(obj.failed_execution.job_id, backend_alias=obj.backend_alias)
652
- self.message_user(request, "Retried failed job", level=messages.SUCCESS)
653
- return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
685
+ return self._run_change_operation(
686
+ request,
687
+ operation=lambda: retry_failed_job(
688
+ obj.failed_execution.job_id, backend_alias=obj.backend_alias
689
+ ),
690
+ success_message="Retried failed job",
691
+ error_message="Could not retry failed job",
692
+ success_redirect=lambda _result: self._current_object_redirect(
693
+ obj, backend_alias=obj.backend_alias
694
+ ),
695
+ error_redirect=lambda: self._current_object_redirect(obj, backend_alias=obj.backend_alias),
696
+ )
654
697
 
655
698
  if action == "discard":
656
- discard_failed_job(obj.failed_execution.job_id, backend_alias=obj.backend_alias)
657
- self.message_user(request, "Discarded failed job", level=messages.SUCCESS)
658
- return HttpResponseRedirect(self._changelist_url(backend_alias=obj.backend_alias))
699
+ return self._run_change_operation(
700
+ request,
701
+ operation=lambda: discard_failed_job(
702
+ obj.failed_execution.job_id, backend_alias=obj.backend_alias
703
+ ),
704
+ success_message="Discarded failed job",
705
+ error_message="Could not discard failed job",
706
+ success_redirect=lambda _result: HttpResponseRedirect(
707
+ self._changelist_url(backend_alias=obj.backend_alias)
708
+ ),
709
+ error_redirect=lambda: self._current_object_redirect(obj, backend_alias=obj.backend_alias),
710
+ )
659
711
 
660
712
  return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
661
713
 
@@ -676,18 +728,28 @@ class FailedExecutionAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
676
728
 
677
729
  @admin.action(description="Retry selected failed jobs")
678
730
  def retry_jobs(self, request, queryset):
679
- retried = retry_failed_jobs(
680
- job_ids=list(queryset.values_list("job_id", flat=True)),
681
- batch_size=queryset.count() or 1,
682
- backend_alias=self._backend_alias(request),
683
- )
731
+ try:
732
+ retried = retry_failed_jobs(
733
+ job_ids=list(queryset.values_list("job_id", flat=True)),
734
+ batch_size=queryset.count() or 1,
735
+ backend_alias=self._backend_alias(request),
736
+ )
737
+ except ADMIN_ACTION_ERRORS as exc:
738
+ self.message_user(request, f"Could not retry failed jobs: {exc}", level=messages.ERROR)
739
+ return
684
740
  self.message_user(request, f"Retried {retried} failed jobs", level=messages.SUCCESS)
685
741
 
686
742
  @admin.action(description="Discard selected failed jobs")
687
743
  def discard_jobs(self, request, queryset):
688
- discarded = 0
689
- for execution in queryset.select_related("job"):
690
- discarded += discard_failed_job(execution.job_id, backend_alias=execution.job.backend_alias)
744
+ try:
745
+ discarded = 0
746
+ for execution in queryset.select_related("job"):
747
+ discarded += discard_failed_job(
748
+ execution.job_id, backend_alias=execution.job.backend_alias
749
+ )
750
+ except ADMIN_ACTION_ERRORS as exc:
751
+ self.message_user(request, f"Could not discard failed jobs: {exc}", level=messages.ERROR)
752
+ return
691
753
  self.message_user(request, f"Discarded {discarded} failed jobs", level=messages.SUCCESS)
692
754
 
693
755
  @admin.display(description="created at", ordering="created_at")
@@ -711,15 +773,28 @@ class FailedExecutionAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
711
773
 
712
774
  if action == "retry":
713
775
  job_id = obj.job_id
714
- retry_failed_job(job_id, backend_alias=backend_alias)
715
- self.message_user(request, "Retried failed job", level=messages.SUCCESS)
716
- url = reverse("admin:dj_queue_job_change", args=[job_id])
717
- return HttpResponseRedirect(f"{url}?{urlencode({'backend': backend_alias})}")
776
+ return self._run_change_operation(
777
+ request,
778
+ operation=lambda: retry_failed_job(job_id, backend_alias=backend_alias),
779
+ success_message="Retried failed job",
780
+ error_message="Could not retry failed job",
781
+ success_redirect=lambda _result: HttpResponseRedirect(
782
+ f"{reverse('admin:dj_queue_job_change', args=[job_id])}?{urlencode({'backend': backend_alias})}"
783
+ ),
784
+ error_redirect=lambda: self._current_object_redirect(obj, backend_alias=backend_alias),
785
+ )
718
786
 
719
787
  if action == "discard":
720
- discard_failed_job(obj.job_id, backend_alias=backend_alias)
721
- self.message_user(request, "Discarded failed job", level=messages.SUCCESS)
722
- return HttpResponseRedirect(self._changelist_url(backend_alias=backend_alias))
788
+ return self._run_change_operation(
789
+ request,
790
+ operation=lambda: discard_failed_job(obj.job_id, backend_alias=backend_alias),
791
+ success_message="Discarded failed job",
792
+ error_message="Could not discard failed job",
793
+ success_redirect=lambda _result: HttpResponseRedirect(
794
+ self._changelist_url(backend_alias=backend_alias)
795
+ ),
796
+ error_redirect=lambda: self._current_object_redirect(obj, backend_alias=backend_alias),
797
+ )
723
798
 
724
799
  return self._current_object_redirect(obj, backend_alias=backend_alias)
725
800
 
@@ -752,16 +827,8 @@ class ProcessAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
752
827
 
753
828
  def get_queryset(self, request):
754
829
  queryset = super().get_queryset(request)
755
- cutoff = timezone.now() - timedelta(
756
- seconds=load_backend_config(self._backend_alias(request)).process_alive_threshold
757
- )
758
- return queryset.annotate(
759
- live_rank=Case(
760
- When(last_heartbeat_at__gte=cutoff, then=Value(0)),
761
- default=Value(1),
762
- output_field=IntegerField(),
763
- )
764
- )
830
+ cutoff = observability.process_cutoff_for_backend(self._backend_alias(request))
831
+ return queryset.annotate(live_rank=observability.process_live_rank_expression(cutoff))
765
832
 
766
833
  @admin.display(description="status", ordering="live_rank")
767
834
  def display_status(self, obj):
@@ -872,7 +939,11 @@ class PauseAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
872
939
  def handle_change_action(self, request, obj, action):
873
940
  backend_alias = self._backend_alias(request)
874
941
  if action == "resume":
875
- QueueInfo(obj.queue_name, backend_alias=backend_alias).resume()
942
+ try:
943
+ QueueInfo(obj.queue_name, backend_alias=backend_alias).resume()
944
+ except ADMIN_ACTION_ERRORS as exc:
945
+ self.message_user(request, f"Could not resume queue: {exc}", level=messages.ERROR)
946
+ return self._current_object_redirect(obj, backend_alias=backend_alias)
876
947
  self.message_user(
877
948
  request,
878
949
  format_html(
@@ -896,18 +967,8 @@ class SemaphoreAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
896
967
  def get_queryset(self, request):
897
968
  queryset = super().get_queryset(request)
898
969
  alias = self._backend_database_alias(request)
899
- blocked_waiters = (
900
- BlockedExecution.objects.using(alias)
901
- .filter(concurrency_key=OuterRef("key"))
902
- .values("concurrency_key")
903
- .annotate(total=Count("id"))
904
- .values("total")[:1]
905
- )
906
970
  return queryset.annotate(
907
- blocked_waiter_count=Coalesce(
908
- Subquery(blocked_waiters, output_field=IntegerField()),
909
- Value(0),
910
- )
971
+ blocked_waiter_count=observability.semaphore_blocked_waiter_count_expression(alias)
911
972
  )
912
973
 
913
974
  @admin.display(description="blocked waiters", ordering="blocked_waiter_count")
@@ -1,19 +1,15 @@
1
- from datetime import timedelta
2
1
  from functools import partial
3
2
 
4
3
  from django.db import transaction
5
- from django.utils import timezone
6
4
 
7
5
  from dj_queue import observability
8
- from dj_queue.config import load_backend_config
9
- from dj_queue.db import get_database_alias
10
- from dj_queue.models import ReadyExecution
11
6
  from dj_queue.operations.jobs import (
12
7
  ClaimedJob,
13
8
  claim_ready_jobs,
14
9
  discard_blocked_jobs,
15
10
  discard_failed_job,
16
11
  discard_failed_jobs,
12
+ discard_ready_jobs_for_queue,
17
13
  discard_ready_jobs,
18
14
  discard_scheduled_jobs,
19
15
  execute_claimed_job,
@@ -42,16 +38,28 @@ __all__ = [
42
38
 
43
39
 
44
40
  class QueueInfo:
45
- def __init__(self, queue_name, *, backend_alias="default"):
41
+ def __init__(self, queue_name, *, backend_alias="default", snapshot=None):
46
42
  self.queue_name = queue_name
47
43
  self.backend_alias = backend_alias
44
+ self._snapshot = snapshot
48
45
 
49
46
  @property
50
47
  def size(self):
51
- return self._ready_queryset().count()
48
+ if self._snapshot is not None:
49
+ return self._snapshot["ready_count"]
50
+ return observability.queue_ready_count(
51
+ backend_alias=self.backend_alias,
52
+ queue_name=self.queue_name,
53
+ )
52
54
 
53
55
  @property
54
56
  def latency(self):
57
+ if self._snapshot is not None:
58
+ latency = self._snapshot["latency_seconds"]
59
+ if latency is None and not self._snapshot["paused"]:
60
+ return 0.0
61
+ return latency
62
+
55
63
  paused = observability.queue_is_paused(
56
64
  backend_alias=self.backend_alias,
57
65
  queue_name=self.queue_name,
@@ -68,6 +76,8 @@ class QueueInfo:
68
76
 
69
77
  @property
70
78
  def paused(self):
79
+ if self._snapshot is not None:
80
+ return self._snapshot["paused"]
71
81
  return observability.queue_is_paused(
72
82
  backend_alias=self.backend_alias,
73
83
  queue_name=self.queue_name,
@@ -75,40 +85,29 @@ class QueueInfo:
75
85
 
76
86
  def pause(self):
77
87
  pause_queue(self.queue_name, backend_alias=self.backend_alias)
88
+ self._snapshot = None
78
89
 
79
90
  def resume(self):
80
91
  resume_queue(self.queue_name, backend_alias=self.backend_alias)
92
+ self._snapshot = None
81
93
 
82
94
  def clear(self, *, batch_size=500):
83
95
  deleted = 0
84
96
  while True:
85
- job_ids = list(self._ready_queryset().values_list("job_id", flat=True)[:batch_size])
86
- if not job_ids:
87
- return deleted
88
- deleted += discard_ready_jobs(
89
- job_ids=job_ids,
97
+ batch_deleted = discard_ready_jobs_for_queue(
98
+ self.queue_name,
90
99
  batch_size=batch_size,
91
100
  backend_alias=self.backend_alias,
92
101
  )
102
+ if not batch_deleted:
103
+ self._snapshot = None
104
+ return deleted
105
+ deleted += batch_deleted
93
106
 
94
107
  @classmethod
95
108
  def all(cls, *, backend_alias="default"):
96
- now = timezone.now()
97
- config = load_backend_config(backend_alias)
98
- process_cutoff = now - timedelta(seconds=config.process_alive_threshold)
99
- queue_rows = observability.queue_rows(
100
- backend_alias=backend_alias,
101
- now=now,
102
- process_cutoff=process_cutoff,
103
- )
104
- return [cls(row["name"], backend_alias=backend_alias) for row in queue_rows]
105
-
106
- def _ready_queryset(self):
107
- alias = get_database_alias(self.backend_alias)
108
- return ReadyExecution.objects.using(alias).filter(
109
- backend_alias=self.backend_alias,
110
- queue_name=self.queue_name,
111
- )
109
+ queue_rows = observability.queue_rows_for_backend(backend_alias=backend_alias)
110
+ return [cls(row["name"], backend_alias=backend_alias, snapshot=row) for row in queue_rows]
112
111
 
113
112
 
114
113
  def enqueue_on_commit(task, *args, using=None, **kwargs):