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,328 @@
1
+ import uuid
2
+
3
+ from django.core.exceptions import ObjectDoesNotExist, ValidationError
4
+ from django.db import models
5
+ from django.db.models import Q
6
+ from django.utils.module_loading import import_string
7
+
8
+ from dj_queue.db import get_database_alias
9
+ from dj_queue.exceptions import UndiscardableError
10
+
11
+ JOB_STATUS_RELATIONS = (
12
+ ("ready", "ready_execution"),
13
+ ("scheduled", "scheduled_execution"),
14
+ ("claimed", "claimed_execution"),
15
+ ("blocked", "blocked_execution"),
16
+ ("failed", "failed_execution"),
17
+ )
18
+
19
+
20
+ class JobQuerySet(models.QuerySet):
21
+ def ready(self):
22
+ return self.filter(ready_execution__isnull=False)
23
+
24
+ def scheduled(self):
25
+ return self.filter(scheduled_execution__isnull=False)
26
+
27
+ def claimed(self):
28
+ return self.filter(claimed_execution__isnull=False)
29
+
30
+ def blocked(self):
31
+ return self.filter(blocked_execution__isnull=False)
32
+
33
+ def failed(self):
34
+ return self.filter(failed_execution__isnull=False)
35
+
36
+ def finished(self):
37
+ return self.filter(finished_at__isnull=False)
38
+
39
+
40
+ class Job(models.Model):
41
+ objects = JobQuerySet.as_manager()
42
+
43
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
44
+ task_path = models.TextField()
45
+ queue_name = models.CharField(max_length=64, default="default")
46
+ priority = models.SmallIntegerField(default=0)
47
+ payload = models.JSONField(default=dict)
48
+ backend_name = models.CharField(max_length=64)
49
+ scheduled_at = models.DateTimeField(null=True, blank=True)
50
+ concurrency_key = models.CharField(max_length=255, null=True, blank=True)
51
+ finished_at = models.DateTimeField(null=True, blank=True)
52
+ return_value = models.JSONField(null=True, blank=True)
53
+ created_at = models.DateTimeField(auto_now_add=True)
54
+ updated_at = models.DateTimeField(auto_now=True)
55
+
56
+ class Meta:
57
+ db_table = "dj_queue_jobs"
58
+ constraints = [
59
+ models.CheckConstraint(
60
+ condition=Q(priority__gte=-100) & Q(priority__lte=100),
61
+ name="dj_queue_jobs_priority_range",
62
+ )
63
+ ]
64
+ indexes = [
65
+ models.Index(fields=["queue_name", "finished_at"]),
66
+ models.Index(fields=["scheduled_at", "finished_at"]),
67
+ models.Index(fields=["finished_at"]),
68
+ ]
69
+
70
+ @property
71
+ def status(self):
72
+ if self.finished_at is not None:
73
+ return "finished"
74
+
75
+ for status_name, relation_name in JOB_STATUS_RELATIONS:
76
+ if self._has_state_relation(relation_name):
77
+ return status_name
78
+ return None
79
+
80
+ @property
81
+ def ready(self):
82
+ return self.status == "ready"
83
+
84
+ @property
85
+ def scheduled(self):
86
+ return self.status == "scheduled"
87
+
88
+ @property
89
+ def claimed(self):
90
+ return self.status == "claimed"
91
+
92
+ @property
93
+ def blocked(self):
94
+ return self.status == "blocked"
95
+
96
+ @property
97
+ def failed(self):
98
+ return self.status == "failed"
99
+
100
+ @property
101
+ def finished(self):
102
+ return self.status == "finished"
103
+
104
+ def _has_state_relation(self, relation_name):
105
+ try:
106
+ getattr(self, relation_name)
107
+ except ObjectDoesNotExist:
108
+ return False
109
+ return True
110
+
111
+
112
+ class ReadyExecution(models.Model):
113
+ job = models.OneToOneField(
114
+ Job,
115
+ on_delete=models.CASCADE,
116
+ related_name="ready_execution",
117
+ )
118
+ queue_name = models.CharField(max_length=64)
119
+ priority = models.SmallIntegerField()
120
+ created_at = models.DateTimeField(auto_now_add=True)
121
+
122
+ class Meta:
123
+ db_table = "dj_queue_ready_executions"
124
+ indexes = [
125
+ models.Index(fields=["priority", "id"]),
126
+ models.Index(fields=["queue_name", "priority", "id"]),
127
+ ]
128
+
129
+ def clean(self):
130
+ super().clean()
131
+ _validate_live_state(self)
132
+
133
+ def save(self, *args, **kwargs):
134
+ self.full_clean()
135
+ return super().save(*args, **kwargs)
136
+
137
+ @classmethod
138
+ def discard_all_in_batches(cls, *, batch_size=500, backend_alias="default"):
139
+ operation = import_string("dj_queue.operations.jobs.discard_ready_jobs")
140
+ return _discard_jobs_for_state(
141
+ cls,
142
+ operation,
143
+ batch_size=batch_size,
144
+ backend_alias=backend_alias,
145
+ )
146
+
147
+
148
+ class ScheduledExecution(models.Model):
149
+ job = models.OneToOneField(
150
+ Job,
151
+ on_delete=models.CASCADE,
152
+ related_name="scheduled_execution",
153
+ )
154
+ queue_name = models.CharField(max_length=64)
155
+ priority = models.SmallIntegerField()
156
+ scheduled_at = models.DateTimeField()
157
+ created_at = models.DateTimeField(auto_now_add=True)
158
+
159
+ class Meta:
160
+ db_table = "dj_queue_scheduled_executions"
161
+ indexes = [models.Index(fields=["scheduled_at", "priority", "id"])]
162
+
163
+ def clean(self):
164
+ super().clean()
165
+ _validate_live_state(self)
166
+
167
+ def save(self, *args, **kwargs):
168
+ self.full_clean()
169
+ return super().save(*args, **kwargs)
170
+
171
+
172
+ class ClaimedExecution(models.Model):
173
+ job = models.OneToOneField(
174
+ Job,
175
+ on_delete=models.CASCADE,
176
+ related_name="claimed_execution",
177
+ )
178
+ process = models.ForeignKey(
179
+ "dj_queue.Process",
180
+ null=True,
181
+ blank=True,
182
+ on_delete=models.SET_NULL,
183
+ related_name="claimed_executions",
184
+ )
185
+ created_at = models.DateTimeField(auto_now_add=True)
186
+
187
+ class Meta:
188
+ db_table = "dj_queue_claimed_executions"
189
+ indexes = [models.Index(fields=["process", "job"])]
190
+
191
+ def clean(self):
192
+ super().clean()
193
+ _validate_live_state(self)
194
+
195
+ def save(self, *args, **kwargs):
196
+ self.full_clean()
197
+ return super().save(*args, **kwargs)
198
+
199
+ @classmethod
200
+ def discard_all_in_batches(cls, **_kwargs):
201
+ raise UndiscardableError("cannot discard in-progress jobs")
202
+
203
+
204
+ class BlockedExecution(models.Model):
205
+ job = models.OneToOneField(
206
+ Job,
207
+ on_delete=models.CASCADE,
208
+ related_name="blocked_execution",
209
+ )
210
+ queue_name = models.CharField(max_length=64)
211
+ priority = models.SmallIntegerField()
212
+ concurrency_key = models.CharField(max_length=255)
213
+ expires_at = models.DateTimeField()
214
+ created_at = models.DateTimeField(auto_now_add=True)
215
+
216
+ class Meta:
217
+ db_table = "dj_queue_blocked_executions"
218
+ indexes = [
219
+ models.Index(fields=["concurrency_key", "priority", "id"]),
220
+ models.Index(fields=["expires_at", "concurrency_key"]),
221
+ ]
222
+
223
+ def clean(self):
224
+ super().clean()
225
+ _validate_live_state(self)
226
+
227
+ def save(self, *args, **kwargs):
228
+ self.full_clean()
229
+ return super().save(*args, **kwargs)
230
+
231
+ @classmethod
232
+ def discard_all_in_batches(cls, *, batch_size=500, backend_alias="default"):
233
+ operation = import_string("dj_queue.operations.jobs.discard_blocked_jobs")
234
+ return _discard_jobs_for_state(
235
+ cls,
236
+ operation,
237
+ batch_size=batch_size,
238
+ backend_alias=backend_alias,
239
+ )
240
+
241
+
242
+ class FailedExecution(models.Model):
243
+ job = models.OneToOneField(
244
+ Job,
245
+ on_delete=models.CASCADE,
246
+ related_name="failed_execution",
247
+ )
248
+ exception_class = models.CharField(max_length=255)
249
+ message = models.TextField(default="")
250
+ traceback = models.TextField(default="")
251
+ created_at = models.DateTimeField(auto_now_add=True)
252
+
253
+ class Meta:
254
+ db_table = "dj_queue_failed_executions"
255
+
256
+ def clean(self):
257
+ super().clean()
258
+ _validate_live_state(self)
259
+
260
+ def save(self, *args, **kwargs):
261
+ self.full_clean()
262
+ return super().save(*args, **kwargs)
263
+
264
+ def retry(self):
265
+ return _retry_failed_job(self.job_id, backend_alias=self.job.backend_name)
266
+
267
+ def discard(self):
268
+ return _discard_failed_job(self.job_id, backend_alias=self.job.backend_name)
269
+
270
+ @classmethod
271
+ def retry_all(cls, queryset):
272
+ retried = 0
273
+ for execution in queryset.select_related("job"):
274
+ execution.retry()
275
+ retried += 1
276
+ return retried
277
+
278
+ @classmethod
279
+ def discard_all_in_batches(cls, *, batch_size=500, backend_alias="default"):
280
+ alias = get_database_alias(backend_alias)
281
+ deleted = 0
282
+ while True:
283
+ job_ids = list(cls.objects.using(alias).values_list("job_id", flat=True)[:batch_size])
284
+ if not job_ids:
285
+ return deleted
286
+ for job_id in job_ids:
287
+ deleted += _discard_failed_job(job_id, backend_alias=backend_alias)
288
+
289
+
290
+ def _retry_failed_job(job_id, *, backend_alias):
291
+ operation = import_string("dj_queue.operations.jobs.retry_failed_job")
292
+ return operation(job_id, backend_alias=backend_alias)
293
+
294
+
295
+ def _discard_failed_job(job_id, *, backend_alias):
296
+ operation = import_string("dj_queue.operations.jobs.discard_failed_job")
297
+ return operation(job_id, backend_alias=backend_alias)
298
+
299
+
300
+ def _discard_jobs_for_state(model, operation, *, batch_size, backend_alias):
301
+ alias = get_database_alias(backend_alias)
302
+ deleted = 0
303
+ while True:
304
+ job_ids = list(model.objects.using(alias).values_list("job_id", flat=True)[:batch_size])
305
+ if not job_ids:
306
+ return deleted
307
+ deleted += operation(job_ids=job_ids, batch_size=batch_size, backend_alias=backend_alias)
308
+
309
+
310
+ def _validate_live_state(instance):
311
+ if not instance.job_id:
312
+ return
313
+
314
+ for model in LIVE_STATE_MODELS:
315
+ queryset = model._default_manager.filter(job_id=instance.job_id)
316
+ if model is instance.__class__ and instance.pk is not None:
317
+ queryset = queryset.exclude(pk=instance.pk)
318
+ if queryset.exists():
319
+ raise ValidationError({"job": "job already has a live execution state"})
320
+
321
+
322
+ LIVE_STATE_MODELS = (
323
+ ReadyExecution,
324
+ ScheduledExecution,
325
+ ClaimedExecution,
326
+ BlockedExecution,
327
+ FailedExecution,
328
+ )
@@ -0,0 +1,51 @@
1
+ from croniter import croniter
2
+ from django.core.exceptions import ValidationError
3
+ from django.db import models
4
+
5
+
6
+ class RecurringTask(models.Model):
7
+ key = models.CharField(max_length=255, unique=True)
8
+ task_path = models.CharField(max_length=255)
9
+ payload = models.JSONField(null=True, blank=True)
10
+ schedule = models.CharField(max_length=255)
11
+ queue_name = models.CharField(max_length=64, default="default")
12
+ priority = models.SmallIntegerField(default=0)
13
+ description = models.TextField(default="", blank=True)
14
+ static = models.BooleanField(default=False)
15
+ created_at = models.DateTimeField(auto_now_add=True)
16
+ updated_at = models.DateTimeField(auto_now=True)
17
+
18
+ class Meta:
19
+ db_table = "dj_queue_recurring_tasks"
20
+
21
+ def clean(self):
22
+ super().clean()
23
+ if not croniter.is_valid(str(self.schedule)):
24
+ raise ValidationError({"schedule": "schedule must be a valid cron expression"})
25
+
26
+ def save(self, *args, **kwargs):
27
+ self.full_clean()
28
+ return super().save(*args, **kwargs)
29
+
30
+
31
+ class RecurringExecution(models.Model):
32
+ job = models.OneToOneField(
33
+ "dj_queue.Job",
34
+ null=True,
35
+ blank=True,
36
+ on_delete=models.CASCADE,
37
+ related_name="recurring_execution",
38
+ )
39
+ task_key = models.CharField(max_length=255)
40
+ run_at = models.DateTimeField()
41
+ created_at = models.DateTimeField(auto_now_add=True)
42
+
43
+ class Meta:
44
+ db_table = "dj_queue_recurring_executions"
45
+ constraints = [
46
+ models.UniqueConstraint(
47
+ fields=["task_key", "run_at"],
48
+ name="dj_queue_recurring_executions_task_key_run_at_unique",
49
+ )
50
+ ]
51
+ indexes = [models.Index(fields=["task_key", "run_at"])]
@@ -0,0 +1,55 @@
1
+ from django.db import models
2
+
3
+
4
+ class Semaphore(models.Model):
5
+ key = models.CharField(max_length=255, unique=True)
6
+ value = models.IntegerField()
7
+ limit = models.IntegerField()
8
+ expires_at = models.DateTimeField()
9
+ created_at = models.DateTimeField(auto_now_add=True)
10
+ updated_at = models.DateTimeField(auto_now=True)
11
+
12
+ class Meta:
13
+ db_table = "dj_queue_semaphores"
14
+ indexes = [
15
+ models.Index(fields=["key", "value"]),
16
+ models.Index(fields=["expires_at"]),
17
+ ]
18
+
19
+
20
+ class Process(models.Model):
21
+ kind = models.CharField(max_length=32)
22
+ pid = models.IntegerField()
23
+ hostname = models.CharField(max_length=255)
24
+ name = models.CharField(max_length=255)
25
+ metadata = models.JSONField(default=dict)
26
+ supervisor = models.ForeignKey(
27
+ "self",
28
+ null=True,
29
+ blank=True,
30
+ on_delete=models.SET_NULL,
31
+ related_name="children",
32
+ )
33
+ last_heartbeat_at = models.DateTimeField()
34
+ created_at = models.DateTimeField(auto_now_add=True)
35
+
36
+ class Meta:
37
+ db_table = "dj_queue_processes"
38
+ constraints = [
39
+ models.UniqueConstraint(
40
+ fields=["name", "supervisor"],
41
+ name="dj_queue_processes_name_supervisor_unique",
42
+ )
43
+ ]
44
+ indexes = [
45
+ models.Index(fields=["name", "supervisor"]),
46
+ models.Index(fields=["last_heartbeat_at"]),
47
+ ]
48
+
49
+
50
+ class Pause(models.Model):
51
+ queue_name = models.CharField(max_length=64, unique=True)
52
+ created_at = models.DateTimeField(auto_now_add=True)
53
+
54
+ class Meta:
55
+ db_table = "dj_queue_pauses"
@@ -0,0 +1 @@
1
+ """Transactional state transitions live here."""
@@ -0,0 +1,37 @@
1
+ from datetime import timedelta
2
+
3
+ from django.utils import timezone
4
+
5
+ from dj_queue.config import load_backend_config
6
+ from dj_queue.db import get_database_alias
7
+ from dj_queue.models import Job
8
+
9
+
10
+ def clear_finished_jobs(
11
+ *,
12
+ older_than=None,
13
+ task_path=None,
14
+ batch_size=500,
15
+ backend_alias="default",
16
+ now=None,
17
+ ):
18
+ config = load_backend_config(backend_alias)
19
+ if older_than is None:
20
+ older_than = config.clear_finished_jobs_after
21
+ if older_than is None:
22
+ return 0
23
+
24
+ alias = get_database_alias(backend_alias)
25
+ if now is None:
26
+ now = timezone.now()
27
+ cutoff = now - timedelta(seconds=older_than)
28
+ queryset = Job.objects.using(alias).filter(finished_at__lt=cutoff).order_by("finished_at", "id")
29
+ if task_path is not None:
30
+ queryset = queryset.filter(task_path=task_path)
31
+
32
+ job_ids = list(queryset.values_list("pk", flat=True)[:batch_size])
33
+ if not job_ids:
34
+ return 0
35
+
36
+ Job.objects.using(alias).filter(pk__in=job_ids).delete()
37
+ return len(job_ids)
@@ -0,0 +1,176 @@
1
+ from datetime import timedelta
2
+
3
+ from django.db import IntegrityError, transaction
4
+ from django.utils import timezone
5
+ from django.utils.module_loading import import_string
6
+
7
+ from dj_queue.config import load_backend_config
8
+ from dj_queue.db import get_database_alias, locked_queryset
9
+ from dj_queue.log import log_event
10
+ from dj_queue.models import BlockedExecution, ReadyExecution, Semaphore
11
+ from dj_queue.runtime import notify as runtime_notify
12
+
13
+
14
+ def semaphore_acquire(
15
+ key,
16
+ *,
17
+ limit,
18
+ duration_seconds,
19
+ backend_alias="default",
20
+ ):
21
+ alias = get_database_alias(backend_alias)
22
+ expires_at = timezone.now() + timedelta(seconds=duration_seconds)
23
+
24
+ for attempt in range(2):
25
+ try:
26
+ with transaction.atomic(using=alias):
27
+ semaphore = Semaphore.objects.using(alias).select_for_update().filter(key=key).first()
28
+ if semaphore is None:
29
+ Semaphore.objects.using(alias).create(
30
+ key=key,
31
+ value=limit - 1,
32
+ limit=limit,
33
+ expires_at=expires_at,
34
+ )
35
+ return True
36
+
37
+ if semaphore.value <= 0:
38
+ return False
39
+
40
+ semaphore.value -= 1
41
+ semaphore.expires_at = expires_at
42
+ semaphore.save(using=alias, update_fields=["value", "expires_at", "updated_at"])
43
+ return True
44
+ except IntegrityError:
45
+ # two workers can both miss the row, then race to create the unique key
46
+ # retry once so the loser can load the row created by the winner
47
+ if attempt == 0:
48
+ continue
49
+ continue
50
+
51
+ return False
52
+
53
+
54
+ def semaphore_release(key, *, duration_seconds, backend_alias="default"):
55
+ alias = get_database_alias(backend_alias)
56
+ expires_at = timezone.now() + timedelta(seconds=duration_seconds)
57
+
58
+ with transaction.atomic(using=alias):
59
+ semaphore = Semaphore.objects.using(alias).select_for_update().filter(key=key).first()
60
+ if semaphore is None:
61
+ return False
62
+
63
+ semaphore.value = min(semaphore.limit, semaphore.value + 1)
64
+ semaphore.expires_at = expires_at
65
+ semaphore.save(using=alias, update_fields=["value", "expires_at", "updated_at"])
66
+ return True
67
+
68
+
69
+ def unblock_next_blocked_job(
70
+ key,
71
+ *,
72
+ limit,
73
+ duration_seconds,
74
+ backend_alias="default",
75
+ use_skip_locked=True,
76
+ ):
77
+ alias = get_database_alias(backend_alias)
78
+
79
+ with transaction.atomic(using=alias):
80
+ queryset = (
81
+ BlockedExecution.objects.using(alias)
82
+ .select_related("job")
83
+ .filter(concurrency_key=key)
84
+ .order_by("-priority", "id")
85
+ )
86
+ blocked = locked_queryset(queryset, use_skip_locked=use_skip_locked).first()
87
+ if blocked is None:
88
+ return None
89
+
90
+ if not semaphore_acquire(
91
+ key,
92
+ limit=limit,
93
+ duration_seconds=duration_seconds,
94
+ backend_alias=backend_alias,
95
+ ):
96
+ return None
97
+
98
+ job = blocked.job
99
+ queue_name = blocked.queue_name
100
+ priority = blocked.priority
101
+ blocked.delete(using=alias)
102
+ ReadyExecution.objects.using(alias).create(
103
+ job=job,
104
+ queue_name=queue_name,
105
+ priority=priority,
106
+ )
107
+
108
+ log_event(
109
+ "job.unblocked",
110
+ job_id=str(job.id),
111
+ concurrency_key=key,
112
+ )
113
+ runtime_notify.notify_ready_queues((job.queue_name,), backend_alias=backend_alias)
114
+ return job
115
+
116
+
117
+ def cleanup_expired_semaphores(*, backend_alias="default"):
118
+ alias = get_database_alias(backend_alias)
119
+ queryset = Semaphore.objects.using(alias).filter(expires_at__lte=timezone.now())
120
+ deleted = queryset.count()
121
+ if not deleted:
122
+ return 0
123
+
124
+ queryset.delete()
125
+ return deleted
126
+
127
+
128
+ def promote_expired_blocked_jobs(*, batch_size=500, backend_alias="default", use_skip_locked=None):
129
+ alias = get_database_alias(backend_alias)
130
+ if use_skip_locked is None:
131
+ use_skip_locked = load_backend_config(backend_alias).use_skip_locked
132
+ now = timezone.now()
133
+ promoted_jobs = []
134
+ task_settings = {}
135
+
136
+ with transaction.atomic(using=alias):
137
+ queryset = (
138
+ BlockedExecution.objects.using(alias)
139
+ .select_related("job")
140
+ .filter(expires_at__lte=now)
141
+ .order_by("expires_at", "-priority", "id")
142
+ )
143
+ blocked_rows = list(locked_queryset(queryset, use_skip_locked=use_skip_locked)[:batch_size])
144
+
145
+ for blocked in blocked_rows:
146
+ limit, duration_seconds = task_settings.get(blocked.job.task_path, (None, None))
147
+ if limit is None:
148
+ task = import_string(blocked.job.task_path)
149
+ limit = int(getattr(task.func, "concurrency_limit"))
150
+ duration_seconds = int(getattr(task.func, "concurrency_duration", 60))
151
+ task_settings[blocked.job.task_path] = (limit, duration_seconds)
152
+
153
+ if semaphore_acquire(
154
+ blocked.concurrency_key,
155
+ limit=limit,
156
+ duration_seconds=duration_seconds,
157
+ backend_alias=backend_alias,
158
+ ):
159
+ job = blocked.job
160
+ queue_name = blocked.queue_name
161
+ priority = blocked.priority
162
+ blocked.delete(using=alias)
163
+ ReadyExecution.objects.using(alias).create(
164
+ job=job,
165
+ queue_name=queue_name,
166
+ priority=priority,
167
+ )
168
+ promoted_jobs.append(job)
169
+ else:
170
+ blocked.expires_at = now + timedelta(seconds=duration_seconds)
171
+ blocked.save(using=alias, update_fields=["expires_at"])
172
+
173
+ for job in promoted_jobs:
174
+ log_event("job.unblocked", job_id=str(job.id), concurrency_key=job.concurrency_key)
175
+ runtime_notify.notify_ready_queues((job.queue_name,), backend_alias=backend_alias)
176
+ return promoted_jobs