dj-queue 0.14.0__tar.gz → 0.14.2__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 (123) hide show
  1. {dj_queue-0.14.0 → dj_queue-0.14.2}/PKG-INFO +18 -10
  2. {dj_queue-0.14.0 → dj_queue-0.14.2}/README.md +17 -9
  3. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/admin.py +93 -95
  4. dj_queue-0.14.2/dj_queue/admin_permissions.py +27 -0
  5. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/api.py +7 -7
  6. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/backend.py +2 -2
  7. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/config.py +42 -34
  8. dj_queue-0.14.2/dj_queue/dashboard.py +466 -0
  9. dj_queue-0.14.2/dj_queue/dashboard_actions.py +71 -0
  10. dj_queue-0.14.2/dj_queue/dashboard_tables.py +640 -0
  11. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/db.py +8 -4
  12. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/exceptions.py +4 -0
  13. dj_queue-0.14.2/dj_queue/health.py +258 -0
  14. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/hooks.py +2 -1
  15. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/management/commands/dj_queue.py +3 -1
  16. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/management/commands/dj_queue_health.py +4 -3
  17. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/management/commands/dj_queue_postgres_autovacuum.py +2 -2
  18. dj_queue-0.14.2/dj_queue/metrics.py +201 -0
  19. dj_queue-0.14.2/dj_queue/observability.py +224 -0
  20. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/operations/_helpers.py +18 -0
  21. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/operations/claiming.py +6 -4
  22. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/operations/cleanup.py +10 -7
  23. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/operations/concurrency.py +194 -185
  24. dj_queue-0.14.2/dj_queue/operations/dispatch.py +200 -0
  25. dj_queue-0.14.2/dj_queue/operations/enqueue.py +253 -0
  26. dj_queue-0.14.2/dj_queue/operations/execution.py +263 -0
  27. dj_queue-0.14.2/dj_queue/operations/jobs.py +640 -0
  28. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/operations/queues.py +5 -5
  29. dj_queue-0.14.2/dj_queue/operations/recovery.py +247 -0
  30. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/operations/recurring.py +55 -28
  31. dj_queue-0.14.2/dj_queue/postgres_diagnostics.py +241 -0
  32. dj_queue-0.14.2/dj_queue/reads/controls.py +120 -0
  33. dj_queue-0.14.2/dj_queue/reads/jobs.py +49 -0
  34. dj_queue-0.14.2/dj_queue/reads/processes.py +161 -0
  35. dj_queue-0.14.2/dj_queue/reads/queues.py +181 -0
  36. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/base.py +34 -12
  37. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/dispatcher.py +6 -0
  38. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/errors.py +3 -3
  39. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/notify.py +32 -20
  40. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/scheduler.py +9 -1
  41. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/supervisor.py +12 -1
  42. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/worker.py +23 -8
  43. dj_queue-0.14.2/dj_queue/static/admin/dj_queue/breadcrumbs.css +46 -0
  44. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/_queue_controls.html +3 -1
  45. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/change_form.html +8 -7
  46. dj_queue-0.14.2/dj_queue/templates/admin/dj_queue/change_list.html +16 -0
  47. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/dashboard.html +5 -4
  48. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/queue_jobs.html +7 -6
  49. dj_queue-0.14.2/dj_queue/templatetags/__init__.py +0 -0
  50. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/wakeup.py +9 -4
  51. {dj_queue-0.14.0 → dj_queue-0.14.2}/pyproject.toml +2 -2
  52. dj_queue-0.14.0/dj_queue/dashboard.py +0 -1343
  53. dj_queue-0.14.0/dj_queue/dashboard_actions.py +0 -140
  54. dj_queue-0.14.0/dj_queue/metrics.py +0 -232
  55. dj_queue-0.14.0/dj_queue/observability.py +0 -991
  56. dj_queue-0.14.0/dj_queue/operations/jobs.py +0 -1653
  57. dj_queue-0.14.0/dj_queue/templates/admin/dj_queue/change_list.html +0 -11
  58. {dj_queue-0.14.0 → dj_queue-0.14.2}/LICENSE +0 -0
  59. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/__init__.py +0 -0
  60. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/apps.py +0 -0
  61. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/contrib/__init__.py +0 -0
  62. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/contrib/asgi.py +0 -0
  63. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/contrib/gunicorn.py +0 -0
  64. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/contrib/prometheus.py +0 -0
  65. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/cron.py +0 -0
  66. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/log.py +0 -0
  67. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/management/__init__.py +0 -0
  68. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/management/commands/__init__.py +0 -0
  69. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/management/commands/dj_queue_prune.py +0 -0
  70. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0001_initial.py +0 -0
  71. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
  72. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
  73. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0004_dashboard.py +0 -0
  74. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0005_remove_recurringexecution_dj_queue_recurring_executions_task_key_run_at_unique_and_more.py +0 -0
  75. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0006_blockedexecution_dj_queue_bl_concurr_2d8393_idx_and_more.py +0 -0
  76. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
  77. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0008_remove_blockedexecution_dj_queue_bl_concurr_1ce730_idx_and_more.py +0 -0
  78. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0009_remove_process_dj_queue_processes_name_supervisor_unique_and_more.py +0 -0
  79. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0010_remove_process_djq_pr_name_parent_uniq_and_more.py +0 -0
  80. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0011_remove_blockedexecution_djq_bl_b_conc_idx_and_more.py +0 -0
  81. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0012_job_djq_jobs_b_queue_id_idx_job_djq_jobs_b_conc_idx_and_more.py +0 -0
  82. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0013_failedexecution_retry_at_and_more.py +0 -0
  83. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0014_semaphore_active_count.py +0 -0
  84. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0015_job_concurrency_duration.py +0 -0
  85. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0016_job_concurrency_limit.py +0 -0
  86. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0017_job_concurrency_on_conflict.py +0 -0
  87. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/0018_recurringexecution_intended_job_id.py +0 -0
  88. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/migrations/__init__.py +0 -0
  89. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/models/__init__.py +0 -0
  90. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/models/jobs.py +0 -0
  91. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/models/recurring.py +0 -0
  92. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/models/runtime.py +0 -0
  93. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/operations/__init__.py +0 -0
  94. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/operations/_insert.py +0 -0
  95. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/py.typed +0 -0
  96. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/queue_selectors.py +0 -0
  97. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/queue_state.py +0 -0
  98. {dj_queue-0.14.0/dj_queue/templatetags → dj_queue-0.14.2/dj_queue/reads}/__init__.py +0 -0
  99. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/routers.py +0 -0
  100. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/__init__.py +0 -0
  101. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/connection_budget.py +0 -0
  102. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/interruptible.py +0 -0
  103. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/pidfile.py +0 -0
  104. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/pool.py +0 -0
  105. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/procline.py +0 -0
  106. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/runtime/topology.py +0 -0
  107. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/sql/__init__.py +0 -0
  108. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/sql/common.py +0 -0
  109. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/sql/mysql.py +0 -0
  110. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/sql/postgres.py +0 -0
  111. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/sql/sqlite.py +0 -0
  112. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/sql/state.py +0 -0
  113. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/task_results.py +0 -0
  114. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
  115. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
  116. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
  117. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
  118. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
  119. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +0 -0
  120. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
  121. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/templatetags/dj_queue_admin.py +0 -0
  122. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/urls.py +0 -0
  123. {dj_queue-0.14.0 → dj_queue-0.14.2}/dj_queue/views.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dj-queue
3
- Version: 0.14.0
3
+ Version: 0.14.2
4
4
  Summary: Database-backed task queue backend for Django’s Tasks framework.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -425,7 +425,7 @@ application deployment, so keep your task import paths and accepted argument
425
425
  shapes compatible. Use a new task name when a payload change cannot be backward
426
426
  compatible.
427
427
 
428
- Maintainers can prove a candidate rollout against two immutable revisions:
428
+ Maintainers can check a candidate rollout against two immutable revisions:
429
429
 
430
430
  ```bash
431
431
  bin/prerelease.py \
@@ -435,14 +435,22 @@ bin/prerelease.py \
435
435
  --django '>=6.0,<6.1'
436
436
  ```
437
437
 
438
- The command calibrates release N−1, applies release N migrations under live
439
- writes, runs both versions together, drains with release N, and verifies queue
440
- state and side effects. It rejects revisions that do not publish one shared
441
- rollout protocol. It writes wheel hashes, one-second metrics, logs, and a result
442
- manifest under `benchmark-results/`. The **Pre-release migration load** workflow
443
- runs the database floor and current-version lanes. `--smoke` and custom workload
444
- profiles keep all validity and stability gates and record performance. Only the
445
- default ten-minute release profile enforces the X-to-Y performance ratios.
438
+ The command builds isolated wheels, applies N migrations while an N−1 producer
439
+ and supervisor run, then checks both cross-version producer/worker directions
440
+ and N-only operation. Each phase must complete immediate, scheduled, limited,
441
+ bulk, recurring, and fail-once/retry work before its deadline.
442
+
443
+ The check requires one shared rollout protocol and the same Django version.
444
+ It verifies planned side effects, queue drain, deep health, and clean shutdown.
445
+ It creates a fresh database and drops it before reporting success; use
446
+ `--keep-database` to retain it for inspection. Wheels, hashes, phase results,
447
+ migration timing, and logs stay under `benchmark-results/`.
448
+
449
+ The manual/reusable **Mixed-version upgrade check** workflow uses hosted runners
450
+ for PostgreSQL 14, MySQL 8.0, and MariaDB 10.6 with Django 6.0. Run this check for
451
+ upgrade-sensitive changes; backend-specific DDL changes may need extra database
452
+ versions. This is a correctness check, not a capacity or latency benchmark.
453
+ Use the [benchmark tools](docs/benchmarks.md) for performance comparisons.
446
454
 
447
455
  ## Recurring Tasks
448
456
 
@@ -398,7 +398,7 @@ application deployment, so keep your task import paths and accepted argument
398
398
  shapes compatible. Use a new task name when a payload change cannot be backward
399
399
  compatible.
400
400
 
401
- Maintainers can prove a candidate rollout against two immutable revisions:
401
+ Maintainers can check a candidate rollout against two immutable revisions:
402
402
 
403
403
  ```bash
404
404
  bin/prerelease.py \
@@ -408,14 +408,22 @@ bin/prerelease.py \
408
408
  --django '>=6.0,<6.1'
409
409
  ```
410
410
 
411
- The command calibrates release N−1, applies release N migrations under live
412
- writes, runs both versions together, drains with release N, and verifies queue
413
- state and side effects. It rejects revisions that do not publish one shared
414
- rollout protocol. It writes wheel hashes, one-second metrics, logs, and a result
415
- manifest under `benchmark-results/`. The **Pre-release migration load** workflow
416
- runs the database floor and current-version lanes. `--smoke` and custom workload
417
- profiles keep all validity and stability gates and record performance. Only the
418
- default ten-minute release profile enforces the X-to-Y performance ratios.
411
+ The command builds isolated wheels, applies N migrations while an N−1 producer
412
+ and supervisor run, then checks both cross-version producer/worker directions
413
+ and N-only operation. Each phase must complete immediate, scheduled, limited,
414
+ bulk, recurring, and fail-once/retry work before its deadline.
415
+
416
+ The check requires one shared rollout protocol and the same Django version.
417
+ It verifies planned side effects, queue drain, deep health, and clean shutdown.
418
+ It creates a fresh database and drops it before reporting success; use
419
+ `--keep-database` to retain it for inspection. Wheels, hashes, phase results,
420
+ migration timing, and logs stay under `benchmark-results/`.
421
+
422
+ The manual/reusable **Mixed-version upgrade check** workflow uses hosted runners
423
+ for PostgreSQL 14, MySQL 8.0, and MariaDB 10.6 with Django 6.0. Run this check for
424
+ upgrade-sensitive changes; backend-specific DDL changes may need extra database
425
+ versions. This is a correctness check, not a capacity or latency benchmark.
426
+ Use the [benchmark tools](docs/benchmarks.md) for performance comparisons.
419
427
 
420
428
  ## Recurring Tasks
421
429
 
@@ -10,7 +10,12 @@ from django.utils import timezone
10
10
  from django.utils.html import format_html
11
11
  from django.utils.http import url_has_allowed_host_and_scheme
12
12
 
13
- from dj_queue import dashboard, dashboard_actions, observability
13
+ from dj_queue import dashboard, dashboard_actions
14
+ from dj_queue.admin_permissions import (
15
+ has_action_permission,
16
+ permitted_actions,
17
+ require_action_permission,
18
+ )
14
19
  from dj_queue.api import QueueInfo, unschedule_recurring_task
15
20
  from dj_queue.db import get_database_alias
16
21
  from dj_queue.exceptions import EnqueueError
@@ -23,9 +28,10 @@ from dj_queue.models import (
23
28
  RecurringTask,
24
29
  Semaphore,
25
30
  )
31
+ from dj_queue.operations.dispatch import DispatchOutcome
26
32
  from dj_queue.operations.jobs import (
27
- DispatchOutcome,
28
33
  discard_failed_job,
34
+ discard_failed_jobs,
29
35
  dispatch_scheduled_job_now,
30
36
  enqueue_job_again,
31
37
  retry_failed_job,
@@ -37,6 +43,7 @@ from dj_queue.queue_state import (
37
43
  is_queue_state,
38
44
  status_rank_expression,
39
45
  )
46
+ from dj_queue.reads import controls, processes
40
47
 
41
48
  ADMIN_ACTION_ERRORS = (
42
49
  EnqueueError,
@@ -160,7 +167,9 @@ class DashboardAdmin(admin.ModelAdmin):
160
167
  context = {
161
168
  **self.admin_site.each_context(request),
162
169
  **queue_context,
163
- "job_actions": dashboard_actions.job_actions_for_state(state),
170
+ "job_actions": permitted_actions(
171
+ request.user, dashboard_actions.job_actions_for_state(state)
172
+ ),
164
173
  "title": "dj_queue",
165
174
  "subtitle": queue_name,
166
175
  }
@@ -198,6 +207,9 @@ class DashboardAdmin(admin.ModelAdmin):
198
207
  def _post_action_response(self, request, operation, fallback_url):
199
208
  if request.method != "POST":
200
209
  return HttpResponseNotAllowed(["POST"])
210
+ action = request.POST.get("action")
211
+ if action:
212
+ require_action_permission(request.user, action)
201
213
  try:
202
214
  message = operation()
203
215
  except ADMIN_POST_ACTION_ERRORS as exc:
@@ -243,13 +255,14 @@ class HiddenSidebarAdminMixin:
243
255
  if request.method == "POST":
244
256
  action = request.POST.get("_djq_object_action")
245
257
  if action and obj is not None:
258
+ require_action_permission(request.user, action)
246
259
  return self.handle_change_action(request, obj, action)
247
260
 
248
261
  extra_context = {
249
262
  **(extra_context or {}),
250
263
  "dashboard_url": self._dashboard_url(request),
251
264
  "changelist_url": self._changelist_url(backend_alias=self._backend_alias(request)),
252
- "change_actions": self.get_change_actions(request, obj),
265
+ "change_actions": permitted_actions(request.user, self.get_change_actions(request, obj)),
253
266
  }
254
267
  return super().changeform_view(
255
268
  request,
@@ -334,18 +347,18 @@ class HiddenSidebarAdminMixin:
334
347
  operation,
335
348
  success_message,
336
349
  error_message,
337
- success_redirect,
338
- error_redirect,
350
+ success_url,
351
+ error_url,
339
352
  ):
340
353
  try:
341
354
  result = operation()
342
355
  except ADMIN_ACTION_ERRORS as exc:
343
356
  self.message_user(request, f"{error_message}: {exc}", level=messages.ERROR)
344
- return error_redirect()
357
+ return HttpResponseRedirect(error_url)
345
358
 
346
359
  message = success_message(result) if callable(success_message) else success_message
347
360
  self.message_user(request, message, level=messages.SUCCESS)
348
- return success_redirect(result)
361
+ return HttpResponseRedirect(success_url)
349
362
 
350
363
  def _change_redirect(self, *, object_id, backend_alias):
351
364
  return HttpResponseRedirect(self._change_url(object_id=object_id, backend_alias=backend_alias))
@@ -453,10 +466,10 @@ class ProcessStatusListFilter(admin.SimpleListFilter):
453
466
  value = self.value()
454
467
  if not value:
455
468
  return queryset
456
- cutoff = observability.process_cutoff_for_backend(
469
+ cutoff = processes.process_cutoff_for_backend(
457
470
  dashboard.resolve_backend_alias(request.GET.get("backend"))
458
471
  )
459
- return observability.filter_process_status(queryset, value, process_cutoff=cutoff)
472
+ return processes.filter_process_status(queryset, value, process_cutoff=cutoff)
460
473
 
461
474
 
462
475
  @admin.register(Job)
@@ -625,92 +638,68 @@ class JobAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
625
638
  return tuple(actions)
626
639
 
627
640
  def handle_change_action(self, request, obj, action):
641
+ backend_alias = obj.backend_alias
642
+ object_url = self._change_url(object_id=obj.pk, backend_alias=backend_alias)
628
643
  if action == "run_now":
629
- try:
630
- _job, dispatch_outcome = dispatch_scheduled_job_now(
631
- obj.pk, backend_alias=obj.backend_alias
632
- )
633
- except ADMIN_ACTION_ERRORS as exc:
634
- self.message_user(request, f"Could not dispatch job now: {exc}", level=messages.ERROR)
635
- return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
636
-
637
- message = "Dispatched scheduled job for immediate execution"
638
- if dispatch_outcome is DispatchOutcome.BLOCKED:
639
- message = "Dispatched scheduled job immediately and it is now blocked"
640
- if dispatch_outcome is DispatchOutcome.DISCARDED:
641
- message = "Dispatched scheduled job immediately and it was discarded"
642
- self.message_user(request, message, level=messages.SUCCESS)
643
- return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
644
-
645
- if action == "enqueue_copy_now":
646
- try:
647
- new_job = enqueue_job_again(obj.pk, backend_alias=obj.backend_alias, run_after=None)
648
- except ADMIN_ACTION_ERRORS as exc:
649
- self.message_user(request, f"Could not enqueue job: {exc}", level=messages.ERROR)
650
- return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
651
-
652
- self.message_user(
644
+ return self._run_change_operation(
653
645
  request,
654
- format_html(
655
- 'Enqueued immediate copy <a href="{}">{}</a>.',
656
- self._change_url(object_id=new_job.pk, backend_alias=new_job.backend_alias),
657
- new_job.pk,
658
- ),
659
- level=messages.SUCCESS,
646
+ operation=lambda: dispatch_scheduled_job_now(obj.pk, backend_alias=backend_alias),
647
+ success_message=lambda result: {
648
+ DispatchOutcome.BLOCKED: "Dispatched scheduled job immediately and it is now blocked",
649
+ DispatchOutcome.DISCARDED: "Dispatched scheduled job immediately and it was discarded",
650
+ }.get(result[1], "Dispatched scheduled job for immediate execution"),
651
+ error_message="Could not dispatch job now",
652
+ success_url=object_url,
653
+ error_url=object_url,
660
654
  )
661
- return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
662
655
 
663
- if action == "enqueue":
664
- try:
665
- new_job = enqueue_job_again(obj.pk, backend_alias=obj.backend_alias)
666
- except ADMIN_ACTION_ERRORS as exc:
667
- self.message_user(request, f"Could not enqueue job: {exc}", level=messages.ERROR)
668
- return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
669
-
670
- self.message_user(
656
+ if action in {"enqueue", "enqueue_copy_now"}:
657
+ immediate = action == "enqueue_copy_now"
658
+ options = {"run_after": None} if immediate else {}
659
+ label = "Enqueued immediate copy" if immediate else "Enqueued job"
660
+ return self._run_change_operation(
671
661
  request,
672
- format_html(
673
- 'Enqueued job <a href="{}">{}</a>.',
662
+ operation=lambda: enqueue_job_again(obj.pk, backend_alias=backend_alias, **options),
663
+ success_message=lambda new_job: format_html(
664
+ '{} <a href="{}">{}</a>.',
665
+ label,
674
666
  self._change_url(object_id=new_job.pk, backend_alias=new_job.backend_alias),
675
667
  new_job.pk,
676
668
  ),
677
- level=messages.SUCCESS,
669
+ error_message="Could not enqueue job",
670
+ success_url=object_url,
671
+ error_url=object_url,
678
672
  )
679
- return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
680
673
 
681
674
  if obj.status != "failed":
682
675
  self.message_user(request, "This job is not failed", level=messages.ERROR)
683
- return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
676
+ return HttpResponseRedirect(object_url)
684
677
 
685
678
  if action == "retry":
686
679
  return self._run_change_operation(
687
680
  request,
688
681
  operation=lambda: retry_failed_job(
689
- obj.failed_execution.job_id, backend_alias=obj.backend_alias
682
+ obj.failed_execution.job_id, backend_alias=backend_alias
690
683
  ),
691
684
  success_message="Retried failed job",
692
685
  error_message="Could not retry failed job",
693
- success_redirect=lambda _result: self._current_object_redirect(
694
- obj, backend_alias=obj.backend_alias
695
- ),
696
- error_redirect=lambda: self._current_object_redirect(obj, backend_alias=obj.backend_alias),
686
+ success_url=object_url,
687
+ error_url=object_url,
697
688
  )
698
689
 
699
690
  if action == "discard":
700
691
  return self._run_change_operation(
701
692
  request,
702
693
  operation=lambda: discard_failed_job(
703
- obj.failed_execution.job_id, backend_alias=obj.backend_alias
694
+ obj.failed_execution.job_id, backend_alias=backend_alias
704
695
  ),
705
696
  success_message="Discarded failed job",
706
697
  error_message="Could not discard failed job",
707
- success_redirect=lambda _result: HttpResponseRedirect(
708
- self._changelist_url(backend_alias=obj.backend_alias)
709
- ),
710
- error_redirect=lambda: self._current_object_redirect(obj, backend_alias=obj.backend_alias),
698
+ success_url=self._changelist_url(backend_alias=backend_alias),
699
+ error_url=object_url,
711
700
  )
712
701
 
713
- return self._current_object_redirect(obj, backend_alias=obj.backend_alias)
702
+ return HttpResponseRedirect(object_url)
714
703
 
715
704
 
716
705
  @admin.register(FailedExecution)
@@ -727,12 +716,20 @@ class FailedExecutionAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
727
716
  readonly_fields = ("job", "exception_class", "message", "traceback", "retry_at", "created_at")
728
717
  search_fields = ("job__id", "job__task_path", "message", "exception_class")
729
718
 
730
- @admin.action(description="Retry selected failed jobs")
719
+ def has_retry_jobs_permission(self, request):
720
+ return has_action_permission(request.user, "retry")
721
+
722
+ def has_discard_jobs_permission(self, request):
723
+ return has_action_permission(request.user, "discard")
724
+
725
+ @admin.action(description="Retry selected failed jobs", permissions=["retry_jobs"])
731
726
  def retry_jobs(self, request, queryset):
727
+ require_action_permission(request.user, "retry")
732
728
  try:
729
+ job_ids = list(queryset.values_list("job_id", flat=True))
733
730
  retried = retry_failed_jobs(
734
- job_ids=list(queryset.values_list("job_id", flat=True)),
735
- batch_size=queryset.count() or 1,
731
+ job_ids=job_ids,
732
+ batch_size=max(len(job_ids), 1),
736
733
  backend_alias=self._backend_alias(request),
737
734
  )
738
735
  except ADMIN_ACTION_ERRORS as exc:
@@ -740,14 +737,16 @@ class FailedExecutionAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
740
737
  return
741
738
  self.message_user(request, f"Retried {retried} failed jobs", level=messages.SUCCESS)
742
739
 
743
- @admin.action(description="Discard selected failed jobs")
740
+ @admin.action(description="Discard selected failed jobs", permissions=["discard_jobs"])
744
741
  def discard_jobs(self, request, queryset):
742
+ require_action_permission(request.user, "discard")
745
743
  try:
746
- discarded = 0
747
- for execution in queryset.select_related("job"):
748
- discarded += discard_failed_job(
749
- execution.job_id, backend_alias=execution.job.backend_alias
750
- )
744
+ job_ids = list(queryset.values_list("job_id", flat=True))
745
+ discarded = discard_failed_jobs(
746
+ job_ids=job_ids,
747
+ batch_size=max(len(job_ids), 1),
748
+ backend_alias=self._backend_alias(request),
749
+ )
751
750
  except ADMIN_ACTION_ERRORS as exc:
752
751
  self.message_user(request, f"Could not discard failed jobs: {exc}", level=messages.ERROR)
753
752
  return
@@ -783,10 +782,8 @@ class FailedExecutionAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
783
782
  operation=lambda: retry_failed_job(job_id, backend_alias=backend_alias),
784
783
  success_message="Retried failed job",
785
784
  error_message="Could not retry failed job",
786
- success_redirect=lambda _result: HttpResponseRedirect(
787
- f"{reverse('admin:dj_queue_job_change', args=[job_id])}?{urlencode({'backend': backend_alias})}"
788
- ),
789
- error_redirect=lambda: self._current_object_redirect(obj, backend_alias=backend_alias),
785
+ success_url=f"{reverse('admin:dj_queue_job_change', args=[job_id])}?{urlencode({'backend': backend_alias})}",
786
+ error_url=self._change_url(object_id=obj.pk, backend_alias=backend_alias),
790
787
  )
791
788
 
792
789
  if action == "discard":
@@ -795,10 +792,8 @@ class FailedExecutionAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
795
792
  operation=lambda: discard_failed_job(obj.job_id, backend_alias=backend_alias),
796
793
  success_message="Discarded failed job",
797
794
  error_message="Could not discard failed job",
798
- success_redirect=lambda _result: HttpResponseRedirect(
799
- self._changelist_url(backend_alias=backend_alias)
800
- ),
801
- error_redirect=lambda: self._current_object_redirect(obj, backend_alias=backend_alias),
795
+ success_url=self._changelist_url(backend_alias=backend_alias),
796
+ error_url=self._change_url(object_id=obj.pk, backend_alias=backend_alias),
802
797
  )
803
798
 
804
799
  return self._current_object_redirect(obj, backend_alias=backend_alias)
@@ -832,8 +827,8 @@ class ProcessAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
832
827
 
833
828
  def get_queryset(self, request):
834
829
  queryset = super().get_queryset(request)
835
- cutoff = observability.process_cutoff_for_backend(self._backend_alias(request))
836
- return queryset.annotate(live_rank=observability.process_live_rank_expression(cutoff))
830
+ cutoff = processes.process_cutoff_for_backend(self._backend_alias(request))
831
+ return queryset.annotate(live_rank=processes.process_live_rank_expression(cutoff))
837
832
 
838
833
  @admin.display(description="status", ordering="live_rank")
839
834
  def display_status(self, obj):
@@ -868,8 +863,14 @@ class RecurringTaskAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
868
863
  )
869
864
  search_fields = ("key", "task_path", "queue_name")
870
865
 
871
- @admin.action(description="Unschedule selected recurring tasks")
866
+ def has_unschedule_tasks_permission(self, request):
867
+ return has_action_permission(request.user, "unschedule")
868
+
869
+ @admin.action(
870
+ description="Unschedule selected recurring tasks", permissions=["unschedule_tasks"]
871
+ )
872
872
  def unschedule_tasks(self, request, queryset):
873
+ require_action_permission(request.user, "unschedule")
873
874
  backend_alias = self._backend_alias(request)
874
875
  unscheduled = 0
875
876
  static_failures = 0
@@ -944,21 +945,18 @@ class PauseAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
944
945
  def handle_change_action(self, request, obj, action):
945
946
  backend_alias = self._backend_alias(request)
946
947
  if action == "resume":
947
- try:
948
- QueueInfo(obj.queue_name, backend_alias=backend_alias).resume()
949
- except ADMIN_ACTION_ERRORS as exc:
950
- self.message_user(request, f"Could not resume queue: {exc}", level=messages.ERROR)
951
- return self._current_object_redirect(obj, backend_alias=backend_alias)
952
- self.message_user(
948
+ return self._run_change_operation(
953
949
  request,
954
- format_html(
950
+ operation=lambda: QueueInfo(obj.queue_name, backend_alias=backend_alias).resume(),
951
+ success_message=lambda _result: format_html(
955
952
  'Resumed queue <a href="{}">{}</a>',
956
953
  f"{reverse('admin:dj_queue_dashboard_queue', args=[obj.queue_name])}?{urlencode({'backend': backend_alias})}",
957
954
  obj.queue_name,
958
955
  ),
959
- level=messages.SUCCESS,
956
+ error_message="Could not resume queue",
957
+ success_url=self._changelist_url(backend_alias=backend_alias),
958
+ error_url=self._change_url(object_id=obj.pk, backend_alias=backend_alias),
960
959
  )
961
- return HttpResponseRedirect(self._changelist_url(backend_alias=backend_alias))
962
960
 
963
961
  return self._current_object_redirect(obj, backend_alias=backend_alias)
964
962
 
@@ -989,7 +987,7 @@ class SemaphoreAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
989
987
  queryset = super().get_queryset(request)
990
988
  alias = self._backend_database_alias(request)
991
989
  return queryset.annotate(
992
- blocked_waiter_count=observability.semaphore_blocked_waiter_count_expression(alias)
990
+ blocked_waiter_count=controls.semaphore_blocked_waiter_count_expression(alias)
993
991
  )
994
992
 
995
993
  @admin.display(description="active")
@@ -0,0 +1,27 @@
1
+ from django.core.exceptions import PermissionDenied
2
+
3
+ ACTION_PERMISSIONS = {
4
+ "enqueue": "dj_queue.add_job",
5
+ "enqueue_copy_now": "dj_queue.add_job",
6
+ "run_now": "dj_queue.change_job",
7
+ "retry": "dj_queue.change_job",
8
+ "discard": "dj_queue.delete_job",
9
+ "clear": "dj_queue.delete_job",
10
+ "pause": "dj_queue.add_pause",
11
+ "resume": "dj_queue.delete_pause",
12
+ "unschedule": "dj_queue.delete_recurringtask",
13
+ }
14
+
15
+
16
+ def has_action_permission(user, action):
17
+ permission = ACTION_PERMISSIONS.get(action)
18
+ return bool(permission and user.is_active and user.is_staff and user.has_perm(permission))
19
+
20
+
21
+ def require_action_permission(user, action):
22
+ if not has_action_permission(user, action):
23
+ raise PermissionDenied
24
+
25
+
26
+ def permitted_actions(user, actions):
27
+ return tuple(action for action in actions if has_action_permission(user, action["name"]))
@@ -5,8 +5,8 @@ from typing import Any, Literal, Self, TypeVar
5
5
  from django.db import transaction
6
6
  from django.tasks import Task
7
7
 
8
- from dj_queue import observability
9
8
  from dj_queue.operations.claiming import ClaimedJob, claim_ready_jobs
9
+ from dj_queue.operations.execution import execute_claimed_job
10
10
  from dj_queue.operations.jobs import (
11
11
  discard_blocked_jobs,
12
12
  discard_failed_job,
@@ -14,13 +14,13 @@ from dj_queue.operations.jobs import (
14
14
  discard_ready_jobs,
15
15
  discard_ready_jobs_for_queue,
16
16
  discard_scheduled_jobs,
17
- execute_claimed_job,
18
17
  retry_failed_job,
19
18
  retry_failed_jobs,
20
19
  schedule_failed_job_retry,
21
20
  )
22
21
  from dj_queue.operations.queues import pause_queue, resume_queue
23
22
  from dj_queue.operations.recurring import schedule_recurring_task, unschedule_recurring_task
23
+ from dj_queue.reads import queues
24
24
 
25
25
  __all__ = [
26
26
  "ClaimedJob",
@@ -79,7 +79,7 @@ class QueueInfo:
79
79
  def size(self) -> int:
80
80
  if self._snapshot is not None:
81
81
  return self._snapshot["ready_count"]
82
- return observability.queue_ready_count(
82
+ return queues.queue_ready_count(
83
83
  backend_alias=self.backend_alias,
84
84
  queue_name=self.queue_name,
85
85
  )
@@ -92,14 +92,14 @@ class QueueInfo:
92
92
  return 0.0
93
93
  return latency
94
94
 
95
- paused = observability.queue_is_paused(
95
+ paused = queues.queue_is_paused(
96
96
  backend_alias=self.backend_alias,
97
97
  queue_name=self.queue_name,
98
98
  )
99
99
  if paused:
100
100
  return None
101
101
 
102
- latency = observability.queue_latency_seconds(
102
+ latency = queues.queue_latency_seconds(
103
103
  backend_alias=self.backend_alias,
104
104
  queue_name=self.queue_name,
105
105
  paused=False,
@@ -110,7 +110,7 @@ class QueueInfo:
110
110
  def paused(self) -> bool:
111
111
  if self._snapshot is not None:
112
112
  return self._snapshot["paused"]
113
- return observability.queue_is_paused(
113
+ return queues.queue_is_paused(
114
114
  backend_alias=self.backend_alias,
115
115
  queue_name=self.queue_name,
116
116
  )
@@ -138,7 +138,7 @@ class QueueInfo:
138
138
 
139
139
  @classmethod
140
140
  def all(cls, *, backend_alias: str = "default") -> list[Self]:
141
- queue_rows = observability.queue_rows_for_backend(backend_alias=backend_alias)
141
+ queue_rows = queues.queue_rows_for_backend(backend_alias=backend_alias)
142
142
  return [cls(row["name"], backend_alias=backend_alias, snapshot=row) for row in queue_rows]
143
143
 
144
144
 
@@ -6,8 +6,8 @@ from django.tasks.exceptions import TaskResultDoesNotExist
6
6
  from dj_queue.config import load_backend_config
7
7
  from dj_queue.db import get_database_alias
8
8
  from dj_queue.models import Job
9
- from dj_queue.operations.jobs import (
10
- DispatchOutcome,
9
+ from dj_queue.operations.dispatch import DispatchOutcome
10
+ from dj_queue.operations.enqueue import (
11
11
  enqueue_job_with_dispatch,
12
12
  enqueue_jobs_bulk,
13
13
  validate_priority,