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/routers.py ADDED
@@ -0,0 +1,26 @@
1
+ from dj_queue.db import get_database_alias
2
+
3
+
4
+ class DjQueueRouter:
5
+ app_label = "dj_queue"
6
+
7
+ def db_for_read(self, model, **hints):
8
+ if model._meta.app_label == self.app_label:
9
+ return get_database_alias()
10
+ return None
11
+
12
+ def db_for_write(self, model, **hints):
13
+ if model._meta.app_label == self.app_label:
14
+ return get_database_alias()
15
+ return None
16
+
17
+ def allow_relation(self, obj1, obj2, **hints):
18
+ labels = {obj1._meta.app_label, obj2._meta.app_label}
19
+ if self.app_label in labels:
20
+ return labels == {self.app_label}
21
+ return None
22
+
23
+ def allow_migrate(self, db, app_label, **hints):
24
+ if app_label == self.app_label:
25
+ return db == get_database_alias()
26
+ return None
@@ -0,0 +1 @@
1
+ """Runtime components for dj_queue."""
@@ -0,0 +1,198 @@
1
+ from contextlib import contextmanager
2
+ import threading
3
+
4
+ from django.db import close_old_connections
5
+ from django.utils import timezone
6
+
7
+ from dj_queue.config import load_backend_config
8
+ from dj_queue.db import get_database_alias
9
+ from dj_queue.hooks import fire_hooks
10
+ from dj_queue.models import Process
11
+ from dj_queue.runtime.errors import handle_thread_error
12
+ from dj_queue.runtime.interruptible import InterruptibleSleeper
13
+
14
+
15
+ @contextmanager
16
+ def app_executor():
17
+ close_old_connections()
18
+ try:
19
+ yield
20
+ finally:
21
+ close_old_connections()
22
+
23
+
24
+ class BaseRunner:
25
+ process_kind = "Runner"
26
+ hook_prefix = "runner"
27
+
28
+ def __init__(
29
+ self,
30
+ config,
31
+ *,
32
+ backend_alias="default",
33
+ name,
34
+ pid,
35
+ hostname,
36
+ sleeper=None,
37
+ heartbeat_interval=None,
38
+ supervisor=None,
39
+ ):
40
+ self.config = config
41
+ self.backend_alias = backend_alias
42
+ self.name = name
43
+ self.pid = pid
44
+ self.hostname = hostname
45
+ self.sleeper = sleeper or InterruptibleSleeper()
46
+ self.supervisor = supervisor
47
+ self.process = None
48
+ self._stop_event = threading.Event()
49
+ self._heartbeat_thread = None
50
+ if heartbeat_interval is None:
51
+ heartbeat_interval = load_backend_config(backend_alias).process_heartbeat_interval
52
+ self._heartbeat_interval = heartbeat_interval
53
+ self._started = False
54
+ self._stopped = False
55
+
56
+ @property
57
+ def polling_interval(self):
58
+ return getattr(self.config, "polling_interval", 0)
59
+
60
+ def start(self):
61
+ if self.process is None:
62
+ self.process = self._register_process()
63
+ self._start_heartbeat_thread()
64
+ fire_hooks(f"{self.hook_prefix}.start", self.process, backend_alias=self.backend_alias)
65
+ self._started = True
66
+ return self.process
67
+
68
+ def run(self):
69
+ self.start()
70
+ try:
71
+ while self.should_continue():
72
+ try:
73
+ self.poll_once()
74
+ except Exception as error:
75
+ handle_thread_error(
76
+ error,
77
+ context=f"{self.hook_prefix}.run",
78
+ backend_alias=self.backend_alias,
79
+ )
80
+ break
81
+
82
+ if not self.should_continue():
83
+ break
84
+ self.sleeper.sleep(self.polling_interval)
85
+ finally:
86
+ self.stop()
87
+ close_old_connections()
88
+
89
+ def stop(self):
90
+ process = self._begin_stop()
91
+ if process is None:
92
+ return None
93
+ self._finish_stop(process)
94
+ return None
95
+
96
+ def request_stop(self):
97
+ self._stop_event.set()
98
+ wake_up = getattr(self.sleeper, "wake_up", None)
99
+ if callable(wake_up):
100
+ wake_up()
101
+
102
+ def should_continue(self):
103
+ if self._stop_event.is_set():
104
+ return False
105
+ if self.process is None:
106
+ return False
107
+
108
+ alias = get_database_alias(self.backend_alias)
109
+ if Process.objects.using(alias).filter(pk=self.process.pk).exists():
110
+ return True
111
+
112
+ self.request_stop()
113
+ return False
114
+
115
+ def poll_once(self):
116
+ raise NotImplementedError
117
+
118
+ def process_metadata(self):
119
+ return {}
120
+
121
+ def _begin_stop(self):
122
+ if self._stopped:
123
+ return None
124
+
125
+ self._stopped = True
126
+ self.request_stop()
127
+ process = self.process
128
+ if process is not None and self._started:
129
+ fire_hooks(f"{self.hook_prefix}.stop", process, backend_alias=self.backend_alias)
130
+ self._stop_heartbeat_thread()
131
+ return process
132
+
133
+ def _finish_stop(self, process):
134
+ self._deregister_process()
135
+ if process is not None and self._started:
136
+ fire_hooks(f"{self.hook_prefix}.exit", process, backend_alias=self.backend_alias)
137
+
138
+ close = getattr(self.sleeper, "close", None)
139
+ if callable(close):
140
+ close()
141
+
142
+ def _register_process(self):
143
+ alias = get_database_alias(self.backend_alias)
144
+ return Process.objects.using(alias).create(
145
+ kind=self.process_kind,
146
+ pid=self.pid,
147
+ hostname=self.hostname,
148
+ name=self.name,
149
+ metadata=self.process_metadata(),
150
+ supervisor=self.supervisor,
151
+ last_heartbeat_at=timezone.now(),
152
+ )
153
+
154
+ def _deregister_process(self):
155
+ if self.process is None:
156
+ return
157
+
158
+ alias = get_database_alias(self.backend_alias)
159
+ Process.objects.using(alias).filter(pk=self.process.pk).delete()
160
+ self.process = None
161
+
162
+ def _start_heartbeat_thread(self):
163
+ if self._heartbeat_interval <= 0:
164
+ return
165
+ self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
166
+ self._heartbeat_thread.start()
167
+
168
+ def _stop_heartbeat_thread(self):
169
+ thread = self._heartbeat_thread
170
+ if thread is None:
171
+ return
172
+ thread.join(timeout=max(self._heartbeat_interval, 0.1) + 0.1)
173
+ self._heartbeat_thread = None
174
+
175
+ def _heartbeat_loop(self):
176
+ alias = get_database_alias(self.backend_alias)
177
+ while not self._stop_event.wait(self._heartbeat_interval):
178
+ if self.process is None:
179
+ return
180
+ try:
181
+ with app_executor():
182
+ updated = (
183
+ Process.objects.using(alias)
184
+ .filter(pk=self.process.pk)
185
+ .update(last_heartbeat_at=timezone.now())
186
+ )
187
+ except Exception as error:
188
+ handle_thread_error(
189
+ error,
190
+ context=f"{self.hook_prefix}.heartbeat",
191
+ backend_alias=self.backend_alias,
192
+ )
193
+ self.request_stop()
194
+ return
195
+
196
+ if updated == 0:
197
+ self.request_stop()
198
+ return
@@ -0,0 +1,78 @@
1
+ import os
2
+ import socket
3
+
4
+ from django.utils import timezone
5
+
6
+ from dj_queue.operations.concurrency import (
7
+ cleanup_expired_semaphores,
8
+ promote_expired_blocked_jobs,
9
+ )
10
+ from dj_queue.operations.jobs import promote_scheduled_jobs
11
+ from dj_queue.runtime.base import BaseRunner, app_executor
12
+
13
+
14
+ class Dispatcher(BaseRunner):
15
+ process_kind = "Dispatcher"
16
+ hook_prefix = "dispatcher"
17
+
18
+ def __init__(
19
+ self,
20
+ config,
21
+ *,
22
+ backend_alias="default",
23
+ name=None,
24
+ pid=None,
25
+ hostname=None,
26
+ sleeper=None,
27
+ heartbeat_interval=None,
28
+ supervisor=None,
29
+ ):
30
+ super().__init__(
31
+ config,
32
+ backend_alias=backend_alias,
33
+ name=name or f"dispatcher-{os.getpid()}",
34
+ pid=pid or os.getpid(),
35
+ hostname=hostname or socket.gethostname(),
36
+ sleeper=sleeper,
37
+ heartbeat_interval=heartbeat_interval,
38
+ supervisor=supervisor,
39
+ )
40
+ self._last_maintenance_at = None
41
+
42
+ def poll_once(self):
43
+ if self.process is None:
44
+ self.start()
45
+
46
+ with app_executor():
47
+ promoted_jobs = promote_scheduled_jobs(
48
+ batch_size=self.config.batch_size,
49
+ backend_alias=self.backend_alias,
50
+ )
51
+ if self._maintenance_due():
52
+ cleanup_expired_semaphores(backend_alias=self.backend_alias)
53
+ promote_expired_blocked_jobs(
54
+ batch_size=self.config.batch_size,
55
+ backend_alias=self.backend_alias,
56
+ )
57
+ self._last_maintenance_at = timezone.now()
58
+ return promoted_jobs
59
+
60
+ def stop(self):
61
+ return super().stop()
62
+
63
+ def process_metadata(self):
64
+ return {
65
+ "batch_size": self.config.batch_size,
66
+ "polling_interval": self.config.polling_interval,
67
+ "concurrency_maintenance": self.config.concurrency_maintenance,
68
+ "concurrency_maintenance_interval": self.config.concurrency_maintenance_interval,
69
+ }
70
+
71
+ def _maintenance_due(self):
72
+ if not self.config.concurrency_maintenance:
73
+ return False
74
+ if self._last_maintenance_at is None:
75
+ return True
76
+ return (
77
+ timezone.now() - self._last_maintenance_at
78
+ ).total_seconds() >= self.config.concurrency_maintenance_interval
@@ -0,0 +1,39 @@
1
+ import logging
2
+
3
+ from django.utils.module_loading import import_string
4
+
5
+ from dj_queue.config import load_backend_config
6
+
7
+ logger = logging.getLogger("dj_queue")
8
+
9
+
10
+ def handle_thread_error(error, *, context="", backend_alias="default"):
11
+ callback_path = load_backend_config(backend_alias).on_thread_error
12
+ if callback_path:
13
+ try:
14
+ callback = import_string(callback_path)
15
+ callback(error)
16
+ return
17
+ except Exception:
18
+ logger.exception(
19
+ "on_thread_error callback raised",
20
+ extra={
21
+ "event": "dj_queue.thread_error_callback_failed",
22
+ "backend_alias": backend_alias,
23
+ "thread_error_context": context,
24
+ "on_thread_error": callback_path,
25
+ "thread_error_type": error.__class__.__name__,
26
+ },
27
+ )
28
+ return
29
+
30
+ logger.error(
31
+ "dj_queue infrastructure error",
32
+ exc_info=(error.__class__, error, error.__traceback__),
33
+ extra={
34
+ "event": "dj_queue.thread_error",
35
+ "backend_alias": backend_alias,
36
+ "thread_error_context": context,
37
+ "thread_error_type": error.__class__.__name__,
38
+ },
39
+ )
@@ -0,0 +1,46 @@
1
+ import os
2
+ import select
3
+ import threading
4
+
5
+
6
+ class InterruptibleSleeper:
7
+ def __init__(self):
8
+ self._read_fd, self._write_fd = os.pipe()
9
+ os.set_blocking(self._read_fd, False)
10
+ os.set_blocking(self._write_fd, False)
11
+ self._closed = False
12
+ self._lock = threading.Lock()
13
+
14
+ def sleep(self, seconds):
15
+ if self._closed:
16
+ return
17
+
18
+ ready, _, _ = select.select([self._read_fd], [], [], max(seconds, 0))
19
+ if ready:
20
+ self._drain()
21
+
22
+ def wake_up(self):
23
+ with self._lock:
24
+ if self._closed:
25
+ return
26
+
27
+ try:
28
+ os.write(self._write_fd, b".")
29
+ except (BlockingIOError, OSError):
30
+ pass
31
+
32
+ def close(self):
33
+ with self._lock:
34
+ if self._closed:
35
+ return
36
+
37
+ self._closed = True
38
+ os.close(self._read_fd)
39
+ os.close(self._write_fd)
40
+
41
+ def _drain(self):
42
+ try:
43
+ while os.read(self._read_fd, 1024):
44
+ continue
45
+ except BlockingIOError:
46
+ return
@@ -0,0 +1,119 @@
1
+ import threading
2
+
3
+ from django.db import connections
4
+
5
+ from dj_queue.config import load_backend_config
6
+ from dj_queue.db import get_database_alias, supports_listen_notify
7
+ from dj_queue.runtime.errors import handle_thread_error
8
+
9
+ READY_CHANNEL = "dj_queue_ready"
10
+
11
+
12
+ class NoopWakeupBackend:
13
+ def start(self):
14
+ return None
15
+
16
+ def stop(self):
17
+ return None
18
+
19
+
20
+ class NotifyWakeupBackend:
21
+ def __init__(self, *, backend_alias, wake_up):
22
+ self.backend_alias = backend_alias
23
+ self.wake_up = wake_up
24
+ self.failed = False
25
+ self._connection = None
26
+ self._watcher = None
27
+ self._stop_event = threading.Event()
28
+
29
+ def start(self):
30
+ if self._watcher is not None:
31
+ return None
32
+
33
+ try:
34
+ self._connection = self._open_connection()
35
+ self._start_watcher()
36
+ except Exception as error:
37
+ self.failed = True
38
+ self._close_connection()
39
+ handle_thread_error(error, context="worker.notify", backend_alias=self.backend_alias)
40
+ return None
41
+
42
+ def stop(self):
43
+ self._stop_event.set()
44
+ if self._watcher is not None:
45
+ self._watcher.join(timeout=1)
46
+ self._watcher = None
47
+ self._close_connection()
48
+ return None
49
+
50
+ def _start_watcher(self):
51
+ self._watcher = threading.Thread(target=self._watch, daemon=True, name="dj-queue-notify")
52
+ self._watcher.start()
53
+
54
+ def _watch(self):
55
+ connection = self._connection
56
+ if connection is None:
57
+ return
58
+
59
+ while not self._stop_event.is_set():
60
+ try:
61
+ notifications = connection.notifies(timeout=0.5, stop_after=1)
62
+ except Exception as error:
63
+ self.failed = True
64
+ handle_thread_error(error, context="worker.notify", backend_alias=self.backend_alias)
65
+ return
66
+
67
+ for _notify in notifications:
68
+ self.wake_up()
69
+
70
+ def _open_connection(self):
71
+ alias = get_database_alias(self.backend_alias)
72
+ wrapper = connections[alias]
73
+ wrapper.ensure_connection()
74
+ connection = wrapper.Database.connect(**wrapper.get_connection_params())
75
+ connection.autocommit = True
76
+ with connection.cursor() as cursor:
77
+ cursor.execute(f"LISTEN {READY_CHANNEL}")
78
+ return connection
79
+
80
+ def _close_connection(self):
81
+ connection = self._connection
82
+ if connection is None:
83
+ return
84
+ self._connection = None
85
+ try:
86
+ connection.close()
87
+ except Exception:
88
+ return
89
+
90
+
91
+ def notify_ready_queues(queue_names, *, backend_alias="default"):
92
+ config = load_backend_config(backend_alias)
93
+ if not queue_names or not config.listen_notify:
94
+ return None
95
+
96
+ alias = get_database_alias(backend_alias)
97
+ if not supports_listen_notify(alias):
98
+ return None
99
+
100
+ for queue_name in queue_names:
101
+ _notify(READY_CHANNEL, queue_name, backend_alias=backend_alias)
102
+ return None
103
+
104
+
105
+ def build_wakeup_backend(*, backend_alias="default", queues=(), wake_up=None):
106
+ config = load_backend_config(backend_alias)
107
+ alias = get_database_alias(backend_alias)
108
+ if wake_up is None or not config.listen_notify or not supports_listen_notify(alias):
109
+ return NoopWakeupBackend()
110
+ return NotifyWakeupBackend(backend_alias=backend_alias, wake_up=wake_up)
111
+
112
+
113
+ def _notify(channel, payload, *, backend_alias):
114
+ try:
115
+ with connections[get_database_alias(backend_alias)].cursor() as cursor:
116
+ cursor.execute("SELECT pg_notify(%s, %s)", [channel, payload])
117
+ except Exception:
118
+ return None
119
+ return None
@@ -0,0 +1,39 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+
5
+ class PidFile:
6
+ def __init__(self, path, *, pid=None, probe=None):
7
+ self.path = Path(path)
8
+ self.pid = os.getpid() if pid is None else pid
9
+ self._probe = probe or _process_alive
10
+
11
+ def acquire(self):
12
+ if self.path.exists():
13
+ existing_pid = _read_pid(self.path)
14
+ if existing_pid is not None and self._probe(existing_pid):
15
+ raise RuntimeError(f"a dj_queue supervisor is already running (pid={existing_pid})")
16
+
17
+ self.path.parent.mkdir(parents=True, exist_ok=True)
18
+ self.path.write_text(str(self.pid))
19
+
20
+ def release(self):
21
+ try:
22
+ self.path.unlink(missing_ok=True)
23
+ except OSError:
24
+ pass
25
+
26
+
27
+ def _read_pid(path):
28
+ try:
29
+ return int(path.read_text().strip())
30
+ except (OSError, TypeError, ValueError):
31
+ return None
32
+
33
+
34
+ def _process_alive(pid):
35
+ try:
36
+ os.kill(pid, 0)
37
+ except (ProcessLookupError, PermissionError):
38
+ return False
39
+ return True
@@ -0,0 +1,62 @@
1
+ from concurrent.futures import ThreadPoolExecutor, wait
2
+ import threading
3
+
4
+
5
+ class WorkerPool:
6
+ def __init__(self, max_workers, *, wake_up):
7
+ self.max_workers = max_workers
8
+ self._wake_up = wake_up
9
+ self._executor = ThreadPoolExecutor(max_workers=max_workers)
10
+ self._lock = threading.Lock()
11
+ self._futures = set()
12
+ self._in_flight = 0
13
+ self._on_drained = None
14
+
15
+ @property
16
+ def idle_capacity(self):
17
+ with self._lock:
18
+ return max(0, self.max_workers - self._in_flight)
19
+
20
+ def submit(self, fn, *args, **kwargs):
21
+ with self._lock:
22
+ self._in_flight += 1
23
+
24
+ future = self._executor.submit(fn, *args, **kwargs)
25
+ with self._lock:
26
+ self._futures.add(future)
27
+ future.add_done_callback(self._complete)
28
+ return future
29
+
30
+ def shutdown(self, timeout, *, on_drained=None):
31
+ with self._lock:
32
+ self._on_drained = on_drained
33
+ futures = list(self._futures)
34
+ if not futures:
35
+ self._executor.shutdown(wait=False, cancel_futures=False)
36
+ self._notify_drained()
37
+ return True
38
+
39
+ _, not_done = wait(futures, timeout=timeout)
40
+ self._executor.shutdown(wait=False, cancel_futures=False)
41
+ if not not_done:
42
+ self._notify_drained()
43
+ return len(not_done) == 0
44
+
45
+ def _complete(self, future):
46
+ callback = None
47
+ with self._lock:
48
+ self._futures.discard(future)
49
+ self._in_flight -= 1
50
+ if self._in_flight == 0 and not self._futures:
51
+ callback = self._on_drained
52
+ self._on_drained = None
53
+ if callback is not None:
54
+ callback()
55
+ self._wake_up()
56
+
57
+ def _notify_drained(self):
58
+ with self._lock:
59
+ callback = self._on_drained
60
+ self._on_drained = None
61
+ if callback is not None:
62
+ callback()
@@ -0,0 +1,11 @@
1
+ import importlib
2
+
3
+
4
+ def set_process_title(title):
5
+ try:
6
+ setproctitle = importlib.import_module("setproctitle")
7
+ except ModuleNotFoundError:
8
+ return False
9
+
10
+ setproctitle.setproctitle(title)
11
+ return True