dj-queue 0.10.1__tar.gz → 0.10.3__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 (86) hide show
  1. {dj_queue-0.10.1 → dj_queue-0.10.3}/PKG-INFO +9 -1
  2. {dj_queue-0.10.1 → dj_queue-0.10.3}/README.md +8 -0
  3. dj_queue-0.10.3/dj_queue/management/commands/dj_queue.py +106 -0
  4. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/operations/concurrency.py +117 -7
  5. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/operations/jobs.py +117 -13
  6. {dj_queue-0.10.1 → dj_queue-0.10.3}/pyproject.toml +1 -1
  7. dj_queue-0.10.1/dj_queue/management/commands/dj_queue.py +0 -44
  8. {dj_queue-0.10.1 → dj_queue-0.10.3}/LICENSE +0 -0
  9. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/__init__.py +0 -0
  10. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/admin.py +0 -0
  11. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/api.py +0 -0
  12. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/apps.py +0 -0
  13. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/backend.py +0 -0
  14. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/config.py +0 -0
  15. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/contrib/__init__.py +0 -0
  16. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/contrib/asgi.py +0 -0
  17. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/contrib/gunicorn.py +0 -0
  18. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/contrib/prometheus.py +0 -0
  19. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/cron.py +0 -0
  20. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/dashboard.py +0 -0
  21. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/db.py +0 -0
  22. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/exceptions.py +0 -0
  23. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/hooks.py +0 -0
  24. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/log.py +0 -0
  25. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/management/__init__.py +0 -0
  26. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/management/commands/__init__.py +0 -0
  27. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/management/commands/dj_queue_health.py +0 -0
  28. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/management/commands/dj_queue_prune.py +0 -0
  29. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/metrics.py +0 -0
  30. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0001_initial.py +0 -0
  31. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
  32. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
  33. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0004_dashboard.py +0 -0
  34. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0005_remove_recurringexecution_dj_queue_recurring_executions_task_key_run_at_unique_and_more.py +0 -0
  35. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0006_blockedexecution_dj_queue_bl_concurr_2d8393_idx_and_more.py +0 -0
  36. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
  37. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0008_remove_blockedexecution_dj_queue_bl_concurr_1ce730_idx_and_more.py +0 -0
  38. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0009_remove_process_dj_queue_processes_name_supervisor_unique_and_more.py +0 -0
  39. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/0010_remove_process_djq_pr_name_parent_uniq_and_more.py +0 -0
  40. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/migrations/__init__.py +0 -0
  41. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/models/__init__.py +0 -0
  42. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/models/jobs.py +0 -0
  43. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/models/recurring.py +0 -0
  44. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/models/runtime.py +0 -0
  45. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/observability.py +0 -0
  46. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/operations/__init__.py +0 -0
  47. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/operations/_helpers.py +0 -0
  48. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/operations/_insert.py +0 -0
  49. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/operations/cleanup.py +0 -0
  50. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/operations/queues.py +0 -0
  51. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/operations/recurring.py +0 -0
  52. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/queue_selectors.py +0 -0
  53. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/queue_state.py +0 -0
  54. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/routers.py +0 -0
  55. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/__init__.py +0 -0
  56. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/base.py +0 -0
  57. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/connection_budget.py +0 -0
  58. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/dispatcher.py +0 -0
  59. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/errors.py +0 -0
  60. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/interruptible.py +0 -0
  61. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/notify.py +0 -0
  62. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/pidfile.py +0 -0
  63. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/pool.py +0 -0
  64. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/procline.py +0 -0
  65. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/scheduler.py +0 -0
  66. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/supervisor.py +0 -0
  67. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/topology.py +0 -0
  68. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/runtime/worker.py +0 -0
  69. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/task_results.py +0 -0
  70. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
  71. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
  72. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
  73. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
  74. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
  75. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/_queue_controls.html +0 -0
  76. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +0 -0
  77. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/change_form.html +0 -0
  78. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
  79. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/dashboard.html +1 -1
  80. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
  81. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templates/admin/dj_queue/queue_jobs.html +0 -0
  82. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templatetags/__init__.py +0 -0
  83. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/templatetags/dj_queue_admin.py +0 -0
  84. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/urls.py +0 -0
  85. {dj_queue-0.10.1 → dj_queue-0.10.3}/dj_queue/views.py +0 -0
  86. {dj_queue-0.10.1 → dj_queue-0.10.3}/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.1
3
+ Version: 0.10.3
4
4
  Summary: Database-backed task queue backend for Django’s Tasks framework.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -115,6 +115,11 @@ Run migrations:
115
115
  python manage.py migrate
116
116
  ```
117
117
 
118
+ `python manage.py dj_queue` checks for pending `dj_queue` migrations before it
119
+ starts workers. If another process is applying migrations during startup, the
120
+ runtime waits up to 60 seconds by default instead of starting against a stale
121
+ queue schema.
122
+
118
123
  ## Quick Start
119
124
 
120
125
  Define a task with Django's `@task` decorator:
@@ -274,11 +279,14 @@ python manage.py dj_queue --backend <alias>
274
279
  python manage.py dj_queue --only-work
275
280
  python manage.py dj_queue --only-dispatch
276
281
  python manage.py dj_queue --skip-recurring
282
+ python manage.py dj_queue --migration-wait-timeout 120
277
283
  ```
278
284
 
279
285
  Notes:
280
286
 
281
287
  - `fork` is the default standalone mode
288
+ - startup waits up to 60 seconds for pending `dj_queue` migrations to finish;
289
+ use `--migration-wait-timeout 0` to fail fast instead
282
290
  - `async` is also supported as a standalone mode and runs supervised actors in threads inside one process
283
291
  - `--backend` targets a non-default backend alias
284
292
  - `--only-work` starts workers without dispatchers or scheduler
@@ -89,6 +89,11 @@ Run migrations:
89
89
  python manage.py migrate
90
90
  ```
91
91
 
92
+ `python manage.py dj_queue` checks for pending `dj_queue` migrations before it
93
+ starts workers. If another process is applying migrations during startup, the
94
+ runtime waits up to 60 seconds by default instead of starting against a stale
95
+ queue schema.
96
+
92
97
  ## Quick Start
93
98
 
94
99
  Define a task with Django's `@task` decorator:
@@ -248,11 +253,14 @@ python manage.py dj_queue --backend <alias>
248
253
  python manage.py dj_queue --only-work
249
254
  python manage.py dj_queue --only-dispatch
250
255
  python manage.py dj_queue --skip-recurring
256
+ python manage.py dj_queue --migration-wait-timeout 120
251
257
  ```
252
258
 
253
259
  Notes:
254
260
 
255
261
  - `fork` is the default standalone mode
262
+ - startup waits up to 60 seconds for pending `dj_queue` migrations to finish;
263
+ use `--migration-wait-timeout 0` to fail fast instead
256
264
  - `async` is also supported as a standalone mode and runs supervised actors in threads inside one process
257
265
  - `--backend` targets a non-default backend alias
258
266
  - `--only-work` starts workers without dispatchers or scheduler
@@ -0,0 +1,106 @@
1
+ import math
2
+ import time
3
+
4
+ from django.core.exceptions import ImproperlyConfigured
5
+ from django.core.management.base import BaseCommand
6
+ from django.core.management.base import CommandError
7
+ from django.db import connections
8
+ from django.db.migrations.executor import MigrationExecutor
9
+
10
+ from dj_queue.config import load_backend_config
11
+ from dj_queue.runtime.supervisor import AsyncSupervisor, ForkSupervisor
12
+
13
+ MIGRATION_WAIT_TIMEOUT = 60
14
+ MIGRATION_WAIT_INTERVAL = 0.5
15
+
16
+
17
+ def build_supervisor(*, backend_alias, cli_overrides):
18
+ mode = load_backend_config(backend_alias, cli_overrides=cli_overrides).mode
19
+ supervisor_class = AsyncSupervisor if mode == "async" else ForkSupervisor
20
+ return supervisor_class.from_backend_config(
21
+ backend_alias=backend_alias,
22
+ cli_overrides=cli_overrides,
23
+ )
24
+
25
+
26
+ def wait_for_dj_queue_migrations(
27
+ database_alias,
28
+ *,
29
+ timeout=MIGRATION_WAIT_TIMEOUT,
30
+ interval=MIGRATION_WAIT_INTERVAL,
31
+ stdout=None,
32
+ ):
33
+ if not math.isfinite(timeout) or timeout < 0:
34
+ raise CommandError("dj_queue migration wait timeout must be non-negative")
35
+
36
+ deadline = time.monotonic() + timeout
37
+ announced = False
38
+ while True:
39
+ pending = pending_dj_queue_migrations(database_alias)
40
+ if not pending:
41
+ return
42
+
43
+ migration_names = ", ".join(
44
+ f"{migration.app_label}.{migration.name}" for migration, _backwards in pending
45
+ )
46
+ if stdout is not None and not announced:
47
+ stdout.write(
48
+ f"waiting for dj_queue migrations on database {database_alias!r}: {migration_names}"
49
+ )
50
+ announced = True
51
+
52
+ if time.monotonic() >= deadline:
53
+ raise CommandError(
54
+ f"dj_queue migrations are pending on database {database_alias!r}: {migration_names}; "
55
+ f"run manage.py migrate dj_queue --database {database_alias} before starting dj_queue"
56
+ )
57
+ time.sleep(min(interval, max(deadline - time.monotonic(), 0)))
58
+
59
+
60
+ def pending_dj_queue_migrations(database_alias):
61
+ executor = MigrationExecutor(connections[database_alias])
62
+ targets = [node for node in executor.loader.graph.leaf_nodes() if node[0] == "dj_queue"]
63
+ return [
64
+ (migration, backwards)
65
+ for migration, backwards in executor.migration_plan(targets)
66
+ if migration.app_label == "dj_queue"
67
+ ]
68
+
69
+
70
+ class Command(BaseCommand):
71
+ help = "Start the dj_queue supervisor"
72
+
73
+ def add_arguments(self, parser):
74
+ parser.add_argument("--backend", default="default")
75
+ parser.add_argument("--config")
76
+ parser.add_argument("--mode", choices=("fork", "async"))
77
+ parser.add_argument("--only-work", action="store_true")
78
+ parser.add_argument("--only-dispatch", action="store_true")
79
+ parser.add_argument("--skip-recurring", action="store_true")
80
+ parser.add_argument("--migration-wait-timeout", type=float, default=MIGRATION_WAIT_TIMEOUT)
81
+
82
+ def handle(self, *args, **options):
83
+ cli_overrides = {
84
+ "config": options["config"],
85
+ "mode": options["mode"],
86
+ "only_work": options["only_work"],
87
+ "only_dispatch": options["only_dispatch"],
88
+ "skip_recurring": options["skip_recurring"],
89
+ }
90
+ try:
91
+ config = load_backend_config(
92
+ options["backend"],
93
+ cli_overrides=cli_overrides,
94
+ )
95
+ wait_for_dj_queue_migrations(
96
+ config.database_alias,
97
+ timeout=options["migration_wait_timeout"],
98
+ stdout=self.stdout,
99
+ )
100
+ supervisor = build_supervisor(
101
+ backend_alias=options["backend"],
102
+ cli_overrides=cli_overrides,
103
+ )
104
+ except ImproperlyConfigured as exc:
105
+ raise CommandError(str(exc)) from exc
106
+ supervisor.run()
@@ -10,11 +10,20 @@ from dj_queue.config import load_backend_config
10
10
  from dj_queue.db import database_capabilities, get_database_alias, locked_queryset
11
11
  from dj_queue.exceptions import EnqueueError
12
12
  from dj_queue.log import log_event
13
- from dj_queue.models import BlockedExecution, ClaimedExecution, ReadyExecution, Semaphore
13
+ from dj_queue.models import (
14
+ BlockedExecution,
15
+ ClaimedExecution,
16
+ FailedExecution,
17
+ Job,
18
+ ReadyExecution,
19
+ ScheduledExecution,
20
+ Semaphore,
21
+ )
14
22
  from dj_queue.operations._helpers import (
15
23
  _consume_selected_rows,
16
24
  _create_blocked_execution,
17
25
  _create_ready_execution,
26
+ _lock_active_pauses,
18
27
  _task_option,
19
28
  )
20
29
  from dj_queue.operations._insert import create_ignore_conflicts
@@ -42,7 +51,7 @@ def semaphore_acquire(
42
51
  now=now,
43
52
  )
44
53
 
45
- with transaction.atomic(using=alias):
54
+ with _operation_atomic(alias):
46
55
  if create_ignore_conflicts(
47
56
  Semaphore,
48
57
  using=alias,
@@ -54,7 +63,7 @@ def semaphore_acquire(
54
63
  return True
55
64
 
56
65
  reconciled_available = _reconciled_available_expression(limit)
57
- with transaction.atomic(using=alias):
66
+ with _operation_atomic(alias):
58
67
  updated = (
59
68
  Semaphore.objects.using(alias)
60
69
  .filter(key=key, value__gt=F("limit") - Value(limit))
@@ -177,6 +186,29 @@ def _consume_released_semaphore_slot(alias, key, *, limit, duration_seconds, now
177
186
  return updated > 0
178
187
 
179
188
 
189
+ def _handoff_released_claimed_slot(alias, key, *, limit, duration_seconds, now):
190
+ expires_at = now + timedelta(seconds=duration_seconds)
191
+ released_available = _released_available_expression(limit)
192
+ updated = (
193
+ Semaphore.objects.using(alias)
194
+ .filter(key=key, value__gt=F("limit") - Value(limit) - Value(1))
195
+ .update(
196
+ value=released_available - Value(1),
197
+ limit=limit,
198
+ expires_at=expires_at,
199
+ updated_at=now,
200
+ )
201
+ )
202
+ return updated > 0
203
+
204
+
205
+ def _released_available_expression(limit):
206
+ return Least(
207
+ Value(limit),
208
+ Greatest(Value(0), F("value") + Value(limit) - F("limit") + Value(1)),
209
+ )
210
+
211
+
180
212
  def _reconciled_available_expression(limit):
181
213
  return Least(Value(limit), Greatest(Value(0), F("value") + Value(limit) - F("limit")))
182
214
 
@@ -209,11 +241,12 @@ def unblock_next_blocked_job(
209
241
  backend_alias="default",
210
242
  use_skip_locked=True,
211
243
  handoff_released_slot=False,
244
+ release_slot=False,
212
245
  ):
213
246
  alias = get_database_alias(backend_alias)
214
247
  now = timezone.now()
215
248
 
216
- with transaction.atomic(using=alias):
249
+ with _operation_atomic(alias):
217
250
  queryset = (
218
251
  BlockedExecution.objects.using(alias)
219
252
  .select_related("job")
@@ -229,7 +262,15 @@ def unblock_next_blocked_job(
229
262
  return None
230
263
 
231
264
  slot_acquired = False
232
- if handoff_released_slot:
265
+ if release_slot:
266
+ slot_acquired = _handoff_released_claimed_slot(
267
+ alias,
268
+ key,
269
+ limit=limit,
270
+ duration_seconds=duration_seconds,
271
+ now=now,
272
+ )
273
+ elif handoff_released_slot:
233
274
  slot_acquired = _consume_released_semaphore_slot(
234
275
  alias,
235
276
  key,
@@ -238,7 +279,7 @@ def unblock_next_blocked_job(
238
279
  now=now,
239
280
  )
240
281
 
241
- if not slot_acquired:
282
+ if not slot_acquired and not release_slot:
242
283
  slot_acquired = semaphore_acquire(
243
284
  key,
244
285
  limit=limit,
@@ -261,7 +302,7 @@ def unblock_next_blocked_job(
261
302
  job = blocked.job
262
303
  queue_name = blocked.queue_name
263
304
  priority = blocked.priority
264
- _create_ready_execution(
305
+ _create_ready_execution_after_blocked_consume(
265
306
  alias,
266
307
  job=job,
267
308
  backend_alias=backend_alias,
@@ -279,6 +320,71 @@ def unblock_next_blocked_job(
279
320
  return job
280
321
 
281
322
 
323
+ def _create_ready_execution_after_blocked_consume(
324
+ alias,
325
+ *,
326
+ job,
327
+ backend_alias,
328
+ queue_name,
329
+ priority,
330
+ ready_at,
331
+ ):
332
+ _lock_active_pauses(alias, backend_alias, {queue_name})
333
+ connection = connections[alias]
334
+ quote = connection.ops.quote_name
335
+ ready_table = quote(ReadyExecution._meta.db_table)
336
+ job_id_column = quote(ReadyExecution._meta.get_field("job").column)
337
+ backend_alias_column = quote(ReadyExecution._meta.get_field("backend_alias").column)
338
+ queue_name_column = quote(ReadyExecution._meta.get_field("queue_name").column)
339
+ priority_column = quote(ReadyExecution._meta.get_field("priority").column)
340
+ created_at_column = quote(ReadyExecution._meta.get_field("created_at").column)
341
+ latency_started_at_column = quote(ReadyExecution._meta.get_field("latency_started_at").column)
342
+ job_id = Job._meta.get_field("id").get_db_prep_value(
343
+ job.pk,
344
+ connection=connection,
345
+ prepared=False,
346
+ )
347
+ state_models = (ReadyExecution, ScheduledExecution, ClaimedExecution, FailedExecution)
348
+ state_checks = " AND ".join(
349
+ _state_absence_sql(model, job_id_column=job_id_column, quote=quote) for model in state_models
350
+ )
351
+
352
+ with connection.cursor() as cursor:
353
+ cursor.execute(
354
+ f"""
355
+ INSERT INTO {ready_table} (
356
+ {job_id_column},
357
+ {backend_alias_column},
358
+ {queue_name_column},
359
+ {priority_column},
360
+ {created_at_column},
361
+ {latency_started_at_column}
362
+ )
363
+ SELECT %s, %s, %s, %s, %s, %s
364
+ WHERE {state_checks}
365
+ """,
366
+ [
367
+ job_id,
368
+ backend_alias,
369
+ queue_name,
370
+ priority,
371
+ ready_at,
372
+ ready_at,
373
+ *([job_id] * len(state_models)),
374
+ ],
375
+ )
376
+ created = cursor.rowcount
377
+
378
+ if created != 1:
379
+ raise EnqueueError(f"job {job.id} already has an execution-state row")
380
+
381
+
382
+ def _state_absence_sql(model, *, job_id_column, quote):
383
+ state_table = quote(model._meta.db_table)
384
+ state_job_id_column = quote(model._meta.get_field("job").column)
385
+ return f"NOT EXISTS (SELECT 1 FROM {state_table} WHERE {state_table}.{state_job_id_column} = %s)"
386
+
387
+
282
388
  def cleanup_expired_semaphores(*, backend_alias="default"):
283
389
  alias = get_database_alias(backend_alias)
284
390
  claimed_concurrency_keys = (
@@ -397,3 +503,7 @@ def _positive_int_option(value, name):
397
503
  if number <= 0:
398
504
  raise EnqueueError(f"{name} must be a positive integer")
399
505
  return number
506
+
507
+
508
+ def _operation_atomic(alias):
509
+ return transaction.atomic(using=alias, savepoint=not connections[alias].in_atomic_block)
@@ -32,7 +32,6 @@ from dj_queue.operations._helpers import (
32
32
  _ensure_no_other_execution_state,
33
33
  _consume_selected_rows,
34
34
  _create_blocked_execution,
35
- _create_ready_execution,
36
35
  _create_ready_execution_locked,
37
36
  _create_scheduled_execution,
38
37
  _exclude_active_pauses,
@@ -110,7 +109,12 @@ def enqueue_job_with_dispatch(task, args, kwargs, *, backend_alias="default"):
110
109
  scheduled_at=task.run_after,
111
110
  concurrency_key=concurrency_key,
112
111
  )
113
- dispatch_outcome = _dispatch_job(job, task=task, backend_alias=backend_alias)
112
+ dispatch_outcome = _dispatch_job(
113
+ job,
114
+ task=task,
115
+ backend_alias=backend_alias,
116
+ check_conflicts=False,
117
+ )
114
118
 
115
119
  if dispatch_outcome.should_notify:
116
120
  notify_ready_queues_on_commit((job.queue_name,), backend_alias=backend_alias)
@@ -429,15 +433,13 @@ def _complete_claimed_job(job, return_value, *, backend_alias="default", task=No
429
433
 
430
434
  with transaction.atomic(using=alias):
431
435
  _delete_claimed_execution(alias, job.id)
432
- _ensure_no_other_execution_state(alias, job, ignored_models=(ClaimedExecution,))
433
436
  now = timezone.now()
434
437
  config = load_backend_config(job.backend_alias)
435
438
 
436
439
  if config.preserve_finished_jobs:
437
- job.finished_at = now
438
- job.return_value = return_value
439
- job.save(using=alias, update_fields=["finished_at", "return_value", "updated_at"])
440
+ _finish_job_if_no_execution_state(alias, job, return_value, finished_at=now)
440
441
  else:
442
+ _ensure_no_other_execution_state(alias, job, ignored_models=(ClaimedExecution,))
441
443
  job.delete(using=alias)
442
444
 
443
445
  _release_concurrency_slot(job, task=task)
@@ -910,7 +912,7 @@ def _dispatch_existing_job(job):
910
912
  return _dispatch_job(job, task=task, backend_alias=job.backend_alias)
911
913
 
912
914
 
913
- def _dispatch_job(job, *, task, backend_alias, now=None):
915
+ def _dispatch_job(job, *, task, backend_alias, now=None, check_conflicts=True):
914
916
  alias = get_database_alias(backend_alias)
915
917
  if now is None:
916
918
  now = timezone.now()
@@ -925,11 +927,13 @@ def _dispatch_job(job, *, task, backend_alias, now=None):
925
927
  return DispatchOutcome.SCHEDULED
926
928
 
927
929
  if not job.concurrency_key:
928
- _create_ready_execution(
930
+ _create_ready_execution_locked(
929
931
  alias,
930
932
  job=job,
931
933
  backend_alias=backend_alias,
934
+ queue_name=job.queue_name,
932
935
  ready_at=now,
936
+ check_conflicts=check_conflicts,
933
937
  )
934
938
  return DispatchOutcome.READY
935
939
 
@@ -979,6 +983,19 @@ def _release_concurrency_slot(job, *, task=None):
979
983
  limit = _semaphore_limit(job) or 1
980
984
  duration_seconds = config.default_concurrency_duration
981
985
 
986
+ if (
987
+ unblock_next_blocked_job(
988
+ job.concurrency_key,
989
+ limit=limit,
990
+ duration_seconds=duration_seconds,
991
+ backend_alias=job.backend_alias,
992
+ use_skip_locked=config.use_skip_locked,
993
+ release_slot=True,
994
+ )
995
+ is not None
996
+ ):
997
+ return
998
+
982
999
  semaphore_release(
983
1000
  job.concurrency_key,
984
1001
  limit=limit,
@@ -1064,11 +1081,19 @@ def _select_ready_rows(queryset, *, limit, queues, use_skip_locked):
1064
1081
  ordered_selectors = selectors if star_index is None else selectors[:star_index]
1065
1082
 
1066
1083
  if ordered_selectors:
1067
- ordered = _ordered_selector_rows_queryset(
1068
- queryset.exclude(pk__in=selected_ids),
1069
- ordered_selectors,
1070
- )
1071
- rows = list(locked_queryset(ordered, use_skip_locked=use_skip_locked)[:limit])
1084
+ if _selectors_are_exact(ordered_selectors):
1085
+ rows = _select_exact_selector_rows(
1086
+ queryset.exclude(pk__in=selected_ids),
1087
+ ordered_selectors,
1088
+ limit=limit,
1089
+ use_skip_locked=use_skip_locked,
1090
+ )
1091
+ else:
1092
+ ordered = _ordered_selector_rows_queryset(
1093
+ queryset.exclude(pk__in=selected_ids),
1094
+ ordered_selectors,
1095
+ )
1096
+ rows = list(locked_queryset(ordered, use_skip_locked=use_skip_locked)[:limit])
1072
1097
  selected_rows.extend(rows)
1073
1098
  selected_ids.update(row.pk for row in rows)
1074
1099
 
@@ -1082,6 +1107,22 @@ def _select_ready_rows(queryset, *, limit, queues, use_skip_locked):
1082
1107
  return selected_rows
1083
1108
 
1084
1109
 
1110
+ def _select_exact_selector_rows(queryset, selectors, *, limit, use_skip_locked):
1111
+ selected_rows = []
1112
+ for selector in dict.fromkeys(selectors):
1113
+ remaining = limit - len(selected_rows)
1114
+ if remaining <= 0:
1115
+ break
1116
+ ordered = queryset.filter(queue_name=selector).order_by("-priority", "id")
1117
+ rows = list(locked_queryset(ordered, use_skip_locked=use_skip_locked)[:remaining])
1118
+ selected_rows.extend(rows)
1119
+ return selected_rows
1120
+
1121
+
1122
+ def _selectors_are_exact(selectors):
1123
+ return all(selector != "*" and not selector.endswith("*") for selector in selectors)
1124
+
1125
+
1085
1126
  def _ordered_selector_rows_queryset(queryset, selectors):
1086
1127
  filtered = _filter_queue_selectors(queryset, selectors)
1087
1128
  selector_rank = Case(
@@ -1141,6 +1182,69 @@ def _delete_claimed_execution(alias, job_id):
1141
1182
  raise ClaimedExecution.DoesNotExist
1142
1183
 
1143
1184
 
1185
+ def _finish_job_if_no_execution_state(alias, job, return_value, *, finished_at):
1186
+ connection = connections[alias]
1187
+ quote = connection.ops.quote_name
1188
+ jobs_table = quote(Job._meta.db_table)
1189
+ job_id_column = quote(Job._meta.get_field("id").column)
1190
+ backend_alias_column = quote(Job._meta.get_field("backend_alias").column)
1191
+ finished_at_column = quote(Job._meta.get_field("finished_at").column)
1192
+ return_value_column = quote(Job._meta.get_field("return_value").column)
1193
+ updated_at_column = quote(Job._meta.get_field("updated_at").column)
1194
+ state_checks = " AND ".join(
1195
+ _state_absence_sql(model, jobs_table=jobs_table, job_id_column=job_id_column, quote=quote)
1196
+ for model in (
1197
+ ReadyExecution,
1198
+ ScheduledExecution,
1199
+ BlockedExecution,
1200
+ FailedExecution,
1201
+ )
1202
+ )
1203
+ job_id = Job._meta.get_field("id").get_db_prep_value(
1204
+ job.pk,
1205
+ connection=connection,
1206
+ prepared=False,
1207
+ )
1208
+ prepared_return_value = Job._meta.get_field("return_value").get_db_prep_save(
1209
+ return_value,
1210
+ connection=connection,
1211
+ )
1212
+
1213
+ with connection.cursor() as cursor:
1214
+ cursor.execute(
1215
+ f"""
1216
+ UPDATE {jobs_table}
1217
+ SET
1218
+ {finished_at_column} = %s,
1219
+ {return_value_column} = %s,
1220
+ {updated_at_column} = %s
1221
+ WHERE
1222
+ {jobs_table}.{job_id_column} = %s
1223
+ AND {jobs_table}.{backend_alias_column} = %s
1224
+ AND {state_checks}
1225
+ """,
1226
+ [finished_at, prepared_return_value, finished_at, job_id, job.backend_alias],
1227
+ )
1228
+ updated = cursor.rowcount
1229
+
1230
+ if updated != 1:
1231
+ raise EnqueueError(f"job {job.id} already has an execution-state row")
1232
+ job.finished_at = finished_at
1233
+ job.return_value = return_value
1234
+ job.updated_at = finished_at
1235
+
1236
+
1237
+ def _state_absence_sql(model, *, jobs_table, job_id_column, quote):
1238
+ state_table = quote(model._meta.db_table)
1239
+ state_job_id_column = quote(model._meta.get_field("job").column)
1240
+ return (
1241
+ f"NOT EXISTS ("
1242
+ f"SELECT 1 FROM {state_table} "
1243
+ f"WHERE {state_table}.{state_job_id_column} = {jobs_table}.{job_id_column}"
1244
+ f")"
1245
+ )
1246
+
1247
+
1144
1248
  def _bulk_create(alias, model, objects):
1145
1249
  if not objects:
1146
1250
  return None
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "dj-queue"
7
- version = "0.10.1"
7
+ version = "0.10.3"
8
8
  description = "Database-backed task queue backend for Django’s Tasks framework."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,44 +0,0 @@
1
- from django.core.exceptions import ImproperlyConfigured
2
- from django.core.management.base import BaseCommand
3
- from django.core.management.base import CommandError
4
-
5
- from dj_queue.config import load_backend_config
6
- from dj_queue.runtime.supervisor import AsyncSupervisor, ForkSupervisor
7
-
8
-
9
- def build_supervisor(*, backend_alias, cli_overrides):
10
- mode = load_backend_config(backend_alias, cli_overrides=cli_overrides).mode
11
- supervisor_class = AsyncSupervisor if mode == "async" else ForkSupervisor
12
- return supervisor_class.from_backend_config(
13
- backend_alias=backend_alias,
14
- cli_overrides=cli_overrides,
15
- )
16
-
17
-
18
- class Command(BaseCommand):
19
- help = "Start the dj_queue supervisor"
20
-
21
- def add_arguments(self, parser):
22
- parser.add_argument("--backend", default="default")
23
- parser.add_argument("--config")
24
- parser.add_argument("--mode", choices=("fork", "async"))
25
- parser.add_argument("--only-work", action="store_true")
26
- parser.add_argument("--only-dispatch", action="store_true")
27
- parser.add_argument("--skip-recurring", action="store_true")
28
-
29
- def handle(self, *args, **options):
30
- cli_overrides = {
31
- "config": options["config"],
32
- "mode": options["mode"],
33
- "only_work": options["only_work"],
34
- "only_dispatch": options["only_dispatch"],
35
- "skip_recurring": options["skip_recurring"],
36
- }
37
- try:
38
- supervisor = build_supervisor(
39
- backend_alias=options["backend"],
40
- cli_overrides=cli_overrides,
41
- )
42
- except ImproperlyConfigured as exc:
43
- raise CommandError(str(exc)) from exc
44
- supervisor.run()
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
File without changes
File without changes
@@ -586,8 +586,8 @@
586
586
  <tr class="{% cycle 'row1' 'row2' %}">
587
587
  <th class="djq-col-name"><a href="{% url 'admin:dj_queue_dashboard_queue' queue.name %}?backend={{ backend_alias }}&state=ready">{{ queue.name }}</a></th>
588
588
  <td><a href="{% url 'admin:dj_queue_dashboard_queue' queue.name %}?backend={{ backend_alias }}&state=ready">{{ queue.ready_count }}</a></td>
589
- <td><a href="{% url 'admin:dj_queue_dashboard_queue' queue.name %}?backend={{ backend_alias }}&state=claimed">{{ queue.claimed_count }}</a></td>
590
589
  <td><a href="{% url 'admin:dj_queue_dashboard_queue' queue.name %}?backend={{ backend_alias }}&state=scheduled">{{ queue.scheduled_count }}</a></td>
590
+ <td><a href="{% url 'admin:dj_queue_dashboard_queue' queue.name %}?backend={{ backend_alias }}&state=claimed">{{ queue.claimed_count }}</a></td>
591
591
  <td><a href="{% url 'admin:dj_queue_dashboard_queue' queue.name %}?backend={{ backend_alias }}&state=blocked">{{ queue.blocked_count }}</a></td>
592
592
  <td><a href="{% url 'admin:dj_queue_dashboard_queue' queue.name %}?backend={{ backend_alias }}&state=failed">{{ queue.failed_count }}</a></td>
593
593
  <td><a href="{% url 'admin:dj_queue_dashboard_queue' queue.name %}?backend={{ backend_alias }}&state=finished">{{ queue.finished_count }}</a></td>
File without changes
File without changes
File without changes