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 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
@@ -0,0 +1,9 @@
1
+ import os
2
+
3
+
4
+ def process_running(pid):
5
+ try:
6
+ os.kill(pid, 0)
7
+ return True
8
+ except OSError:
9
+ return False
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 1.7.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any