blueque 1.0.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.
- blueque/__init__.py +1 -0
- blueque/client.py +32 -0
- blueque/forking_runner.py +80 -0
- blueque/listener.py +55 -0
- blueque/process_helpers.py +9 -0
- blueque/processor.py +18 -0
- blueque/queue.py +21 -0
- blueque/redis_queue.py +224 -0
- blueque/redis_task.py +32 -0
- blueque/task.py +46 -0
- blueque-1.0.0.dist-info/LICENSE +11 -0
- blueque-1.0.0.dist-info/METADATA +422 -0
- blueque-1.0.0.dist-info/RECORD +14 -0
- blueque-1.0.0.dist-info/WHEEL +4 -0
blueque/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from blueque.client import Client # noqa: F401
|
blueque/client.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from blueque.listener import Listener
|
|
2
|
+
from blueque.processor import Processor
|
|
3
|
+
from blueque.queue import Queue
|
|
4
|
+
from blueque.redis_queue import RedisQueue
|
|
5
|
+
from blueque.redis_task import RedisTask
|
|
6
|
+
from blueque.task import Task
|
|
7
|
+
|
|
8
|
+
import redis
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Client(object):
|
|
12
|
+
def __init__(self, url, **kwargs):
|
|
13
|
+
super(Client, self).__init__()
|
|
14
|
+
|
|
15
|
+
self._redis = redis.StrictRedis.from_url(url, decode_responses=True, **kwargs)
|
|
16
|
+
|
|
17
|
+
def get_queue(self, name):
|
|
18
|
+
redis_queue = RedisQueue(name, self._redis)
|
|
19
|
+
return Queue(name, redis_queue)
|
|
20
|
+
|
|
21
|
+
def get_task(self, task_id):
|
|
22
|
+
redis_task = RedisTask(task_id, self._redis)
|
|
23
|
+
return Task(task_id, redis_task)
|
|
24
|
+
|
|
25
|
+
def get_listener(self, queue_name):
|
|
26
|
+
redis_queue = RedisQueue(queue_name, self._redis)
|
|
27
|
+
return Listener(redis_queue, self.get_task)
|
|
28
|
+
|
|
29
|
+
def get_processor(self, task):
|
|
30
|
+
redis_queue = RedisQueue(task.queue, self._redis)
|
|
31
|
+
|
|
32
|
+
return Processor(task, redis_queue)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from blueque.process_helpers import process_running
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ForkingRunner(object):
|
|
11
|
+
def __init__(self, client, queue, task_callback):
|
|
12
|
+
super(ForkingRunner, self).__init__()
|
|
13
|
+
|
|
14
|
+
self._client = client
|
|
15
|
+
self._queue = queue
|
|
16
|
+
self._task_callback = task_callback
|
|
17
|
+
|
|
18
|
+
def fork_task(self, task):
|
|
19
|
+
pid = os.fork()
|
|
20
|
+
|
|
21
|
+
if pid > 0:
|
|
22
|
+
return pid
|
|
23
|
+
|
|
24
|
+
logging.info("Process forked to run task %s" % (task.id))
|
|
25
|
+
|
|
26
|
+
# Reseed the random number generator, since we inherited it
|
|
27
|
+
# from our parent after the fork
|
|
28
|
+
random.seed()
|
|
29
|
+
|
|
30
|
+
os.setsid()
|
|
31
|
+
|
|
32
|
+
logging.info("Getting Processor to run task %s" % (task.id))
|
|
33
|
+
processor = self._client.get_processor(task)
|
|
34
|
+
|
|
35
|
+
logging.info("Starting to run task %s" % (task.id))
|
|
36
|
+
processor.start(os.getpid())
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
result = self._task_callback(task)
|
|
40
|
+
|
|
41
|
+
processor.complete(result)
|
|
42
|
+
except Exception as e:
|
|
43
|
+
processor.fail(str(e))
|
|
44
|
+
finally:
|
|
45
|
+
# _exit won't flush, so we need to, in case there are
|
|
46
|
+
# error messages we want to see.
|
|
47
|
+
logging.shutdown()
|
|
48
|
+
sys.stdout.flush()
|
|
49
|
+
sys.stderr.flush()
|
|
50
|
+
|
|
51
|
+
os._exit(0)
|
|
52
|
+
|
|
53
|
+
def _run_task(self, task):
|
|
54
|
+
pid = self.fork_task(task)
|
|
55
|
+
|
|
56
|
+
logging.info("Forked task %s to pid %i" % (task.id, pid))
|
|
57
|
+
|
|
58
|
+
_, status = os.waitpid(pid, 0)
|
|
59
|
+
|
|
60
|
+
logging.info(
|
|
61
|
+
"Forked task %s exited with status %i" % (task.id, os.WEXITSTATUS(status)))
|
|
62
|
+
|
|
63
|
+
def run(self):
|
|
64
|
+
listener = self._client.get_listener(self._queue)
|
|
65
|
+
|
|
66
|
+
task = listener.claim_orphan()
|
|
67
|
+
|
|
68
|
+
while task is not None:
|
|
69
|
+
if task.status == "reserved":
|
|
70
|
+
self._run_task(task)
|
|
71
|
+
elif task.status == "started":
|
|
72
|
+
while process_running(task.pid):
|
|
73
|
+
time.sleep(0.1)
|
|
74
|
+
|
|
75
|
+
task = listener.claim_orphan()
|
|
76
|
+
|
|
77
|
+
while True:
|
|
78
|
+
task = listener.listen()
|
|
79
|
+
|
|
80
|
+
self._run_task(task)
|
blueque/listener.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from blueque.process_helpers import process_running
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Listener(object):
|
|
9
|
+
def __init__(self, queue, task_factory):
|
|
10
|
+
super(Listener, self).__init__()
|
|
11
|
+
|
|
12
|
+
self._hostname = socket.getfqdn()
|
|
13
|
+
self._pid = os.getpid()
|
|
14
|
+
self._name = "_".join((self._hostname, str(self._pid)))
|
|
15
|
+
self._queue = queue
|
|
16
|
+
self._queue.add_listener(self._name)
|
|
17
|
+
self._task_factory = task_factory
|
|
18
|
+
|
|
19
|
+
def _parse_name(self, name):
|
|
20
|
+
host, pid = name.rsplit('_', 1)
|
|
21
|
+
|
|
22
|
+
return host, int(pid)
|
|
23
|
+
|
|
24
|
+
def listen(self):
|
|
25
|
+
while True:
|
|
26
|
+
task_id = self._queue.dequeue(self._name)
|
|
27
|
+
if task_id is not None:
|
|
28
|
+
return self._task_factory(task_id)
|
|
29
|
+
else:
|
|
30
|
+
time.sleep(1)
|
|
31
|
+
|
|
32
|
+
def claim_orphan(self):
|
|
33
|
+
for listener in self._queue.get_listeners():
|
|
34
|
+
host, pid = self._parse_name(listener)
|
|
35
|
+
|
|
36
|
+
if host != self._hostname:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
if pid == self._pid:
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
if process_running(pid):
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
if self._queue.remove_listener(listener) == 0:
|
|
46
|
+
# already claimed
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
task_id = self._queue.reclaim_task(listener, self._name)
|
|
50
|
+
if task_id is None:
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
return self._task_factory(task_id)
|
|
54
|
+
|
|
55
|
+
return None
|
blueque/processor.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class Processor(object):
|
|
2
|
+
def __init__(self, task, redis_queue):
|
|
3
|
+
super(Processor, self).__init__()
|
|
4
|
+
|
|
5
|
+
self._listener_id = task.node
|
|
6
|
+
self._task_id = task.id
|
|
7
|
+
self._pid = task.pid
|
|
8
|
+
self._redis_queue = redis_queue
|
|
9
|
+
|
|
10
|
+
def start(self, pid):
|
|
11
|
+
self._pid = pid
|
|
12
|
+
self._redis_queue.start(self._task_id, self._listener_id, self._pid)
|
|
13
|
+
|
|
14
|
+
def complete(self, result):
|
|
15
|
+
self._redis_queue.complete(self._task_id, self._listener_id, self._pid, result)
|
|
16
|
+
|
|
17
|
+
def fail(self, error):
|
|
18
|
+
self._redis_queue.fail(self._task_id, self._listener_id, self._pid, error)
|
blueque/queue.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class Queue(object):
|
|
2
|
+
def __init__(self, name, redis_queue):
|
|
3
|
+
super(Queue, self).__init__()
|
|
4
|
+
|
|
5
|
+
self._name = name
|
|
6
|
+
self._redis_queue = redis_queue
|
|
7
|
+
|
|
8
|
+
def enqueue(self, parameters):
|
|
9
|
+
return self._redis_queue.enqueue(parameters)
|
|
10
|
+
|
|
11
|
+
def schedule(self, parameters, eta):
|
|
12
|
+
return self._redis_queue.schedule(parameters, eta)
|
|
13
|
+
|
|
14
|
+
def enqueue_due_tasks(self):
|
|
15
|
+
self._redis_queue.enqueue_due_tasks()
|
|
16
|
+
|
|
17
|
+
def delete_task(self, task):
|
|
18
|
+
if task.queue != self._name:
|
|
19
|
+
raise ValueError("Task %s is not in queue %s" % (task.id, self._name))
|
|
20
|
+
|
|
21
|
+
self._redis_queue.delete_task(task.id, task.status)
|
blueque/redis_queue.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from blueque.redis_task import RedisTask
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RedisQueue(object):
|
|
9
|
+
def __init__(self, name, redis_client):
|
|
10
|
+
super(RedisQueue, self).__init__()
|
|
11
|
+
|
|
12
|
+
self._name = name
|
|
13
|
+
self._pending_name = self._key("pending_tasks", self._name)
|
|
14
|
+
self._scheduled_key = self._key("scheduled_tasks", self._name)
|
|
15
|
+
|
|
16
|
+
self._queues_key = self._key("queues")
|
|
17
|
+
self._started_key = self._key("started_tasks", self._name)
|
|
18
|
+
self._listeners_key = self._key("listeners", self._name)
|
|
19
|
+
|
|
20
|
+
self._complete_key = self._key("complete_tasks", self._name)
|
|
21
|
+
self._failed_key = self._key("failed_tasks", self._name)
|
|
22
|
+
|
|
23
|
+
self._redis = redis_client
|
|
24
|
+
|
|
25
|
+
def _running_job(self, node_id, pid, task_id):
|
|
26
|
+
return " ".join((node_id, str(pid), task_id))
|
|
27
|
+
|
|
28
|
+
def _key(self, *args):
|
|
29
|
+
return '_'.join(("blueque",) + args)
|
|
30
|
+
|
|
31
|
+
def _reserved_key(self, node_id):
|
|
32
|
+
return self._key("reserved_tasks", self._name, node_id)
|
|
33
|
+
|
|
34
|
+
def _generate_task_id(self):
|
|
35
|
+
return str(uuid.uuid4())
|
|
36
|
+
|
|
37
|
+
def _log(self, message):
|
|
38
|
+
logging.info("Blueque queue %s: %s" % (self._name, message))
|
|
39
|
+
|
|
40
|
+
def _debug(self, message):
|
|
41
|
+
logging.debug("Blueque queue %s: %s" % (self._name, message))
|
|
42
|
+
|
|
43
|
+
def add_listener(self, node_id):
|
|
44
|
+
self._log("adding listener %s" % (node_id))
|
|
45
|
+
with self._redis.pipeline() as pipeline:
|
|
46
|
+
pipeline.sadd(self._listeners_key, node_id)
|
|
47
|
+
pipeline.zincrby(self._queues_key, 1, self._name)
|
|
48
|
+
pipeline.execute()
|
|
49
|
+
|
|
50
|
+
def remove_listener(self, node_id):
|
|
51
|
+
self._log("removing listener %s" % (node_id))
|
|
52
|
+
|
|
53
|
+
removed = self._redis.srem(self._listeners_key, node_id)
|
|
54
|
+
|
|
55
|
+
if removed > 0:
|
|
56
|
+
self._log("removed listener")
|
|
57
|
+
self._redis.zincrby(self._queues_key, -1, self._name)
|
|
58
|
+
|
|
59
|
+
return removed
|
|
60
|
+
|
|
61
|
+
def get_listeners(self):
|
|
62
|
+
return self._redis.smembers(self._listeners_key)
|
|
63
|
+
|
|
64
|
+
def _generate_task(self, pipeline, status, parameters, **kwargs):
|
|
65
|
+
task_id = self._generate_task_id()
|
|
66
|
+
|
|
67
|
+
self._log("adding %s task %s, parameters: %s" % (status, task_id, parameters))
|
|
68
|
+
|
|
69
|
+
now = time.time()
|
|
70
|
+
|
|
71
|
+
task_data = {
|
|
72
|
+
"status": status,
|
|
73
|
+
"queue": self._name,
|
|
74
|
+
"parameters": parameters,
|
|
75
|
+
"created": now,
|
|
76
|
+
"updated": now
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
task_data.update(kwargs)
|
|
80
|
+
|
|
81
|
+
pipeline.hset(RedisTask.task_key(task_id), mapping=task_data)
|
|
82
|
+
|
|
83
|
+
pipeline.zincrby(self._key("queues"), 0, self._name)
|
|
84
|
+
|
|
85
|
+
return task_id
|
|
86
|
+
|
|
87
|
+
def schedule(self, parameters, eta):
|
|
88
|
+
if eta < time.time():
|
|
89
|
+
return self.enqueue(parameters)
|
|
90
|
+
|
|
91
|
+
with self._redis.pipeline() as pipeline:
|
|
92
|
+
task_id = self._generate_task(pipeline, "scheduled", parameters, eta=eta)
|
|
93
|
+
|
|
94
|
+
pipeline.zadd(self._scheduled_key, {task_id: eta})
|
|
95
|
+
|
|
96
|
+
pipeline.execute()
|
|
97
|
+
|
|
98
|
+
return task_id
|
|
99
|
+
|
|
100
|
+
def enqueue(self, parameters):
|
|
101
|
+
with self._redis.pipeline() as pipeline:
|
|
102
|
+
task_id = self._generate_task(pipeline, "pending", parameters)
|
|
103
|
+
|
|
104
|
+
pipeline.lpush(self._pending_name, task_id)
|
|
105
|
+
|
|
106
|
+
pipeline.execute()
|
|
107
|
+
|
|
108
|
+
return task_id
|
|
109
|
+
|
|
110
|
+
def enqueue_due_tasks(self):
|
|
111
|
+
def enqueue_transaction(pipeline):
|
|
112
|
+
now = time.time()
|
|
113
|
+
due_tasks = pipeline.zrangebyscore(self._scheduled_key, 0, now)
|
|
114
|
+
|
|
115
|
+
if len(due_tasks) == 0:
|
|
116
|
+
self._debug("no due tasks")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
self._log("enqueuing due tasks: %s" % (due_tasks))
|
|
120
|
+
|
|
121
|
+
pipeline.multi()
|
|
122
|
+
|
|
123
|
+
pipeline.zremrangebyscore(self._scheduled_key, 0, now)
|
|
124
|
+
|
|
125
|
+
for task in due_tasks:
|
|
126
|
+
pipeline.lpush(self._pending_name, task)
|
|
127
|
+
pipeline.hset(
|
|
128
|
+
RedisTask.task_key(task), mapping={"status": "pending", "updated": now})
|
|
129
|
+
|
|
130
|
+
self._redis.transaction(enqueue_transaction, self._scheduled_key)
|
|
131
|
+
|
|
132
|
+
def dequeue(self, node_id):
|
|
133
|
+
self._debug("reserving task on %s" % (node_id))
|
|
134
|
+
|
|
135
|
+
task_id = self._redis.rpoplpush(
|
|
136
|
+
self._pending_name, self._reserved_key(node_id))
|
|
137
|
+
|
|
138
|
+
if task_id is None:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
self._log("got task %s" % (task_id))
|
|
142
|
+
|
|
143
|
+
self._redis.hset(
|
|
144
|
+
RedisTask.task_key(task_id),
|
|
145
|
+
mapping={
|
|
146
|
+
"status": "reserved",
|
|
147
|
+
"node": node_id,
|
|
148
|
+
"updated": time.time()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
return task_id
|
|
152
|
+
|
|
153
|
+
def start(self, task_id, node_id, pid):
|
|
154
|
+
self._log("starting task %s on %s, pid %i" % (task_id, node_id, pid))
|
|
155
|
+
with self._redis.pipeline() as pipeline:
|
|
156
|
+
pipeline.sadd(self._started_key, self._running_job(node_id, pid, task_id))
|
|
157
|
+
pipeline.hset(
|
|
158
|
+
RedisTask.task_key(task_id),
|
|
159
|
+
mapping={"status": "started", "pid": pid, "updated": time.time()})
|
|
160
|
+
|
|
161
|
+
pipeline.execute()
|
|
162
|
+
|
|
163
|
+
def reclaim_task(self, old_node, new_node):
|
|
164
|
+
task_id = self._redis.lindex(self._reserved_key(old_node), 0)
|
|
165
|
+
|
|
166
|
+
if task_id is not None:
|
|
167
|
+
self._redis.hset(RedisTask.task_key(task_id), "reclaimed_node", new_node)
|
|
168
|
+
|
|
169
|
+
return task_id
|
|
170
|
+
|
|
171
|
+
def complete(self, task_id, node_id, pid, result):
|
|
172
|
+
self._log(
|
|
173
|
+
"completing task %s on %s, pid: %i, result: %s" % (task_id, node_id, pid, result))
|
|
174
|
+
|
|
175
|
+
with self._redis.pipeline() as pipeline:
|
|
176
|
+
pipeline.lrem(self._reserved_key(node_id), 1, task_id)
|
|
177
|
+
pipeline.srem(self._started_key, self._running_job(node_id, pid, task_id))
|
|
178
|
+
|
|
179
|
+
pipeline.hset(
|
|
180
|
+
RedisTask.task_key(task_id),
|
|
181
|
+
mapping={
|
|
182
|
+
"status": "complete",
|
|
183
|
+
"result": result,
|
|
184
|
+
"updated": time.time()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
pipeline.lpush(self._complete_key, task_id)
|
|
188
|
+
|
|
189
|
+
pipeline.execute()
|
|
190
|
+
|
|
191
|
+
def fail(self, task_id, node_id, pid, error):
|
|
192
|
+
self._log("failed task %s on %s, pid: %i, error: %s" % (task_id, node_id, pid, error))
|
|
193
|
+
|
|
194
|
+
with self._redis.pipeline() as pipeline:
|
|
195
|
+
pipeline.lrem(self._reserved_key(node_id), 1, task_id)
|
|
196
|
+
pipeline.srem(self._started_key, self._running_job(node_id, pid, task_id))
|
|
197
|
+
|
|
198
|
+
pipeline.hset(
|
|
199
|
+
RedisTask.task_key(task_id),
|
|
200
|
+
mapping={
|
|
201
|
+
"status": "failed",
|
|
202
|
+
"error": error,
|
|
203
|
+
"updated": time.time()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
pipeline.lpush(self._failed_key, task_id)
|
|
207
|
+
|
|
208
|
+
pipeline.execute()
|
|
209
|
+
|
|
210
|
+
def delete_task(self, task_id, task_status):
|
|
211
|
+
if task_status == "complete":
|
|
212
|
+
finished_queue = self._complete_key
|
|
213
|
+
elif task_status == "failed":
|
|
214
|
+
finished_queue = self._failed_key
|
|
215
|
+
else:
|
|
216
|
+
raise ValueError("Cannot delete task with status %s" % (task_status))
|
|
217
|
+
|
|
218
|
+
self._log("deleting task %s with status %s" % (task_id, task_status))
|
|
219
|
+
|
|
220
|
+
with self._redis.pipeline() as pipeline:
|
|
221
|
+
pipeline.delete(RedisTask.task_key(task_id))
|
|
222
|
+
pipeline.lrem(finished_queue, 1, task_id)
|
|
223
|
+
|
|
224
|
+
pipeline.execute()
|
blueque/redis_task.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RedisTask(object):
|
|
5
|
+
_field_types = defaultdict(lambda: str, {
|
|
6
|
+
"pid": int,
|
|
7
|
+
"created": float,
|
|
8
|
+
"updated": float
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
def __init__(self, id, redis):
|
|
12
|
+
super(RedisTask, self).__init__()
|
|
13
|
+
|
|
14
|
+
self._id = id
|
|
15
|
+
self._redis = redis
|
|
16
|
+
self._task_key = self.task_key(self._id)
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def task_key(task_id):
|
|
20
|
+
return RedisTask._key("task", task_id)
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def _key(*args):
|
|
24
|
+
return '_'.join(("blueque",) + args)
|
|
25
|
+
|
|
26
|
+
def get_task_data(self):
|
|
27
|
+
task_data = {}
|
|
28
|
+
|
|
29
|
+
for field, value in self._redis.hgetall(self._task_key).items():
|
|
30
|
+
task_data[field] = self._field_types[field](value)
|
|
31
|
+
|
|
32
|
+
return task_data
|
blueque/task.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
class Task(object):
|
|
2
|
+
def __init__(self, id, redis_task):
|
|
3
|
+
super(Task, self).__init__()
|
|
4
|
+
|
|
5
|
+
self._id = id
|
|
6
|
+
self._attributes = redis_task.get_task_data()
|
|
7
|
+
|
|
8
|
+
@property
|
|
9
|
+
def id(self):
|
|
10
|
+
return self._id
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def status(self):
|
|
14
|
+
return self._attributes.get("status")
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def queue(self):
|
|
18
|
+
return self._attributes.get("queue")
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def parameters(self):
|
|
22
|
+
return self._attributes.get("parameters")
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def result(self):
|
|
26
|
+
return self._attributes.get("result")
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def error(self):
|
|
30
|
+
return self._attributes.get("error")
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def node(self):
|
|
34
|
+
return self._attributes.get("node")
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def pid(self):
|
|
38
|
+
return self._attributes.get("pid")
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def created(self):
|
|
42
|
+
return self._attributes.get("created")
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def updated(self):
|
|
46
|
+
return self._attributes.get("updated")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
2
|
+
you may not use this file except in compliance with the License.
|
|
3
|
+
You may obtain a copy of the License at
|
|
4
|
+
|
|
5
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
|
|
7
|
+
Unless required by applicable law or agreed to in writing, software
|
|
8
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
9
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
10
|
+
See the License for the specific language governing permissions and
|
|
11
|
+
limitations under the License.
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: blueque
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Simple job queueing for very long tasks
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Author: uStudio Developers
|
|
7
|
+
Author-email: dev@ustudio.com
|
|
8
|
+
Requires-Python: >=3.9,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Requires-Dist: redis (>=5.0.1,<6.0.0)
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Blueque #
|
|
18
|
+
|
|
19
|
+
A simple task queue system optimized for very long running
|
|
20
|
+
tasks.
|
|
21
|
+
|
|
22
|
+
## Terminology ##
|
|
23
|
+
|
|
24
|
+
* *Queue*
|
|
25
|
+
|
|
26
|
+
A list of tasks, of a given type, to be run. Multiple *queues* may
|
|
27
|
+
be used to execute different types of tasks on different *nodes*.
|
|
28
|
+
|
|
29
|
+
* *Task*
|
|
30
|
+
|
|
31
|
+
A piece of work to be done, including its parameters, current
|
|
32
|
+
status, result (if done), etc.
|
|
33
|
+
|
|
34
|
+
* *Node*
|
|
35
|
+
|
|
36
|
+
A computer running tasks from the queue.
|
|
37
|
+
|
|
38
|
+
* *Listener*
|
|
39
|
+
|
|
40
|
+
A process running on a node, listening for new tasks on a single
|
|
41
|
+
queue. The system does not support multiple listeners listening to
|
|
42
|
+
the *same queue* on the *same node*.
|
|
43
|
+
|
|
44
|
+
* *Process*
|
|
45
|
+
|
|
46
|
+
A process, running on a *node*, created by a *listener*, executing
|
|
47
|
+
a *task*.
|
|
48
|
+
|
|
49
|
+
## API ##
|
|
50
|
+
|
|
51
|
+
### Client Connection ###
|
|
52
|
+
|
|
53
|
+
The `Blueque.Client` object stores the connection to the Blueque
|
|
54
|
+
system, and constructs the other objects necessary for interacting
|
|
55
|
+
with the system
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
client = Blueque.Client("redis://hostname:port/db")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Queue ###
|
|
62
|
+
|
|
63
|
+
The `Blueque.queue.Queue` object provides the interface to a named
|
|
64
|
+
queue of tasks.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
queue = client.get_queue("some.queue")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
#### `Queue.enqueue` ####
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
task_id = queue.enqueue(parameters)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Returns the Task ID (a string) of the newly enqueued task
|
|
77
|
+
|
|
78
|
+
### Task ###
|
|
79
|
+
|
|
80
|
+
The task object provides a basic, read-only view of all the attributes
|
|
81
|
+
of a task.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
task = client.get_task(task_id)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
There is a read-only attribute for all of the task attributes stored
|
|
88
|
+
in Redis (see below).
|
|
89
|
+
|
|
90
|
+
### Listener ###
|
|
91
|
+
|
|
92
|
+
The listener object provides the interface used to listen for new
|
|
93
|
+
tasks.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
listener = client.get_listener("some.queue")
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
#### `Listener.listen` ####
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
task = listener.listen(on_new_task)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Blocks listening for a new task to execute until one is ready, then
|
|
106
|
+
returns a `Task` object describing that task.
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
### Processor ###
|
|
110
|
+
|
|
111
|
+
A Processor object provides the interface used to update a task while
|
|
112
|
+
it is being processed.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
processor = client.get_processor(task)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
#### `Processor.start` ####
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
parameters = processor.start(pid)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Marks a task as being started, by the specified pid, and returns the
|
|
125
|
+
parameters of that task.
|
|
126
|
+
|
|
127
|
+
#### `Processor.complete` ####
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
processor.complete("some result")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Marks a task as being complete, and stores the result.
|
|
134
|
+
|
|
135
|
+
#### `Processor.fail` ####
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
processor.fail("some error")
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Marks a task as having failed, and stores the error.
|
|
142
|
+
|
|
143
|
+
## Data Storage ##
|
|
144
|
+
|
|
145
|
+
Currently, the backend structure is Redis. Keys are prefixed with a
|
|
146
|
+
namespace, i.e. named "bluequeue_foo", so that other systems can use
|
|
147
|
+
the Redis DB for other things.
|
|
148
|
+
|
|
149
|
+
### Task Queue ###
|
|
150
|
+
|
|
151
|
+
`blueque_pending_tasks_[queue name]`
|
|
152
|
+
|
|
153
|
+
Stored in a `List`, accessed as a queue: new tasks are added via
|
|
154
|
+
`LPUSH`, tasks are removed for execution via `RPOP`. There is a `List`
|
|
155
|
+
for each task queue (channel). All that is stored in the `List` is a
|
|
156
|
+
task ID, which will be used to retrieve the task data.
|
|
157
|
+
|
|
158
|
+
### Listener Task List ###
|
|
159
|
+
|
|
160
|
+
`blueque_reserved_tasks_[queue name]_[listener ID]`
|
|
161
|
+
|
|
162
|
+
Stored in a `List`, this is used to keep track of which listeners are
|
|
163
|
+
running which tasks. Tasks should be atomically moved from the *Task
|
|
164
|
+
Queue* to the *Listener Task List* via `RPOPLPUSH`, so that they don't get
|
|
165
|
+
lost.
|
|
166
|
+
|
|
167
|
+
### Task Channel ###
|
|
168
|
+
|
|
169
|
+
Note: this will not be implemented in the first pass.
|
|
170
|
+
|
|
171
|
+
There is a Pub/Sub `Channel` for each task queue (channel). This can
|
|
172
|
+
be used by the listener client to listen for new tasks if the task queue
|
|
173
|
+
they were interested in was empty when they last checked for a task.
|
|
174
|
+
|
|
175
|
+
Messages should *not* include the task ID of the newly created task,
|
|
176
|
+
because listeners must be required to manually try to `LPOP` the task
|
|
177
|
+
off the task queue, so that only one work runs each task.
|
|
178
|
+
|
|
179
|
+
### Task List ###
|
|
180
|
+
|
|
181
|
+
`blueque_tasks_[queue name]`
|
|
182
|
+
|
|
183
|
+
There is a list of all the tasks in a queue, regardless of their
|
|
184
|
+
state. This is mostly used for introspection/management purposes.
|
|
185
|
+
|
|
186
|
+
### Task Data ###
|
|
187
|
+
|
|
188
|
+
`blueque_task_[task id]`
|
|
189
|
+
|
|
190
|
+
The actual data associated with a task will be stored as a hash, where
|
|
191
|
+
the key is built from the task ID (i.e. "bluequeue_task_NNNN"). Each
|
|
192
|
+
subsystem will be able to add its own fields to the hash.
|
|
193
|
+
|
|
194
|
+
Current fields are
|
|
195
|
+
|
|
196
|
+
* `status`
|
|
197
|
+
|
|
198
|
+
One of: `scheduled`, `pending`, `reserved`, `started`, `complete`
|
|
199
|
+
or `failed`.
|
|
200
|
+
|
|
201
|
+
* `queue`
|
|
202
|
+
|
|
203
|
+
The queue that the task is in (mostly just for debugging
|
|
204
|
+
purposes).
|
|
205
|
+
|
|
206
|
+
* `parameters`
|
|
207
|
+
|
|
208
|
+
String containing data to be passed to the process. Totally task
|
|
209
|
+
specific.
|
|
210
|
+
|
|
211
|
+
* `result`
|
|
212
|
+
|
|
213
|
+
String containing the result of the process; totally
|
|
214
|
+
task-specific. Will not be set if the task hasn't completed yet.
|
|
215
|
+
|
|
216
|
+
* `error`
|
|
217
|
+
|
|
218
|
+
String containing the error generated by the process. Set only if
|
|
219
|
+
`status` is `failed`.
|
|
220
|
+
|
|
221
|
+
* `node`
|
|
222
|
+
|
|
223
|
+
The listener ID of the node running the task. Will not be set if the
|
|
224
|
+
task has not been started yet.
|
|
225
|
+
|
|
226
|
+
* `pid`
|
|
227
|
+
|
|
228
|
+
The PID of the process running the task on the node. Will not be
|
|
229
|
+
set if the task has not been started yet.
|
|
230
|
+
|
|
231
|
+
* `created`
|
|
232
|
+
|
|
233
|
+
A floating point value containing the Python timestampe
|
|
234
|
+
(`time.time()`) of the time when the task was first created
|
|
235
|
+
(enqueued).
|
|
236
|
+
|
|
237
|
+
* `updated`
|
|
238
|
+
|
|
239
|
+
A floating point value containing the Python timestamp
|
|
240
|
+
(`time.time()`) of the last time any value in the task changed.
|
|
241
|
+
|
|
242
|
+
* `eta`
|
|
243
|
+
|
|
244
|
+
The timestamp when the task is scheduled to be executed. Will not
|
|
245
|
+
be set if the task was not scheduled with an ETA.
|
|
246
|
+
|
|
247
|
+
### Listeners ###
|
|
248
|
+
|
|
249
|
+
`blueque_listeners_[queue name]`
|
|
250
|
+
|
|
251
|
+
In order for the system to be easily introspected, the currently
|
|
252
|
+
active listeners will be stored in a Redis `Set`. Listeners are stored
|
|
253
|
+
by their `LISTENER ID`, which must be `[hostname]_[pid]`.
|
|
254
|
+
|
|
255
|
+
Note that this means that all hosts in the system must have unique
|
|
256
|
+
names.
|
|
257
|
+
|
|
258
|
+
### Queues ###
|
|
259
|
+
|
|
260
|
+
`blueque_queues`
|
|
261
|
+
|
|
262
|
+
There is a `Sorted Set` containing the names of all the queues, where
|
|
263
|
+
the score of the set is the number of nodes listening to that set.
|
|
264
|
+
|
|
265
|
+
When a node comes online, it increments the score by 1; when a node
|
|
266
|
+
goes offline (cleanly) it increments the score by -1. Every time a
|
|
267
|
+
task is enqueued, the score should be incremented by 0, so that a
|
|
268
|
+
queue with tasks, but no listeners, still shows up in the set.
|
|
269
|
+
|
|
270
|
+
## Task Workflow ##
|
|
271
|
+
|
|
272
|
+
Tasks are executed via this workflow. Note that any `HMSET` call which
|
|
273
|
+
modifies a `blueque_task_[TASK ID]` should be assumed to also set the
|
|
274
|
+
`updated` field of the task.
|
|
275
|
+
|
|
276
|
+
### Submission ###
|
|
277
|
+
|
|
278
|
+
Tasks should be submitted by creating a UUID, [TID], JSON encoding the
|
|
279
|
+
parameters, [PARAMS], and then executing:
|
|
280
|
+
|
|
281
|
+
```
|
|
282
|
+
MULTI
|
|
283
|
+
HMSET blueque_task_[TASK ID] status pending queue [QUEUE] parameters [PARAMS]
|
|
284
|
+
ZINCRBY blueque_queues 0 [QUEUE]
|
|
285
|
+
LPUSH blueque_pending_tasks_[QUEUE] [TASK ID]
|
|
286
|
+
EXEC
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
### Node Task Pop ###
|
|
290
|
+
|
|
291
|
+
Nodes should pop a task off the queue and then set the status of the
|
|
292
|
+
task to `started`, and set the `node` field of the task.
|
|
293
|
+
|
|
294
|
+
If no task is popped off the queue, the Node should wait for a new
|
|
295
|
+
task notification. Ideally, this will be via Pub/Sub, but, at first,
|
|
296
|
+
we can do it by polling.
|
|
297
|
+
|
|
298
|
+
Tasks are popped using the following commands.
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
RPOPLPUSH blueque_pending_tasks_[QUEUE] [NODE TASKS]
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
HMSET blueque_task_[TASK ID] status reserved node [NODE]
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Note that these two commands cannot be executed atomically because the
|
|
309
|
+
second depends on the first, and, even with Lua scripting, that cannot
|
|
310
|
+
be done atomically and safely. Therefore, there is a chance that a
|
|
311
|
+
task is popped off the pending queue, but its record is not
|
|
312
|
+
updated. This can be detected if a task is in a node's queue, but has
|
|
313
|
+
a status of `pending`.
|
|
314
|
+
|
|
315
|
+
### Task Queue Channel Message ###
|
|
316
|
+
|
|
317
|
+
Note: We will not implement this in the first pass. Listeners will just
|
|
318
|
+
poll once every few seconds.
|
|
319
|
+
|
|
320
|
+
If a listener receives a message via a Pub/Sub channel that a queue has
|
|
321
|
+
a task in it, it should try to atomically pop a task off that channel
|
|
322
|
+
(see above). If it does get a task, it should unsubscribe from the
|
|
323
|
+
channel; if it does not (i.e. another listener got the task) it should
|
|
324
|
+
remain subscribed.
|
|
325
|
+
|
|
326
|
+
### Task Started ###
|
|
327
|
+
|
|
328
|
+
When a process starts executing a task on a node, it should update the
|
|
329
|
+
task to indicate that, and also add itself to a set of all active
|
|
330
|
+
tasks:
|
|
331
|
+
|
|
332
|
+
```
|
|
333
|
+
MULTI
|
|
334
|
+
SADD blueque_started_tasks_[QUEUE] "[LISTENER ID] [PID] [TASK ID]"
|
|
335
|
+
HMSET blueque_task_[TASK ID] status started pid [PID]
|
|
336
|
+
EXEC
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Note that this assumes that the process is told what task to execute,
|
|
340
|
+
rather than pulling it off the node's task list.
|
|
341
|
+
|
|
342
|
+
### Task Completed Successfully ###
|
|
343
|
+
|
|
344
|
+
If a task completes successfully, it should set the `status` field of
|
|
345
|
+
the task to `succeeded` and set the `result` field to the
|
|
346
|
+
JSON-serialized result of the task, as a single atomic transaction.
|
|
347
|
+
|
|
348
|
+
```
|
|
349
|
+
MULTI
|
|
350
|
+
LREM blueque_reserved_tasks_[QUEUE]_[LISTENER ID] 1 [TASK ID]
|
|
351
|
+
ZREM blueque_started_tasks_[QUEUE] "[LISTENER ID] [PID] [TASK ID]"
|
|
352
|
+
HMSET blueque_task_[TASK ID] status complete result [RESULT]
|
|
353
|
+
LPUSH blueque_complete_tasks_[QUEUE] [TASK ID]
|
|
354
|
+
EXEC
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Task Failed ###
|
|
358
|
+
|
|
359
|
+
If a task fails for any reason (process function raises an exception,
|
|
360
|
+
or some monitoring process determines that the process fails more
|
|
361
|
+
catastrophically), the process that detects the error should set the
|
|
362
|
+
`status` field of the task to `failed` and the `error` field to a
|
|
363
|
+
JSON-serialized description of the error (see above).
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
MULTI
|
|
367
|
+
LREM blueque_reserved_tasks_[QUEUE]_[LISTENER ID] 1 [TASK ID]
|
|
368
|
+
ZREM blueque_started_tasks_[QUEUE] "[LISTENER ID] [PID] [TASK ID]"
|
|
369
|
+
HMSET blueque_task_[TASK ID] status failed error [ERROR]
|
|
370
|
+
LPUSH blueque_failed_tasks_[QUEUE] [TASK ID]
|
|
371
|
+
EXEC
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### Delete Finished Task ###
|
|
375
|
+
|
|
376
|
+
Once everybody interested in a task's result (or error) has been
|
|
377
|
+
notified, the task data needs to be deleted from Redis, so that it
|
|
378
|
+
does not leak data.
|
|
379
|
+
|
|
380
|
+
The command to do this is slightly different, depending on whether it
|
|
381
|
+
is complete or failed:
|
|
382
|
+
|
|
383
|
+
```
|
|
384
|
+
MULTI
|
|
385
|
+
DEL blueque_task_[TASK ID]
|
|
386
|
+
LREM blueque_[status]_tasks_[QUEUE] 1 [TASK ID]
|
|
387
|
+
EXEC
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Schedule Task ###
|
|
391
|
+
|
|
392
|
+
A task can be scheduled for execution at a later time by adding it to
|
|
393
|
+
a sorted set, where the score is the timestamp when the task should be
|
|
394
|
+
executed.
|
|
395
|
+
|
|
396
|
+
```
|
|
397
|
+
HMSET blueque_task_[TASK ID] status scheduled queue [QUEUE] parameters [PARAMS] eta [TIMESTAMP]
|
|
398
|
+
ZINCRBY blueque_queues 0 [QUEUE]
|
|
399
|
+
ZADD blueque_scheduled_tasks_[QUEUE] [TIMESTAMP] [TASK ID]
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Enqueue Scheduled Tasks ###
|
|
403
|
+
|
|
404
|
+
A process must be running which periodically checks the scheduled task
|
|
405
|
+
list for each queue and adds them to the queue to be run at the
|
|
406
|
+
scheduled time.
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
WATCH blueque_scheduled_tasks_[QUEUE]
|
|
410
|
+
to_run = ZRANGEBYSCORE blueque_scheduled_tasks_[QUEUE] 0 [CURRENT TIME]
|
|
411
|
+
MULTI
|
|
412
|
+
ZREMRANGEBYSCORE blueque_scheduled_tasks_[QUEUE] 0 [CURRENT TIME]
|
|
413
|
+
LPUSH blueque_pending_tasks_[QUEUE] to_run[0] ... to_run[n]
|
|
414
|
+
for task in to_run:
|
|
415
|
+
HMSET blueque_task_[TASK ID] status pending
|
|
416
|
+
EXEC
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Note that `[CURRENT TIME]` should only be read once, before executing
|
|
420
|
+
the transaction, so that the same tasks which were fetched are the
|
|
421
|
+
ones that are removed.
|
|
422
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
blueque/__init__.py,sha256=ydb7zXAy_VQ5VKlqkZIU2JedKzpPddl_FCrYrwTQkd0,48
|
|
2
|
+
blueque/client.py,sha256=uSMqvryOU0UvRvb3F0GmD84zMSlRkB0K0c9YF0V0-5o,970
|
|
3
|
+
blueque/forking_runner.py,sha256=Y9GlQCpcXWhtKIc6w2Nbg9T2t_guTpLCbBJDE_Wu0UI,2082
|
|
4
|
+
blueque/listener.py,sha256=5xKaEyemPFSvS8wk11SAyW176k69F9mFcZ4rkK8HEAE,1427
|
|
5
|
+
blueque/process_helpers.py,sha256=B7bx6bUjhNPBkki6cSBRQqWRrtE_wKxqhRgOf1wQcgw,132
|
|
6
|
+
blueque/processor.py,sha256=z7FBd_pWyXe3obCbfBGNfcHI7ZfH6cWJVUYKU4OZijQ,610
|
|
7
|
+
blueque/queue.py,sha256=jIV5V5RkmRWjrQgNOPHLfKaWlNoJg4syWMWJIfibsVc,654
|
|
8
|
+
blueque/redis_queue.py,sha256=kTD73AqgXkdVIDQ5iHl-YajG4eDyqCIenHC9qn4_nqM,7155
|
|
9
|
+
blueque/redis_task.py,sha256=1U1b7JAIQCS9V6yxcoolVh-rWv8lcvdpazj7mBlKpPo,759
|
|
10
|
+
blueque/task.py,sha256=iDGK5cWQ_YQYKnnwjB0masFCfXvpbFl29tvC4Ml7OYA,976
|
|
11
|
+
blueque-1.0.0.dist-info/LICENSE,sha256=8q_SGAyD_CufqtdBWZ3-IJX9JVffLkeYc7-Hr2LPER0,524
|
|
12
|
+
blueque-1.0.0.dist-info/METADATA,sha256=t_eOSY8aX8xQDK3N2PVeETgr3q6d_dQ6o_d0PijNgLk,11381
|
|
13
|
+
blueque-1.0.0.dist-info/WHEEL,sha256=d2fvjOD7sXsVzChCqf0Ty0JbHKBaLYwDbGQDwQTnJ50,88
|
|
14
|
+
blueque-1.0.0.dist-info/RECORD,,
|