conductor-python 1.4.0__py3-none-any.whl → 1.5.0__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.
@@ -4,10 +4,11 @@ import importlib
4
4
  import inspect
5
5
  import logging
6
6
  import os
7
+ import pickle
7
8
  import signal
8
9
  import threading
9
10
  import time
10
- from multiprocessing import Process, freeze_support, Queue, set_start_method
11
+ from multiprocessing import Process, freeze_support, Queue, set_start_method, get_start_method
11
12
  from sys import platform
12
13
  from typing import List, Optional, Any, Dict
13
14
 
@@ -37,12 +38,25 @@ _VALID_MP_START_METHODS = {"spawn", "fork", "forkserver"}
37
38
  _mp_fork_set = False
38
39
  if not _mp_fork_set:
39
40
  try:
40
- # The prometheus_client library holds a module-level threading lock;
41
- # forking while that lock is held causes a deadlock in child processes.
42
- # Set CONDUCTOR_MP_START_METHOD=spawn to avoid this if you hit the
43
- # deadlock. Default is fork for backward compatibility (spawn requires
44
- # all Process arguments to be picklable).
45
- _default_method = "spawn" if platform == "win32" else "fork"
41
+ # Default start method: "spawn" on every platform.
42
+ #
43
+ # fork() is fundamentally unsafe in a process that holds native
44
+ # framework state or non-main threads:
45
+ # - macOS: Apple frameworks (CFNetwork, Security, libdispatch) may
46
+ # SIGSEGV in forked children, producing silently-restarting worker
47
+ # processes with exitcode=-11. CPython switched its own macOS
48
+ # default to spawn in 3.8 for the same reason (bpo-33725).
49
+ # - all POSIX: forking while another thread holds a lock (e.g. the
50
+ # prometheus_client module-level lock, or the TaskHandler monitor
51
+ # thread restarting a worker) leaves that lock permanently held in
52
+ # the child -> silent deadlock.
53
+ # Workers (including the @worker_task decorator path) are pickle-safe,
54
+ # so spawn works everywhere. Deployments that rely on fork's
55
+ # copy-on-write inheritance can opt back in with
56
+ # CONDUCTOR_MP_START_METHOD=fork (re-exposing the hazards above).
57
+ # NOTE: spawn requires the standard `if __name__ == "__main__":` guard
58
+ # in the entrypoint script.
59
+ _default_method = "spawn"
46
60
  _method = os.environ.get("CONDUCTOR_MP_START_METHOD", "").strip().lower() or _default_method
47
61
  if _method not in _VALID_MP_START_METHODS:
48
62
  logger.warning(
@@ -341,8 +355,26 @@ class TaskHandler:
341
355
  logger.info("Starting worker processes...")
342
356
  freeze_support()
343
357
  self._monitor_stop_event.clear()
344
- self.__start_task_runner_processes()
345
- self.__start_metrics_provider_process()
358
+ try:
359
+ self.__start_task_runner_processes()
360
+ self.__start_metrics_provider_process()
361
+ except (TypeError, pickle.PicklingError, AttributeError) as e:
362
+ # Under the 'spawn'/'forkserver' start methods, Process arguments
363
+ # are pickled at start(); unpicklable state fails here. Clean up
364
+ # everything already started - otherwise the non-daemon logger
365
+ # subprocess keeps the interpreter alive forever after the
366
+ # exception (observed as a hang after "cannot pickle
367
+ # '_thread.lock' object").
368
+ logger.error(
369
+ "Failed to start worker processes with start method %r: %s. "
370
+ "Workers must be defined at module level (not nested inside "
371
+ "functions), and configuration, metrics_settings and "
372
+ "event_listeners must be picklable. "
373
+ "See https://github.com/conductor-oss/conductor-python/issues/264",
374
+ get_start_method(allow_none=True), e,
375
+ )
376
+ self.stop_processes()
377
+ raise
346
378
  self.__start_monitor_thread()
347
379
  logger.info("Started all processes")
348
380
 
@@ -445,6 +477,21 @@ class TaskHandler:
445
477
  worker = self.workers[i] if i < len(self.workers) else None
446
478
  worker_name = worker.get_task_definition_name() if worker is not None else f"worker[{i}]"
447
479
  logger.warning("Worker process exited (worker=%s, pid=%s, exitcode=%s)", worker_name, process.pid, exitcode)
480
+ if exitcode < 0:
481
+ # Negative exitcode == killed by signal (e.g. -11 is
482
+ # SIGSEGV). Under the 'fork' start method this is the
483
+ # classic symptom of fork-unsafe native libraries in the
484
+ # parent (Apple frameworks on macOS, numpy/Accelerate,
485
+ # grpc, ...).
486
+ logger.warning(
487
+ "Worker process %s was killed by signal %s. If this "
488
+ "repeats on every restart, the process is likely "
489
+ "crashing at startup: use the 'spawn' start method "
490
+ "(CONDUCTOR_MP_START_METHOD=spawn, the default) and "
491
+ "set PYTHONFAULTHANDLER=1 to capture the crashing "
492
+ "stack trace.",
493
+ worker_name, -exitcode,
494
+ )
448
495
  if not self.restart_on_failure:
449
496
  continue
450
497
  self.__restart_worker_process(i)
@@ -2,8 +2,10 @@ from __future__ import annotations
2
2
  import asyncio
3
3
  import atexit
4
4
  import dataclasses
5
+ import importlib
5
6
  import inspect
6
7
  import logging
8
+ import sys
7
9
  import threading
8
10
  import time
9
11
  import traceback
@@ -275,6 +277,79 @@ class BackgroundEventLoop:
275
277
  logger.debug("Background event loop stopped")
276
278
 
277
279
 
280
+ class _ExecuteFunctionReference:
281
+ """Pickle surrogate for functions whose module-level name was rebound.
282
+
283
+ The ``@worker_task`` decorator registers the *original* function but rebinds
284
+ the module-level name to its wrapper. Standard pickle-by-reference then
285
+ fails with ``PicklingError: it's not the same object as module.name`` when
286
+ the ``spawn``/``forkserver`` start methods pickle Process arguments
287
+ (GitHub issues #264/#271).
288
+
289
+ Instead of the raw function we pickle ``(module, qualname, unwrap_depth)``
290
+ and re-resolve in the child process: import the module, walk the qualname,
291
+ then follow ``__wrapped__`` (set by ``functools.wraps``) ``unwrap_depth``
292
+ times to get back to the registered function.
293
+ """
294
+
295
+ __slots__ = ("module_name", "qualname", "unwrap_depth")
296
+
297
+ def __init__(self, module_name: str, qualname: str, unwrap_depth: int):
298
+ self.module_name = module_name
299
+ self.qualname = qualname
300
+ self.unwrap_depth = unwrap_depth
301
+
302
+ def resolve(self) -> Callable:
303
+ module = importlib.import_module(self.module_name)
304
+ obj: Any = module
305
+ for part in self.qualname.split("."):
306
+ obj = getattr(obj, part)
307
+ for _ in range(self.unwrap_depth):
308
+ obj = obj.__wrapped__
309
+ return obj
310
+
311
+
312
+ _MAX_UNWRAP_DEPTH = 32
313
+
314
+
315
+ def _importable_function_reference(func: Any) -> Optional[_ExecuteFunctionReference]:
316
+ """Return a pickle surrogate for *func* if it needs one, else None.
317
+
318
+ None is returned when normal pickle-by-reference works (module-level
319
+ function whose name still points at itself) or when the function is not
320
+ resolvable by import at all (lambdas, closures, bound methods); in the
321
+ latter case pickling fails with the standard, descriptive pickle error.
322
+ """
323
+ if not inspect.isfunction(func):
324
+ return None
325
+ module_name = getattr(func, "__module__", None)
326
+ qualname = getattr(func, "__qualname__", None)
327
+ if not module_name or not qualname or "<locals>" in qualname or "<lambda>" in qualname:
328
+ return None
329
+ module = sys.modules.get(module_name)
330
+ if module is None:
331
+ return None
332
+ obj: Any = module
333
+ try:
334
+ for part in qualname.split("."):
335
+ obj = getattr(obj, part)
336
+ except AttributeError:
337
+ return None
338
+ if obj is func:
339
+ # Plain module-level function: default pickling works, no surrogate.
340
+ return None
341
+ # The name was rebound (typically by @worker_task). Walk the wrapper chain
342
+ # to find the registered function and record how many hops it takes.
343
+ depth = 0
344
+ current = obj
345
+ while hasattr(current, "__wrapped__") and depth < _MAX_UNWRAP_DEPTH:
346
+ current = current.__wrapped__
347
+ depth += 1
348
+ if current is func:
349
+ return _ExecuteFunctionReference(module_name, qualname, depth)
350
+ return None
351
+
352
+
278
353
  def is_callable_input_parameter_a_task(callable: ExecuteTaskFunction, object_type: Any) -> bool:
279
354
  parameters = inspect.signature(callable).parameters
280
355
  if len(parameters) != 1:
@@ -306,7 +381,11 @@ class Worker(WorkerInterface):
306
381
  register_schema: bool = False
307
382
  ) -> Self:
308
383
  super().__init__(task_definition_name)
309
- self.api_client = ApiClient()
384
+ # Lazily constructed (see the api_client property): an eager ApiClient
385
+ # holds an httpx.Client and threading locks, which made Worker
386
+ # unpicklable under the 'spawn' start method and initialized TLS state
387
+ # in the parent process before fork() (unsafe on macOS).
388
+ self._api_client = None
310
389
  if poll_interval is None:
311
390
  self.poll_interval = DEFAULT_POLLING_INTERVAL
312
391
  else:
@@ -335,6 +414,46 @@ class Worker(WorkerInterface):
335
414
  # Add thread lock for safe concurrent access to _pending_async_tasks
336
415
  self._pending_tasks_lock = threading.Lock()
337
416
 
417
+ @property
418
+ def api_client(self) -> ApiClient:
419
+ """ApiClient used for output serialization, created on first use.
420
+
421
+ Lazy so that Worker instances remain picklable (required by the
422
+ 'spawn'/'forkserver' multiprocessing start methods) and so the parent
423
+ process never initializes HTTP/TLS state on behalf of workers.
424
+ """
425
+ if self._api_client is None:
426
+ self._api_client = ApiClient()
427
+ return self._api_client
428
+
429
+ @api_client.setter
430
+ def api_client(self, value: ApiClient) -> None:
431
+ self._api_client = value
432
+
433
+ def __getstate__(self):
434
+ state = self.__dict__.copy()
435
+ # Process-local runtime state must not cross process boundaries: the
436
+ # 'spawn'/'forkserver' start methods pickle Process args (issues
437
+ # #264/#271: "cannot pickle '_thread.lock' object"). Everything below
438
+ # is rebuilt lazily in the child process.
439
+ state["_api_client"] = None
440
+ state["_background_loop"] = None
441
+ state["_pending_async_tasks"] = {}
442
+ state["_pending_tasks_lock"] = None
443
+ ref = _importable_function_reference(state.get("_execute_function"))
444
+ if ref is not None:
445
+ state["_execute_function"] = ref
446
+ return state
447
+
448
+ def __setstate__(self, state):
449
+ self.__dict__.update(state)
450
+ if self._pending_tasks_lock is None:
451
+ self._pending_tasks_lock = threading.Lock()
452
+ if isinstance(self._execute_function, _ExecuteFunctionReference):
453
+ # Assign through the property setter so the signature-derived
454
+ # flags are recomputed against the resolved function.
455
+ self.execute_function = self._execute_function.resolve()
456
+
338
457
  def execute(self, task: Task) -> TaskResult:
339
458
  task_input = {}
340
459
  task_output = None
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conductor-python
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: Python SDK for working with https://github.com/conductor-oss/conductor
5
5
  License: Apache-2.0
6
6
  Author: Orkes
@@ -9,7 +9,7 @@ conductor/client/automator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJ
9
9
  conductor/client/automator/async_task_runner.py,sha256=VtG-3300GJK3klCX-WuxjUfkcpVcwayzi5tTUqAxiWw,50394
10
10
  conductor/client/automator/json_schema_generator.py,sha256=Rgi74jBctJXgqv3SQl9gi062tQvIQRrFwVXIIztig6E,9718
11
11
  conductor/client/automator/lease_tracker.py,sha256=0mOB_3G-RV8QlCtQFUTCRW8rb1VVDw0dAaa4v1bUTX4,8541
12
- conductor/client/automator/task_handler.py,sha256=IrlNCPMcTiFN44kfQXM2M2AZ2-qveiz_0bmmBUTFkGo,29876
12
+ conductor/client/automator/task_handler.py,sha256=5b2EhVyQwDF_Y-GQ4UsdUh4Inf_0nVj-dTlgNqfyKXs,32576
13
13
  conductor/client/automator/task_runner.py,sha256=yrBYjAJy71a_GA8BEIpqQCwAMpOVwYn0YJE4o1f5YnY,58255
14
14
  conductor/client/automator/utils.py,sha256=IenXCf_LLORSCD06XBOE_IbVu4yaOiELxH46-xNe7dE,7593
15
15
  conductor/client/configuration/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -185,7 +185,7 @@ conductor/client/telemetry/model/metric_label.py,sha256=NVC9GWsDMMBSIK38AjA4W4ih
185
185
  conductor/client/telemetry/model/metric_name.py,sha256=7_WEMWMhv_zu8yKYFJ-r6gqKtHj5brwq79UJYsfv2RY,1361
186
186
  conductor/client/worker/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
187
187
  conductor/client/worker/exception.py,sha256=-QzD8EdJoRBLaVzNOOIdTpvQWm4XvLeTTZxgPz3dtYc,120
188
- conductor/client/worker/worker.py,sha256=E6OSwrcoxzK1zxMp4jv8P_ktGttTZzGWQn4VIsR9-40,24525
188
+ conductor/client/worker/worker.py,sha256=rpR7bkzr--a6xJEWRG7qzpGbQxa4-gedvr8sCet0-xA,29412
189
189
  conductor/client/worker/worker_config.py,sha256=zrG2xgzNkQfic_3HrPAQwsB9YSybU48IDBrkE1JxsA8,15274
190
190
  conductor/client/worker/worker_interface.py,sha256=79ow6eqVOh4ogl51vXYKJYPlXpEi_jTWK-sMLEauRuE,5970
191
191
  conductor/client/worker/worker_loader.py,sha256=egEXSyHsh714NZEFcR7A3OiGvUezloGPHu8xjaDlJOo,10552
@@ -243,6 +243,6 @@ conductor/client/workflow/task/timeout_policy.py,sha256=JzoZGtCQ4scykATG1oXksHTS
243
243
  conductor/client/workflow/task/wait_for_webhook_task.py,sha256=irmcSu-sKoR8AHff_KCsQYTn2RIwXdize9bck0Jc4Uc,1546
244
244
  conductor/client/workflow/task/wait_task.py,sha256=yHfuFf7N0HhNMP4LHU6_sw2AOVDAcnCb-6cS9sRoeW0,1618
245
245
  conductor/client/workflow_client.py,sha256=sr2c9oYvm7unCKsuuYaJB_NIdfqz2SnyS1_DK11KV1M,4821
246
- conductor_python-1.4.0.dist-info/METADATA,sha256=xe5BM9OsyiIhjG2beJ0w7m5dSys8Tbcuia7qguhk4Rg,27225
247
- conductor_python-1.4.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
248
- conductor_python-1.4.0.dist-info/RECORD,,
246
+ conductor_python-1.5.0.dist-info/METADATA,sha256=MmYaPlTtxV_r-5V0JbRk0EyPCqQOWWKiAUsBKWOJvwA,27225
247
+ conductor_python-1.5.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
248
+ conductor_python-1.5.0.dist-info/RECORD,,