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 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
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .console import main
4
+
5
+ sys.exit(main())
@@ -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()