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,617 @@
1
+ """
2
+ Plugin Manager: Lightweight multiprocessing-based plugin orchestration.
3
+
4
+ Manages plugin lifecycle, pub/sub messaging, environment variables, and services.
5
+ Uses simplified request/response protocol via Channel.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ import logging
10
+ import sys
11
+ import threading
12
+ import torch.multiprocessing as mp
13
+ import time
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from parallelazy.utils.utils import process_start_spawn
17
+ from parallelazy.plugin.plugin_base import PluginDescriptor
18
+ from parallelazy.plugin.plugin_rpc import PluginRPC, RequestEnvGetAll, RequestEnvUnsubscribe, RequestTopicAnnounce
19
+ from parallelazy.plugin.plugin_worker import PluginWorker, Queue, RequestPluginCall, RequestTopicSubscribe, RequestTopicUnsubscribe
20
+ from parallelazy.plugin.plugin_data import CommandType, ProcessStatus, RequestEnvGet, RequestEnvSet, RequestEnvSubscribe, PluginStatus, Subscriber
21
+ from parallelazy.plugin.environment_manager import EnvironmentManager
22
+
23
+
24
+ logger = logging.getLogger(__name__)
25
+ logger.setLevel(logging.DEBUG)
26
+ logging.getLogger().addHandler(logging.StreamHandler(sys.stdout))
27
+
28
+
29
+ process_start_spawn()
30
+
31
+
32
+ @dataclass
33
+ class PluginInfo:
34
+ plugin_id: str
35
+ process: mp.Process
36
+ rpc: PluginRPC
37
+ descriptor: PluginDescriptor
38
+ options: Dict[str, Any]
39
+ stop_confirmed: bool = False
40
+
41
+ class PluginManager:
42
+ """
43
+ Lightweight manager for multiprocessing-based plugin orchestration.
44
+
45
+ Responsibilities:
46
+ - Plugin lifecycle (load, start, stop, unload)
47
+ - Pub/sub topic routing
48
+ - Environment variable management and subscriptions
49
+ - Service request/response dispatch
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ timeout_request: float = 5.0,
55
+ timeout_start: float = 5.0,
56
+ worker_pydebug: bool = None
57
+ ):
58
+ """
59
+ Initialize plugin manager.
60
+
61
+ Args:
62
+ timeout_request: Default timeout for sync requests (seconds)
63
+ timeout_start: Timeout for start command (seconds)
64
+ worker_pydebug: Whether to enable pydebug for worker processes (optional, for debugging plugins)
65
+ """
66
+ self._plugins: Dict[str, PluginInfo] = {} # plugin_id -> PluginInfo
67
+ self._plugins_lock = threading.Lock() # For thread-safe plugin management
68
+
69
+ self._topic_publishers: Dict[str, str] = {} # topic -> plugin_id
70
+ self._topic_subscribers: Dict[str, Dict[str, Subscriber]] = {} # topic -> [queue, queue, ...]
71
+ self._env_subscribers: Dict[str, List[str]] = {} # env_key -> [plugin_id, ...]
72
+
73
+ self._env = EnvironmentManager()
74
+
75
+ self.timeout_request = timeout_request
76
+ self.timeout_start = timeout_start
77
+ self.worker_pydebug = False if worker_pydebug is None else bool(worker_pydebug)
78
+
79
+
80
+ def load_plugins(self, descriptors: List[PluginDescriptor], parallel: bool = True) -> None:
81
+
82
+ """
83
+ Load and initialize multiple plugins.
84
+
85
+ Args:
86
+ descriptors: List of PluginDescriptor objects
87
+ parallel: Whether to load plugins in parallel
88
+ """
89
+
90
+ if parallel:
91
+ barrier = mp.Barrier(len(descriptors) + 1) # +1 for the main process
92
+ for descriptor in descriptors:
93
+ thread = threading.Thread(target=self._load_plugin_thread, args=(descriptor, barrier))
94
+ thread.start()
95
+ barrier.wait() # Wait for all plugins to be loaded
96
+ else:
97
+ for descriptor in descriptors:
98
+ self.load_plugin(descriptor)
99
+
100
+ def _load_plugin_thread(self, descriptor: PluginDescriptor, barrier: 'mp.Barrier') -> None:
101
+ """
102
+ Thread target for loading a plugin and waiting at the barrier.
103
+
104
+ Args:
105
+ descriptor: PluginDescriptor object
106
+ barrier: Multiprocessing barrier for synchronization
107
+ """
108
+ self.load_plugin(descriptor)
109
+ barrier.wait() # Wait for all plugins to be loaded
110
+
111
+ def load_plugin(
112
+ self,
113
+ descriptor: PluginDescriptor,
114
+ options: Optional[Dict[str, Any]] = None
115
+ ) -> bool:
116
+ """
117
+ Load and initialize a plugin (spawn subprocess).
118
+
119
+ Args:
120
+ descriptor: Plugin descriptor
121
+ options: Initial options for plugin (passed to plugin on start)
122
+
123
+ Returns:
124
+ True if loaded successfully
125
+ """
126
+ plugin_id = descriptor.name
127
+ options = options or descriptor.options or {}
128
+
129
+ try:
130
+ plugin_rpc = PluginRPC(plugin_id)
131
+ self._init_plugin_rpc(plugin_rpc)
132
+ worker_channel = plugin_rpc.channel.remote_descriptor() # For worker to manager communication
133
+ initialized = mp.Event()
134
+ initialized.clear() # Ensure not set
135
+
136
+ # Spawn worker process
137
+ process = mp.Process(
138
+ target=PluginWorker.run,
139
+ args=(plugin_id, descriptor.to_dict(), options, worker_channel, initialized, self.worker_pydebug ),
140
+ name=f"plugin-{plugin_id}"
141
+ )
142
+
143
+
144
+ self._plugins[plugin_id] = PluginInfo(
145
+ plugin_id=plugin_id,
146
+ process=process,
147
+ rpc=plugin_rpc,
148
+ descriptor=descriptor,
149
+ options=options
150
+ )
151
+
152
+ process.start()
153
+ initialized.wait(timeout=self.timeout_start)
154
+
155
+ logger.info(f"Plugin {plugin_id} loaded")
156
+ return True
157
+
158
+ except Exception as e:
159
+ logger.error(f"Error loading plugin {descriptor}: {e}")
160
+ return False
161
+
162
+ def start_plugin(self, plugin_id: str) -> bool:
163
+ """
164
+ Start a loaded plugin and register subscriptions.
165
+
166
+ Args:
167
+ plugin_id: Plugin identifier
168
+
169
+ Returns:
170
+ True if started successfully
171
+ """
172
+ if plugin_id not in self._plugins:
173
+ logger.error(f"Plugin {plugin_id} not loaded")
174
+ return False
175
+
176
+ try:
177
+ info = self._plugins[plugin_id]
178
+ pending = info.rpc.plugin_start()
179
+ res = pending.wait(timeout=self.timeout_start)
180
+ if not res:
181
+ logger.warning(f"Plugin {plugin_id} failed to start within {self.timeout_start} seconds")
182
+ return False
183
+ if res.error:
184
+ logger.error(f"Plugin {plugin_id} start error: {res.error}")
185
+ return False
186
+
187
+ return res.result
188
+ except Exception as e:
189
+ logger.error(f"Error starting plugin {plugin_id}: {e}")
190
+ return False
191
+
192
+
193
+ def stop_plugin(self, plugin_id: str) -> bool:
194
+ """
195
+ Stop a running plugin.
196
+
197
+ Args:
198
+ plugin_id: Plugin identifier
199
+
200
+ Returns:
201
+ True if stopped successfully
202
+ """
203
+ if plugin_id not in self._plugins:
204
+ return False
205
+
206
+ try:
207
+ info = self._plugins[plugin_id]
208
+ if not info.process.is_alive(): return True # Already stopped
209
+
210
+
211
+ pending = info.rpc.plugin_stop()
212
+ res = pending.wait(timeout=self.timeout_start)
213
+ if not res:
214
+ logger.warning(f"Plugin {plugin_id} failed to stop within {self.timeout_start} seconds")
215
+ return False
216
+ if res.error:
217
+ logger.error(f"Plugin {plugin_id} stop error: {res.error}")
218
+ return False
219
+
220
+ logger.info(f"Plugin {plugin_id} stop: {res.result}")
221
+ if res.result:
222
+ info.stop_confirmed = True
223
+ return res.result
224
+ except Exception as e:
225
+ logger.error(f"Error stopping plugin {plugin_id}: {e}")
226
+ return False
227
+
228
+ def unload_plugin(self, plugin_id: str) -> bool:
229
+ """
230
+ Unload a plugin and terminate its process.
231
+
232
+ Args:
233
+ plugin_id: Plugin identifier
234
+
235
+ Returns:
236
+ True if unloaded successfully
237
+ """
238
+ try:
239
+ if plugin_id in self._plugins:
240
+ info = self._plugins[plugin_id]
241
+ if info.process.is_alive() and not info.stop_confirmed:
242
+ self.stop_plugin(plugin_id)
243
+
244
+ if plugin_id in self._plugins:
245
+ info = self._plugins[plugin_id]
246
+ process = info.process
247
+
248
+ info.rpc.shutdown() # Clean up RPC resources
249
+
250
+ process.join(timeout=self.timeout_start)
251
+
252
+ if process.is_alive():
253
+ process.terminate()
254
+ process.join(timeout=self.timeout_start)
255
+
256
+ if process.is_alive():
257
+ process.kill()
258
+
259
+ # Clean up environment subscriptions
260
+ for env, plugins in list(self._env_subscribers.items()):
261
+ if plugin_id in plugins:
262
+ plugins.remove(plugin_id)
263
+ if len(plugins) == 0:
264
+ del self._env_subscribers[env]
265
+
266
+ # Clean up topic subscriptions
267
+ for topic, plugins in list(self._topic_subscribers.items()):
268
+ if plugin_id in plugins.keys():
269
+ del plugins[plugin_id]
270
+
271
+ # Clean up topic publishers
272
+ for topic, publisher in list(self._topic_publishers.items()):
273
+ if publisher == plugin_id:
274
+ del self._topic_publishers[topic]
275
+
276
+ del self._plugins[plugin_id]
277
+
278
+ logger.info(f"Plugin {plugin_id} unloaded")
279
+ return True
280
+
281
+ except Exception as e:
282
+ import traceback
283
+ dump = traceback.print_exc()
284
+ logger.error(f"Error unloading plugin {plugin_id}: {e}\n{dump}", stack_info=True)
285
+ return False
286
+
287
+ def start_all_plugins(self) -> None:
288
+ """Start all loaded plugins."""
289
+ for plugin_id in list(self._plugins.keys()):
290
+ self.start_plugin(plugin_id)
291
+
292
+ def stop_all_plugins(self) -> None:
293
+ """Stop all running plugins."""
294
+ for plugin_id in list(self._plugins.keys()):
295
+ self.stop_plugin(plugin_id)
296
+
297
+
298
+ def unload_all_plugins(self) -> None:
299
+ """Shut down the plugin manager and all plugins."""
300
+ self.stop_all_plugins()
301
+ for plugin_id in list(self._plugins.keys()):
302
+ self.unload_plugin(plugin_id)
303
+
304
+ # ========================================================================
305
+ # Utility Methods for Plugin Management
306
+ # ========================================================================
307
+
308
+
309
+
310
+ def plugin_status(self, plugin_id: str) -> Optional[ProcessStatus]:
311
+ """
312
+ Get status of a plugin.
313
+
314
+ Args:
315
+ plugin_id: Plugin identifier
316
+
317
+ Returns:
318
+ ProcessStatus object, or None if not found
319
+ """
320
+ if plugin_id not in self._plugins:
321
+ return None
322
+
323
+ info = self._plugins[plugin_id]
324
+ process = info.process
325
+ alive = process.is_alive()
326
+ pid = process.pid
327
+ worker_status = None
328
+
329
+ if alive:
330
+ try:
331
+ pending = info.rpc.plugin_status()
332
+ status_response = pending.wait(timeout=1.0)
333
+ if status_response is None:
334
+ logger.debug(f"[{plugin_id}] No status received")
335
+ elif status_response.error:
336
+ logger.debug(f"[{plugin_id}] Error getting status: {status_response.error}")
337
+ else:
338
+ worker_status = status_response.result
339
+ except Exception as e:
340
+ logger.debug(f"[{plugin_id}] Failed to get status: {e}")
341
+
342
+ return ProcessStatus(
343
+ plugin_id=plugin_id,
344
+ process_alive=alive,
345
+ process_pid=pid,
346
+ worker_status=worker_status
347
+ )
348
+
349
+ def plugin_status_all(self) -> Dict[str, ProcessStatus]:
350
+ """
351
+ Get status of all plugins.
352
+
353
+ Returns:
354
+ Dictionary of plugin_id -> ProcessStatus
355
+ """
356
+ return {
357
+ plugin_id: self.plugin_status(plugin_id)
358
+ for plugin_id in self._plugins.keys()
359
+ }
360
+
361
+ def plugin_list(self) -> List[str]:
362
+ """List all loaded plugin IDs."""
363
+ return list(self._plugins.keys())
364
+
365
+
366
+
367
+
368
+
369
+ # ========================================================================
370
+ # Environment Variable Management
371
+ # ========================================================================
372
+
373
+ def env_set(self, name:str, value:Any) -> None:
374
+ self._env.set(name, value)
375
+ threading.Thread(target=self._notify_env_update, args=(name,), daemon=True).start()
376
+ return True
377
+
378
+ def _notify_env_update(self, name:str) -> None:
379
+ # Notify subscribed plugins
380
+ if name in self._env_subscribers:
381
+ for plugin_id in self._env_subscribers[name]:
382
+ if plugin_id in self._plugins:
383
+ rpc = self._plugins[plugin_id].rpc
384
+ rpc.env_update(name)
385
+
386
+ def env_get(self, name:str=None, default:Any=None) -> Any:
387
+ if name is None:
388
+ return self._env.get_all()
389
+ return self._env.get(name, default)
390
+
391
+
392
+ def env_subscribe(self, plugin_id: str, var_name: str) -> bool:
393
+ if plugin_id not in self._plugins:
394
+ return False
395
+
396
+ if var_name not in self._env_subscribers:
397
+ self._env_subscribers[var_name] = []
398
+
399
+ if plugin_id not in self._env_subscribers[var_name]:
400
+ self._env_subscribers[var_name].append(plugin_id)
401
+
402
+ return True
403
+
404
+ def env_unsubscribe(self, plugin_id: str, var_name: str) -> bool:
405
+ if plugin_id not in self._plugins:
406
+ return False
407
+
408
+ if var_name in self._env_subscribers and plugin_id in self._env_subscribers[var_name]:
409
+ self._env_subscribers[var_name].remove(plugin_id)
410
+ if not self._env_subscribers[var_name]:
411
+ del self._env_subscribers[var_name]
412
+ return True
413
+
414
+ # ========================================================================
415
+ # Service Management (Optional, for future extension)
416
+ # ========================================================================
417
+
418
+ def topic_publish(self, topic: str, data: Any):
419
+ """
420
+ Publish data to a topic.
421
+
422
+ Args:
423
+ topic: Topic name
424
+ data: Message data
425
+
426
+ Returns:
427
+ Number of subscribers that received the message
428
+ """
429
+
430
+ if topic not in self._topic_subscribers: return True
431
+
432
+ for subscriber in self._topic_subscribers[topic].values():
433
+ try:
434
+ subscriber.queue.put_nowait(data)
435
+ except Exception as e:
436
+ logger.debug(f"Failed to deliver to topic '{topic}': {e}")
437
+
438
+ return True
439
+
440
+ def topic_list(self) -> List[str]:
441
+ return list(self._topic_subscribers.keys())
442
+
443
+ # ========================================================================
444
+ # PLUGIN Lifecycle
445
+ # ========================================================================
446
+
447
+
448
+
449
+ # ========================================================================
450
+ # RPC Responder: Handle requests from plugins
451
+ # ========================================================================
452
+
453
+ def _init_plugin_rpc(self, plugin_rpc: PluginRPC) -> None:
454
+ """Initialize RPC callbacks for a master.
455
+
456
+ #PLUGIN_START = "plugin_start"
457
+ #PLUGIN_STOP = "plugin_stop"
458
+ #PLUGIN_STATUS = "plugin_status"
459
+ #PLUGIN_STATUS_ALL = "plugin_status_all"
460
+ #PLUGIN_ERRORS = "plugin_errors"
461
+ PLUGIN_CALL = "plugin_call"
462
+
463
+ ENV_SET = "env_set"
464
+ ENV_GET = "env_get"
465
+ ENV_GET_ALL = "env_get_all"
466
+ ENV_SUBSCRIBE = "env_subscribe"
467
+ ENV_UNSUBSCRIBE = "env_unsubscribe"
468
+ #ENV_UPDATE = "env_update"
469
+
470
+ TOPIC_SUBSCRIBE = "topic_subscribe"
471
+ TOPIC_UNSUBSCRIBE = "topic_unsubscribe"
472
+ TOPIC_PUBLISH = "topic_publish"
473
+ TOPIC_LIST = "topic_list"
474
+ """
475
+
476
+ #TODO: Handy but later
477
+ #plugin_rpc.on_request(CommandType.PLUGIN_START, self._handle_plugin_start)
478
+ #plugin_rpc.on_request(CommandType.PLUGIN_STOP, self._handle_plugin_stop)
479
+ plugin_rpc.on_request(CommandType.PLUGIN_CALL, self._respond_call) # forward
480
+ #plugin_rpc.on_request(CommandType.PLUGIN_STATUS, self.get_plugin_status)
481
+ #plugin_rpc.on_request(CommandType.PLUGIN_STATUS_ALL, self.get_all_plugin_status)
482
+
483
+ plugin_rpc.on_request(CommandType.ENV_SET, self._respond_env_set)
484
+ plugin_rpc.on_request(CommandType.ENV_GET, self._respond_env_get)
485
+ plugin_rpc.on_request(CommandType.ENV_GET_ALL, self._respond_env_get_all)
486
+ plugin_rpc.on_request(CommandType.ENV_SUBSCRIBE, self._respond_env_subscribe)
487
+ plugin_rpc.on_request(CommandType.ENV_UNSUBSCRIBE, self._respond_env_unsubscribe)
488
+
489
+ plugin_rpc.on_request(CommandType.TOPIC_SUBSCRIBE, self._respond_topic_subscribe)
490
+ plugin_rpc.on_request(CommandType.TOPIC_UNSUBSCRIBE, self._respond_topic_unsubscribe)
491
+ plugin_rpc.on_request(CommandType.TOPIC_ANNOUNCE, self._respond_topic_announce)
492
+ plugin_rpc.on_request(CommandType.TOPIC_LIST, self._respond_topic_list)
493
+
494
+ # ========================================================================
495
+ # LIFECYCLE:
496
+ # ========================================================================
497
+
498
+
499
+
500
+ def _respond_call(self, rpc: PluginRPC, req: RequestPluginCall) -> Any:
501
+
502
+ if req.plugin_id not in self._plugins:
503
+ raise ValueError(f"Plugin {req.plugin_id} not found")
504
+
505
+ dst_rpc = self._plugins[req.plugin_id].rpc
506
+ remote_result = dst_rpc.forward(req)
507
+
508
+ return remote_result
509
+
510
+ # ========================================================================
511
+ # ENVIRONMENT: Variables and Subscriptions
512
+ # ========================================================================
513
+
514
+ def _respond_env_set(self, rpc: PluginRPC, req: RequestEnvSet) -> None:
515
+ return self.env_set(req.name, req.value)
516
+
517
+ def _respond_env_get(self, rpc: PluginRPC, req: RequestEnvGet) -> Any:
518
+ return self.env_get(req.name)
519
+
520
+ def _respond_env_get_all(self, rpc: PluginRPC, req: RequestEnvGetAll) -> Dict[str, Any]:
521
+ return self.env_get()
522
+
523
+ def _respond_env_subscribe(self, rpc: PluginRPC, req: RequestEnvSubscribe) -> bool:
524
+ return self.env_subscribe(req.sender, req.name)
525
+
526
+ def _respond_env_unsubscribe(self, rpc: PluginRPC, req: RequestEnvUnsubscribe) -> bool:
527
+ return self.env_unsubscribe(req.sender, req.name)
528
+
529
+ # ========================================================================
530
+ # TOPIC PUB/SUB
531
+ # ========================================================================
532
+
533
+
534
+ def _respond_topic_subscribe(self, rpc: PluginRPC, req: RequestTopicSubscribe) -> bool:
535
+ """
536
+ Subscribe a plugin to a topic.
537
+ Allow subscription also for non (yet, hopefully) existing topics.
538
+ """
539
+
540
+ sub = req.subscriber
541
+ topic = sub.topic
542
+ plugin_sub = req.sender
543
+
544
+ if plugin_sub not in self._plugins: return False
545
+
546
+ plugin_pub = self._topic_publishers.get(topic,None)
547
+ if plugin_pub is not None and plugin_pub not in self._plugins: return False
548
+
549
+ if topic not in self._topic_subscribers:
550
+ self._topic_subscribers[topic] = {}
551
+
552
+ if plugin_sub not in self._topic_subscribers[topic]:
553
+ self._topic_subscribers[topic][plugin_sub] = sub
554
+
555
+ remote_result = True # Default to success
556
+
557
+ if plugin_pub is not None:
558
+ forward_rpc = self._plugins[plugin_pub].rpc
559
+ remote_result = forward_rpc.forward(req)
560
+ logger.info("MANAGER: topic_subscribe: remote_result: %s", remote_result)
561
+
562
+ if not remote_result: return False
563
+
564
+
565
+
566
+ return True
567
+
568
+
569
+ def _respond_topic_unsubscribe(self, rpc: PluginRPC, req: RequestTopicUnsubscribe) -> bool:
570
+ """
571
+ Unsubscribe a plugin from a topic.
572
+
573
+ Args:
574
+ plugin_id: Plugin identifier
575
+ topic: Topic name
576
+
577
+ Returns:
578
+ True if unsubscription successful
579
+ """
580
+ sub = req.subscriber
581
+ topic = sub.topic
582
+ src_id = sub.plugin_id
583
+
584
+ if src_id not in self._plugins: return False
585
+ if topic not in self._topic_publishers: return False
586
+
587
+ dst_id = self._topic_publishers[topic]
588
+
589
+ forward_rpc = self._plugins[dst_id].rpc
590
+ remote_result = forward_rpc.forward(req)
591
+
592
+ if not remote_result: return False
593
+
594
+
595
+ if src_id in self._topic_subscribers[topic]:
596
+ del self._topic_subscribers[topic][src_id]
597
+ if not self._topic_subscribers[topic]:
598
+ del self._topic_subscribers[topic]
599
+
600
+
601
+ return True
602
+
603
+ def _respond_topic_announce(self, rpc: PluginRPC, req: RequestTopicAnnounce) -> Dict[str, Subscriber]:
604
+ """
605
+ Announce a new topic.
606
+
607
+ """
608
+ self._topic_publishers[req.topic] = req.sender
609
+
610
+ if req.topic not in self._topic_subscribers:
611
+ self._topic_subscribers[req.topic] = {}
612
+ subs = self._topic_subscribers[req.topic]
613
+
614
+ return subs
615
+
616
+ def _respond_topic_list(self) -> List[str]:
617
+ return self.topic_list()