dj-queue 0.1.0__py3-none-any.whl
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.
- dj_queue/__init__.py +0 -0
- dj_queue/admin.py +90 -0
- dj_queue/api.py +122 -0
- dj_queue/apps.py +6 -0
- dj_queue/backend.py +161 -0
- dj_queue/config.py +456 -0
- dj_queue/contrib/__init__.py +1 -0
- dj_queue/contrib/asgi.py +32 -0
- dj_queue/contrib/gunicorn.py +25 -0
- dj_queue/db.py +68 -0
- dj_queue/exceptions.py +26 -0
- dj_queue/hooks.py +86 -0
- dj_queue/log.py +27 -0
- dj_queue/management/__init__.py +1 -0
- dj_queue/management/commands/__init__.py +1 -0
- dj_queue/management/commands/dj_queue.py +39 -0
- dj_queue/management/commands/dj_queue_health.py +32 -0
- dj_queue/management/commands/dj_queue_prune.py +22 -0
- dj_queue/migrations/0001_initial.py +262 -0
- dj_queue/migrations/0002_pause_semaphore.py +52 -0
- dj_queue/migrations/0003_recurringtask_recurringexecution.py +73 -0
- dj_queue/migrations/__init__.py +0 -0
- dj_queue/models/__init__.py +24 -0
- dj_queue/models/jobs.py +328 -0
- dj_queue/models/recurring.py +51 -0
- dj_queue/models/runtime.py +55 -0
- dj_queue/operations/__init__.py +1 -0
- dj_queue/operations/cleanup.py +37 -0
- dj_queue/operations/concurrency.py +176 -0
- dj_queue/operations/jobs.py +637 -0
- dj_queue/operations/recurring.py +81 -0
- dj_queue/routers.py +26 -0
- dj_queue/runtime/__init__.py +1 -0
- dj_queue/runtime/base.py +198 -0
- dj_queue/runtime/dispatcher.py +78 -0
- dj_queue/runtime/errors.py +39 -0
- dj_queue/runtime/interruptible.py +46 -0
- dj_queue/runtime/notify.py +119 -0
- dj_queue/runtime/pidfile.py +39 -0
- dj_queue/runtime/pool.py +62 -0
- dj_queue/runtime/procline.py +11 -0
- dj_queue/runtime/scheduler.py +128 -0
- dj_queue/runtime/supervisor.py +460 -0
- dj_queue/runtime/worker.py +116 -0
- dj_queue-0.1.0.dist-info/METADATA +613 -0
- dj_queue-0.1.0.dist-info/RECORD +48 -0
- dj_queue-0.1.0.dist-info/WHEEL +4 -0
- dj_queue-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import json
|
|
3
|
+
import traceback
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
|
|
6
|
+
from django.db import connections, transaction
|
|
7
|
+
from django.db.models import Q
|
|
8
|
+
from django.tasks import TaskContext, TaskResult, TaskResultStatus
|
|
9
|
+
from django.utils import timezone
|
|
10
|
+
from django.utils.module_loading import import_string
|
|
11
|
+
|
|
12
|
+
from dj_queue.config import load_backend_config
|
|
13
|
+
from dj_queue.db import get_database_alias, locked_queryset
|
|
14
|
+
from dj_queue.exceptions import EnqueueError
|
|
15
|
+
from dj_queue.log import log_event
|
|
16
|
+
from dj_queue.models import (
|
|
17
|
+
BlockedExecution,
|
|
18
|
+
ClaimedExecution,
|
|
19
|
+
FailedExecution,
|
|
20
|
+
Job,
|
|
21
|
+
Pause,
|
|
22
|
+
ReadyExecution,
|
|
23
|
+
ScheduledExecution,
|
|
24
|
+
)
|
|
25
|
+
from dj_queue.operations.concurrency import (
|
|
26
|
+
semaphore_acquire,
|
|
27
|
+
semaphore_release,
|
|
28
|
+
unblock_next_blocked_job,
|
|
29
|
+
)
|
|
30
|
+
from dj_queue.runtime import notify as runtime_notify
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def enqueue_job(task, args, kwargs, *, backend_alias="default"):
|
|
34
|
+
job, _ = enqueue_job_with_dispatch(task, args, kwargs, backend_alias=backend_alias)
|
|
35
|
+
return job
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def enqueue_job_with_dispatch(task, args, kwargs, *, backend_alias="default"):
|
|
39
|
+
alias = get_database_alias(backend_alias)
|
|
40
|
+
payload = _normalize_payload(args, kwargs)
|
|
41
|
+
concurrency_key = _resolve_concurrency_key(task, args, kwargs)
|
|
42
|
+
|
|
43
|
+
with transaction.atomic(using=alias):
|
|
44
|
+
job = Job.objects.using(alias).create(
|
|
45
|
+
task_path=task.module_path,
|
|
46
|
+
queue_name=task.queue_name,
|
|
47
|
+
priority=task.priority,
|
|
48
|
+
payload=payload,
|
|
49
|
+
backend_name=backend_alias,
|
|
50
|
+
scheduled_at=task.run_after,
|
|
51
|
+
concurrency_key=concurrency_key,
|
|
52
|
+
)
|
|
53
|
+
dispatched_as = _dispatch_job(job, task=task, backend_alias=backend_alias)
|
|
54
|
+
|
|
55
|
+
if dispatched_as == "ready":
|
|
56
|
+
runtime_notify.notify_ready_queues((job.queue_name,), backend_alias=backend_alias)
|
|
57
|
+
|
|
58
|
+
log_event(
|
|
59
|
+
"job.enqueued",
|
|
60
|
+
job_id=str(job.id),
|
|
61
|
+
task_path=job.task_path,
|
|
62
|
+
queue_name=job.queue_name,
|
|
63
|
+
priority=job.priority,
|
|
64
|
+
)
|
|
65
|
+
return job, dispatched_as
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def enqueue_jobs_bulk(task_calls, *, backend_alias="default"):
|
|
69
|
+
alias = get_database_alias(backend_alias)
|
|
70
|
+
now = timezone.now()
|
|
71
|
+
prepared = []
|
|
72
|
+
|
|
73
|
+
for index, (task, args, kwargs) in enumerate(task_calls):
|
|
74
|
+
payload = _normalize_payload(args, kwargs)
|
|
75
|
+
concurrency_key = _resolve_concurrency_key(task, args, kwargs)
|
|
76
|
+
created_at = now + timedelta(microseconds=index)
|
|
77
|
+
prepared.append(
|
|
78
|
+
{
|
|
79
|
+
"task": task,
|
|
80
|
+
"job": Job(
|
|
81
|
+
task_path=task.module_path,
|
|
82
|
+
queue_name=task.queue_name,
|
|
83
|
+
priority=task.priority,
|
|
84
|
+
payload=payload,
|
|
85
|
+
backend_name=backend_alias,
|
|
86
|
+
scheduled_at=task.run_after,
|
|
87
|
+
concurrency_key=concurrency_key,
|
|
88
|
+
created_at=created_at,
|
|
89
|
+
updated_at=created_at,
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if not prepared:
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
if all(
|
|
98
|
+
entry["job"].scheduled_at is None and not entry["job"].concurrency_key for entry in prepared
|
|
99
|
+
):
|
|
100
|
+
with transaction.atomic(using=alias):
|
|
101
|
+
jobs = [entry["job"] for entry in prepared]
|
|
102
|
+
_bulk_create(alias, Job, jobs)
|
|
103
|
+
_bulk_create(
|
|
104
|
+
alias,
|
|
105
|
+
ReadyExecution,
|
|
106
|
+
[
|
|
107
|
+
ReadyExecution(
|
|
108
|
+
job=job,
|
|
109
|
+
queue_name=job.queue_name,
|
|
110
|
+
priority=job.priority,
|
|
111
|
+
created_at=job.created_at,
|
|
112
|
+
)
|
|
113
|
+
for job in jobs
|
|
114
|
+
],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
ready_queue_names = tuple(dict.fromkeys(job.queue_name for job in jobs))
|
|
118
|
+
if ready_queue_names:
|
|
119
|
+
runtime_notify.notify_ready_queues(ready_queue_names, backend_alias=backend_alias)
|
|
120
|
+
|
|
121
|
+
for entry in prepared:
|
|
122
|
+
job = entry["job"]
|
|
123
|
+
log_event(
|
|
124
|
+
"job.enqueued",
|
|
125
|
+
job_id=str(job.id),
|
|
126
|
+
task_path=job.task_path,
|
|
127
|
+
queue_name=job.queue_name,
|
|
128
|
+
priority=job.priority,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return [(entry["job"], entry["task"], "ready") for entry in prepared]
|
|
132
|
+
|
|
133
|
+
ready_rows = []
|
|
134
|
+
scheduled_rows = []
|
|
135
|
+
ready_queue_names = []
|
|
136
|
+
|
|
137
|
+
with transaction.atomic(using=alias):
|
|
138
|
+
jobs = [entry["job"] for entry in prepared]
|
|
139
|
+
_bulk_create(alias, Job, jobs)
|
|
140
|
+
|
|
141
|
+
for entry in prepared:
|
|
142
|
+
job = entry["job"]
|
|
143
|
+
if job.scheduled_at is not None and job.scheduled_at > now:
|
|
144
|
+
scheduled_rows.append(
|
|
145
|
+
ScheduledExecution(
|
|
146
|
+
job=job,
|
|
147
|
+
queue_name=job.queue_name,
|
|
148
|
+
priority=job.priority,
|
|
149
|
+
scheduled_at=job.scheduled_at,
|
|
150
|
+
created_at=job.created_at,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
entry["dispatched_as"] = "scheduled"
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if not job.concurrency_key:
|
|
157
|
+
ready_rows.append(
|
|
158
|
+
ReadyExecution(
|
|
159
|
+
job=job,
|
|
160
|
+
queue_name=job.queue_name,
|
|
161
|
+
priority=job.priority,
|
|
162
|
+
created_at=job.created_at,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
ready_queue_names.append(job.queue_name)
|
|
166
|
+
entry["dispatched_as"] = "ready"
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
dispatched_as = _dispatch_job(job, task=entry["task"], backend_alias=backend_alias, now=now)
|
|
170
|
+
if dispatched_as == "ready":
|
|
171
|
+
ready_queue_names.append(job.queue_name)
|
|
172
|
+
entry["dispatched_as"] = dispatched_as
|
|
173
|
+
|
|
174
|
+
_bulk_create(alias, ReadyExecution, ready_rows)
|
|
175
|
+
_bulk_create(alias, ScheduledExecution, scheduled_rows)
|
|
176
|
+
|
|
177
|
+
if ready_queue_names:
|
|
178
|
+
runtime_notify.notify_ready_queues(
|
|
179
|
+
tuple(dict.fromkeys(ready_queue_names)),
|
|
180
|
+
backend_alias=backend_alias,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
for entry in prepared:
|
|
184
|
+
job = entry["job"]
|
|
185
|
+
log_event(
|
|
186
|
+
"job.enqueued",
|
|
187
|
+
job_id=str(job.id),
|
|
188
|
+
task_path=job.task_path,
|
|
189
|
+
queue_name=job.queue_name,
|
|
190
|
+
priority=job.priority,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return [(entry["job"], entry["task"], entry["dispatched_as"]) for entry in prepared]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def claim_ready_jobs(
|
|
197
|
+
*,
|
|
198
|
+
limit,
|
|
199
|
+
queues=None,
|
|
200
|
+
process=None,
|
|
201
|
+
backend_alias="default",
|
|
202
|
+
use_skip_locked=None,
|
|
203
|
+
):
|
|
204
|
+
if limit <= 0:
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
alias = get_database_alias(backend_alias)
|
|
208
|
+
if use_skip_locked is None:
|
|
209
|
+
use_skip_locked = load_backend_config(backend_alias).use_skip_locked
|
|
210
|
+
|
|
211
|
+
paused_queue_names = list(Pause.objects.using(alias).values_list("queue_name", flat=True))
|
|
212
|
+
|
|
213
|
+
with transaction.atomic(using=alias):
|
|
214
|
+
queryset = ReadyExecution.objects.using(alias).select_related("job")
|
|
215
|
+
if paused_queue_names:
|
|
216
|
+
queryset = queryset.exclude(queue_name__in=paused_queue_names)
|
|
217
|
+
ready_rows = _select_ready_rows(
|
|
218
|
+
queryset,
|
|
219
|
+
limit=limit,
|
|
220
|
+
queues=queues,
|
|
221
|
+
use_skip_locked=use_skip_locked,
|
|
222
|
+
)
|
|
223
|
+
if not ready_rows:
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
jobs = [row.job for row in ready_rows]
|
|
227
|
+
ReadyExecution.objects.using(alias).filter(pk__in=[row.pk for row in ready_rows]).delete()
|
|
228
|
+
for job in jobs:
|
|
229
|
+
ClaimedExecution.objects.using(alias).create(job=job, process=process)
|
|
230
|
+
|
|
231
|
+
for job in jobs:
|
|
232
|
+
log_event("job.claimed", job_id=str(job.id), queue_name=job.queue_name, priority=job.priority)
|
|
233
|
+
return jobs
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def execute_claimed_job(job_id, *, backend_alias="default"):
|
|
237
|
+
alias = get_database_alias(backend_alias)
|
|
238
|
+
claimed = (
|
|
239
|
+
ClaimedExecution.objects.using(alias).select_related("job", "process").get(job_id=job_id)
|
|
240
|
+
)
|
|
241
|
+
job = claimed.job
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
task = import_string(job.task_path)
|
|
245
|
+
args = list(job.payload.get("args", []))
|
|
246
|
+
kwargs = dict(job.payload.get("kwargs", {}))
|
|
247
|
+
if task.takes_context:
|
|
248
|
+
context = TaskContext(task_result=_task_result_for_claimed_job(task, claimed))
|
|
249
|
+
return_value = task.call(context, *args, **kwargs)
|
|
250
|
+
else:
|
|
251
|
+
return_value = task.call(*args, **kwargs)
|
|
252
|
+
return_value = _normalize_return_value(return_value)
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
return fail_claimed_job(
|
|
255
|
+
job.id,
|
|
256
|
+
exc,
|
|
257
|
+
traceback_text=traceback.format_exc(),
|
|
258
|
+
backend_alias=job.backend_name,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return complete_claimed_job(job.id, return_value, backend_alias=job.backend_name)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def complete_claimed_job(job_id, return_value, *, backend_alias="default"):
|
|
265
|
+
alias = get_database_alias(backend_alias)
|
|
266
|
+
|
|
267
|
+
with transaction.atomic(using=alias):
|
|
268
|
+
claimed = ClaimedExecution.objects.using(alias).select_related("job").get(job_id=job_id)
|
|
269
|
+
job = claimed.job
|
|
270
|
+
now = timezone.now()
|
|
271
|
+
config = load_backend_config(job.backend_name)
|
|
272
|
+
|
|
273
|
+
if config.preserve_finished_jobs:
|
|
274
|
+
job.finished_at = now
|
|
275
|
+
job.return_value = return_value
|
|
276
|
+
job.save(using=alias, update_fields=["finished_at", "return_value", "updated_at"])
|
|
277
|
+
claimed.delete(using=alias)
|
|
278
|
+
else:
|
|
279
|
+
job.delete(using=alias)
|
|
280
|
+
|
|
281
|
+
_release_concurrency_slot(job)
|
|
282
|
+
log_event("job.executed", job_id=str(job.id), status="success")
|
|
283
|
+
return job
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def fail_claimed_job(job_id, error, *, traceback_text="", backend_alias="default"):
|
|
287
|
+
alias = get_database_alias(backend_alias)
|
|
288
|
+
|
|
289
|
+
with transaction.atomic(using=alias):
|
|
290
|
+
claimed = ClaimedExecution.objects.using(alias).select_related("job").get(job_id=job_id)
|
|
291
|
+
job = claimed.job
|
|
292
|
+
claimed.delete(using=alias)
|
|
293
|
+
FailedExecution.objects.using(alias).create(
|
|
294
|
+
job=job,
|
|
295
|
+
exception_class=_exception_path(error),
|
|
296
|
+
message=str(error),
|
|
297
|
+
traceback=traceback_text,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
_release_concurrency_slot(job)
|
|
301
|
+
log_event(
|
|
302
|
+
"job.failed",
|
|
303
|
+
job_id=str(job.id),
|
|
304
|
+
exception_class=_exception_path(error),
|
|
305
|
+
message=str(error),
|
|
306
|
+
)
|
|
307
|
+
return job
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def promote_scheduled_jobs(*, batch_size, backend_alias="default", use_skip_locked=None):
|
|
311
|
+
alias = get_database_alias(backend_alias)
|
|
312
|
+
if use_skip_locked is None:
|
|
313
|
+
use_skip_locked = load_backend_config(backend_alias).use_skip_locked
|
|
314
|
+
now = timezone.now()
|
|
315
|
+
|
|
316
|
+
with transaction.atomic(using=alias):
|
|
317
|
+
queryset = (
|
|
318
|
+
ScheduledExecution.objects.using(alias)
|
|
319
|
+
.select_related("job")
|
|
320
|
+
.filter(scheduled_at__lte=now)
|
|
321
|
+
.order_by("scheduled_at", "-priority", "id")
|
|
322
|
+
)
|
|
323
|
+
scheduled_rows = list(locked_queryset(queryset, use_skip_locked=use_skip_locked)[:batch_size])
|
|
324
|
+
jobs = [row.job for row in scheduled_rows]
|
|
325
|
+
if not jobs:
|
|
326
|
+
return []
|
|
327
|
+
|
|
328
|
+
ScheduledExecution.objects.using(alias).filter(
|
|
329
|
+
pk__in=[row.pk for row in scheduled_rows]
|
|
330
|
+
).delete()
|
|
331
|
+
for job in jobs:
|
|
332
|
+
dispatched_as = _dispatch_existing_job(job)
|
|
333
|
+
if dispatched_as == "ready":
|
|
334
|
+
runtime_notify.notify_ready_queues((job.queue_name,), backend_alias=backend_alias)
|
|
335
|
+
return jobs
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def retry_failed_job(job_id, *, backend_alias="default"):
|
|
339
|
+
alias = get_database_alias(backend_alias)
|
|
340
|
+
|
|
341
|
+
with transaction.atomic(using=alias):
|
|
342
|
+
failed = FailedExecution.objects.using(alias).select_related("job").get(job_id=job_id)
|
|
343
|
+
job = failed.job
|
|
344
|
+
failed.delete(using=alias)
|
|
345
|
+
job.return_value = None
|
|
346
|
+
job.finished_at = None
|
|
347
|
+
job.save(using=alias, update_fields=["return_value", "finished_at", "updated_at"])
|
|
348
|
+
dispatched_as = _dispatch_existing_job(job)
|
|
349
|
+
|
|
350
|
+
if dispatched_as == "ready":
|
|
351
|
+
runtime_notify.notify_ready_queues((job.queue_name,), backend_alias=backend_alias)
|
|
352
|
+
|
|
353
|
+
log_event("job.retried", job_id=str(job.id), queue_name=job.queue_name, priority=job.priority)
|
|
354
|
+
return job
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def discard_failed_job(job_id, *, backend_alias="default"):
|
|
358
|
+
alias = get_database_alias(backend_alias)
|
|
359
|
+
queryset = Job.objects.using(alias).filter(pk=job_id, failed_execution__isnull=False)
|
|
360
|
+
deleted = queryset.count()
|
|
361
|
+
if not deleted:
|
|
362
|
+
return 0
|
|
363
|
+
|
|
364
|
+
queryset.delete()
|
|
365
|
+
log_event("job.discarded", job_id=str(job_id), reason="failed")
|
|
366
|
+
return deleted
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def discard_ready_jobs(*, job_ids=None, batch_size=500, backend_alias="default"):
|
|
370
|
+
alias = get_database_alias(backend_alias)
|
|
371
|
+
config = load_backend_config(backend_alias)
|
|
372
|
+
|
|
373
|
+
with transaction.atomic(using=alias):
|
|
374
|
+
queryset = ReadyExecution.objects.using(alias).select_related("job").order_by("id")
|
|
375
|
+
if job_ids is not None:
|
|
376
|
+
queryset = queryset.filter(job_id__in=job_ids)
|
|
377
|
+
ready_rows = list(
|
|
378
|
+
locked_queryset(queryset, use_skip_locked=config.use_skip_locked)[:batch_size]
|
|
379
|
+
)
|
|
380
|
+
jobs = [row.job for row in ready_rows]
|
|
381
|
+
if not jobs:
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
Job.objects.using(alias).filter(pk__in=[job.pk for job in jobs]).delete()
|
|
385
|
+
|
|
386
|
+
for job in jobs:
|
|
387
|
+
_release_concurrency_slot(job)
|
|
388
|
+
log_event("job.discarded", job_id=str(job.id), reason="ready")
|
|
389
|
+
return len(jobs)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def discard_blocked_jobs(*, job_ids=None, batch_size=500, backend_alias="default"):
|
|
393
|
+
alias = get_database_alias(backend_alias)
|
|
394
|
+
config = load_backend_config(backend_alias)
|
|
395
|
+
|
|
396
|
+
with transaction.atomic(using=alias):
|
|
397
|
+
queryset = BlockedExecution.objects.using(alias).select_related("job").order_by("id")
|
|
398
|
+
if job_ids is not None:
|
|
399
|
+
queryset = queryset.filter(job_id__in=job_ids)
|
|
400
|
+
blocked_rows = list(
|
|
401
|
+
locked_queryset(queryset, use_skip_locked=config.use_skip_locked)[:batch_size]
|
|
402
|
+
)
|
|
403
|
+
jobs = [row.job for row in blocked_rows]
|
|
404
|
+
if not jobs:
|
|
405
|
+
return 0
|
|
406
|
+
|
|
407
|
+
Job.objects.using(alias).filter(pk__in=[job.pk for job in jobs]).delete()
|
|
408
|
+
|
|
409
|
+
for job in jobs:
|
|
410
|
+
log_event("job.discarded", job_id=str(job.id), reason="blocked")
|
|
411
|
+
return len(jobs)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _dispatch_existing_job(job):
|
|
415
|
+
task = import_string(job.task_path)
|
|
416
|
+
return _dispatch_job(job, task=task, backend_alias=job.backend_name)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _dispatch_job(job, *, task, backend_alias, now=None):
|
|
420
|
+
alias = get_database_alias(backend_alias)
|
|
421
|
+
if now is None:
|
|
422
|
+
now = timezone.now()
|
|
423
|
+
|
|
424
|
+
if job.scheduled_at is not None and job.scheduled_at > now:
|
|
425
|
+
ScheduledExecution.objects.using(alias).create(
|
|
426
|
+
job=job,
|
|
427
|
+
queue_name=job.queue_name,
|
|
428
|
+
priority=job.priority,
|
|
429
|
+
scheduled_at=job.scheduled_at,
|
|
430
|
+
)
|
|
431
|
+
return "scheduled"
|
|
432
|
+
|
|
433
|
+
if not job.concurrency_key:
|
|
434
|
+
ReadyExecution.objects.using(alias).create(
|
|
435
|
+
job=job,
|
|
436
|
+
queue_name=job.queue_name,
|
|
437
|
+
priority=job.priority,
|
|
438
|
+
)
|
|
439
|
+
return "ready"
|
|
440
|
+
|
|
441
|
+
limit, duration_seconds, on_conflict = _concurrency_settings(task, backend_alias=backend_alias)
|
|
442
|
+
if semaphore_acquire(
|
|
443
|
+
job.concurrency_key,
|
|
444
|
+
limit=limit,
|
|
445
|
+
duration_seconds=duration_seconds,
|
|
446
|
+
backend_alias=backend_alias,
|
|
447
|
+
):
|
|
448
|
+
ReadyExecution.objects.using(alias).create(
|
|
449
|
+
job=job,
|
|
450
|
+
queue_name=job.queue_name,
|
|
451
|
+
priority=job.priority,
|
|
452
|
+
)
|
|
453
|
+
return "ready"
|
|
454
|
+
|
|
455
|
+
if on_conflict == "discard":
|
|
456
|
+
job.finished_at = now
|
|
457
|
+
job.return_value = None
|
|
458
|
+
job.save(using=alias, update_fields=["finished_at", "return_value", "updated_at"])
|
|
459
|
+
return "discarded"
|
|
460
|
+
|
|
461
|
+
BlockedExecution.objects.using(alias).create(
|
|
462
|
+
job=job,
|
|
463
|
+
queue_name=job.queue_name,
|
|
464
|
+
priority=job.priority,
|
|
465
|
+
concurrency_key=job.concurrency_key,
|
|
466
|
+
expires_at=now + timedelta(seconds=duration_seconds),
|
|
467
|
+
)
|
|
468
|
+
return "blocked"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _release_concurrency_slot(job):
|
|
472
|
+
if not job.concurrency_key:
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
task = import_string(job.task_path)
|
|
476
|
+
limit, duration_seconds, _ = _concurrency_settings(task, backend_alias=job.backend_name)
|
|
477
|
+
semaphore_release(
|
|
478
|
+
job.concurrency_key,
|
|
479
|
+
duration_seconds=duration_seconds,
|
|
480
|
+
backend_alias=job.backend_name,
|
|
481
|
+
)
|
|
482
|
+
unblock_next_blocked_job(
|
|
483
|
+
job.concurrency_key,
|
|
484
|
+
limit=limit,
|
|
485
|
+
duration_seconds=duration_seconds,
|
|
486
|
+
backend_alias=job.backend_name,
|
|
487
|
+
use_skip_locked=load_backend_config(job.backend_name).use_skip_locked,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _concurrency_settings(task, *, backend_alias):
|
|
492
|
+
limit = _task_option(task, "concurrency_limit")
|
|
493
|
+
if limit in (None, ""):
|
|
494
|
+
raise EnqueueError("concurrency_limit is required when concurrency_key is set")
|
|
495
|
+
|
|
496
|
+
limit = int(limit)
|
|
497
|
+
duration_seconds = int(
|
|
498
|
+
_task_option(
|
|
499
|
+
task,
|
|
500
|
+
"concurrency_duration",
|
|
501
|
+
load_backend_config(backend_alias).default_concurrency_duration,
|
|
502
|
+
)
|
|
503
|
+
)
|
|
504
|
+
on_conflict = str(_task_option(task, "on_conflict", "block"))
|
|
505
|
+
if on_conflict not in {"block", "discard"}:
|
|
506
|
+
raise EnqueueError("on_conflict must be 'block' or 'discard'")
|
|
507
|
+
return limit, duration_seconds, on_conflict
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _resolve_concurrency_key(task, args, kwargs):
|
|
511
|
+
option = _task_option(task, "concurrency_key")
|
|
512
|
+
if option in (None, ""):
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
if callable(option):
|
|
516
|
+
value = option(*args, **kwargs)
|
|
517
|
+
elif isinstance(option, str):
|
|
518
|
+
try:
|
|
519
|
+
value = option.format(**_bound_arguments(task, args, kwargs))
|
|
520
|
+
except (IndexError, KeyError, ValueError) as exc:
|
|
521
|
+
raise EnqueueError("could not resolve concurrency_key") from exc
|
|
522
|
+
else:
|
|
523
|
+
raise EnqueueError("concurrency_key must be a string or callable")
|
|
524
|
+
|
|
525
|
+
if not isinstance(value, str) or not value or len(value) > 255:
|
|
526
|
+
raise EnqueueError("concurrency_key must resolve to a non-empty string up to 255 chars")
|
|
527
|
+
return value
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _bound_arguments(task, args, kwargs):
|
|
531
|
+
signature = inspect.signature(task.func)
|
|
532
|
+
parameters = tuple(signature.parameters.values())
|
|
533
|
+
if task.takes_context and parameters:
|
|
534
|
+
signature = signature.replace(parameters=parameters[1:])
|
|
535
|
+
|
|
536
|
+
bound = signature.bind(*args, **kwargs)
|
|
537
|
+
bound.apply_defaults()
|
|
538
|
+
return bound.arguments
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _filter_queue_selectors(queryset, queues):
|
|
542
|
+
if queues in (None, (), "*", ["*"], ("*",)):
|
|
543
|
+
return queryset
|
|
544
|
+
|
|
545
|
+
selectors = (queues,) if isinstance(queues, str) else tuple(queues)
|
|
546
|
+
condition = Q()
|
|
547
|
+
for selector in selectors:
|
|
548
|
+
if selector == "*":
|
|
549
|
+
return queryset
|
|
550
|
+
if selector.endswith("*"):
|
|
551
|
+
condition |= Q(queue_name__startswith=selector[:-1])
|
|
552
|
+
else:
|
|
553
|
+
condition |= Q(queue_name=selector)
|
|
554
|
+
|
|
555
|
+
if not condition:
|
|
556
|
+
return queryset.none()
|
|
557
|
+
return queryset.filter(condition)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _select_ready_rows(queryset, *, limit, queues, use_skip_locked):
|
|
561
|
+
if queues in (None, (), "*", ["*"], ("*",)):
|
|
562
|
+
ordered = queryset.order_by("-priority", "id")
|
|
563
|
+
return list(locked_queryset(ordered, use_skip_locked=use_skip_locked)[:limit])
|
|
564
|
+
|
|
565
|
+
selectors = (queues,) if isinstance(queues, str) else tuple(queues)
|
|
566
|
+
selected_rows = []
|
|
567
|
+
selected_ids = set()
|
|
568
|
+
|
|
569
|
+
for selector in selectors:
|
|
570
|
+
remaining = limit - len(selected_rows)
|
|
571
|
+
if remaining <= 0:
|
|
572
|
+
break
|
|
573
|
+
|
|
574
|
+
ordered = queryset.exclude(pk__in=selected_ids).order_by("-priority", "id")
|
|
575
|
+
filtered = _filter_queue_selectors(ordered, selector)
|
|
576
|
+
rows = list(locked_queryset(filtered, use_skip_locked=use_skip_locked)[:remaining])
|
|
577
|
+
selected_rows.extend(rows)
|
|
578
|
+
selected_ids.update(row.pk for row in rows)
|
|
579
|
+
|
|
580
|
+
return selected_rows
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _normalize_payload(args, kwargs):
|
|
584
|
+
try:
|
|
585
|
+
return json.loads(json.dumps({"args": list(args), "kwargs": dict(kwargs)}))
|
|
586
|
+
except (TypeError, ValueError) as exc:
|
|
587
|
+
raise EnqueueError("payload must be JSON round-trippable") from exc
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _normalize_return_value(return_value):
|
|
591
|
+
try:
|
|
592
|
+
return json.loads(json.dumps(return_value))
|
|
593
|
+
except (TypeError, ValueError) as exc:
|
|
594
|
+
raise ValueError("return value must be JSON round-trippable") from exc
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _task_option(task, name, default=None):
|
|
598
|
+
if hasattr(task, name):
|
|
599
|
+
return getattr(task, name)
|
|
600
|
+
return getattr(task.func, name, default)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _bulk_create(alias, model, objects):
|
|
604
|
+
if not objects:
|
|
605
|
+
return None
|
|
606
|
+
|
|
607
|
+
fields = [field for field in model._meta.concrete_fields if not field.generated]
|
|
608
|
+
batch_size = connections[alias].ops.bulk_batch_size(fields, objects)
|
|
609
|
+
if batch_size is None or batch_size <= 0:
|
|
610
|
+
batch_size = len(objects)
|
|
611
|
+
model.objects.using(alias).bulk_create(objects, batch_size=batch_size)
|
|
612
|
+
return None
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _exception_path(error):
|
|
616
|
+
return f"{error.__class__.__module__}.{error.__class__.__qualname__}"
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _task_result_for_claimed_job(task, claimed):
|
|
620
|
+
worker_ids = []
|
|
621
|
+
if claimed.process_id is not None:
|
|
622
|
+
worker_ids = [claimed.process.name]
|
|
623
|
+
|
|
624
|
+
return TaskResult(
|
|
625
|
+
task=task,
|
|
626
|
+
id=str(claimed.job.id),
|
|
627
|
+
status=TaskResultStatus.RUNNING,
|
|
628
|
+
enqueued_at=claimed.job.created_at,
|
|
629
|
+
started_at=claimed.created_at,
|
|
630
|
+
finished_at=None,
|
|
631
|
+
last_attempted_at=claimed.created_at,
|
|
632
|
+
args=claimed.job.payload.get("args", []),
|
|
633
|
+
kwargs=claimed.job.payload.get("kwargs", {}),
|
|
634
|
+
backend=claimed.job.backend_name,
|
|
635
|
+
errors=[],
|
|
636
|
+
worker_ids=worker_ids,
|
|
637
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from django.db import IntegrityError, transaction
|
|
2
|
+
from django.utils.module_loading import import_string
|
|
3
|
+
|
|
4
|
+
from dj_queue.db import get_database_alias
|
|
5
|
+
from dj_queue.models import RecurringExecution, RecurringTask
|
|
6
|
+
from dj_queue.operations.jobs import enqueue_job
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def upsert_static_recurring_tasks(recurring_configs, *, backend_alias="default"):
|
|
10
|
+
alias = get_database_alias(backend_alias)
|
|
11
|
+
active_keys = set()
|
|
12
|
+
existing = {task.key: task for task in RecurringTask.objects.using(alias).filter(static=True)}
|
|
13
|
+
to_create = []
|
|
14
|
+
|
|
15
|
+
for recurring_config in recurring_configs.values():
|
|
16
|
+
active_keys.add(recurring_config.key)
|
|
17
|
+
desired = {
|
|
18
|
+
"task_path": recurring_config.task_path,
|
|
19
|
+
"payload": {
|
|
20
|
+
"args": list(recurring_config.args),
|
|
21
|
+
"kwargs": dict(recurring_config.kwargs),
|
|
22
|
+
},
|
|
23
|
+
"schedule": recurring_config.schedule,
|
|
24
|
+
"queue_name": recurring_config.queue_name,
|
|
25
|
+
"priority": recurring_config.priority,
|
|
26
|
+
"description": recurring_config.description,
|
|
27
|
+
"static": True,
|
|
28
|
+
}
|
|
29
|
+
existing_task = existing.get(recurring_config.key)
|
|
30
|
+
if existing_task is None:
|
|
31
|
+
to_create.append(RecurringTask(key=recurring_config.key, **desired))
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
changed_fields = []
|
|
35
|
+
for field, value in desired.items():
|
|
36
|
+
if getattr(existing_task, field) == value:
|
|
37
|
+
continue
|
|
38
|
+
setattr(existing_task, field, value)
|
|
39
|
+
changed_fields.append(field)
|
|
40
|
+
|
|
41
|
+
if changed_fields:
|
|
42
|
+
existing_task.save(using=alias, update_fields=[*changed_fields, "updated_at"])
|
|
43
|
+
|
|
44
|
+
if to_create:
|
|
45
|
+
RecurringTask.objects.using(alias).bulk_create(to_create)
|
|
46
|
+
|
|
47
|
+
queryset = RecurringTask.objects.using(alias).filter(static=True)
|
|
48
|
+
if active_keys:
|
|
49
|
+
queryset = queryset.exclude(key__in=active_keys)
|
|
50
|
+
queryset.delete()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fire_recurring_task(recurring_task, run_at, *, backend_alias="default"):
|
|
54
|
+
alias = get_database_alias(backend_alias)
|
|
55
|
+
|
|
56
|
+
with transaction.atomic(using=alias):
|
|
57
|
+
try:
|
|
58
|
+
execution = RecurringExecution.objects.using(alias).create(
|
|
59
|
+
task_key=recurring_task.key,
|
|
60
|
+
run_at=run_at,
|
|
61
|
+
)
|
|
62
|
+
except IntegrityError:
|
|
63
|
+
# treat an existing reservation row as authoritative even if its job backfill
|
|
64
|
+
# has not happened yet, so duplicate scheduler ticks never enqueue twice
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
task = import_string(recurring_task.task_path).using(
|
|
68
|
+
queue_name=recurring_task.queue_name,
|
|
69
|
+
priority=recurring_task.priority,
|
|
70
|
+
backend=backend_alias,
|
|
71
|
+
)
|
|
72
|
+
payload = recurring_task.payload or {}
|
|
73
|
+
job = enqueue_job(
|
|
74
|
+
task,
|
|
75
|
+
payload.get("args", []),
|
|
76
|
+
payload.get("kwargs", {}),
|
|
77
|
+
backend_alias=backend_alias,
|
|
78
|
+
)
|
|
79
|
+
execution.job = job
|
|
80
|
+
execution.save(using=alias, update_fields=["job"])
|
|
81
|
+
return execution
|