-
Notifications
You must be signed in to change notification settings - Fork 45
Expand file tree
/
Copy pathruntime.py
More file actions
367 lines (300 loc) · 15.2 KB
/
Copy pathruntime.py
File metadata and controls
367 lines (300 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
import asyncio
import os
from asyncio import Queue, sleep
from typing import List, Dict
from pydantic import BaseModel
from .node.BaseNode import BaseNode
from aiohttp import ClientSession
from logging import getLogger
logger = getLogger(__name__)
class Runtime:
"""
Runtime for distributed execution of Exosphere nodes.
The `Runtime` class manages the lifecycle and execution of a set of `BaseNode` subclasses
in a distributed environment. It handles node registration, state polling, execution,
and communication with a remote state manager service.
Key Features:
- Registers node schemas and runtime metadata with the state manager.
- Polls for new states to process and enqueues them for execution.
- Spawns worker tasks to execute node logic asynchronously.
- Notifies the state manager of successful or failed executions.
- Handles configuration via constructor arguments or environment variables.
Args:
namespace (str): Namespace for this runtime instance.
name (str): Name of this runtime instance.
nodes (List[type[BaseNode]]): List of node classes to register and execute.
state_manager_uri (str | None, optional): URI of the state manager service.
If not provided, will use the EXOSPHERE_STATE_MANAGER_URI environment variable.
key (str | None, optional): API key for authentication.
If not provided, will use the EXOSPHERE_API_KEY environment variable.
batch_size (int, optional): Number of states to fetch per poll. Defaults to 16.
workers (int, optional): Number of concurrent worker tasks. Defaults to 4.
state_manage_version (str, optional): State manager API version. Defaults to "v0".
poll_interval (int, optional): Seconds between polling for new states. Defaults to 1.
Raises:
ValueError: If configuration is invalid (e.g., missing URI or key, batch_size/workers < 1).
ValidationError: If node classes are invalid or duplicate.
Usage:
runtime = Runtime(namespace="myspace", name="myruntime", nodes=[MyNode])
runtime.start()
"""
def __init__(self, namespace: str, name: str, nodes: List[type[BaseNode]], state_manager_uri: str | None = None, key: str | None = None, batch_size: int = 16, workers: int = 4, state_manage_version: str = "v0", poll_interval: int = 1):
self._name = name
self._namespace = namespace
self._key = key
self._batch_size = batch_size
self._state_queue = Queue(maxsize=2*batch_size)
self._workers = workers
self._nodes = nodes
self._node_names = [node.__name__ for node in nodes]
self._state_manager_uri = state_manager_uri
self._state_manager_version = state_manage_version
self._poll_interval = poll_interval
self._node_mapping = {
node.__name__: node for node in nodes
}
self._set_config_from_env()
self._validate_runtime()
self._validate_nodes()
def _set_config_from_env(self):
"""
Set configuration from environment variables if not provided.
"""
if self._state_manager_uri is None:
self._state_manager_uri = os.environ.get("EXOSPHERE_STATE_MANAGER_URI")
if self._key is None:
self._key = os.environ.get("EXOSPHERE_API_KEY")
def _validate_runtime(self):
"""
Validate runtime configuration.
Raises:
ValueError: If batch_size or workers is less than 1, or if required
configuration (state_manager_uri, key) is not provided.
"""
if self._batch_size < 1:
raise ValueError("Batch size should be at least 1")
if self._workers < 1:
raise ValueError("Workers should be at least 1")
if self._state_manager_uri is None:
raise ValueError("State manager URI is not set")
if self._key is None:
raise ValueError("API key is not set")
def _get_enque_endpoint(self):
"""
Construct the endpoint URL for enqueueing states.
"""
return f"{self._state_manager_uri}/{str(self._state_manager_version)}/namespace/{self._namespace}/states/enqueue"
def _get_executed_endpoint(self, state_id: str):
"""
Construct the endpoint URL for notifying executed states.
"""
return f"{self._state_manager_uri}/{str(self._state_manager_version)}/namespace/{self._namespace}/states/{state_id}/executed"
def _get_errored_endpoint(self, state_id: str):
"""
Construct the endpoint URL for notifying errored states.
"""
return f"{self._state_manager_uri}/{str(self._state_manager_version)}/namespace/{self._namespace}/states/{state_id}/errored"
def _get_register_endpoint(self):
"""
Construct the endpoint URL for registering nodes with the runtime.
"""
return f"{self._state_manager_uri}/{str(self._state_manager_version)}/namespace/{self._namespace}/nodes/"
def _get_secrets_endpoint(self, state_id: str):
"""
Construct the endpoint URL for getting secrets.
"""
return f"{self._state_manager_uri}/{str(self._state_manager_version)}/namespace/{self._namespace}/state/{state_id}/secrets"
async def _register(self):
"""
Register node schemas and runtime metadata with the state manager.
Raises:
RuntimeError: If registration fails.
"""
async with ClientSession() as session:
endpoint = self._get_register_endpoint()
body = {
"runtime_name": self._name,
"runtime_namespace": self._namespace,
"nodes": [
{
"name": node.__name__,
"namespace": self._namespace,
"inputs_schema": node.Inputs.model_json_schema(),
"outputs_schema": node.Outputs.model_json_schema(),
"secrets": [
secret_name for secret_name in node.Secrets.model_fields.keys()
]
} for node in self._nodes
]
}
headers = {"x-api-key": self._key}
async with session.put(endpoint, json=body, headers=headers) as response: # type: ignore
res = await response.json()
if response.status != 200:
raise RuntimeError(f"Failed to register nodes: {res}")
return res
async def _enqueue_call(self):
"""
Request a batch of states to process from the state manager.
Returns:
dict: Response from the state manager containing states to process.
"""
async with ClientSession() as session:
endpoint = self._get_enque_endpoint()
body = {"nodes": self._node_names, "batch_size": self._batch_size}
headers = {"x-api-key": self._key}
async with session.post(endpoint, json=body, headers=headers) as response: # type: ignore
res = await response.json()
if response.status != 200:
logger.error(f"Failed to enqueue states: {res}")
return res
async def _enqueue(self):
"""
Poll the state manager for new states and enqueue them for processing.
This runs continuously, polling at the configured interval.
"""
while True:
try:
if self._state_queue.qsize() < self._batch_size:
data = await self._enqueue_call()
for state in data.get("states", []):
await self._state_queue.put(state)
except Exception as e:
logger.error(f"Error enqueuing states: {e}")
await sleep(self._poll_interval)
async def _notify_executed(self, state_id: str, outputs: List[BaseNode.Outputs]):
"""
Notify the state manager that a state was executed successfully.
Args:
state_id (str): The ID of the executed state.
outputs (List[BaseNode.Outputs]): Outputs from the node execution.
"""
async with ClientSession() as session:
endpoint = self._get_executed_endpoint(state_id)
body = {"outputs": [output.model_dump() for output in outputs]}
headers = {"x-api-key": self._key}
async with session.post(endpoint, json=body, headers=headers) as response: # type: ignore
res = await response.json()
if response.status != 200:
logger.error(f"Failed to notify executed state {state_id}: {res}")
async def _notify_errored(self, state_id: str, error: str):
"""
Notify the state manager that a state execution failed.
Args:
state_id (str): The ID of the errored state.
error (str): The error message.
"""
async with ClientSession() as session:
endpoint = self._get_errored_endpoint(state_id)
body = {"error": error}
headers = {"x-api-key": self._key}
async with session.post(endpoint, json=body, headers=headers) as response: # type: ignore
res = await response.json()
if response.status != 200:
logger.error(f"Failed to notify errored state {state_id}: {res}")
async def _get_secrets(self, state_id: str) -> Dict[str, str]:
"""
Get secrets for a state.
"""
async with ClientSession() as session:
endpoint = self._get_secrets_endpoint(state_id)
headers = {"x-api-key": self._key}
async with session.get(endpoint, headers=headers) as response: # type: ignore
res = await response.json()
if response.status != 200:
logger.error(f"Failed to get secrets for state {state_id}: {res}")
return {}
return res
def _validate_nodes(self):
"""
Validate that all provided nodes are valid BaseNode subclasses.
Args:
nodes (List[type[BaseNode]]): List of node classes to validate.
Returns:
List[type[BaseNode]]: The validated list of node classes.
Raises:
ValidationError: If any node is invalid or duplicate class names are found.
"""
errors = []
for node in self._nodes:
if not issubclass(node, BaseNode):
errors.append(f"{node.__name__} does not inherit from exospherehost.BaseNode")
if not hasattr(node, "Inputs"):
errors.append(f"{node.__name__} does not have an Inputs class")
if not hasattr(node, "Outputs"):
errors.append(f"{node.__name__} does not have an Outputs class")
inputs_is_basemodel = hasattr(node, "Inputs") and issubclass(node.Inputs, BaseModel)
if not inputs_is_basemodel:
errors.append(f"{node.__name__} does not have an Inputs class that inherits from pydantic.BaseModel")
outputs_is_basemodel = hasattr(node, "Outputs") and issubclass(node.Outputs, BaseModel)
if not outputs_is_basemodel:
errors.append(f"{node.__name__} does not have an Outputs class that inherits from pydantic.BaseModel")
if not hasattr(node, "Secrets"):
errors.append(f"{node.__name__} does not have an Secrets class")
secrets_is_basemodel = hasattr(node, "Secrets") and issubclass(node.Secrets, BaseModel)
if not secrets_is_basemodel:
errors.append(f"{node.__name__} does not have an Secrets class that inherits from pydantic.BaseModel")
# check all data objects are strings
if inputs_is_basemodel:
for field_name, field_info in node.Inputs.model_fields.items():
if field_info.annotation is not str:
errors.append(f"{node.__name__}.Inputs field '{field_name}' must be of type str, got {field_info.annotation}")
if outputs_is_basemodel:
for field_name, field_info in node.Outputs.model_fields.items():
if field_info.annotation is not str:
errors.append(f"{node.__name__}.Outputs field '{field_name}' must be of type str, got {field_info.annotation}")
if secrets_is_basemodel:
for field_name, field_info in node.Secrets.model_fields.items():
if field_info.annotation is not str:
errors.append(f"{node.__name__}.Secrets field '{field_name}' must be of type str, got {field_info.annotation}")
# Find nodes with the same __class__.__name__
class_names = [node.__name__ for node in self._nodes]
duplicate_class_names = [name for name in set(class_names) if class_names.count(name) > 1]
if duplicate_class_names:
errors.append(f"Duplicate node class names found: {duplicate_class_names}")
if len(errors) > 0:
raise ValueError("Following errors while validating nodes: " + "\n".join(errors))
async def _worker(self):
"""
Worker task that processes states from the queue.
Continuously fetches states from the queue, executes the corresponding node,
and notifies the state manager of the result.
"""
while True:
state = await self._state_queue.get()
try:
node = self._node_mapping[state["node_name"]]
secrets = await self._get_secrets(state["state_id"])
outputs = await node()._execute(node.Inputs(**state["inputs"]), node.Secrets(**secrets["secrets"]))
if outputs is None:
outputs = []
if isinstance(outputs, BaseNode.Outputs):
outputs = [outputs]
await self._notify_executed(state["state_id"], outputs)
except Exception as e:
await self._notify_errored(state["state_id"], str(e))
self._state_queue.task_done() # type: ignore
async def _start(self):
"""
Start the runtime event loop.
Registers nodes, starts the polling and worker tasks, and runs until stopped.
Raises:
RuntimeError: If the runtime is not connected (no nodes registered).
"""
await self._register()
poller = asyncio.create_task(self._enqueue())
worker_tasks = [asyncio.create_task(self._worker()) for _ in range(self._workers)]
await asyncio.gather(poller, *worker_tasks)
def start(self):
"""
Start the runtime in the current or a new asyncio event loop.
If called from within an existing event loop, returns a task for the runtime.
Otherwise, runs the runtime until completion.
Returns:
asyncio.Task | None: The runtime task if running in an existing event loop, else None.
"""
try:
loop = asyncio.get_running_loop()
return loop.create_task(self._start())
except RuntimeError:
asyncio.run(self._start())