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
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."""
@@ -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)