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.
Files changed (48) hide show
  1. dj_queue/__init__.py +0 -0
  2. dj_queue/admin.py +90 -0
  3. dj_queue/api.py +122 -0
  4. dj_queue/apps.py +6 -0
  5. dj_queue/backend.py +161 -0
  6. dj_queue/config.py +456 -0
  7. dj_queue/contrib/__init__.py +1 -0
  8. dj_queue/contrib/asgi.py +32 -0
  9. dj_queue/contrib/gunicorn.py +25 -0
  10. dj_queue/db.py +68 -0
  11. dj_queue/exceptions.py +26 -0
  12. dj_queue/hooks.py +86 -0
  13. dj_queue/log.py +27 -0
  14. dj_queue/management/__init__.py +1 -0
  15. dj_queue/management/commands/__init__.py +1 -0
  16. dj_queue/management/commands/dj_queue.py +39 -0
  17. dj_queue/management/commands/dj_queue_health.py +32 -0
  18. dj_queue/management/commands/dj_queue_prune.py +22 -0
  19. dj_queue/migrations/0001_initial.py +262 -0
  20. dj_queue/migrations/0002_pause_semaphore.py +52 -0
  21. dj_queue/migrations/0003_recurringtask_recurringexecution.py +73 -0
  22. dj_queue/migrations/__init__.py +0 -0
  23. dj_queue/models/__init__.py +24 -0
  24. dj_queue/models/jobs.py +328 -0
  25. dj_queue/models/recurring.py +51 -0
  26. dj_queue/models/runtime.py +55 -0
  27. dj_queue/operations/__init__.py +1 -0
  28. dj_queue/operations/cleanup.py +37 -0
  29. dj_queue/operations/concurrency.py +176 -0
  30. dj_queue/operations/jobs.py +637 -0
  31. dj_queue/operations/recurring.py +81 -0
  32. dj_queue/routers.py +26 -0
  33. dj_queue/runtime/__init__.py +1 -0
  34. dj_queue/runtime/base.py +198 -0
  35. dj_queue/runtime/dispatcher.py +78 -0
  36. dj_queue/runtime/errors.py +39 -0
  37. dj_queue/runtime/interruptible.py +46 -0
  38. dj_queue/runtime/notify.py +119 -0
  39. dj_queue/runtime/pidfile.py +39 -0
  40. dj_queue/runtime/pool.py +62 -0
  41. dj_queue/runtime/procline.py +11 -0
  42. dj_queue/runtime/scheduler.py +128 -0
  43. dj_queue/runtime/supervisor.py +460 -0
  44. dj_queue/runtime/worker.py +116 -0
  45. dj_queue-0.1.0.dist-info/METADATA +613 -0
  46. dj_queue-0.1.0.dist-info/RECORD +48 -0
  47. dj_queue-0.1.0.dist-info/WHEEL +4 -0
  48. 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