dj-queue 0.9.2__tar.gz → 0.10.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dj_queue-0.9.2 → dj_queue-0.10.1}/PKG-INFO +5 -2
- {dj_queue-0.9.2 → dj_queue-0.10.1}/README.md +4 -1
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/admin.py +3 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/api.py +15 -12
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/backend.py +2 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/config.py +108 -29
- dj_queue-0.10.1/dj_queue/contrib/asgi.py +148 -0
- dj_queue-0.10.1/dj_queue/contrib/gunicorn.py +154 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/dashboard.py +43 -1
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/db.py +6 -2
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/log.py +1 -1
- dj_queue-0.10.1/dj_queue/migrations/0009_remove_process_dj_queue_processes_name_supervisor_unique_and_more.py +36 -0
- dj_queue-0.10.1/dj_queue/migrations/0010_remove_process_djq_pr_name_parent_uniq_and_more.py +23 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/models/recurring.py +0 -12
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/models/runtime.py +13 -2
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/operations/_helpers.py +82 -1
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/operations/cleanup.py +24 -12
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/operations/concurrency.py +117 -25
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/operations/jobs.py +227 -60
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/operations/recurring.py +100 -29
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/base.py +52 -20
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/connection_budget.py +3 -2
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/dispatcher.py +2 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/notify.py +1 -0
- dj_queue-0.10.1/dj_queue/runtime/pidfile.py +68 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/scheduler.py +2 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/supervisor.py +56 -18
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/worker.py +4 -1
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/task_results.py +23 -2
- dj_queue-0.10.1/dj_queue/wakeup.py +24 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/pyproject.toml +2 -1
- dj_queue-0.9.2/dj_queue/contrib/asgi.py +0 -47
- dj_queue-0.9.2/dj_queue/contrib/gunicorn.py +0 -45
- dj_queue-0.9.2/dj_queue/runtime/pidfile.py +0 -39
- {dj_queue-0.9.2 → dj_queue-0.10.1}/LICENSE +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/__init__.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/apps.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/contrib/__init__.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/contrib/prometheus.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/cron.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/exceptions.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/hooks.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/management/__init__.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/management/commands/__init__.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/management/commands/dj_queue.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/management/commands/dj_queue_health.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/management/commands/dj_queue_prune.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/metrics.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/migrations/0001_initial.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/migrations/0002_pause_semaphore.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/migrations/0003_recurringtask_recurringexecution.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/migrations/0004_dashboard.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/migrations/0005_remove_recurringexecution_dj_queue_recurring_executions_task_key_run_at_unique_and_more.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/migrations/0006_blockedexecution_dj_queue_bl_concurr_2d8393_idx_and_more.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/migrations/0007_recurringtask_next_run_at.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/migrations/0008_remove_blockedexecution_dj_queue_bl_concurr_1ce730_idx_and_more.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/migrations/__init__.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/models/__init__.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/models/jobs.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/observability.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/operations/__init__.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/operations/_insert.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/operations/queues.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/queue_selectors.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/queue_state.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/routers.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/__init__.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/errors.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/interruptible.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/pool.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/procline.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/runtime/topology.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/_dashboard_process_rows.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/_dashboard_recurring_rows.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/_dashboard_section_table.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/_dashboard_semaphore_rows.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/_paginator.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/_queue_controls.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/_sortable_header_cells.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/change_form.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/change_list.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/dashboard.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/includes/fieldset.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templates/admin/dj_queue/queue_jobs.html +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templatetags/__init__.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/templatetags/dj_queue_admin.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/urls.py +0 -0
- {dj_queue-0.9.2 → dj_queue-0.10.1}/dj_queue/views.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dj-queue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.1
|
|
4
4
|
Summary: Database-backed task queue backend for Django’s Tasks framework.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -713,9 +713,12 @@ from django.core.asgi import get_asgi_application
|
|
|
713
713
|
from dj_queue.contrib.asgi import DjQueueLifespan
|
|
714
714
|
|
|
715
715
|
django_application = get_asgi_application()
|
|
716
|
-
application = DjQueueLifespan(django_application)
|
|
716
|
+
application = DjQueueLifespan(django_application, forward_wrapped_lifespan=False)
|
|
717
717
|
```
|
|
718
718
|
|
|
719
|
+
Set `forward_wrapped_lifespan=False` when the wrapped app does not implement the
|
|
720
|
+
ASGI lifespan protocol itself, such as Django's plain `get_asgi_application()`.
|
|
721
|
+
|
|
719
722
|
### Gunicorn
|
|
720
723
|
|
|
721
724
|
Import the provided hooks in your Gunicorn config:
|
|
@@ -687,9 +687,12 @@ from django.core.asgi import get_asgi_application
|
|
|
687
687
|
from dj_queue.contrib.asgi import DjQueueLifespan
|
|
688
688
|
|
|
689
689
|
django_application = get_asgi_application()
|
|
690
|
-
application = DjQueueLifespan(django_application)
|
|
690
|
+
application = DjQueueLifespan(django_application, forward_wrapped_lifespan=False)
|
|
691
691
|
```
|
|
692
692
|
|
|
693
|
+
Set `forward_wrapped_lifespan=False` when the wrapped app does not implement the
|
|
694
|
+
ASGI lifespan protocol itself, such as Django's plain `get_asgi_application()`.
|
|
695
|
+
|
|
693
696
|
### Gunicorn
|
|
694
697
|
|
|
695
698
|
Import the provided hooks in your Gunicorn config:
|
|
@@ -389,8 +389,10 @@ class JobConcurrencyKeyListFilter(admin.SimpleListFilter):
|
|
|
389
389
|
|
|
390
390
|
def lookups(self, request, model_admin):
|
|
391
391
|
alias = model_admin._backend_database_alias(request)
|
|
392
|
+
backend_alias = model_admin._backend_alias(request)
|
|
392
393
|
return tuple(
|
|
393
394
|
Job.objects.using(alias)
|
|
395
|
+
.filter(backend_alias=backend_alias)
|
|
394
396
|
.exclude(concurrency_key__isnull=True)
|
|
395
397
|
.exclude(concurrency_key="")
|
|
396
398
|
.order_by("concurrency_key")
|
|
@@ -713,6 +715,7 @@ class FailedExecutionAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
|
|
|
713
715
|
|
|
714
716
|
@admin.register(Process)
|
|
715
717
|
class ProcessAdmin(HiddenSidebarAdminMixin, admin.ModelAdmin):
|
|
718
|
+
backend_filter_field = "backend_alias"
|
|
716
719
|
list_display = (
|
|
717
720
|
"name",
|
|
718
721
|
"backend_alias",
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
from datetime import timedelta
|
|
1
2
|
from functools import partial
|
|
2
3
|
|
|
3
4
|
from django.db.models.functions import Coalesce
|
|
4
5
|
from django.db import transaction
|
|
5
6
|
from django.utils import timezone
|
|
6
7
|
|
|
8
|
+
from dj_queue import observability
|
|
9
|
+
from dj_queue.config import load_backend_config
|
|
7
10
|
from dj_queue.db import get_database_alias
|
|
8
11
|
from dj_queue.models import Pause, ReadyExecution
|
|
9
12
|
from dj_queue.operations.jobs import (
|
|
@@ -50,6 +53,9 @@ class QueueInfo:
|
|
|
50
53
|
|
|
51
54
|
@property
|
|
52
55
|
def latency(self):
|
|
56
|
+
if self.paused:
|
|
57
|
+
return None
|
|
58
|
+
|
|
53
59
|
oldest = (
|
|
54
60
|
self._ready_queryset()
|
|
55
61
|
.annotate(latency_at=Coalesce("latency_started_at", "created_at"))
|
|
@@ -59,7 +65,7 @@ class QueueInfo:
|
|
|
59
65
|
)
|
|
60
66
|
if oldest is None:
|
|
61
67
|
return 0.0
|
|
62
|
-
return (timezone.now() - oldest).total_seconds()
|
|
68
|
+
return max((timezone.now() - oldest).total_seconds(), 0.0)
|
|
63
69
|
|
|
64
70
|
@property
|
|
65
71
|
def paused(self):
|
|
@@ -93,18 +99,15 @@ class QueueInfo:
|
|
|
93
99
|
|
|
94
100
|
@classmethod
|
|
95
101
|
def all(cls, *, backend_alias="default"):
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
flat=True,
|
|
104
|
-
)
|
|
105
|
-
.distinct()
|
|
102
|
+
now = timezone.now()
|
|
103
|
+
config = load_backend_config(backend_alias)
|
|
104
|
+
process_cutoff = now - timedelta(seconds=config.process_alive_threshold)
|
|
105
|
+
queue_rows = observability.queue_rows(
|
|
106
|
+
backend_alias=backend_alias,
|
|
107
|
+
now=now,
|
|
108
|
+
process_cutoff=process_cutoff,
|
|
106
109
|
)
|
|
107
|
-
return [cls(
|
|
110
|
+
return [cls(row["name"], backend_alias=backend_alias) for row in queue_rows]
|
|
108
111
|
|
|
109
112
|
def _ready_queryset(self):
|
|
110
113
|
alias = get_database_alias(self.backend_alias)
|
|
@@ -9,6 +9,7 @@ from dj_queue.operations.jobs import (
|
|
|
9
9
|
DispatchOutcome,
|
|
10
10
|
enqueue_job_with_dispatch,
|
|
11
11
|
enqueue_jobs_bulk,
|
|
12
|
+
validate_priority,
|
|
12
13
|
validate_queue_allowed,
|
|
13
14
|
)
|
|
14
15
|
from dj_queue.task_results import task_result_from_enqueued_job, task_result_from_job
|
|
@@ -22,6 +23,7 @@ class DjQueueBackend(BaseTaskBackend):
|
|
|
22
23
|
|
|
23
24
|
def validate_task(self, task):
|
|
24
25
|
validate_queue_allowed(task.queue_name, backend_alias=self.alias)
|
|
26
|
+
validate_priority(task.priority)
|
|
25
27
|
return super().validate_task(task)
|
|
26
28
|
|
|
27
29
|
def enqueue(self, task, args, kwargs):
|
|
@@ -5,7 +5,6 @@ import tomllib
|
|
|
5
5
|
import warnings
|
|
6
6
|
from collections.abc import Mapping, Sequence
|
|
7
7
|
from dataclasses import asdict, dataclass, field, replace
|
|
8
|
-
from functools import lru_cache
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
from typing import Any
|
|
11
10
|
|
|
@@ -61,6 +60,7 @@ DJ_QUEUE_BACKEND_PATH = "dj_queue.backend.DjQueueBackend"
|
|
|
61
60
|
TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
|
|
62
61
|
FALSY_ENV_VALUES = {"0", "false", "no", "off"}
|
|
63
62
|
CONFIG_ENV_KEYS = ("DJ_QUEUE_CONFIG", "DJ_QUEUE_MODE", "DJ_QUEUE_SKIP_RECURRING")
|
|
63
|
+
_BACKEND_CONFIG_CACHE = {}
|
|
64
64
|
|
|
65
65
|
|
|
66
66
|
@dataclass(frozen=True, slots=True)
|
|
@@ -149,24 +149,41 @@ def load_backend_config(
|
|
|
149
149
|
if tasks_settings is None:
|
|
150
150
|
tasks_settings = getattr(settings, "TASKS", {})
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
env_values = {key: env.get(key) for key in CONFIG_ENV_KEYS if env.get(key) is not None}
|
|
153
|
+
cache_key = (
|
|
153
154
|
backend_alias,
|
|
154
155
|
_cache_key(cli_overrides),
|
|
155
|
-
_cache_key(
|
|
156
|
+
_cache_key(env_values),
|
|
156
157
|
_cache_key(tasks_settings),
|
|
157
158
|
)
|
|
159
|
+
if cache_key not in _BACKEND_CONFIG_CACHE:
|
|
160
|
+
_BACKEND_CONFIG_CACHE[cache_key] = _load_backend_config_uncached(
|
|
161
|
+
backend_alias,
|
|
162
|
+
cli_overrides,
|
|
163
|
+
env_values,
|
|
164
|
+
tasks_settings,
|
|
165
|
+
)
|
|
166
|
+
return _BACKEND_CONFIG_CACHE[cache_key]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def load_allowed_queues(
|
|
170
|
+
backend_alias: str = "default",
|
|
171
|
+
*,
|
|
172
|
+
tasks_settings: Mapping[str, Any] | None = None,
|
|
173
|
+
) -> tuple[str, ...]:
|
|
174
|
+
if tasks_settings is None:
|
|
175
|
+
tasks_settings = getattr(settings, "TASKS", {})
|
|
176
|
+
ensure_dj_queue_backend_alias(tasks_settings, backend_alias)
|
|
177
|
+
backend_block = _backend_block(tasks_settings, backend_alias)
|
|
178
|
+
return _as_string_tuple(backend_block.get("QUEUES", []))
|
|
158
179
|
|
|
159
180
|
|
|
160
|
-
|
|
161
|
-
def _load_backend_config_cached(
|
|
181
|
+
def _load_backend_config_uncached(
|
|
162
182
|
backend_alias: str,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
183
|
+
cli_overrides: Mapping[str, Any],
|
|
184
|
+
env: Mapping[str, str],
|
|
185
|
+
tasks_settings: Mapping[str, Any],
|
|
166
186
|
) -> BackendConfig:
|
|
167
|
-
cli_overrides = json.loads(cli_overrides_key)
|
|
168
|
-
env = json.loads(env_key)
|
|
169
|
-
tasks_settings = json.loads(tasks_settings_key)
|
|
170
187
|
ensure_dj_queue_backend_alias(tasks_settings, backend_alias)
|
|
171
188
|
backend_block = _backend_block(tasks_settings, backend_alias)
|
|
172
189
|
resolved_options = _resolved_options(backend_alias, backend_block, cli_overrides, env)
|
|
@@ -184,8 +201,13 @@ def _load_backend_config_cached(
|
|
|
184
201
|
preserve_finished_jobs = _bool_option(
|
|
185
202
|
resolved_options["preserve_finished_jobs"], "preserve_finished_jobs"
|
|
186
203
|
)
|
|
204
|
+
allowed_queues = _as_string_tuple(backend_block.get("QUEUES", []))
|
|
187
205
|
on_thread_error = _validated_callback_path(resolved_options.get("on_thread_error"))
|
|
188
|
-
recurring = _build_recurring_config(
|
|
206
|
+
recurring = _build_recurring_config(
|
|
207
|
+
resolved_options.get("recurring", {}),
|
|
208
|
+
allowed_queues=allowed_queues,
|
|
209
|
+
backend_alias=backend_alias,
|
|
210
|
+
)
|
|
189
211
|
scheduler = _build_scheduler_config(resolved_options.get("scheduler", DEFAULT_SCHEDULER))
|
|
190
212
|
workers = _build_worker_configs(resolved_options.get("workers", []), mode)
|
|
191
213
|
dispatchers = _build_dispatcher_configs(resolved_options.get("dispatchers", []))
|
|
@@ -213,7 +235,7 @@ def _load_backend_config_cached(
|
|
|
213
235
|
|
|
214
236
|
return BackendConfig(
|
|
215
237
|
backend_alias=backend_alias,
|
|
216
|
-
allowed_queues=
|
|
238
|
+
allowed_queues=allowed_queues,
|
|
217
239
|
mode=mode,
|
|
218
240
|
workers=workers,
|
|
219
241
|
dispatchers=dispatchers,
|
|
@@ -228,10 +250,15 @@ def _load_backend_config_cached(
|
|
|
228
250
|
shutdown_timeout=_nonnegative_float(resolved_options["shutdown_timeout"], "shutdown_timeout"),
|
|
229
251
|
supervisor_pidfile=resolved_options["supervisor_pidfile"],
|
|
230
252
|
preserve_finished_jobs=preserve_finished_jobs,
|
|
231
|
-
clear_finished_jobs_after=
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
253
|
+
clear_finished_jobs_after=_optional_nonnegative_int(
|
|
254
|
+
resolved_options["clear_finished_jobs_after"], "clear_finished_jobs_after"
|
|
255
|
+
),
|
|
256
|
+
clear_failed_jobs_after=_optional_nonnegative_int(
|
|
257
|
+
resolved_options["clear_failed_jobs_after"], "clear_failed_jobs_after"
|
|
258
|
+
),
|
|
259
|
+
clear_recurring_executions_after=_optional_nonnegative_int(
|
|
260
|
+
resolved_options["clear_recurring_executions_after"],
|
|
261
|
+
"clear_recurring_executions_after",
|
|
235
262
|
),
|
|
236
263
|
default_concurrency_duration=_positive_int(
|
|
237
264
|
resolved_options["default_concurrency_duration"],
|
|
@@ -506,7 +533,12 @@ def _build_scheduler_config(raw_scheduler: Any) -> SchedulerConfig:
|
|
|
506
533
|
)
|
|
507
534
|
|
|
508
535
|
|
|
509
|
-
def _build_recurring_config(
|
|
536
|
+
def _build_recurring_config(
|
|
537
|
+
raw_recurring: Any,
|
|
538
|
+
*,
|
|
539
|
+
allowed_queues: tuple[str, ...],
|
|
540
|
+
backend_alias: str,
|
|
541
|
+
) -> dict[str, RecurringTaskConfig]:
|
|
510
542
|
if raw_recurring is None:
|
|
511
543
|
return {}
|
|
512
544
|
if not isinstance(raw_recurring, Mapping):
|
|
@@ -524,14 +556,29 @@ def _build_recurring_config(raw_recurring: Any) -> dict[str, RecurringTaskConfig
|
|
|
524
556
|
if not is_valid_cron(str(schedule)):
|
|
525
557
|
raise ImproperlyConfigured(f"recurring task {key!r} has an invalid cron schedule")
|
|
526
558
|
|
|
559
|
+
queue_name = str(raw_entry.get("queue_name", "default"))
|
|
560
|
+
priority = _priority_int(raw_entry.get("priority", 0), f"recurring task {key!r} priority")
|
|
561
|
+
if allowed_queues and queue_name not in allowed_queues:
|
|
562
|
+
raise ImproperlyConfigured(
|
|
563
|
+
f"recurring task {key!r} is invalid: queue {queue_name!r} is not allowed for backend {backend_alias!r}"
|
|
564
|
+
)
|
|
565
|
+
try:
|
|
566
|
+
task = import_string(str(task_path))
|
|
567
|
+
except ImportError as exc:
|
|
568
|
+
raise ImproperlyConfigured(f"recurring task {key!r} is invalid: {exc}") from exc
|
|
569
|
+
if not hasattr(task, "using"):
|
|
570
|
+
raise ImproperlyConfigured(
|
|
571
|
+
f"recurring task {key!r} is invalid: task_path must reference a Django task"
|
|
572
|
+
)
|
|
573
|
+
|
|
527
574
|
recurring[str(key)] = RecurringTaskConfig(
|
|
528
575
|
key=str(key),
|
|
529
576
|
task_path=str(task_path),
|
|
530
577
|
schedule=str(schedule),
|
|
531
578
|
args=tuple(raw_entry.get("args", [])),
|
|
532
579
|
kwargs=dict(raw_entry.get("kwargs", {})),
|
|
533
|
-
queue_name=
|
|
534
|
-
priority=
|
|
580
|
+
queue_name=queue_name,
|
|
581
|
+
priority=priority,
|
|
535
582
|
description=str(raw_entry.get("description", "")),
|
|
536
583
|
)
|
|
537
584
|
return recurring
|
|
@@ -570,13 +617,20 @@ def _as_string_tuple(value: Any) -> tuple[str, ...]:
|
|
|
570
617
|
return tuple(str(item) for item in value)
|
|
571
618
|
|
|
572
619
|
|
|
573
|
-
def
|
|
620
|
+
def _optional_nonnegative_int(value: Any, setting_name: str) -> int | None:
|
|
574
621
|
if value is None:
|
|
575
622
|
return None
|
|
576
|
-
|
|
623
|
+
number = _integer(value, setting_name, "a non-negative integer")
|
|
624
|
+
if number < 0:
|
|
625
|
+
raise ImproperlyConfigured(
|
|
626
|
+
f"dj_queue {setting_name} must be a non-negative integer, got {value!r}"
|
|
627
|
+
)
|
|
628
|
+
return number
|
|
577
629
|
|
|
578
630
|
|
|
579
631
|
def _positive_float(value: Any, setting_name: str) -> float:
|
|
632
|
+
if isinstance(value, bool):
|
|
633
|
+
raise ImproperlyConfigured(f"dj_queue {setting_name} must be a positive number, got {value!r}")
|
|
580
634
|
try:
|
|
581
635
|
number = float(value)
|
|
582
636
|
except (TypeError, ValueError) as exc:
|
|
@@ -590,6 +644,10 @@ def _positive_float(value: Any, setting_name: str) -> float:
|
|
|
590
644
|
|
|
591
645
|
|
|
592
646
|
def _nonnegative_float(value: Any, setting_name: str) -> float:
|
|
647
|
+
if isinstance(value, bool):
|
|
648
|
+
raise ImproperlyConfigured(
|
|
649
|
+
f"dj_queue {setting_name} must be a non-negative number, got {value!r}"
|
|
650
|
+
)
|
|
593
651
|
try:
|
|
594
652
|
number = float(value)
|
|
595
653
|
except (TypeError, ValueError) as exc:
|
|
@@ -605,12 +663,7 @@ def _nonnegative_float(value: Any, setting_name: str) -> float:
|
|
|
605
663
|
|
|
606
664
|
|
|
607
665
|
def _positive_int(value: Any, setting_name: str) -> int:
|
|
608
|
-
|
|
609
|
-
number = int(value)
|
|
610
|
-
except (TypeError, ValueError, OverflowError) as exc:
|
|
611
|
-
raise ImproperlyConfigured(
|
|
612
|
-
f"dj_queue {setting_name} must be a positive integer, got {value!r}"
|
|
613
|
-
) from exc
|
|
666
|
+
number = _integer(value, setting_name, "a positive integer")
|
|
614
667
|
|
|
615
668
|
if number <= 0:
|
|
616
669
|
raise ImproperlyConfigured(
|
|
@@ -619,5 +672,31 @@ def _positive_int(value: Any, setting_name: str) -> int:
|
|
|
619
672
|
return number
|
|
620
673
|
|
|
621
674
|
|
|
675
|
+
def _priority_int(value: Any, setting_name: str) -> int:
|
|
676
|
+
number = _integer(value, setting_name, "an integer from -100 to 100")
|
|
677
|
+
|
|
678
|
+
if number < -100 or number > 100:
|
|
679
|
+
raise ImproperlyConfigured(
|
|
680
|
+
f"dj_queue {setting_name} must be an integer from -100 to 100, got {value!r}"
|
|
681
|
+
)
|
|
682
|
+
return number
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _integer(value: Any, setting_name: str, expectation: str) -> int:
|
|
686
|
+
if isinstance(value, bool):
|
|
687
|
+
raise ImproperlyConfigured(f"dj_queue {setting_name} must be {expectation}, got {value!r}")
|
|
688
|
+
if isinstance(value, int):
|
|
689
|
+
return value
|
|
690
|
+
if isinstance(value, str):
|
|
691
|
+
normalized = value.strip()
|
|
692
|
+
unsigned = normalized[1:] if normalized[:1] in {"+", "-"} else normalized
|
|
693
|
+
if unsigned.isdecimal():
|
|
694
|
+
return int(normalized)
|
|
695
|
+
raise ImproperlyConfigured(f"dj_queue {setting_name} must be {expectation}, got {value!r}")
|
|
696
|
+
|
|
697
|
+
|
|
622
698
|
def _cache_key(value: Any) -> str:
|
|
623
|
-
|
|
699
|
+
try:
|
|
700
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
701
|
+
except (TypeError, ValueError) as exc:
|
|
702
|
+
raise ImproperlyConfigured("dj_queue config values must be JSON-serializable") from exc
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
|
|
5
|
+
from dj_queue.runtime.errors import handle_thread_error
|
|
6
|
+
from dj_queue.runtime.supervisor import AsyncSupervisor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_supervisor(backend_alias="default"):
|
|
10
|
+
return AsyncSupervisor.from_backend_config(backend_alias=backend_alias, standalone=False)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DjQueueLifespan:
|
|
14
|
+
def __init__(self, app, *, backend_alias="default", forward_wrapped_lifespan=True):
|
|
15
|
+
self.app = app
|
|
16
|
+
self.backend_alias = backend_alias
|
|
17
|
+
self.forward_wrapped_lifespan = forward_wrapped_lifespan
|
|
18
|
+
self.supervisor = None
|
|
19
|
+
self._poll_task = None
|
|
20
|
+
self._poll_stop = None
|
|
21
|
+
|
|
22
|
+
async def _poll_supervisor(self):
|
|
23
|
+
while (
|
|
24
|
+
self.supervisor is not None and self._poll_stop is not None and not self._poll_stop.is_set()
|
|
25
|
+
):
|
|
26
|
+
try:
|
|
27
|
+
await asyncio.to_thread(self.supervisor.poll_once)
|
|
28
|
+
except Exception as error:
|
|
29
|
+
handle_thread_error(
|
|
30
|
+
error,
|
|
31
|
+
context="supervisor.run",
|
|
32
|
+
backend_alias=self.supervisor.backend_alias,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if self.supervisor is None or self._poll_stop is None or self._poll_stop.is_set():
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
await asyncio.wait_for(self._poll_stop.wait(), timeout=self.supervisor.polling_interval)
|
|
40
|
+
except asyncio.TimeoutError:
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
async def _start_wrapped_lifespan_app(self, scope, receive, send):
|
|
44
|
+
result = self.app(scope, receive, send)
|
|
45
|
+
if inspect.isawaitable(result):
|
|
46
|
+
return asyncio.create_task(result)
|
|
47
|
+
|
|
48
|
+
loop = asyncio.get_running_loop()
|
|
49
|
+
task = loop.create_future()
|
|
50
|
+
task.set_result(result)
|
|
51
|
+
return task
|
|
52
|
+
|
|
53
|
+
async def _forward_lifespan_message(self, app_task, receive_queue, send_queue, message):
|
|
54
|
+
if app_task.done():
|
|
55
|
+
await app_task
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
await receive_queue.put(message)
|
|
59
|
+
response_task = asyncio.create_task(send_queue.get())
|
|
60
|
+
try:
|
|
61
|
+
done, _ = await asyncio.wait({app_task, response_task}, return_when=asyncio.FIRST_COMPLETED)
|
|
62
|
+
if response_task in done:
|
|
63
|
+
return response_task.result()
|
|
64
|
+
await app_task
|
|
65
|
+
return None
|
|
66
|
+
finally:
|
|
67
|
+
if not response_task.done():
|
|
68
|
+
response_task.cancel()
|
|
69
|
+
with suppress(asyncio.CancelledError):
|
|
70
|
+
await response_task
|
|
71
|
+
|
|
72
|
+
async def _start_supervisor(self):
|
|
73
|
+
self.supervisor = build_supervisor(self.backend_alias)
|
|
74
|
+
await asyncio.to_thread(self.supervisor.start)
|
|
75
|
+
self._poll_stop = asyncio.Event()
|
|
76
|
+
self._poll_task = asyncio.create_task(self._poll_supervisor())
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _is_unsupported_lifespan_error(error):
|
|
80
|
+
message = str(error).lower()
|
|
81
|
+
return isinstance(error, ValueError) and (
|
|
82
|
+
"django can only handle asgi/http connections" in message and "lifespan" in message
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
async def _stop_supervisor(self):
|
|
86
|
+
poll_stop = self._poll_stop
|
|
87
|
+
self._poll_stop = None
|
|
88
|
+
if poll_stop is not None:
|
|
89
|
+
poll_stop.set()
|
|
90
|
+
|
|
91
|
+
poll_task = self._poll_task
|
|
92
|
+
self._poll_task = None
|
|
93
|
+
if poll_task is not None:
|
|
94
|
+
await poll_task
|
|
95
|
+
|
|
96
|
+
if self.supervisor is not None:
|
|
97
|
+
await asyncio.to_thread(self.supervisor.stop)
|
|
98
|
+
self.supervisor = None
|
|
99
|
+
|
|
100
|
+
async def __call__(self, scope, receive, send):
|
|
101
|
+
if scope["type"] != "lifespan":
|
|
102
|
+
await self.app(scope, receive, send)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
receive_queue = asyncio.Queue()
|
|
106
|
+
send_queue = asyncio.Queue()
|
|
107
|
+
app_task = await self._start_wrapped_lifespan_app(scope, receive_queue.get, send_queue.put)
|
|
108
|
+
wrapped_app_supports_lifespan = self.forward_wrapped_lifespan
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
while True:
|
|
112
|
+
message = await receive()
|
|
113
|
+
if message["type"] == "lifespan.startup":
|
|
114
|
+
response = None
|
|
115
|
+
if wrapped_app_supports_lifespan:
|
|
116
|
+
try:
|
|
117
|
+
response = await self._forward_lifespan_message(
|
|
118
|
+
app_task, receive_queue, send_queue, message
|
|
119
|
+
)
|
|
120
|
+
except Exception as error:
|
|
121
|
+
if not self._is_unsupported_lifespan_error(error):
|
|
122
|
+
raise
|
|
123
|
+
wrapped_app_supports_lifespan = False
|
|
124
|
+
if response is not None and response["type"] != "lifespan.startup.complete":
|
|
125
|
+
await send(response)
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
await self._start_supervisor()
|
|
129
|
+
await send({"type": "lifespan.startup.complete"})
|
|
130
|
+
elif message["type"] == "lifespan.shutdown":
|
|
131
|
+
await self._stop_supervisor()
|
|
132
|
+
response = None
|
|
133
|
+
if wrapped_app_supports_lifespan:
|
|
134
|
+
response = await self._forward_lifespan_message(
|
|
135
|
+
app_task, receive_queue, send_queue, message
|
|
136
|
+
)
|
|
137
|
+
if response is None:
|
|
138
|
+
response = {"type": "lifespan.shutdown.complete"}
|
|
139
|
+
await send(response)
|
|
140
|
+
if wrapped_app_supports_lifespan:
|
|
141
|
+
await app_task
|
|
142
|
+
return
|
|
143
|
+
finally:
|
|
144
|
+
await self._stop_supervisor()
|
|
145
|
+
if not app_task.done():
|
|
146
|
+
app_task.cancel()
|
|
147
|
+
with suppress(asyncio.CancelledError):
|
|
148
|
+
await app_task
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fcntl
|
|
2
|
+
import tempfile
|
|
3
|
+
import threading
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from dj_queue.runtime.errors import handle_thread_error
|
|
7
|
+
from dj_queue.runtime.supervisor import AsyncSupervisor
|
|
8
|
+
|
|
9
|
+
LOCK_PATH = Path(tempfile.gettempdir()) / "dj_queue_gunicorn_supervisor.lock"
|
|
10
|
+
LOCK_RETRY_INTERVAL = 1.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_supervisor(backend_alias="default"):
|
|
14
|
+
return AsyncSupervisor.from_backend_config(backend_alias=backend_alias, standalone=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _set_supervisor_state(worker, **state):
|
|
18
|
+
for name, value in state.items():
|
|
19
|
+
setattr(worker, f"_dj_queue_{name}", value)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _start_embedded_supervisor(worker, *, backend_alias="default"):
|
|
23
|
+
lock_file = _try_acquire_supervisor_lock()
|
|
24
|
+
if lock_file is None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
supervisor = build_supervisor(backend_alias=backend_alias)
|
|
29
|
+
poll_stop = threading.Event()
|
|
30
|
+
_set_supervisor_state(
|
|
31
|
+
worker,
|
|
32
|
+
supervisor_lock=lock_file,
|
|
33
|
+
supervisor=supervisor,
|
|
34
|
+
supervisor_poll_stop=poll_stop,
|
|
35
|
+
)
|
|
36
|
+
supervisor.start()
|
|
37
|
+
except Exception:
|
|
38
|
+
_release_supervisor_lock(lock_file)
|
|
39
|
+
_set_supervisor_state(
|
|
40
|
+
worker,
|
|
41
|
+
supervisor_lock=None,
|
|
42
|
+
supervisor=None,
|
|
43
|
+
supervisor_poll_stop=None,
|
|
44
|
+
supervisor_poll_thread=None,
|
|
45
|
+
)
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
def poll_supervisor():
|
|
49
|
+
stop_event = worker._dj_queue_supervisor_poll_stop
|
|
50
|
+
while stop_event.wait(supervisor.polling_interval) is False:
|
|
51
|
+
try:
|
|
52
|
+
supervisor.poll_once()
|
|
53
|
+
except Exception as error:
|
|
54
|
+
handle_thread_error(
|
|
55
|
+
error,
|
|
56
|
+
context="supervisor.run",
|
|
57
|
+
backend_alias=supervisor.backend_alias,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
poll_thread = threading.Thread(target=poll_supervisor, daemon=True)
|
|
61
|
+
worker._dj_queue_supervisor_poll_thread = poll_thread
|
|
62
|
+
poll_thread.start()
|
|
63
|
+
return supervisor
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _start_lock_retry_loop(worker, *, backend_alias="default"):
|
|
67
|
+
retry_stop = threading.Event()
|
|
68
|
+
|
|
69
|
+
def retry():
|
|
70
|
+
while retry_stop.wait(LOCK_RETRY_INTERVAL) is False:
|
|
71
|
+
if getattr(worker, "_dj_queue_supervisor", None) is not None:
|
|
72
|
+
return
|
|
73
|
+
if _start_embedded_supervisor(worker, backend_alias=backend_alias) is not None:
|
|
74
|
+
retry_stop.set()
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
retry_thread = threading.Thread(target=retry, daemon=True)
|
|
78
|
+
_set_supervisor_state(
|
|
79
|
+
worker, supervisor_retry_stop=retry_stop, supervisor_retry_thread=retry_thread
|
|
80
|
+
)
|
|
81
|
+
retry_thread.start()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def post_fork(_server, worker):
|
|
85
|
+
_set_supervisor_state(
|
|
86
|
+
worker,
|
|
87
|
+
supervisor=None,
|
|
88
|
+
supervisor_lock=None,
|
|
89
|
+
supervisor_poll_stop=None,
|
|
90
|
+
supervisor_poll_thread=None,
|
|
91
|
+
supervisor_retry_stop=None,
|
|
92
|
+
supervisor_retry_thread=None,
|
|
93
|
+
)
|
|
94
|
+
supervisor = _start_embedded_supervisor(worker)
|
|
95
|
+
if supervisor is not None:
|
|
96
|
+
return supervisor
|
|
97
|
+
|
|
98
|
+
_start_lock_retry_loop(worker)
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def worker_exit(_server, worker):
|
|
103
|
+
supervisor = getattr(worker, "_dj_queue_supervisor", None)
|
|
104
|
+
lock_file = getattr(worker, "_dj_queue_supervisor_lock", None)
|
|
105
|
+
stop_event = getattr(worker, "_dj_queue_supervisor_poll_stop", None)
|
|
106
|
+
poll_thread = getattr(worker, "_dj_queue_supervisor_poll_thread", None)
|
|
107
|
+
retry_stop = getattr(worker, "_dj_queue_supervisor_retry_stop", None)
|
|
108
|
+
retry_thread = getattr(worker, "_dj_queue_supervisor_retry_thread", None)
|
|
109
|
+
|
|
110
|
+
if retry_stop is not None:
|
|
111
|
+
retry_stop.set()
|
|
112
|
+
if retry_thread is not None:
|
|
113
|
+
retry_thread.join(timeout=1)
|
|
114
|
+
worker._dj_queue_supervisor_retry_thread = None
|
|
115
|
+
worker._dj_queue_supervisor_retry_stop = None
|
|
116
|
+
|
|
117
|
+
if stop_event is not None:
|
|
118
|
+
stop_event.set()
|
|
119
|
+
if poll_thread is not None:
|
|
120
|
+
poll_thread.join()
|
|
121
|
+
worker._dj_queue_supervisor_poll_thread = None
|
|
122
|
+
worker._dj_queue_supervisor_poll_stop = None
|
|
123
|
+
if supervisor is None:
|
|
124
|
+
if lock_file is not None:
|
|
125
|
+
_release_supervisor_lock(lock_file)
|
|
126
|
+
worker._dj_queue_supervisor_lock = None
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
supervisor.stop()
|
|
130
|
+
worker._dj_queue_supervisor = None
|
|
131
|
+
_release_supervisor_lock(lock_file)
|
|
132
|
+
worker._dj_queue_supervisor_lock = None
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _try_acquire_supervisor_lock():
|
|
137
|
+
LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
lock_file = LOCK_PATH.open("a+")
|
|
139
|
+
try:
|
|
140
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
141
|
+
except BlockingIOError:
|
|
142
|
+
lock_file.close()
|
|
143
|
+
return None
|
|
144
|
+
return lock_file
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _release_supervisor_lock(lock_file):
|
|
148
|
+
if lock_file is None:
|
|
149
|
+
return None
|
|
150
|
+
try:
|
|
151
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
152
|
+
finally:
|
|
153
|
+
lock_file.close()
|
|
154
|
+
return None
|