parallelazy 0.0.1__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.
@@ -0,0 +1,511 @@
1
+
2
+
3
+ import itertools
4
+ import time
5
+ from typing import Any, Callable, Dict, Optional
6
+ import logging
7
+ from torch import multiprocessing as mp
8
+ import threading
9
+ from concurrent.futures import ThreadPoolExecutor
10
+ from multiprocessing.connection import wait
11
+ from parallelazy.plugin.plugin_data import Channel, CommandType, Message, PendingRequest, Queue, Request, RequestCallback, RequestEnvGet, RequestEnvGetAll, RequestEnvSet, RequestEnvSubscribe, RequestEnvUnsubscribe, RequestEnvUpdate, RequestPluginCall, RequestPluginErrors, RequestPluginStart, RequestPluginStatus, RequestPluginStatusAll, RequestPluginStop, RequestTopicAnnounce, RequestTopicList, RequestTopicSubscribe, RequestTopicUnsubscribe, Response, Subscriber
12
+ from parallelazy.plugin.plugin_transport import sanitize_for_ipc
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+
19
+ class MessageUID:
20
+ """Thread-safe unique ID generator."""
21
+ def __init__(self, minimal=True):
22
+ self._counter = itertools.count()
23
+ self._lock = mp.Lock()
24
+ self.minimal = minimal
25
+ self._last = 0
26
+
27
+ def __next__(self) -> str:
28
+ with self._lock:
29
+ num = next(self._counter)
30
+ self._last = num
31
+ uid = f"{threading.get_ident()}_{num}"
32
+ if self.minimal: return uid
33
+ return f"{threading.current_thread().name}_{uid}"
34
+
35
+ def next(self) -> str: return self.__next__()
36
+ def current(self) -> str: return self._last
37
+
38
+
39
+ class PluginCoreRPC:
40
+ """
41
+ Wraps bidirectional channel for communication between manager and plugin for a clean API interface:
42
+ - Environment variable access
43
+ - Pub/sub publishing
44
+ - Service invocation
45
+ - Manager state queries
46
+
47
+ Usage in plugin:
48
+ def on_start(self):
49
+ model = self.manager.get_env("model")
50
+ self.manager.set_env("training_status", "started")
51
+ self.manager.publish("training_topic", {"epoch": 1})
52
+ """
53
+
54
+
55
+ def __init__(self, plugin_id: str, channel: Channel=None, timeout: float = 5.0):
56
+ """
57
+ Initialize manager proxy.
58
+
59
+ Args:
60
+ plugin_id: Plugin instance identifier
61
+ channel: Bidirectional channel for communication with manager
62
+ timeout: Request timeout in seconds
63
+ """
64
+ self.plugin_id = plugin_id
65
+ self.channel = channel if channel is not None else Channel()
66
+ self.timeout = timeout
67
+
68
+ self.command_handlers: Dict[str, RequestCallback] = {}
69
+
70
+ self.uid = MessageUID()
71
+ self._running = True
72
+ self._shutdown = Queue()
73
+
74
+ # Request/response routing: cmd_id → Queue(maxsize=1)
75
+ self._pending: Dict[str, PendingRequest] = {}
76
+ self._pending_lock = threading.Lock()
77
+
78
+ # Reader thread routes Responses to reply slots
79
+ self._reader = threading.Thread(target=self._channel_listener, daemon=False)
80
+ self._reader.start()
81
+
82
+ # Persistent pool for executing incoming requests — avoids spawning
83
+ # a fresh OS thread per RPC call (was the source of the stutter).
84
+ self._executor = ThreadPoolExecutor(
85
+ max_workers=8, thread_name_prefix=f"rpc-{plugin_id}"
86
+ )
87
+
88
+ def shutdown(self):
89
+ """Clean up resources."""
90
+ # Step 1: Signal reader to stop
91
+ self._running = False
92
+ self.signal_shutdown()
93
+
94
+
95
+ # Step 3: Wait for reader thread to exit
96
+ self._reader.join(timeout=2.0)
97
+ if self._reader.is_alive():
98
+ logger.warning("Reader thread did not exit cleanly")
99
+
100
+ # Step 3b: Stop accepting new work; let in-flight commands finish.
101
+ # wait=False keeps shutdown() from blocking indefinitely on a command
102
+ # handler that's still running — the pending-request wait loop below
103
+ # covers giving them time to complete.
104
+ self._executor.shutdown(wait=False, cancel_futures=False)
105
+
106
+ # Step 4: Wait for pending requests to be processed (with timeout)
107
+ timeout_start = time.time()
108
+ while self.has_pending_requests() and (time.time() - timeout_start) < 2.0:
109
+ logger.debug(f"Plugin {self.plugin_id} waiting for pending requests to complete...")
110
+ time.sleep(0.05)
111
+
112
+ self.channel.shutdown()
113
+
114
+ def signal_shutdown(self):
115
+ """Wake the reader thread without polling."""
116
+ try:
117
+ self._shutdown.put(None)
118
+ except Exception:
119
+ pass
120
+
121
+
122
+ def forward(self, req: Request, wait=True) -> Any:
123
+ forward_req = req.copy()
124
+
125
+ # Forward the call to the target plugin and wait for response
126
+ remote_result = self.request(forward_req)
127
+ if not wait: return remote_result
128
+
129
+ remote_result = remote_result.wait()
130
+ if remote_result.error:
131
+ raise Exception(f"Error on forwarding {req}: {remote_result.error}")
132
+ return remote_result.result
133
+
134
+ def request(self, req: Request) -> PendingRequest:
135
+ """
136
+ send a request
137
+ """
138
+ if req.message_id is None:
139
+ req.message_id = self.uid.next()
140
+ if req.sender is None:
141
+ req.sender = self.plugin_id
142
+
143
+ pending_request = PendingRequest(req.message_id,)
144
+ with self._pending_lock:
145
+ self._pending[req.message_id] = pending_request
146
+ self.channel.send(req)
147
+ return pending_request
148
+
149
+ def respond(self, req: Request, result: Any = None, error: str = None) -> PendingRequest:
150
+ """
151
+ send a response
152
+ """
153
+ result = sanitize_for_ipc(result)
154
+ error = sanitize_for_ipc(error)
155
+
156
+
157
+ if not self._running:
158
+ logger.debug(f"Plugin {self.plugin_id} RPC is shutting down, response discarded")
159
+ return None
160
+
161
+ error = str(error) if error else None
162
+
163
+ resp = Response(result=result, error=error)
164
+ resp.message_id = req.message_id
165
+ resp.sender = self.plugin_id
166
+
167
+ try:
168
+ self.channel.send(resp)
169
+ except OSError:
170
+ # Channel is closed, likely during shutdown
171
+ logger.debug(f"Plugin {self.plugin_id} could not send response (channel closed)")
172
+
173
+ def has_pending_requests(self) -> bool:
174
+ """Check if there are any pending requests."""
175
+ with self._pending_lock:
176
+ return len(self._pending) > 0
177
+
178
+
179
+ def _channel_listener(self) -> None:
180
+ """
181
+ Background thread: drain incoming Responses and route to reply slots.
182
+
183
+ Puts each Response into its pending request's queue.
184
+ Warns on orphaned responses (request already timed out).
185
+ """
186
+ while self._running:
187
+ try:
188
+ ready = wait([self.channel._receive._conn, self._shutdown.receiver._conn])
189
+ if self._shutdown.receiver._conn in ready:
190
+ self._shutdown.get()
191
+ logger.debug(f"Plugin {self.plugin_id} received shutdown sentinel on channel")
192
+ break
193
+ message = self.channel.receive()
194
+ if isinstance(message, Request):
195
+ self._handle_request(message)
196
+ elif isinstance(message, Response):
197
+ self._handle_response(message)
198
+ else:
199
+ logger.error(f"Received unknown message type: {message}")
200
+ except OSError:
201
+ # Channel closed, exit cleanly
202
+ break
203
+ except Exception as e:
204
+ if self._running:
205
+ logger.error(f"Response router error: {e}", exc_info=True)
206
+ else:
207
+ # Expected during shutdown
208
+ break
209
+
210
+
211
+
212
+ def _handle_request(self, req: Request) -> None:
213
+ """Dispatch request to appropriate handler."""
214
+ try:
215
+ cmd = str(req.command)
216
+ if cmd not in CommandType.commands():
217
+ raise ValueError(f"Unknown command: {cmd}")
218
+ if cmd not in self.command_handlers:
219
+ raise ValueError(f"Command not registered: {cmd} (available: {list(self.command_handlers.keys())})")
220
+
221
+ self._executor.submit(self._execute_command, req)
222
+ except Exception as e:
223
+ logger.error(f"Request handler error: {e}", exc_info=True)
224
+ self.respond(req, error=e)
225
+
226
+ def _handle_response(self, resp: Response) -> bool:
227
+ """Route incoming response to pending request."""
228
+ with self._pending_lock:
229
+ pending_request = self._pending.pop(resp.message_id, None)
230
+
231
+ if pending_request is None:
232
+ logger.error(f"Received response for unknown message_id={resp.message_id} {resp.__dict__()}")
233
+ return False
234
+
235
+ pending_request.set_result(resp)
236
+ return True
237
+
238
+ def _execute_command(self, req: Request) -> Any:
239
+ """
240
+ Execute a command and wait for response.
241
+
242
+ Args:
243
+ command: Command name
244
+ *args: Positional arguments
245
+ **kwargs: Keyword arguments
246
+ """
247
+ cmd = str(req.command)
248
+ if cmd not in self.command_handlers:
249
+ raise ValueError(f"Command not registered: {cmd} (available: {list(self.command_handlers.keys())})")
250
+
251
+ handler = self.command_handlers[cmd]
252
+ result = None
253
+ error = None
254
+ try:
255
+ result = handler(self, req)
256
+ except Exception as e:
257
+ import traceback
258
+ stacktrace = traceback.format_exc()
259
+ error = str(e)
260
+ logger.error(f"Error occurred while executing command '{cmd}': {e}\n{stacktrace}", exc_info=True)
261
+
262
+ self.respond(req, result=result, error=error)
263
+
264
+
265
+ def on_request(self, command_name: CommandType, func: RequestCallback) -> None:
266
+ """
267
+ Register a command handler for an incoming message
268
+
269
+ Args:
270
+ command_name: Name of the command
271
+ func: Function to handle the command
272
+ """
273
+ cmd = str(command_name)
274
+ self.command_handlers[cmd] = func
275
+
276
+
277
+ class PluginLifeCycleRPC(PluginCoreRPC):
278
+ """Convience class with manager-specific commands pre-registered.
279
+ PLUGIN_START = "plugin_start"
280
+ PLUGIN_STOP = "plugin_stop"
281
+ PLUGIN_STATUS = "plugin_status"
282
+ PLUGIN_STATUS_ALL = "plugin_status_all"
283
+ PLUGIN_ERRORS = "plugin_errors"
284
+ PLUGIN_CALL = "plugin_call"
285
+ """
286
+ def plugin_start(self):
287
+ """
288
+ Start the plugin process.
289
+ """
290
+ try:
291
+ return self.request(RequestPluginStart())
292
+ except Exception as e:
293
+ logger.error(f"Failed to start plugin: {e}")
294
+
295
+
296
+ def plugin_stop(self) :
297
+ """
298
+ Stop the plugin process.
299
+ """
300
+ try:
301
+ return self.request(RequestPluginStop())
302
+ except Exception as e:
303
+ logger.error(f"Failed to stop plugin: {e}")
304
+
305
+ def plugin_call(self, plugin_id: str, method_name: str, *args, **kwargs):
306
+ """
307
+ Call a method on the plugin.
308
+
309
+ Args:
310
+ plugin_id: ID of the plugin to call
311
+ method_name: Name of the method to call
312
+ *args: Positional arguments
313
+ **kwargs: Keyword arguments
314
+
315
+ Returns:
316
+ Method return value
317
+ """
318
+ try:
319
+ return self.request(RequestPluginCall(plugin_id=plugin_id, method_name=method_name, args=args, kwargs=kwargs))
320
+ except Exception as e:
321
+ logger.error(f"Failed to call plugin method '{method_name}': {e}")
322
+ return None
323
+
324
+
325
+ # ========================================================================
326
+ # STATUS: Query manager state
327
+ # ========================================================================
328
+
329
+ def plugin_status(self, plugin_id: str=None) -> PendingRequest | None:
330
+ """
331
+ Get status of another plugin.
332
+
333
+ Args:
334
+ plugin_id: Plugin identifier
335
+
336
+ Returns:
337
+ Status dict or None if not found
338
+ """
339
+ try:
340
+ return self.request(RequestPluginStatus(plugin_id=plugin_id))
341
+ except Exception as e:
342
+ logger.debug(f"Failed to get status for {plugin_id}: {e}")
343
+ import traceback
344
+ logger.debug(traceback.format_exc())
345
+
346
+ def plugin_status_all(self):
347
+ """
348
+ List all loaded plugins and their status.
349
+
350
+ Returns:
351
+ Dictionary of plugin_id -> status info
352
+ """
353
+ try:
354
+ return self.request(RequestPluginStatusAll())
355
+ except Exception as e:
356
+ logger.error(f"Failed to list plugins: {e}")
357
+
358
+
359
+ def plugin_errors(self, plugin_id:str = None):
360
+ """
361
+ Get any errors from the manager or other plugins.
362
+
363
+ Returns:
364
+ Dictionary of plugin_id -> error info
365
+ """
366
+ try:
367
+ return self.request(RequestPluginErrors(plugin_id=plugin_id))
368
+ except Exception as e:
369
+ logger.error(f"Failed to get plugin errors: {e}")
370
+ return {}
371
+
372
+ class PluginEnvRPC(PluginCoreRPC):
373
+ """Convience class with plugin-specific commands pre-registered."""
374
+
375
+ # ========================================================================
376
+ # ENVIRONMENT VARIABLES
377
+ # ========================================================================
378
+
379
+ def env_get(self, var_name: str):
380
+ """
381
+ Get environment variable from manager.
382
+
383
+ Args:
384
+ key: Variable key
385
+ default: Default value if not found
386
+
387
+ Returns:
388
+ Variable value
389
+ """
390
+ try:
391
+ return self.request(RequestEnvGet(name=var_name))
392
+ except Exception as e:
393
+ logger.error(f"Failed to get env variable '{var_name}': {e}")
394
+
395
+
396
+ def env_set(self, var_name: str, value: Any):
397
+ """
398
+ Set environment variable in manager (notifies subscribed plugins).
399
+
400
+ Args:
401
+ key: Variable key
402
+ value: Variable value
403
+ """
404
+ try:
405
+ value = sanitize_for_ipc(value)
406
+ return self.request(RequestEnvSet(name=var_name, value=value))
407
+ except Exception as e:
408
+ logger.error(f"Failed to set env variable '{var_name}': {e}")
409
+
410
+ def env_get_all(self):
411
+ """
412
+ Get all environment variables.
413
+
414
+ Returns:
415
+ Dictionary of all env variables
416
+ """
417
+ try:
418
+ return self.request(RequestEnvGetAll())
419
+ except Exception as e:
420
+ logger.error(f"Failed to get all env variables: {e}")
421
+ return {}
422
+
423
+ def env_subscribe(self, var_name: str):
424
+ """
425
+ Subscribe to an environment variable.
426
+
427
+ """
428
+ try:
429
+ return self.request(RequestEnvSubscribe(name=var_name))
430
+ except Exception as e:
431
+ logger.error(f"Failed to subscribe to env variable '{var_name}': {e}")
432
+ return {}
433
+
434
+ def env_unsubscribe(self, var_name: str):
435
+ """
436
+ Unsubscribe from an environment variable.
437
+
438
+ """
439
+ try:
440
+ return self.request(RequestEnvUnsubscribe(name=var_name))
441
+ except Exception as e:
442
+ logger.error(f"Failed to unsubscribe from env variable '{var_name}': {e}")
443
+
444
+ def env_update(self, var_name: str) :
445
+ """
446
+ Update an environment variable.
447
+
448
+ """
449
+ try:
450
+ return self.request(RequestEnvUpdate(name=var_name))
451
+ except Exception as e:
452
+ logger.error(f"Failed to notify update env variable '{var_name}': {e}")
453
+
454
+ class PluginTopicRPC(PluginCoreRPC):
455
+ # ========================================================================
456
+ # PUB/SUB: Publishing
457
+ # ========================================================================
458
+
459
+ def topic_announce(self, topic: str):
460
+ """
461
+ Announce a topic.
462
+
463
+ Args:
464
+ topic: Topic name
465
+ """
466
+ try:
467
+ return self.request(RequestTopicAnnounce(topic=topic))
468
+ except Exception as e:
469
+ logger.error(f"Failed to announce topic '{topic}': {e}")
470
+
471
+ def topic_subscribe(self, subscriber: Subscriber):
472
+ """
473
+ Subscribe to a topic.
474
+
475
+ Args:
476
+ topic: Topic name
477
+ callback: Function to call when a message is received
478
+ """
479
+ try:
480
+ return self.request(RequestTopicSubscribe(subscriber=subscriber))
481
+ except Exception as e:
482
+ logger.error(f"Failed to subscribe to '{subscriber}': {e}")
483
+
484
+ def topic_unsubscribe(self, subscriber: Subscriber):
485
+ """
486
+ Unsubscribe from a topic.
487
+
488
+ Args:
489
+ topic: Topic name
490
+ callback: Function to remove from subscribers
491
+ """
492
+ try:
493
+ return self.request(RequestTopicUnsubscribe(subscriber=subscriber))
494
+ except Exception as e:
495
+ logger.error(f"Failed to unsubscribe from '{subscriber}': {e}")
496
+
497
+ def topic_list(self, plugin_id: str=None):
498
+ """
499
+ List all published topics.
500
+ Returns:
501
+ List of topic names
502
+ """
503
+ try:
504
+ return self.request(RequestTopicList(plugin_id=plugin_id))
505
+ except Exception as e:
506
+ logger.error(f"Failed to list topics: {e}")
507
+
508
+ class PluginRPC(PluginLifeCycleRPC, PluginEnvRPC, PluginTopicRPC):
509
+ """Convience class with all command categories pre-registered."""
510
+ pass
511
+