proc-executor 0.1.2__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,7 @@
1
+ process_executor/__init__.py,sha256=fve8rpG091pQD1vAuaB18rMv30gHOKYCG3OZGulNt5M,213
2
+ process_executor/executor.py,sha256=_PZYILiCQjldtNPKpSQNStyxSVpkvkpD9fVswH8Da-g,13575
3
+ process_executor/shared_context.py,sha256=CNsSBde9MS7rTeXXPL-3iJldma69GLqsKwf8hfnecnc,725
4
+ proc_executor-0.1.2.dist-info/METADATA,sha256=aimB9HdN5A_vjniOV3rSRjQiM3yAKialH4A4h0I3RcE,29080
5
+ proc_executor-0.1.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ proc_executor-0.1.2.dist-info/top_level.txt,sha256=Dkvnoe9egAsMDTib01XbnI9cL7oQeh9zItrv4m2aYBU,17
7
+ proc_executor-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ process_executor
@@ -0,0 +1,11 @@
1
+ """Reusable multiprocessing execution support."""
2
+
3
+ from .executor import ProcessExecutor
4
+ from .shared_context import SharedContext
5
+
6
+ __all__ = [
7
+ "ProcessExecutor",
8
+ "SharedContext",
9
+ ]
10
+
11
+ __version__ = "0.0.0"
@@ -0,0 +1,463 @@
1
+ """Generic multiprocessing job executor."""
2
+
3
+ import multiprocessing
4
+ import time
5
+ import traceback
6
+ import uuid
7
+ from typing import Any, Callable, Dict, Optional, Tuple, Type
8
+
9
+ from .shared_context import SharedContext
10
+
11
+
12
+ _STOP = "__PROCESS_EXECUTOR_STOP__"
13
+
14
+
15
+ def _now() -> float:
16
+ """Return the current UNIX timestamp."""
17
+ return time.time()
18
+
19
+
20
+ def _snapshot(value: Any) -> Dict[str, Any]:
21
+ """Convert a manager proxy value into a normal dictionary."""
22
+ if value is None:
23
+ return {}
24
+
25
+ return dict(value)
26
+
27
+
28
+ def _worker_main(
29
+ worker_id: str,
30
+ worker_factory: Callable[[SharedContext], Any],
31
+ job_queue: Any,
32
+ jobs: Any,
33
+ workers: Any,
34
+ state: Any,
35
+ lock: Any,
36
+ ) -> None:
37
+ """Run the generic worker server loop."""
38
+ context = SharedContext(
39
+ jobs=jobs,
40
+ workers=workers,
41
+ state=state,
42
+ lock=lock,
43
+ )
44
+ instance = worker_factory(context)
45
+
46
+ workers[worker_id] = {
47
+ "worker_id": worker_id,
48
+ "pid": multiprocessing.current_process().pid,
49
+ "state": "idle",
50
+ "current_job_id": None,
51
+ "current_handler": None,
52
+ }
53
+
54
+ while True:
55
+ job = job_queue.get()
56
+
57
+ if job == _STOP:
58
+ break
59
+
60
+ job_id = job["job_id"]
61
+ handler_name = job["handler"]
62
+ args = job["args"]
63
+ kwargs = job["kwargs"]
64
+
65
+ workers[worker_id] = {
66
+ "worker_id": worker_id,
67
+ "pid": multiprocessing.current_process().pid,
68
+ "state": "running",
69
+ "current_job_id": job_id,
70
+ "current_handler": handler_name,
71
+ }
72
+
73
+ metadata = _snapshot(jobs.get(job_id))
74
+ metadata["state"] = "running"
75
+ metadata["worker_id"] = worker_id
76
+ metadata["started_at"] = _now()
77
+ jobs[job_id] = metadata
78
+
79
+ try:
80
+ handler = getattr(instance, handler_name)
81
+ result = handler(*args, **kwargs)
82
+
83
+ metadata = _snapshot(jobs.get(job_id))
84
+ metadata["state"] = "succeeded"
85
+ metadata["result"] = result
86
+ metadata["error"] = None
87
+ metadata["finished_at"] = _now()
88
+ jobs[job_id] = metadata
89
+ except Exception as exc:
90
+ metadata = _snapshot(jobs.get(job_id))
91
+ metadata["state"] = "failed"
92
+ metadata["result"] = None
93
+ metadata["error"] = {
94
+ "type": exc.__class__.__name__,
95
+ "message": str(exc),
96
+ "traceback": traceback.format_exc(),
97
+ }
98
+ metadata["finished_at"] = _now()
99
+ jobs[job_id] = metadata
100
+
101
+ workers[worker_id] = {
102
+ "worker_id": worker_id,
103
+ "pid": multiprocessing.current_process().pid,
104
+ "state": "idle",
105
+ "current_job_id": None,
106
+ "current_handler": None,
107
+ }
108
+
109
+ workers[worker_id] = {
110
+ "worker_id": worker_id,
111
+ "pid": multiprocessing.current_process().pid,
112
+ "state": "stopped",
113
+ "current_job_id": None,
114
+ "current_handler": None,
115
+ }
116
+
117
+
118
+ class _WorkerClassFactory:
119
+ """Instantiate an existing controller class inside each worker process.
120
+
121
+ Args:
122
+ worker_class: Existing controller class to instantiate.
123
+ worker_args: Positional arguments passed to the controller constructor.
124
+ worker_kwargs: Keyword arguments passed to the controller constructor.
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ worker_class: Type[Any],
130
+ worker_args: Tuple[Any, ...],
131
+ worker_kwargs: Dict[str, Any],
132
+ ) -> None:
133
+ self._worker_class = worker_class
134
+ self._worker_args = worker_args
135
+ self._worker_kwargs = worker_kwargs
136
+
137
+ def __call__(
138
+ self,
139
+ context: SharedContext,
140
+ ) -> Any:
141
+ """Create one worker-local controller instance.
142
+
143
+ Args:
144
+ context: Shared executor context. Existing controller classes do
145
+ not need to know about it.
146
+
147
+ Returns:
148
+ Newly created controller instance.
149
+ """
150
+ _ = context
151
+
152
+ return self._worker_class(
153
+ *self._worker_args,
154
+ **self._worker_kwargs,
155
+ )
156
+
157
+
158
+ class ProcessExecutor:
159
+ """Generic multiprocessing execution support.
160
+
161
+ The executor does not know device protocols, transports, or controller
162
+ semantics. A worker can be created either from an existing
163
+ ``worker_factory(context)`` or directly from an existing controller
164
+ ``worker_class``.
165
+
166
+ ``worker_class`` exists so device-side controller classes do not need
167
+ worker-specific wrappers merely to run under multiprocessing.
168
+
169
+ Args:
170
+ worker_factory: Callable that receives ``SharedContext`` and returns
171
+ one worker-local controller instance.
172
+ worker_count: Number of worker processes kept ready.
173
+ start_method: Multiprocessing start method. If omitted, Python's
174
+ platform default is used.
175
+ worker_class: Existing controller class instantiated in each worker.
176
+ worker_args: Positional arguments passed to ``worker_class``.
177
+ worker_kwargs: Keyword arguments passed to ``worker_class``.
178
+ logger: Parent-process logger retained by the executor. The logger is
179
+ not passed into worker processes automatically.
180
+ """
181
+
182
+ def __init__(
183
+ self,
184
+ worker_factory: Optional[Callable[[SharedContext], Any]] = None,
185
+ worker_count: int = 4,
186
+ start_method: Optional[str] = None,
187
+ *,
188
+ worker_class: Optional[Type[Any]] = None,
189
+ worker_args: Optional[Tuple[Any, ...]] = None,
190
+ worker_kwargs: Optional[Dict[str, Any]] = None,
191
+ logger: Any = None,
192
+ ) -> None:
193
+ if worker_count < 1:
194
+ raise ValueError("worker_count must be 1 or greater")
195
+
196
+ if worker_factory is None:
197
+ if worker_class is None:
198
+ raise ValueError(
199
+ "worker_factory or worker_class must be specified"
200
+ )
201
+
202
+ if worker_args is None:
203
+ worker_args = ()
204
+
205
+ if worker_kwargs is None:
206
+ worker_kwargs = {}
207
+
208
+ self._worker_factory = _WorkerClassFactory(
209
+ worker_class=worker_class,
210
+ worker_args=worker_args,
211
+ worker_kwargs=worker_kwargs,
212
+ )
213
+ else:
214
+ if worker_class is not None:
215
+ raise ValueError(
216
+ "worker_factory and worker_class cannot be used together"
217
+ )
218
+
219
+ if worker_args is not None:
220
+ raise ValueError(
221
+ "worker_args is only valid with worker_class"
222
+ )
223
+
224
+ if worker_kwargs is not None:
225
+ raise ValueError(
226
+ "worker_kwargs is only valid with worker_class"
227
+ )
228
+
229
+ self._worker_factory = worker_factory
230
+
231
+ self._worker_count = worker_count
232
+ self._logger = logger
233
+ self._mp_context = multiprocessing.get_context(start_method)
234
+
235
+ self._manager = self._mp_context.Manager()
236
+ self._jobs = self._manager.dict()
237
+ self._workers = self._manager.dict()
238
+ self._state = self._manager.dict()
239
+ self._lock = self._manager.RLock()
240
+ self._job_queue = self._mp_context.Queue()
241
+
242
+ self._processes: Dict[str, multiprocessing.Process] = {}
243
+ self._closed = False
244
+
245
+ self._start_workers()
246
+
247
+ @property
248
+ def shared_context(self) -> SharedContext:
249
+ """Return the shared context for the parent controller."""
250
+ return SharedContext(
251
+ jobs=self._jobs,
252
+ workers=self._workers,
253
+ state=self._state,
254
+ lock=self._lock,
255
+ )
256
+
257
+ def submit(self, handler: str, *args: Any, **kwargs: Any) -> str:
258
+ """Submit a handler job and immediately return its job ID."""
259
+ if self._closed:
260
+ raise RuntimeError("ProcessExecutor is already closed")
261
+
262
+ if not isinstance(handler, str):
263
+ raise TypeError("handler must be str")
264
+
265
+ if handler == "":
266
+ raise ValueError("handler must not be empty")
267
+
268
+ self._replenish_workers()
269
+
270
+ job_id = uuid.uuid4().hex
271
+ self._jobs[job_id] = {
272
+ "job_id": job_id,
273
+ "handler": handler,
274
+ "args": args,
275
+ "kwargs": kwargs,
276
+ "state": "queued",
277
+ "worker_id": None,
278
+ "result": None,
279
+ "error": None,
280
+ "created_at": _now(),
281
+ "started_at": None,
282
+ "finished_at": None,
283
+ }
284
+
285
+ self._job_queue.put(
286
+ {
287
+ "job_id": job_id,
288
+ "handler": handler,
289
+ "args": args,
290
+ "kwargs": kwargs,
291
+ }
292
+ )
293
+
294
+ return job_id
295
+
296
+ def state(self, job_id: str) -> Optional[str]:
297
+ """Return a job state."""
298
+ metadata = self.job(job_id)
299
+
300
+ if metadata is None:
301
+ return None
302
+
303
+ return metadata.get("state")
304
+
305
+ def job(self, job_id: str) -> Optional[Dict[str, Any]]:
306
+ """Return a snapshot of one job."""
307
+ metadata = self._jobs.get(job_id)
308
+
309
+ if metadata is None:
310
+ return None
311
+
312
+ return dict(metadata)
313
+
314
+ def jobs(self) -> Dict[str, Dict[str, Any]]:
315
+ """Return snapshots of all jobs."""
316
+ result: Dict[str, Dict[str, Any]] = {}
317
+
318
+ for job_id, metadata in self._jobs.items():
319
+ result[job_id] = dict(metadata)
320
+
321
+ return result
322
+
323
+ def worker(self, worker_id: str) -> Optional[Dict[str, Any]]:
324
+ """Return a snapshot of one worker."""
325
+ self._replenish_workers()
326
+ metadata = self._workers.get(worker_id)
327
+
328
+ if metadata is None:
329
+ return None
330
+
331
+ return dict(metadata)
332
+
333
+ def workers(self) -> Dict[str, Dict[str, Any]]:
334
+ """Return snapshots of all workers."""
335
+ self._replenish_workers()
336
+ result: Dict[str, Dict[str, Any]] = {}
337
+
338
+ for worker_id, metadata in self._workers.items():
339
+ result[worker_id] = dict(metadata)
340
+
341
+ return result
342
+
343
+ def result(self, job_id: str) -> Any:
344
+ """Return the result of a completed job."""
345
+ metadata = self.job(job_id)
346
+
347
+ if metadata is None:
348
+ raise KeyError(job_id)
349
+
350
+ job_state = metadata["state"]
351
+
352
+ if job_state == "failed":
353
+ raise RuntimeError(str(metadata.get("error")))
354
+
355
+ if job_state != "succeeded":
356
+ raise RuntimeError("job is not completed")
357
+
358
+ return metadata.get("result")
359
+
360
+ def wait(
361
+ self,
362
+ job_id: str,
363
+ timeout_sec: Optional[float] = None,
364
+ interval_sec: float = 0.01,
365
+ ) -> Any:
366
+ """Explicitly wait for one job and return its result."""
367
+ started_at = time.monotonic()
368
+
369
+ while True:
370
+ metadata = self.job(job_id)
371
+
372
+ if metadata is None:
373
+ raise KeyError(job_id)
374
+
375
+ job_state = metadata["state"]
376
+
377
+ if job_state == "succeeded":
378
+ return metadata.get("result")
379
+
380
+ if job_state == "failed":
381
+ raise RuntimeError(str(metadata.get("error")))
382
+
383
+ if timeout_sec is not None:
384
+ elapsed = time.monotonic() - started_at
385
+
386
+ if elapsed >= timeout_sec:
387
+ raise TimeoutError("waiting for job timed out")
388
+
389
+ time.sleep(interval_sec)
390
+
391
+ def close(self) -> None:
392
+ """Stop workers and shut down shared resources."""
393
+ if self._closed:
394
+ return
395
+
396
+ self._closed = True
397
+ alive_processes = []
398
+
399
+ for process in self._processes.values():
400
+ if process.is_alive():
401
+ alive_processes.append(process)
402
+
403
+ for _process in alive_processes:
404
+ self._job_queue.put(_STOP)
405
+
406
+ for process in alive_processes:
407
+ process.join()
408
+
409
+ self._job_queue.close()
410
+ self._manager.shutdown()
411
+
412
+ def __enter__(self) -> "ProcessExecutor":
413
+ return self
414
+
415
+ def __exit__(self, exc_type: Any, exc_value: Any, exc_tb: Any) -> None:
416
+ self.close()
417
+
418
+ def _start_workers(self) -> None:
419
+ """Start the requested number of workers."""
420
+ while len(self._processes) < self._worker_count:
421
+ self._start_worker()
422
+
423
+ def _start_worker(self) -> None:
424
+ """Start one generic worker."""
425
+ worker_id = uuid.uuid4().hex
426
+ process = self._mp_context.Process(
427
+ target=_worker_main,
428
+ args=(
429
+ worker_id,
430
+ self._worker_factory,
431
+ self._job_queue,
432
+ self._jobs,
433
+ self._workers,
434
+ self._state,
435
+ self._lock,
436
+ ),
437
+ )
438
+ process.daemon = True
439
+ process.start()
440
+
441
+ self._processes[worker_id] = process
442
+
443
+ def _replenish_workers(self) -> None:
444
+ """Remove dead workers and restore the configured worker count."""
445
+ dead_worker_ids = []
446
+
447
+ for worker_id, process in self._processes.items():
448
+ if not process.is_alive():
449
+ dead_worker_ids.append(worker_id)
450
+
451
+ for worker_id in dead_worker_ids:
452
+ process = self._processes.pop(worker_id)
453
+ process.join()
454
+
455
+ metadata = self._workers.get(worker_id)
456
+
457
+ if metadata is not None:
458
+ snapshot = dict(metadata)
459
+ snapshot["state"] = "dead"
460
+ self._workers[worker_id] = snapshot
461
+
462
+ while len(self._processes) < self._worker_count:
463
+ self._start_worker()
@@ -0,0 +1,26 @@
1
+ """Shared context used by worker processes."""
2
+
3
+ from typing import Any, MutableMapping
4
+
5
+
6
+ class SharedContext:
7
+ """Shared state and lock for worker processes.
8
+
9
+ Args:
10
+ jobs: Shared mapping containing job metadata.
11
+ workers: Shared mapping containing worker metadata.
12
+ state: Shared mapping reserved for controller-specific shared state.
13
+ lock: Process-safe lock shared by all workers.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ jobs: MutableMapping[str, Any],
19
+ workers: MutableMapping[str, Any],
20
+ state: MutableMapping[str, Any],
21
+ lock: Any,
22
+ ) -> None:
23
+ self.jobs = jobs
24
+ self.workers = workers
25
+ self.state = state
26
+ self.lock = lock