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/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."""
|
dj_queue/runtime/base.py
ADDED
|
@@ -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
|
dj_queue/runtime/pool.py
ADDED
|
@@ -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()
|