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 @@
|
|
|
1
|
+
process_executor
|
|
@@ -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
|