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
dj_queue/models/jobs.py
ADDED
|
@@ -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
|