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/config.py
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import warnings
|
|
4
|
+
from collections.abc import Mapping, Sequence
|
|
5
|
+
from dataclasses import asdict, dataclass, field, replace
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from croniter import croniter
|
|
12
|
+
from django.conf import settings
|
|
13
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
14
|
+
from django.utils.module_loading import import_string
|
|
15
|
+
|
|
16
|
+
DEFAULT_WORKER = {
|
|
17
|
+
"queues": "*",
|
|
18
|
+
"threads": 3,
|
|
19
|
+
"processes": 1,
|
|
20
|
+
"polling_interval": 0.1,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
DEFAULT_DISPATCHER = {
|
|
24
|
+
"batch_size": 500,
|
|
25
|
+
"polling_interval": 1,
|
|
26
|
+
"concurrency_maintenance": True,
|
|
27
|
+
"concurrency_maintenance_interval": 600,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
DEFAULT_SCHEDULER = {
|
|
31
|
+
"dynamic_tasks_enabled": False,
|
|
32
|
+
"polling_interval": 5,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
DEFAULT_OPTIONS = {
|
|
36
|
+
"mode": "fork",
|
|
37
|
+
"workers": [DEFAULT_WORKER],
|
|
38
|
+
"dispatchers": [DEFAULT_DISPATCHER],
|
|
39
|
+
"scheduler": DEFAULT_SCHEDULER,
|
|
40
|
+
"recurring": {},
|
|
41
|
+
"process_heartbeat_interval": 60,
|
|
42
|
+
"process_alive_threshold": 300,
|
|
43
|
+
"shutdown_timeout": 5,
|
|
44
|
+
"supervisor_pidfile": None,
|
|
45
|
+
"preserve_finished_jobs": True,
|
|
46
|
+
"clear_finished_jobs_after": 86400,
|
|
47
|
+
"default_concurrency_duration": 180,
|
|
48
|
+
"database_alias": "default",
|
|
49
|
+
"use_skip_locked": True,
|
|
50
|
+
"listen_notify": True,
|
|
51
|
+
"silence_polling": True,
|
|
52
|
+
"on_thread_error": None,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"}
|
|
56
|
+
FALSY_ENV_VALUES = {"0", "false", "no", "off"}
|
|
57
|
+
CONFIG_ENV_KEYS = ("DJ_QUEUE_CONFIG", "DJ_QUEUE_MODE", "DJ_QUEUE_SKIP_RECURRING")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class ConfigValue:
|
|
62
|
+
def as_dict(self) -> dict[str, Any]:
|
|
63
|
+
return asdict(self)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True)
|
|
67
|
+
class WorkerConfig(ConfigValue):
|
|
68
|
+
queues: tuple[str, ...] = ("*",)
|
|
69
|
+
threads: int = 3
|
|
70
|
+
processes: int = 1
|
|
71
|
+
polling_interval: float = 0.1
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class DispatcherConfig(ConfigValue):
|
|
76
|
+
batch_size: int = 500
|
|
77
|
+
polling_interval: float = 1
|
|
78
|
+
concurrency_maintenance: bool = True
|
|
79
|
+
concurrency_maintenance_interval: int = 600
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class SchedulerConfig(ConfigValue):
|
|
84
|
+
dynamic_tasks_enabled: bool = False
|
|
85
|
+
polling_interval: float = 5
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class RecurringTaskConfig(ConfigValue):
|
|
90
|
+
key: str
|
|
91
|
+
task_path: str
|
|
92
|
+
schedule: str
|
|
93
|
+
args: tuple[Any, ...] = ()
|
|
94
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
95
|
+
queue_name: str = "default"
|
|
96
|
+
priority: int = 0
|
|
97
|
+
description: str = ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class BackendConfig(ConfigValue):
|
|
102
|
+
backend_alias: str = "default"
|
|
103
|
+
allowed_queues: tuple[str, ...] = ()
|
|
104
|
+
mode: str = "fork"
|
|
105
|
+
workers: tuple[WorkerConfig, ...] = (WorkerConfig(),)
|
|
106
|
+
dispatchers: tuple[DispatcherConfig, ...] = (DispatcherConfig(),)
|
|
107
|
+
scheduler: SchedulerConfig | None = field(default_factory=SchedulerConfig)
|
|
108
|
+
recurring: dict[str, RecurringTaskConfig] = field(default_factory=dict)
|
|
109
|
+
process_heartbeat_interval: int = 60
|
|
110
|
+
process_alive_threshold: int = 300
|
|
111
|
+
shutdown_timeout: int = 5
|
|
112
|
+
supervisor_pidfile: str | None = None
|
|
113
|
+
preserve_finished_jobs: bool = True
|
|
114
|
+
clear_finished_jobs_after: int | None = 86400
|
|
115
|
+
default_concurrency_duration: int = 180
|
|
116
|
+
database_alias: str = "default"
|
|
117
|
+
use_skip_locked: bool = True
|
|
118
|
+
listen_notify: bool = True
|
|
119
|
+
silence_polling: bool = True
|
|
120
|
+
on_thread_error: str | None = None
|
|
121
|
+
skip_recurring: bool = False
|
|
122
|
+
only_work: bool = False
|
|
123
|
+
only_dispatch: bool = False
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def has_scheduler_work(self) -> bool:
|
|
127
|
+
return self.scheduler is not None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_backend_config(
|
|
131
|
+
backend_alias: str = "default",
|
|
132
|
+
*,
|
|
133
|
+
cli_overrides: Mapping[str, Any] | None = None,
|
|
134
|
+
env: Mapping[str, str] | None = None,
|
|
135
|
+
tasks_settings: Mapping[str, Any] | None = None,
|
|
136
|
+
) -> BackendConfig:
|
|
137
|
+
if cli_overrides is None:
|
|
138
|
+
cli_overrides = {}
|
|
139
|
+
if env is None:
|
|
140
|
+
env = os.environ
|
|
141
|
+
if tasks_settings is None:
|
|
142
|
+
tasks_settings = getattr(settings, "TASKS", {})
|
|
143
|
+
|
|
144
|
+
return _load_backend_config_cached(
|
|
145
|
+
backend_alias,
|
|
146
|
+
_cache_key(cli_overrides),
|
|
147
|
+
_cache_key({key: env.get(key) for key in CONFIG_ENV_KEYS if env.get(key) is not None}),
|
|
148
|
+
_cache_key(tasks_settings),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@lru_cache(maxsize=None)
|
|
153
|
+
def _load_backend_config_cached(
|
|
154
|
+
backend_alias: str,
|
|
155
|
+
cli_overrides_key: str,
|
|
156
|
+
env_key: str,
|
|
157
|
+
tasks_settings_key: str,
|
|
158
|
+
) -> BackendConfig:
|
|
159
|
+
cli_overrides = json.loads(cli_overrides_key)
|
|
160
|
+
env = json.loads(env_key)
|
|
161
|
+
tasks_settings = json.loads(tasks_settings_key)
|
|
162
|
+
backend_block = _backend_block(tasks_settings, backend_alias)
|
|
163
|
+
resolved_options = _resolved_options(backend_block, cli_overrides, env)
|
|
164
|
+
|
|
165
|
+
mode = resolved_options["mode"]
|
|
166
|
+
if mode not in {"fork", "async"}:
|
|
167
|
+
raise ImproperlyConfigured(f"dj_queue mode must be 'fork' or 'async', got {mode!r}")
|
|
168
|
+
|
|
169
|
+
only_work = bool(cli_overrides.get("only_work", False))
|
|
170
|
+
only_dispatch = bool(cli_overrides.get("only_dispatch", False))
|
|
171
|
+
if only_work and only_dispatch:
|
|
172
|
+
raise ImproperlyConfigured("--only-work and --only-dispatch cannot be combined")
|
|
173
|
+
|
|
174
|
+
skip_recurring = _resolve_skip_recurring(cli_overrides, env)
|
|
175
|
+
on_thread_error = _validated_callback_path(resolved_options.get("on_thread_error"))
|
|
176
|
+
recurring = _build_recurring_config(resolved_options.get("recurring", {}))
|
|
177
|
+
scheduler = _build_scheduler_config(resolved_options.get("scheduler", DEFAULT_SCHEDULER))
|
|
178
|
+
workers = _build_worker_configs(resolved_options.get("workers", []), mode)
|
|
179
|
+
dispatchers = _build_dispatcher_configs(resolved_options.get("dispatchers", []))
|
|
180
|
+
|
|
181
|
+
if only_work:
|
|
182
|
+
dispatchers = ()
|
|
183
|
+
scheduler = None
|
|
184
|
+
elif only_dispatch:
|
|
185
|
+
workers = ()
|
|
186
|
+
scheduler = None
|
|
187
|
+
elif skip_recurring or not _scheduler_has_work(
|
|
188
|
+
scheduler,
|
|
189
|
+
recurring,
|
|
190
|
+
preserve_finished_jobs=bool(resolved_options["preserve_finished_jobs"]),
|
|
191
|
+
clear_finished_jobs_after=resolved_options["clear_finished_jobs_after"],
|
|
192
|
+
):
|
|
193
|
+
scheduler = None
|
|
194
|
+
|
|
195
|
+
if not workers and not dispatchers and scheduler is None:
|
|
196
|
+
raise ImproperlyConfigured(
|
|
197
|
+
"dj_queue requires at least one worker, dispatcher, or scheduler workload"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return BackendConfig(
|
|
201
|
+
backend_alias=backend_alias,
|
|
202
|
+
allowed_queues=_as_string_tuple(backend_block.get("QUEUES", [])),
|
|
203
|
+
mode=mode,
|
|
204
|
+
workers=workers,
|
|
205
|
+
dispatchers=dispatchers,
|
|
206
|
+
scheduler=scheduler,
|
|
207
|
+
recurring=recurring,
|
|
208
|
+
process_heartbeat_interval=int(resolved_options["process_heartbeat_interval"]),
|
|
209
|
+
process_alive_threshold=int(resolved_options["process_alive_threshold"]),
|
|
210
|
+
shutdown_timeout=int(resolved_options["shutdown_timeout"]),
|
|
211
|
+
supervisor_pidfile=resolved_options["supervisor_pidfile"],
|
|
212
|
+
preserve_finished_jobs=bool(resolved_options["preserve_finished_jobs"]),
|
|
213
|
+
clear_finished_jobs_after=_optional_int(resolved_options["clear_finished_jobs_after"]),
|
|
214
|
+
default_concurrency_duration=int(resolved_options["default_concurrency_duration"]),
|
|
215
|
+
database_alias=str(resolved_options["database_alias"]),
|
|
216
|
+
use_skip_locked=bool(resolved_options["use_skip_locked"]),
|
|
217
|
+
listen_notify=bool(resolved_options["listen_notify"]),
|
|
218
|
+
silence_polling=bool(resolved_options["silence_polling"]),
|
|
219
|
+
on_thread_error=on_thread_error,
|
|
220
|
+
skip_recurring=skip_recurring,
|
|
221
|
+
only_work=only_work,
|
|
222
|
+
only_dispatch=only_dispatch,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _backend_block(
|
|
227
|
+
tasks_settings: Mapping[str, Any] | None,
|
|
228
|
+
backend_alias: str,
|
|
229
|
+
) -> Mapping[str, Any]:
|
|
230
|
+
resolved_tasks_settings = tasks_settings
|
|
231
|
+
if resolved_tasks_settings is None:
|
|
232
|
+
resolved_tasks_settings = getattr(settings, "TASKS", {})
|
|
233
|
+
|
|
234
|
+
backend_block = resolved_tasks_settings.get(backend_alias, {})
|
|
235
|
+
if not isinstance(backend_block, Mapping):
|
|
236
|
+
raise ImproperlyConfigured(f"TASKS[{backend_alias!r}] must be a mapping")
|
|
237
|
+
return backend_block
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _resolved_options(
|
|
241
|
+
backend_block: Mapping[str, Any],
|
|
242
|
+
cli_overrides: Mapping[str, Any],
|
|
243
|
+
env: Mapping[str, str],
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
settings_options = backend_block.get("OPTIONS", {})
|
|
246
|
+
if not isinstance(settings_options, Mapping):
|
|
247
|
+
raise ImproperlyConfigured("TASKS backend OPTIONS must be a mapping")
|
|
248
|
+
|
|
249
|
+
resolved_options = dict(DEFAULT_OPTIONS)
|
|
250
|
+
resolved_options.update(settings_options)
|
|
251
|
+
|
|
252
|
+
config_path = cli_overrides.get("config") or env.get("DJ_QUEUE_CONFIG")
|
|
253
|
+
resolved_options.update(_load_yaml_options(config_path))
|
|
254
|
+
|
|
255
|
+
env_mode = env.get("DJ_QUEUE_MODE")
|
|
256
|
+
if env_mode is not None:
|
|
257
|
+
resolved_options["mode"] = env_mode
|
|
258
|
+
|
|
259
|
+
cli_mode = cli_overrides.get("mode")
|
|
260
|
+
if cli_mode is not None:
|
|
261
|
+
resolved_options["mode"] = cli_mode
|
|
262
|
+
|
|
263
|
+
return resolved_options
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _load_yaml_options(config_path: Any) -> dict[str, Any]:
|
|
267
|
+
if not config_path:
|
|
268
|
+
return {}
|
|
269
|
+
|
|
270
|
+
config_payload = yaml.safe_load(Path(config_path).read_text(encoding="utf-8"))
|
|
271
|
+
if config_payload is None:
|
|
272
|
+
return {}
|
|
273
|
+
if not isinstance(config_payload, dict):
|
|
274
|
+
raise ImproperlyConfigured("DJ_QUEUE_CONFIG must point to a YAML mapping")
|
|
275
|
+
return config_payload
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _resolve_skip_recurring(
|
|
279
|
+
cli_overrides: Mapping[str, Any],
|
|
280
|
+
env: Mapping[str, str],
|
|
281
|
+
) -> bool:
|
|
282
|
+
if "skip_recurring" in cli_overrides:
|
|
283
|
+
return bool(cli_overrides["skip_recurring"])
|
|
284
|
+
|
|
285
|
+
value = env.get("DJ_QUEUE_SKIP_RECURRING")
|
|
286
|
+
if value is None:
|
|
287
|
+
return False
|
|
288
|
+
return _parse_bool(value, "DJ_QUEUE_SKIP_RECURRING")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_bool(value: str, setting_name: str) -> bool:
|
|
292
|
+
normalized = value.strip().lower()
|
|
293
|
+
if normalized in TRUTHY_ENV_VALUES:
|
|
294
|
+
return True
|
|
295
|
+
if normalized in FALSY_ENV_VALUES:
|
|
296
|
+
return False
|
|
297
|
+
raise ImproperlyConfigured(
|
|
298
|
+
f"{setting_name} must be one of {sorted(TRUTHY_ENV_VALUES | FALSY_ENV_VALUES)}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _validated_callback_path(callback_path: Any) -> str | None:
|
|
303
|
+
if callback_path in (None, ""):
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
callback_path = str(callback_path)
|
|
307
|
+
try:
|
|
308
|
+
import_string(callback_path)
|
|
309
|
+
except ImportError as exc:
|
|
310
|
+
raise ImproperlyConfigured(
|
|
311
|
+
f"dj_queue on_thread_error must be importable: {callback_path}"
|
|
312
|
+
) from exc
|
|
313
|
+
return callback_path
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _build_worker_configs(raw_workers: Any, mode: str) -> tuple[WorkerConfig, ...]:
|
|
317
|
+
workers: list[WorkerConfig] = []
|
|
318
|
+
for raw_worker in raw_workers or []:
|
|
319
|
+
if not isinstance(raw_worker, Mapping):
|
|
320
|
+
raise ImproperlyConfigured("worker entries must be mappings")
|
|
321
|
+
|
|
322
|
+
worker = WorkerConfig(
|
|
323
|
+
queues=_as_queue_selectors(raw_worker.get("queues", DEFAULT_WORKER["queues"])),
|
|
324
|
+
threads=int(raw_worker.get("threads", DEFAULT_WORKER["threads"])),
|
|
325
|
+
processes=int(raw_worker.get("processes", DEFAULT_WORKER["processes"])),
|
|
326
|
+
polling_interval=float(
|
|
327
|
+
raw_worker.get("polling_interval", DEFAULT_WORKER["polling_interval"])
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if mode == "async" and worker.processes > 1:
|
|
332
|
+
warnings.warn(
|
|
333
|
+
"dj_queue async mode ignores worker processes > 1; normalizing to 1",
|
|
334
|
+
UserWarning,
|
|
335
|
+
stacklevel=3,
|
|
336
|
+
)
|
|
337
|
+
worker = replace(worker, processes=1)
|
|
338
|
+
|
|
339
|
+
workers.append(worker)
|
|
340
|
+
return tuple(workers)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _build_dispatcher_configs(raw_dispatchers: Any) -> tuple[DispatcherConfig, ...]:
|
|
344
|
+
dispatchers: list[DispatcherConfig] = []
|
|
345
|
+
for raw_dispatcher in raw_dispatchers or []:
|
|
346
|
+
if not isinstance(raw_dispatcher, Mapping):
|
|
347
|
+
raise ImproperlyConfigured("dispatcher entries must be mappings")
|
|
348
|
+
|
|
349
|
+
dispatchers.append(
|
|
350
|
+
DispatcherConfig(
|
|
351
|
+
batch_size=int(raw_dispatcher.get("batch_size", DEFAULT_DISPATCHER["batch_size"])),
|
|
352
|
+
polling_interval=float(
|
|
353
|
+
raw_dispatcher.get("polling_interval", DEFAULT_DISPATCHER["polling_interval"])
|
|
354
|
+
),
|
|
355
|
+
concurrency_maintenance=bool(
|
|
356
|
+
raw_dispatcher.get(
|
|
357
|
+
"concurrency_maintenance",
|
|
358
|
+
DEFAULT_DISPATCHER["concurrency_maintenance"],
|
|
359
|
+
)
|
|
360
|
+
),
|
|
361
|
+
concurrency_maintenance_interval=int(
|
|
362
|
+
raw_dispatcher.get(
|
|
363
|
+
"concurrency_maintenance_interval",
|
|
364
|
+
DEFAULT_DISPATCHER["concurrency_maintenance_interval"],
|
|
365
|
+
)
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
return tuple(dispatchers)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _build_scheduler_config(raw_scheduler: Any) -> SchedulerConfig:
|
|
373
|
+
if raw_scheduler is None:
|
|
374
|
+
raw_scheduler = {}
|
|
375
|
+
if not isinstance(raw_scheduler, Mapping):
|
|
376
|
+
raise ImproperlyConfigured("scheduler config must be a mapping")
|
|
377
|
+
|
|
378
|
+
return SchedulerConfig(
|
|
379
|
+
dynamic_tasks_enabled=bool(
|
|
380
|
+
raw_scheduler.get(
|
|
381
|
+
"dynamic_tasks_enabled",
|
|
382
|
+
DEFAULT_SCHEDULER["dynamic_tasks_enabled"],
|
|
383
|
+
)
|
|
384
|
+
),
|
|
385
|
+
polling_interval=float(
|
|
386
|
+
raw_scheduler.get("polling_interval", DEFAULT_SCHEDULER["polling_interval"])
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _build_recurring_config(raw_recurring: Any) -> dict[str, RecurringTaskConfig]:
|
|
392
|
+
if raw_recurring is None:
|
|
393
|
+
return {}
|
|
394
|
+
if not isinstance(raw_recurring, Mapping):
|
|
395
|
+
raise ImproperlyConfigured("recurring config must be a mapping")
|
|
396
|
+
|
|
397
|
+
recurring: dict[str, RecurringTaskConfig] = {}
|
|
398
|
+
for key, raw_entry in raw_recurring.items():
|
|
399
|
+
if not isinstance(raw_entry, Mapping):
|
|
400
|
+
raise ImproperlyConfigured("recurring entries must be mappings")
|
|
401
|
+
|
|
402
|
+
task_path = raw_entry.get("task_path")
|
|
403
|
+
schedule = raw_entry.get("schedule")
|
|
404
|
+
if not task_path or not schedule:
|
|
405
|
+
raise ImproperlyConfigured(f"recurring task {key!r} requires task_path and schedule")
|
|
406
|
+
if not croniter.is_valid(str(schedule)):
|
|
407
|
+
raise ImproperlyConfigured(f"recurring task {key!r} has an invalid cron schedule")
|
|
408
|
+
|
|
409
|
+
recurring[str(key)] = RecurringTaskConfig(
|
|
410
|
+
key=str(key),
|
|
411
|
+
task_path=str(task_path),
|
|
412
|
+
schedule=str(schedule),
|
|
413
|
+
args=tuple(raw_entry.get("args", [])),
|
|
414
|
+
kwargs=dict(raw_entry.get("kwargs", {})),
|
|
415
|
+
queue_name=str(raw_entry.get("queue_name", "default")),
|
|
416
|
+
priority=int(raw_entry.get("priority", 0)),
|
|
417
|
+
description=str(raw_entry.get("description", "")),
|
|
418
|
+
)
|
|
419
|
+
return recurring
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _scheduler_has_work(
|
|
423
|
+
scheduler: SchedulerConfig,
|
|
424
|
+
recurring: Mapping[str, RecurringTaskConfig],
|
|
425
|
+
*,
|
|
426
|
+
preserve_finished_jobs: bool,
|
|
427
|
+
clear_finished_jobs_after: Any,
|
|
428
|
+
) -> bool:
|
|
429
|
+
has_cleanup = preserve_finished_jobs and clear_finished_jobs_after is not None
|
|
430
|
+
return scheduler.dynamic_tasks_enabled or bool(recurring) or has_cleanup
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _as_queue_selectors(value: Any) -> tuple[str, ...]:
|
|
434
|
+
if isinstance(value, str):
|
|
435
|
+
return (value,)
|
|
436
|
+
return _as_string_tuple(value)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _as_string_tuple(value: Any) -> tuple[str, ...]:
|
|
440
|
+
if value in (None, []):
|
|
441
|
+
return ()
|
|
442
|
+
if isinstance(value, str):
|
|
443
|
+
return (value,)
|
|
444
|
+
if not isinstance(value, Sequence):
|
|
445
|
+
raise ImproperlyConfigured("expected a string or a sequence of strings")
|
|
446
|
+
return tuple(str(item) for item in value)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _optional_int(value: Any) -> int | None:
|
|
450
|
+
if value is None:
|
|
451
|
+
return None
|
|
452
|
+
return int(value)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _cache_key(value: Any) -> str:
|
|
456
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Server integrations for dj_queue."""
|
dj_queue/contrib/asgi.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
from dj_queue.runtime.supervisor import AsyncSupervisor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_supervisor(backend_alias="default"):
|
|
7
|
+
return AsyncSupervisor.from_backend_config(backend_alias=backend_alias, standalone=False)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DjQueueLifespan:
|
|
11
|
+
def __init__(self, app, *, backend_alias="default"):
|
|
12
|
+
self.app = app
|
|
13
|
+
self.backend_alias = backend_alias
|
|
14
|
+
self.supervisor = None
|
|
15
|
+
|
|
16
|
+
async def __call__(self, scope, receive, send):
|
|
17
|
+
if scope["type"] != "lifespan":
|
|
18
|
+
await self.app(scope, receive, send)
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
while True:
|
|
22
|
+
message = await receive()
|
|
23
|
+
if message["type"] == "lifespan.startup":
|
|
24
|
+
self.supervisor = build_supervisor(self.backend_alias)
|
|
25
|
+
await asyncio.to_thread(self.supervisor.start)
|
|
26
|
+
await send({"type": "lifespan.startup.complete"})
|
|
27
|
+
elif message["type"] == "lifespan.shutdown":
|
|
28
|
+
if self.supervisor is not None:
|
|
29
|
+
await asyncio.to_thread(self.supervisor.stop)
|
|
30
|
+
self.supervisor = None
|
|
31
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
32
|
+
return
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from dj_queue.runtime.supervisor import AsyncSupervisor
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def build_supervisor(backend_alias="default"):
|
|
5
|
+
return AsyncSupervisor.from_backend_config(backend_alias=backend_alias, standalone=False)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def post_fork(_server, worker):
|
|
9
|
+
if worker.age != 1:
|
|
10
|
+
return None
|
|
11
|
+
|
|
12
|
+
supervisor = build_supervisor()
|
|
13
|
+
worker._dj_queue_supervisor = supervisor
|
|
14
|
+
supervisor.start()
|
|
15
|
+
return supervisor
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def worker_exit(_server, worker):
|
|
19
|
+
supervisor = getattr(worker, "_dj_queue_supervisor", None)
|
|
20
|
+
if supervisor is None:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
supervisor.stop()
|
|
24
|
+
worker._dj_queue_supervisor = None
|
|
25
|
+
return None
|
dj_queue/db.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from collections.abc import Iterator
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from django.db import DEFAULT_DB_ALIAS, connections
|
|
7
|
+
|
|
8
|
+
from dj_queue.config import load_backend_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class DatabaseCapabilities:
|
|
13
|
+
backend_family: Literal["postgresql", "mysql", "mariadb", "sqlite"]
|
|
14
|
+
supports_skip_locked: bool
|
|
15
|
+
supports_listen_notify: bool
|
|
16
|
+
uses_serialized_writes: bool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_database_alias(backend_alias: str = "default") -> str:
|
|
20
|
+
return load_backend_config(backend_alias).database_alias
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def locked_queryset(qs, use_skip_locked: bool = True):
|
|
24
|
+
alias = getattr(qs, "db", DEFAULT_DB_ALIAS)
|
|
25
|
+
if use_skip_locked and supports_skip_locked(alias):
|
|
26
|
+
return qs.select_for_update(skip_locked=True)
|
|
27
|
+
return qs.select_for_update()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def database_capabilities(alias: str) -> DatabaseCapabilities:
|
|
31
|
+
connection = connections[alias]
|
|
32
|
+
backend_family = _backend_family(connection)
|
|
33
|
+
supports_skip_locked_flag = bool(connection.features.has_select_for_update_skip_locked)
|
|
34
|
+
|
|
35
|
+
return DatabaseCapabilities(
|
|
36
|
+
backend_family=backend_family,
|
|
37
|
+
supports_skip_locked=supports_skip_locked_flag,
|
|
38
|
+
supports_listen_notify=backend_family == "postgresql",
|
|
39
|
+
uses_serialized_writes=backend_family == "sqlite",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def supports_skip_locked(alias: str) -> bool:
|
|
44
|
+
return database_capabilities(alias).supports_skip_locked
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def supports_listen_notify(alias: str) -> bool:
|
|
48
|
+
return database_capabilities(alias).supports_listen_notify
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_queue_connection(backend_alias: str = "default"):
|
|
52
|
+
return connections[get_database_alias(backend_alias)]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def queue_cursor(backend_alias: str = "default") -> Iterator:
|
|
57
|
+
with get_queue_connection(backend_alias).cursor() as cursor:
|
|
58
|
+
yield cursor
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _backend_family(connection) -> Literal["postgresql", "mysql", "mariadb", "sqlite"]:
|
|
62
|
+
if connection.vendor == "postgresql":
|
|
63
|
+
return "postgresql"
|
|
64
|
+
if connection.vendor == "sqlite":
|
|
65
|
+
return "sqlite"
|
|
66
|
+
if connection.vendor == "mysql" and getattr(connection, "mysql_is_mariadb", False):
|
|
67
|
+
return "mariadb"
|
|
68
|
+
return "mysql"
|
dj_queue/exceptions.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class DjQueueError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class EnqueueError(DjQueueError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UndiscardableError(DjQueueError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AlreadyRecorded(DjQueueError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProcessExitError(DjQueueError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProcessMissingError(DjQueueError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProcessPrunedError(DjQueueError):
|
|
26
|
+
pass
|
dj_queue/hooks.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from dj_queue.runtime.errors import handle_thread_error
|
|
6
|
+
|
|
7
|
+
_hooks: dict[str, list[Callable[..., Any]]] = defaultdict(list)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_hook(event: str, fn: Callable[..., Any] | None = None):
|
|
11
|
+
if fn is not None:
|
|
12
|
+
_hooks[event].append(fn)
|
|
13
|
+
return fn
|
|
14
|
+
|
|
15
|
+
def decorator(callback: Callable[..., Any]):
|
|
16
|
+
_hooks[event].append(callback)
|
|
17
|
+
return callback
|
|
18
|
+
|
|
19
|
+
return decorator
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def on_start(fn: Callable[..., Any]):
|
|
23
|
+
return register_hook("supervisor.start", fn)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def on_stop(fn: Callable[..., Any]):
|
|
27
|
+
return register_hook("supervisor.stop", fn)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def on_exit(fn: Callable[..., Any]):
|
|
31
|
+
return register_hook("supervisor.exit", fn)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def on_worker_start(fn: Callable[..., Any]):
|
|
35
|
+
return register_hook("worker.start", fn)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def on_worker_stop(fn: Callable[..., Any]):
|
|
39
|
+
return register_hook("worker.stop", fn)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def on_worker_exit(fn: Callable[..., Any]):
|
|
43
|
+
return register_hook("worker.exit", fn)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def on_dispatcher_start(fn: Callable[..., Any]):
|
|
47
|
+
return register_hook("dispatcher.start", fn)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def on_dispatcher_stop(fn: Callable[..., Any]):
|
|
51
|
+
return register_hook("dispatcher.stop", fn)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def on_dispatcher_exit(fn: Callable[..., Any]):
|
|
55
|
+
return register_hook("dispatcher.exit", fn)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def on_scheduler_start(fn: Callable[..., Any]):
|
|
59
|
+
return register_hook("scheduler.start", fn)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def on_scheduler_stop(fn: Callable[..., Any]):
|
|
63
|
+
return register_hook("scheduler.stop", fn)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def on_scheduler_exit(fn: Callable[..., Any]):
|
|
67
|
+
return register_hook("scheduler.exit", fn)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fire_hooks(event: str, process: Any, *, backend_alias: str = "default"):
|
|
71
|
+
for hook in tuple(_hooks.get(event, ())):
|
|
72
|
+
try:
|
|
73
|
+
hook(process)
|
|
74
|
+
except Exception as error:
|
|
75
|
+
handle_thread_error(
|
|
76
|
+
error,
|
|
77
|
+
context=f"hook:{event}",
|
|
78
|
+
backend_alias=backend_alias,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def clear_hooks(event: str | None = None):
|
|
83
|
+
if event is None:
|
|
84
|
+
_hooks.clear()
|
|
85
|
+
return
|
|
86
|
+
_hooks.pop(event, None)
|