solid-jobs-py 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.
- solid_jobs/__init__.py +28 -0
- solid_jobs/__main__.py +5 -0
- solid_jobs/blueprint.py +164 -0
- solid_jobs/catalog.py +285 -0
- solid_jobs/claim.py +319 -0
- solid_jobs/conductor.py +235 -0
- solid_jobs/console.py +118 -0
- solid_jobs/engine.py +179 -0
- solid_jobs/errors.py +38 -0
- solid_jobs/executor.py +36 -0
- solid_jobs/failure_policy.py +98 -0
- solid_jobs/gateway.py +111 -0
- solid_jobs/heartbeat.py +53 -0
- solid_jobs/instrumentation.py +48 -0
- solid_jobs/integrity_check.py +164 -0
- solid_jobs/interceptors.py +48 -0
- solid_jobs/ipc.py +136 -0
- solid_jobs/keyspace.py +40 -0
- solid_jobs/lab.py +53 -0
- solid_jobs/publication_barrier.py +41 -0
- solid_jobs/publisher.py +189 -0
- solid_jobs/recovery.py +88 -0
- solid_jobs/settings.py +32 -0
- solid_jobs/solid_redis_gateway.rb +106 -0
- solid_jobs/task.py +134 -0
- solid_jobs/timer.py +58 -0
- solid_jobs/utilities.py +65 -0
- solid_jobs/version.py +1 -0
- solid_jobs/worker.py +64 -0
- solid_jobs_py-0.1.0.dist-info/METADATA +231 -0
- solid_jobs_py-0.1.0.dist-info/RECORD +34 -0
- solid_jobs_py-0.1.0.dist-info/WHEEL +5 -0
- solid_jobs_py-0.1.0.dist-info/entry_points.txt +2 -0
- solid_jobs_py-0.1.0.dist-info/top_level.txt +1 -0
solid_jobs/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""SolidJobs for Python: Redis task runner on PEP 734 subinterpreters.
|
|
2
|
+
|
|
3
|
+
Redis access goes through the Ruby ``solid-redis`` gem, run as a sidecar.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from . import lab
|
|
7
|
+
from .blueprint import Blueprint
|
|
8
|
+
from .catalog import (Channel, Claims, Counters, DiscardedTasks, Execution, Executions, Node, Nodes,
|
|
9
|
+
PlannedTasks, RetryingTasks, StoredTask)
|
|
10
|
+
from .conductor import Conductor
|
|
11
|
+
from .errors import (ConfigurationError, Error, ExecutionHalt, GatewayError, InvalidArgumentError,
|
|
12
|
+
InvalidJobError, NoCapturedTask, RedisCommandError, RedisConnectionError, RedisError)
|
|
13
|
+
from .integrity_check import IntegrityCheck
|
|
14
|
+
from .publisher import Publisher
|
|
15
|
+
from .settings import config, configure, reset, use_config
|
|
16
|
+
from .task import Task
|
|
17
|
+
from .version import VERSION
|
|
18
|
+
|
|
19
|
+
__version__ = VERSION
|
|
20
|
+
testing = lab.testing
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Blueprint", "Channel", "Claims", "Conductor", "ConfigurationError", "Counters", "DiscardedTasks",
|
|
24
|
+
"Error", "Execution", "ExecutionHalt", "Executions", "GatewayError", "IntegrityCheck",
|
|
25
|
+
"InvalidArgumentError", "InvalidJobError", "NoCapturedTask", "Node", "Nodes", "PlannedTasks",
|
|
26
|
+
"Publisher", "RedisCommandError", "RedisConnectionError", "RedisError", "RetryingTasks",
|
|
27
|
+
"StoredTask", "Task", "VERSION", "config", "configure", "lab", "reset", "testing", "use_config",
|
|
28
|
+
]
|
solid_jobs/__main__.py
ADDED
solid_jobs/blueprint.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import socket
|
|
7
|
+
|
|
8
|
+
from . import gateway
|
|
9
|
+
from .errors import ConfigurationError
|
|
10
|
+
from .instrumentation import NullInstrumenter
|
|
11
|
+
from .interceptors import InterceptorRegistry
|
|
12
|
+
from .ipc import GatewayPool
|
|
13
|
+
from .utilities import class_path, constantize, stringify_keys
|
|
14
|
+
|
|
15
|
+
DEFAULT_TASK_OPTIONS = {"channel": "default", "max_failures": 25}
|
|
16
|
+
CHANNEL_ORDERS = ("priority", "weighted", "shuffle")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _normalize_channels(values):
|
|
20
|
+
if isinstance(values, str):
|
|
21
|
+
values = [values]
|
|
22
|
+
entries = []
|
|
23
|
+
for value in values:
|
|
24
|
+
name, weight = value if isinstance(value, (list, tuple)) else (value, 1)
|
|
25
|
+
name, weight = str(name), int(weight)
|
|
26
|
+
if weight <= 0:
|
|
27
|
+
raise ValueError("Channel weight must be positive")
|
|
28
|
+
entries.append((name, weight))
|
|
29
|
+
return entries
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Blueprint:
|
|
33
|
+
"""Runtime configuration. Redis is reached through the Ruby solid-redis sidecar."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, redis_url=None, concurrency=5, channels=("default",), gateway_socket=None):
|
|
36
|
+
self.redis_url = redis_url or os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/0")
|
|
37
|
+
self.gateway_socket = gateway_socket or os.environ.get("SOLID_JOBS_GATEWAY_SOCKET")
|
|
38
|
+
self.ruby_command = tuple(os.environ.get("SOLID_JOBS_RUBY", "ruby").split())
|
|
39
|
+
self.concurrency = int(concurrency)
|
|
40
|
+
self._channels = _normalize_channels(channels)
|
|
41
|
+
self._default_task_options = dict(DEFAULT_TASK_OPTIONS)
|
|
42
|
+
self.publish_interceptors = InterceptorRegistry()
|
|
43
|
+
self.execute_interceptors = InterceptorRegistry()
|
|
44
|
+
self.on_complex_arguments = "raise"
|
|
45
|
+
self.poll_interval_average = 5.0
|
|
46
|
+
self.shutdown_timeout = 25.0
|
|
47
|
+
self._channel_order = "weighted"
|
|
48
|
+
self.reliable_fetch = True
|
|
49
|
+
self.retry_base_delay = 15.0
|
|
50
|
+
self.retry_max_delay = 3_600.0
|
|
51
|
+
self.discarded_limit = 10_000
|
|
52
|
+
self.discarded_retention = 180 * 24 * 60 * 60
|
|
53
|
+
self.logger = logging.getLogger("solid_jobs")
|
|
54
|
+
self.identity = f"{socket.gethostname()}:{os.getpid()}:{secrets.token_hex(6)}"
|
|
55
|
+
self.instrumenter = NullInstrumenter()
|
|
56
|
+
self.requires = []
|
|
57
|
+
self._callbacks = {}
|
|
58
|
+
self._redis_pool = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def redis_pool(self):
|
|
62
|
+
if self._redis_pool is None:
|
|
63
|
+
path = self.gateway_socket or gateway.ensure(self.redis_url, self.ruby_command)
|
|
64
|
+
self._redis_pool = GatewayPool(path)
|
|
65
|
+
return self._redis_pool
|
|
66
|
+
|
|
67
|
+
def close(self):
|
|
68
|
+
if self._redis_pool is not None:
|
|
69
|
+
self._redis_pool.close()
|
|
70
|
+
self._redis_pool = None
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def channels(self):
|
|
75
|
+
return [name for name, _ in self._channels]
|
|
76
|
+
|
|
77
|
+
@channels.setter
|
|
78
|
+
def channels(self, values):
|
|
79
|
+
self._channels = _normalize_channels(values)
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def channel_entries(self):
|
|
83
|
+
return list(self._channels)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def channel_order(self):
|
|
87
|
+
return self._channel_order
|
|
88
|
+
|
|
89
|
+
@channel_order.setter
|
|
90
|
+
def channel_order(self, mode):
|
|
91
|
+
if mode not in CHANNEL_ORDERS:
|
|
92
|
+
raise ValueError("channel_order must be 'priority', 'weighted', or 'shuffle'")
|
|
93
|
+
self._channel_order = mode
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def default_task_options(self):
|
|
97
|
+
return dict(self._default_task_options)
|
|
98
|
+
|
|
99
|
+
@default_task_options.setter
|
|
100
|
+
def default_task_options(self, options):
|
|
101
|
+
self._default_task_options = {**DEFAULT_TASK_OPTIONS, **stringify_keys(options)}
|
|
102
|
+
|
|
103
|
+
def on(self, event, callback):
|
|
104
|
+
self._callbacks.setdefault(event, []).append(callback)
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def fire(self, event, *arguments):
|
|
108
|
+
for callback in self._callbacks.get(event, ()):
|
|
109
|
+
callback(*arguments)
|
|
110
|
+
|
|
111
|
+
def __repr__(self):
|
|
112
|
+
return f"<Blueprint concurrency={self.concurrency} channels={self.channels!r}>"
|
|
113
|
+
|
|
114
|
+
def snapshot(self):
|
|
115
|
+
"""JSON string a subinterpreter uses to rebuild an equivalent configuration."""
|
|
116
|
+
if self.gateway_socket is None:
|
|
117
|
+
self.gateway_socket = gateway.ensure(self.redis_url, self.ruby_command)
|
|
118
|
+
return json.dumps({
|
|
119
|
+
"redis_url": self.redis_url,
|
|
120
|
+
"gateway_socket": self.gateway_socket,
|
|
121
|
+
"concurrency": self.concurrency,
|
|
122
|
+
"channels": self._channels,
|
|
123
|
+
"channel_order": self._channel_order,
|
|
124
|
+
"reliable_fetch": self.reliable_fetch,
|
|
125
|
+
"default_task_options": self._default_task_options,
|
|
126
|
+
"on_complex_arguments": self.on_complex_arguments,
|
|
127
|
+
"poll_interval_average": self.poll_interval_average,
|
|
128
|
+
"shutdown_timeout": self.shutdown_timeout,
|
|
129
|
+
"discarded_limit": self.discarded_limit,
|
|
130
|
+
"discarded_retention": self.discarded_retention,
|
|
131
|
+
"retry_base_delay": self.retry_base_delay,
|
|
132
|
+
"retry_max_delay": self.retry_max_delay,
|
|
133
|
+
"publish_interceptors": self.publish_interceptors.export(),
|
|
134
|
+
"execute_interceptors": self.execute_interceptors.export(),
|
|
135
|
+
"identity": self.identity,
|
|
136
|
+
"instrumenter": class_path(type(self.instrumenter)),
|
|
137
|
+
"requires": list(self.requires),
|
|
138
|
+
"log_level": self.logger.getEffectiveLevel(),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_snapshot(cls, raw):
|
|
143
|
+
data = json.loads(raw)
|
|
144
|
+
config = cls(
|
|
145
|
+
redis_url=data["redis_url"], concurrency=data["concurrency"],
|
|
146
|
+
channels=data["channels"], gateway_socket=data["gateway_socket"],
|
|
147
|
+
)
|
|
148
|
+
config.channel_order = data["channel_order"]
|
|
149
|
+
for name in ("reliable_fetch", "on_complex_arguments", "poll_interval_average", "shutdown_timeout",
|
|
150
|
+
"discarded_limit", "discarded_retention", "retry_base_delay", "retry_max_delay"):
|
|
151
|
+
setattr(config, name, data[name])
|
|
152
|
+
config.default_task_options = data["default_task_options"]
|
|
153
|
+
config.identity = data["identity"]
|
|
154
|
+
config.requires = data["requires"]
|
|
155
|
+
config.logger.setLevel(data["log_level"])
|
|
156
|
+
for module in config.requires:
|
|
157
|
+
importlib.import_module(module)
|
|
158
|
+
config.publish_interceptors.import_(data["publish_interceptors"])
|
|
159
|
+
config.execute_interceptors.import_(data["execute_interceptors"])
|
|
160
|
+
try:
|
|
161
|
+
config.instrumenter = constantize(data["instrumenter"])()
|
|
162
|
+
except Exception as error:
|
|
163
|
+
raise ConfigurationError(f"instrumenter must be a no-argument class: {error}") from error
|
|
164
|
+
return config
|
solid_jobs/catalog.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Read/inspect API over the SolidJobs Redis keyspace (counters, channels, nodes, claims...)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from . import keyspace, settings
|
|
7
|
+
from .publisher import Publisher
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _cfg(config):
|
|
11
|
+
return config or settings.config()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _scan(pool, pattern):
|
|
15
|
+
cursor, found = "0", []
|
|
16
|
+
while True:
|
|
17
|
+
cursor, batch = pool.call("SCAN", cursor, "MATCH", pattern, "COUNT", 100)
|
|
18
|
+
found.extend(batch)
|
|
19
|
+
if cursor == "0":
|
|
20
|
+
return found
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _time(seconds):
|
|
24
|
+
return datetime.fromtimestamp(float(seconds)) if seconds is not None else None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _pairs(flat):
|
|
28
|
+
flat = flat or []
|
|
29
|
+
return list(zip(flat[0::2], flat[1::2]))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Counters:
|
|
33
|
+
def __init__(self, config=None):
|
|
34
|
+
self.config = _cfg(config)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def _pool(self):
|
|
38
|
+
return self.config.redis_pool
|
|
39
|
+
|
|
40
|
+
def completed(self):
|
|
41
|
+
return int(self._pool.call("GET", keyspace.PROCESSED) or 0)
|
|
42
|
+
|
|
43
|
+
def failed(self):
|
|
44
|
+
return int(self._pool.call("GET", keyspace.FAILED) or 0)
|
|
45
|
+
|
|
46
|
+
def ready(self):
|
|
47
|
+
return sum(channel.size() for channel in Channel.catalog(self.config))
|
|
48
|
+
|
|
49
|
+
def planned(self):
|
|
50
|
+
return int(self._pool.call("ZCARD", keyspace.PLANNED))
|
|
51
|
+
|
|
52
|
+
def retrying(self):
|
|
53
|
+
return int(self._pool.call("ZCARD", keyspace.RETRIES))
|
|
54
|
+
|
|
55
|
+
def discarded(self):
|
|
56
|
+
return int(self._pool.call("ZCARD", keyspace.DISCARDED))
|
|
57
|
+
|
|
58
|
+
def nodes(self):
|
|
59
|
+
return int(self._pool.call("SCARD", keyspace.NODES))
|
|
60
|
+
|
|
61
|
+
def executions(self):
|
|
62
|
+
return sum(node.active_count() for node in Nodes(self.config))
|
|
63
|
+
|
|
64
|
+
def reset(self):
|
|
65
|
+
self._pool.call("DEL", keyspace.PROCESSED, keyspace.FAILED)
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class StoredTask:
|
|
70
|
+
def __init__(self, raw, score=None, config=None, owner=None):
|
|
71
|
+
self.raw = raw
|
|
72
|
+
self.envelope = json.loads(raw)
|
|
73
|
+
self.score = float(score) if score is not None else None
|
|
74
|
+
self.config = _cfg(config)
|
|
75
|
+
self.owner = owner
|
|
76
|
+
|
|
77
|
+
id = property(lambda self: self.envelope.get("id"))
|
|
78
|
+
channel = property(lambda self: self.envelope.get("channel"))
|
|
79
|
+
task = property(lambda self: self.envelope.get("task"))
|
|
80
|
+
arguments = property(lambda self: self.envelope.get("arguments"))
|
|
81
|
+
created_at = property(lambda self: self._millis("created_ms"))
|
|
82
|
+
queued_at = property(lambda self: self._millis("queued_ms"))
|
|
83
|
+
due_at = property(lambda self: _time(self.score))
|
|
84
|
+
|
|
85
|
+
def remove(self):
|
|
86
|
+
if isinstance(self.owner, TimedTasks):
|
|
87
|
+
return self.config.redis_pool.call("ZREM", self.owner.key, self.raw)
|
|
88
|
+
if isinstance(self.owner, Channel):
|
|
89
|
+
return self.config.redis_pool.call("LREM", self.owner.storage_key, 1, self.raw)
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
def requeue(self):
|
|
93
|
+
self.remove()
|
|
94
|
+
return Publisher(config=self.config).publish(self.envelope)
|
|
95
|
+
|
|
96
|
+
def _millis(self, field):
|
|
97
|
+
value = self.envelope.get(field)
|
|
98
|
+
return _time(float(value) / 1000) if value else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Channel:
|
|
102
|
+
def __init__(self, name, config=None):
|
|
103
|
+
self.name = str(name)
|
|
104
|
+
self.config = _cfg(config)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def catalog(cls, config=None):
|
|
108
|
+
config = _cfg(config)
|
|
109
|
+
return [cls(name, config) for name in sorted(config.redis_pool.call("SMEMBERS", keyspace.CHANNELS) or [])]
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def storage_key(self):
|
|
113
|
+
return keyspace.channel(self.name)
|
|
114
|
+
|
|
115
|
+
def __iter__(self):
|
|
116
|
+
for raw in reversed(self.config.redis_pool.call("LRANGE", self.storage_key, 0, -1) or []):
|
|
117
|
+
yield StoredTask(raw, config=self.config, owner=self)
|
|
118
|
+
|
|
119
|
+
def size(self):
|
|
120
|
+
return int(self.config.redis_pool.call("LLEN", self.storage_key))
|
|
121
|
+
|
|
122
|
+
def waiting_time(self):
|
|
123
|
+
import time
|
|
124
|
+
|
|
125
|
+
raw = self.config.redis_pool.call("LINDEX", self.storage_key, -1)
|
|
126
|
+
if not raw:
|
|
127
|
+
return 0.0
|
|
128
|
+
envelope = json.loads(raw)
|
|
129
|
+
stamp = envelope.get("queued_ms") or envelope.get("created_ms")
|
|
130
|
+
return max(time.time() - float(stamp) / 1000, 0.0) if stamp else 0.0
|
|
131
|
+
|
|
132
|
+
def purge(self):
|
|
133
|
+
with self.config.redis_pool.pipelined() as pipeline:
|
|
134
|
+
pipeline.call("DEL", self.storage_key)
|
|
135
|
+
pipeline.call("SREM", keyspace.CHANNELS, self.name)
|
|
136
|
+
return self
|
|
137
|
+
|
|
138
|
+
def paused(self):
|
|
139
|
+
return int(self.config.redis_pool.call("SISMEMBER", keyspace.PAUSED_CHANNELS, self.name)) == 1
|
|
140
|
+
|
|
141
|
+
def pause(self):
|
|
142
|
+
self.config.redis_pool.call("SADD", keyspace.PAUSED_CHANNELS, self.name)
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
def resume(self):
|
|
146
|
+
self.config.redis_pool.call("SREM", keyspace.PAUSED_CHANNELS, self.name)
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TimedTasks:
|
|
151
|
+
def __init__(self, key, config=None):
|
|
152
|
+
self.key = key
|
|
153
|
+
self.config = _cfg(config)
|
|
154
|
+
|
|
155
|
+
def __iter__(self):
|
|
156
|
+
values = self.config.redis_pool.call("ZRANGE", self.key, 0, -1, "WITHSCORES")
|
|
157
|
+
for raw, score in _pairs(values):
|
|
158
|
+
yield StoredTask(raw, score=score, config=self.config, owner=self)
|
|
159
|
+
|
|
160
|
+
def size(self):
|
|
161
|
+
return int(self.config.redis_pool.call("ZCARD", self.key))
|
|
162
|
+
|
|
163
|
+
def purge(self):
|
|
164
|
+
self.config.redis_pool.call("DEL", self.key)
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def locate(self, *ids):
|
|
168
|
+
wanted = {i for group in ids for i in (group if isinstance(group, (list, tuple, set)) else [group])}
|
|
169
|
+
return [task for task in self if task.id in wanted]
|
|
170
|
+
|
|
171
|
+
def requeue_all(self):
|
|
172
|
+
for task in list(self):
|
|
173
|
+
task.requeue()
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class PlannedTasks(TimedTasks):
|
|
177
|
+
def __init__(self, config=None):
|
|
178
|
+
super().__init__(keyspace.PLANNED, config)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class RetryingTasks(TimedTasks):
|
|
182
|
+
def __init__(self, config=None):
|
|
183
|
+
super().__init__(keyspace.RETRIES, config)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class DiscardedTasks(TimedTasks):
|
|
187
|
+
def __init__(self, config=None):
|
|
188
|
+
super().__init__(keyspace.DISCARDED, config)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Execution:
|
|
192
|
+
def __init__(self, node_id, executor_id, raw):
|
|
193
|
+
self.node_id = node_id
|
|
194
|
+
self.executor_id = executor_id
|
|
195
|
+
self.attributes = json.loads(raw)
|
|
196
|
+
|
|
197
|
+
channel = property(lambda self: self.attributes.get("channel"))
|
|
198
|
+
envelope = property(lambda self: self.attributes.get("envelope"))
|
|
199
|
+
claim_token = property(lambda self: self.attributes.get("claim_token"))
|
|
200
|
+
started_at = property(lambda self: _time(self.attributes["started_at"]))
|
|
201
|
+
attempt = property(lambda self: int(self.attributes.get("attempt") or 1))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Node:
|
|
205
|
+
CONTROL_SIGNALS = {"pause": "TSTP", "shutdown": "TERM", "backtraces": "TTIN"}
|
|
206
|
+
|
|
207
|
+
def __init__(self, identity, config=None):
|
|
208
|
+
self.identity = identity
|
|
209
|
+
self.config = _cfg(config)
|
|
210
|
+
self.attributes = dict(_pairs(self.config.redis_pool.call("HGETALL", keyspace.node(identity))))
|
|
211
|
+
self.info = json.loads(self.attributes.get("info", "{}"))
|
|
212
|
+
|
|
213
|
+
hostname = property(lambda self: self.info.get("hostname"))
|
|
214
|
+
pid = property(lambda self: int(self.info.get("pid") or 0))
|
|
215
|
+
channels = property(lambda self: self.info.get("channels") or [])
|
|
216
|
+
|
|
217
|
+
def active_count(self):
|
|
218
|
+
return int(self.attributes.get("busy") or 0)
|
|
219
|
+
|
|
220
|
+
def concurrency(self):
|
|
221
|
+
return int(self.attributes.get("concurrency") or 0)
|
|
222
|
+
|
|
223
|
+
def quiet(self):
|
|
224
|
+
return self.attributes.get("quiet") == "true"
|
|
225
|
+
|
|
226
|
+
def last_seen_at(self):
|
|
227
|
+
return _time(self.attributes["beat"]) if "beat" in self.attributes else None
|
|
228
|
+
|
|
229
|
+
def request_control(self, action):
|
|
230
|
+
signal = self.CONTROL_SIGNALS[action]
|
|
231
|
+
self.config.redis_pool.call("LPUSH", keyspace.node_signals(self.identity), signal)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
def executions(self):
|
|
235
|
+
fields = self.config.redis_pool.call("HGETALL", keyspace.node_work(self.identity))
|
|
236
|
+
return [Execution(self.identity, executor, raw) for executor, raw in _pairs(fields)]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class Nodes:
|
|
240
|
+
def __init__(self, config=None):
|
|
241
|
+
self.config = _cfg(config)
|
|
242
|
+
|
|
243
|
+
def __iter__(self):
|
|
244
|
+
for identity in self.config.redis_pool.call("SMEMBERS", keyspace.NODES) or []:
|
|
245
|
+
node = Node(identity, self.config)
|
|
246
|
+
if node.last_seen_at() is not None:
|
|
247
|
+
yield node
|
|
248
|
+
else:
|
|
249
|
+
self.config.redis_pool.call("SREM", keyspace.NODES, identity)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class ClaimInfo:
|
|
253
|
+
def __init__(self, raw):
|
|
254
|
+
self.attributes = json.loads(raw)
|
|
255
|
+
|
|
256
|
+
task_id = property(lambda self: self.attributes.get("task_id"))
|
|
257
|
+
claim_token = property(lambda self: self.attributes.get("claim_token"))
|
|
258
|
+
node_id = property(lambda self: self.attributes.get("node_id"))
|
|
259
|
+
executor_id = property(lambda self: self.attributes.get("executor_id"))
|
|
260
|
+
channel = property(lambda self: self.attributes.get("channel"))
|
|
261
|
+
attempt = property(lambda self: int(self.attributes["attempt"]))
|
|
262
|
+
claimed_at = property(lambda self: _time(self.attributes["claimed_at"]))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
class Claims:
|
|
266
|
+
def __init__(self, config=None):
|
|
267
|
+
self.config = _cfg(config)
|
|
268
|
+
|
|
269
|
+
def __iter__(self):
|
|
270
|
+
pool = self.config.redis_pool
|
|
271
|
+
for key in _scan(pool, f"{keyspace.PREFIX}:node:*:claims"):
|
|
272
|
+
for raw in pool.call("HVALS", key) or []:
|
|
273
|
+
yield ClaimInfo(raw)
|
|
274
|
+
|
|
275
|
+
def find_task(self, task_id):
|
|
276
|
+
return next((claim for claim in self if claim.task_id == task_id), None)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class Executions:
|
|
280
|
+
def __init__(self, config=None):
|
|
281
|
+
self.nodes = Nodes(config)
|
|
282
|
+
|
|
283
|
+
def __iter__(self):
|
|
284
|
+
for node in self.nodes:
|
|
285
|
+
yield from node.executions()
|