conductor-python 1.3.10__py3-none-any.whl → 1.4.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.
Files changed (30) hide show
  1. conductor/client/__init__.py +30 -0
  2. conductor/client/automator/async_task_runner.py +93 -7
  3. conductor/client/automator/lease_tracker.py +224 -0
  4. conductor/client/automator/task_handler.py +63 -15
  5. conductor/client/automator/task_runner.py +210 -25
  6. conductor/client/configuration/configuration.py +12 -1
  7. conductor/client/configuration/settings/metrics_settings.py +94 -2
  8. conductor/client/http/api/workflow_resource_api.py +6 -2
  9. conductor/client/http/api_client.py +69 -28
  10. conductor/client/http/async_api_client.py +43 -20
  11. conductor/client/http/async_rest.py +37 -33
  12. conductor/client/http/rest.py +187 -67
  13. conductor/client/orkes/orkes_base_client.py +9 -2
  14. conductor/client/orkes/orkes_workflow_client.py +48 -5
  15. conductor/client/orkes_clients.py +14 -3
  16. conductor/client/telemetry/canonical_metrics_collector.py +242 -0
  17. conductor/client/telemetry/legacy_metrics_collector.py +324 -0
  18. conductor/client/telemetry/metrics_collector.py +29 -918
  19. conductor/client/telemetry/metrics_collector_base.py +504 -0
  20. conductor/client/telemetry/metrics_factory.py +62 -0
  21. conductor/client/telemetry/model/metric_documentation.py +12 -1
  22. conductor/client/telemetry/model/metric_label.py +1 -0
  23. conductor/client/telemetry/model/metric_name.py +9 -0
  24. conductor/client/worker/worker.py +3 -1
  25. conductor/client/worker/worker_config.py +10 -2
  26. conductor/client/worker/worker_task.py +14 -4
  27. conductor/client/workflow/executor/workflow_executor.py +22 -6
  28. {conductor_python-1.3.10.dist-info → conductor_python-1.4.0.dist-info}/METADATA +67 -9
  29. {conductor_python-1.3.10.dist-info → conductor_python-1.4.0.dist-info}/RECORD +30 -25
  30. {conductor_python-1.3.10.dist-info → conductor_python-1.4.0.dist-info}/WHEEL +1 -1
@@ -0,0 +1,30 @@
1
+ # Convenience re-exports for common symbols
2
+ # Allows: from conductor.client import Configuration, TaskHandler, ...
3
+ from conductor.client.configuration.configuration import Configuration
4
+ from conductor.client.automator.task_handler import TaskHandler
5
+ from conductor.client.automator.task_runner import TaskRunner
6
+ from conductor.client.orkes_clients import OrkesClients, ConductorClients
7
+ from conductor.client.workflow.conductor_workflow import ConductorWorkflow
8
+ from conductor.client.workflow.executor.workflow_executor import WorkflowExecutor
9
+ from conductor.client.worker.worker_task import worker_task
10
+ from conductor.client.worker.worker_interface import WorkerInterface
11
+ from conductor.client.http.models.task import Task
12
+ from conductor.client.http.models.task_result import TaskResult
13
+ from conductor.client.http.models.task_result_status import TaskResultStatus
14
+ from conductor.client.http.models.start_workflow_request import StartWorkflowRequest
15
+
16
+ __all__ = [
17
+ "Configuration",
18
+ "TaskHandler",
19
+ "TaskRunner",
20
+ "OrkesClients",
21
+ "ConductorClients",
22
+ "ConductorWorkflow",
23
+ "WorkflowExecutor",
24
+ "worker_task",
25
+ "WorkerInterface",
26
+ "Task",
27
+ "TaskResult",
28
+ "TaskResultStatus",
29
+ "StartWorkflowRequest",
30
+ ]
@@ -27,11 +27,12 @@ from conductor.client.http.models.schema_def import SchemaDef, SchemaType
27
27
  from conductor.client.http.rest import AuthorizationException, ApiException
28
28
  from conductor.client.orkes.orkes_metadata_client import OrkesMetadataClient
29
29
  from conductor.client.orkes.orkes_schema_client import OrkesSchemaClient
30
- from conductor.client.telemetry.metrics_collector import MetricsCollector
30
+ from conductor.client.telemetry.metrics_factory import create_metrics_collector
31
31
  from conductor.client.worker.worker_interface import WorkerInterface
32
32
  from conductor.client.worker.worker_config import resolve_worker_config, get_worker_config_oneline
33
33
  from conductor.client.worker.exception import NonRetryableException
34
34
  from conductor.client.automator.json_schema_generator import generate_json_schema_from_function
35
+ from conductor.client.automator.lease_tracker import LeaseManager
35
36
 
36
37
  logger = logging.getLogger(
37
38
  Configuration.get_logging_formatted_name(
@@ -87,7 +88,7 @@ class AsyncTaskRunner:
87
88
 
88
89
  self.metrics_collector = None
89
90
  if metrics_settings is not None:
90
- self.metrics_collector = MetricsCollector(
91
+ self.metrics_collector = create_metrics_collector(
91
92
  metrics_settings
92
93
  )
93
94
  # Register metrics collector as event listener
@@ -112,6 +113,9 @@ class AsyncTaskRunner:
112
113
  self._semaphore = None
113
114
  self._shutdown = False # Flag to indicate graceful shutdown
114
115
  self._use_update_v2 = True # Will be set to False if server doesn't support v2 endpoint
116
+ self._lease_manager = LeaseManager.get_instance()
117
+ self._tracked_task_ids = set() # Local set for cleanup on shutdown
118
+ self._sync_task_client = None # Created after fork for LeaseManager heartbeats
115
119
 
116
120
  async def run(self) -> None:
117
121
  """Main async loop - runs continuously in single event loop."""
@@ -131,6 +135,17 @@ class AsyncTaskRunner:
131
135
  api_client=self.async_api_client
132
136
  )
133
137
 
138
+ # Create a sync TaskResourceApi for LeaseManager heartbeats
139
+ # (LeaseManager sends heartbeats from its own ThreadPoolExecutor)
140
+ from conductor.client.http.api.task_resource_api import TaskResourceApi
141
+ from conductor.client.http.api_client import ApiClient
142
+ self._sync_task_client = TaskResourceApi(
143
+ ApiClient(
144
+ configuration=self.configuration,
145
+ metrics_collector=self.metrics_collector
146
+ )
147
+ )
148
+
134
149
  # Create semaphore in the event loop (must be created within the loop)
135
150
  self._semaphore = asyncio.Semaphore(self._max_workers)
136
151
 
@@ -166,6 +181,11 @@ class AsyncTaskRunner:
166
181
  """Clean up async resources."""
167
182
  logger.debug("Cleaning up AsyncTaskRunner resources...")
168
183
 
184
+ # Untrack all tasks this runner was tracking from the shared LeaseManager
185
+ for task_id in list(self._tracked_task_ids):
186
+ self._lease_manager.untrack(task_id)
187
+ self._tracked_task_ids.clear()
188
+
169
189
  # Cancel any running tasks (EAFP style)
170
190
  try:
171
191
  for task in list(self._running_tasks):
@@ -182,6 +202,13 @@ class AsyncTaskRunner:
182
202
  except (IOError, OSError) as e:
183
203
  logger.warning(f"Error closing async client: {e}")
184
204
 
205
+ # Close sync HTTP client used for lease heartbeats
206
+ if self._sync_task_client:
207
+ try:
208
+ self._sync_task_client.api_client.rest_client.connection.close()
209
+ except Exception:
210
+ pass
211
+
185
212
  # Clear event listeners
186
213
  self.event_dispatcher = None
187
214
 
@@ -227,7 +254,18 @@ class AsyncTaskRunner:
227
254
  output_schema_name = None
228
255
  schema_registry_available = True
229
256
 
230
- if hasattr(self.worker, 'execute_function'):
257
+ # Check if schema registration is enabled for this worker
258
+ register_schema = getattr(self.worker, 'register_schema', False)
259
+ # Also check global Configuration default
260
+ if hasattr(self.configuration, 'register_schema') and self.configuration.register_schema is not None:
261
+ # Worker-level setting takes precedence if explicitly set (not default)
262
+ if not hasattr(self.worker, 'register_schema'):
263
+ register_schema = self.configuration.register_schema
264
+
265
+ if not register_schema:
266
+ logger.debug(f"Schema registration disabled for {task_name} (register_schema=False)")
267
+
268
+ if register_schema and hasattr(self.worker, 'execute_function'):
231
269
  logger.debug(f"Generating JSON schemas from function signature...")
232
270
  # Pass strict_schema flag to control additionalProperties
233
271
  strict_mode = getattr(self.worker, 'strict_schema', False)
@@ -309,6 +347,8 @@ class AsyncTaskRunner:
309
347
  logger.debug(f"Could not register schemas for {task_name}: {e}")
310
348
  else:
311
349
  logger.debug(f" ⚠ No schemas generated (unable to analyze function signature)")
350
+ elif not register_schema:
351
+ pass # Already logged above
312
352
  else:
313
353
  logger.debug(f" ⚠ Class-based worker (no execute_function) - registering task without schemas")
314
354
 
@@ -573,15 +613,13 @@ class AsyncTaskRunner:
573
613
  # Acquire semaphore for entire task lifecycle (execution + update)
574
614
  # This ensures we never exceed thread_count tasks in any stage of processing
575
615
  async with self._semaphore:
616
+ self._track_lease(task)
576
617
  try:
577
618
  while task is not None and not self._shutdown:
578
619
  task_result = await self.__async_execute_task(task)
579
- # If task returned TaskInProgress, don't update yet
580
- if isinstance(task_result, TaskInProgress):
581
- logger.debug("Task %s is in progress, will update when complete", task.task_id)
582
- return
583
620
  if task_result is None:
584
621
  return
622
+ self._untrack_lease(task.task_id)
585
623
  # Update task and get next task from v2 response
586
624
  task = await self.__async_update_task(task_result)
587
625
  # v2 returns the next task; if v1 was used (returns None), immediately
@@ -589,12 +627,17 @@ class AsyncTaskRunner:
589
627
  if task is None and not self._use_update_v2 and not self._shutdown:
590
628
  tasks = await self.__async_batch_poll(1)
591
629
  task = tasks[0] if tasks else None
630
+ if task is not None:
631
+ self._track_lease(task)
592
632
  except Exception as e:
593
633
  logger.error(
594
634
  "Error executing/updating task %s: %s",
595
635
  task.task_id if task else "unknown",
596
636
  traceback.format_exc()
597
637
  )
638
+ finally:
639
+ if task is not None:
640
+ self._untrack_lease(task.task_id)
598
641
 
599
642
  async def __async_execute_task(self, task: Task) -> TaskResult:
600
643
  """Execute async worker function directly (no threads, no BackgroundEventLoop)."""
@@ -820,6 +863,7 @@ class AsyncTaskRunner:
820
863
  if attempt > 0:
821
864
  # Exponential backoff: [10s, 20s, 30s] before retry
822
865
  await asyncio.sleep(attempt * 10)
866
+ update_start = time.time()
823
867
  try:
824
868
  if self._use_update_v2:
825
869
  next_task = await self.async_task_client.update_task_v2(body=task_result)
@@ -830,6 +874,10 @@ class AsyncTaskRunner:
830
874
  task_definition_name,
831
875
  next_task.task_id if next_task else None
832
876
  )
877
+ if self.metrics_collector is not None:
878
+ self.metrics_collector.record_task_update_time(
879
+ task_definition_name, time.time() - update_start, status="SUCCESS"
880
+ )
833
881
  return next_task
834
882
  else:
835
883
  await self.async_task_client.update_task(body=task_result)
@@ -839,6 +887,10 @@ class AsyncTaskRunner:
839
887
  task_result.workflow_instance_id,
840
888
  task_definition_name,
841
889
  )
890
+ if self.metrics_collector is not None:
891
+ self.metrics_collector.record_task_update_time(
892
+ task_definition_name, time.time() - update_start, status="SUCCESS"
893
+ )
842
894
  return None
843
895
  except ApiException as e:
844
896
  if e.status in (404, 405) and self._use_update_v2:
@@ -852,12 +904,19 @@ class AsyncTaskRunner:
852
904
  # Retry immediately with v1
853
905
  try:
854
906
  await self.async_task_client.update_task(body=task_result)
907
+ if self.metrics_collector is not None:
908
+ self.metrics_collector.record_task_update_time(
909
+ task_definition_name, time.time() - update_start, status="SUCCESS"
910
+ )
855
911
  return None
856
912
  except Exception as fallback_e:
857
913
  last_exception = fallback_e
858
914
  continue
859
915
  last_exception = e
860
916
  if self.metrics_collector is not None:
917
+ self.metrics_collector.record_task_update_time(
918
+ task_definition_name, time.time() - update_start, status="FAILURE"
919
+ )
861
920
  self.metrics_collector.increment_task_update_error(
862
921
  task_definition_name, type(e)
863
922
  )
@@ -874,6 +933,9 @@ class AsyncTaskRunner:
874
933
  except Exception as e:
875
934
  last_exception = e
876
935
  if self.metrics_collector is not None:
936
+ self.metrics_collector.record_task_update_time(
937
+ task_definition_name, time.time() - update_start, status="FAILURE"
938
+ )
877
939
  self.metrics_collector.increment_task_update_error(
878
940
  task_definition_name, type(e)
879
941
  )
@@ -908,6 +970,30 @@ class AsyncTaskRunner:
908
970
 
909
971
  return None
910
972
 
973
+ # -- Lease extension (heartbeat) delegation to LeaseManager ----------------
974
+
975
+ def _track_lease(self, task) -> None:
976
+ """Start tracking a task for lease extension via the shared LeaseManager."""
977
+ if not getattr(self.worker, 'lease_extend_enabled', False):
978
+ return
979
+ timeout = getattr(task, 'response_timeout_seconds', None) or 0
980
+ if timeout <= 0:
981
+ return
982
+ self._lease_manager.track(
983
+ task_id=task.task_id,
984
+ workflow_instance_id=task.workflow_instance_id,
985
+ response_timeout_seconds=timeout,
986
+ task_client=self._sync_task_client,
987
+ )
988
+ self._tracked_task_ids.add(task.task_id)
989
+
990
+ def _untrack_lease(self, task_id: str) -> None:
991
+ """Stop tracking a task for lease extension."""
992
+ self._lease_manager.untrack(task_id)
993
+ self._tracked_task_ids.discard(task_id)
994
+
995
+ # --------------------------------------------------------------------------
996
+
911
997
  def __set_worker_properties(self) -> None:
912
998
  """
913
999
  Resolve worker configuration using hierarchical override (same as TaskRunner).
@@ -0,0 +1,224 @@
1
+ """Centralized lease extension (heartbeat) management for Conductor task runners.
2
+
3
+ Architecture:
4
+ LeaseManager runs a single background daemon thread that periodically checks
5
+ for tasks needing lease extension heartbeats. Due heartbeats are dispatched
6
+ to a small fixed ThreadPoolExecutor for parallel, non-blocking API calls.
7
+
8
+ This decouples heartbeat work entirely from worker poll loops, preventing
9
+ heartbeat API calls (and their retries) from blocking task polling.
10
+
11
+ Thread-safe: track() and untrack() can be called from any thread or event loop.
12
+ """
13
+
14
+ import logging
15
+ import os
16
+ import threading
17
+ import time
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from dataclasses import dataclass
20
+ from typing import Any, Dict, Optional
21
+
22
+ from conductor.client.http.models.task_result import TaskResult
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # Lease extension constants (matches Java SDK)
27
+ LEASE_EXTEND_RETRY_COUNT = 3
28
+ LEASE_EXTEND_DURATION_FACTOR = 0.8
29
+
30
+
31
+ @dataclass
32
+ class LeaseInfo:
33
+ """Tracks when a heartbeat is next due for an in-flight task."""
34
+ task_id: str
35
+ workflow_instance_id: str
36
+ response_timeout_seconds: float
37
+ last_heartbeat_time: float # time.monotonic() of last heartbeat (or task start)
38
+ interval_seconds: float # 80% of responseTimeoutSeconds
39
+ task_client: Any = None # Sync TaskResourceApi for sending heartbeats
40
+
41
+
42
+ class LeaseManager:
43
+ """Centralized lease extension manager for all workers in a process.
44
+
45
+ One background daemon thread checks for due heartbeats at a fixed interval.
46
+ A small ThreadPoolExecutor sends heartbeat API calls in parallel.
47
+ Poll loops are never blocked by heartbeat work.
48
+
49
+ Usage:
50
+ manager = LeaseManager.get_instance()
51
+ manager.track(task_id, workflow_id, timeout, task_client)
52
+ # ... task completes ...
53
+ manager.untrack(task_id)
54
+ """
55
+
56
+ _instance: Optional['LeaseManager'] = None
57
+ _instance_lock = threading.Lock()
58
+ _instance_pid: Optional[int] = None
59
+
60
+ @classmethod
61
+ def get_instance(cls, check_interval: float = 1.0,
62
+ max_heartbeat_workers: int = 4) -> 'LeaseManager':
63
+ """Get or create the process-wide LeaseManager singleton.
64
+
65
+ Fork-safe: a new instance is created after fork (threads don't survive fork).
66
+ """
67
+ current_pid = os.getpid()
68
+ if cls._instance is None or cls._instance_pid != current_pid:
69
+ with cls._instance_lock:
70
+ if cls._instance is None or cls._instance_pid != current_pid:
71
+ cls._instance = cls(
72
+ check_interval=check_interval,
73
+ max_heartbeat_workers=max_heartbeat_workers,
74
+ )
75
+ cls._instance_pid = current_pid
76
+ return cls._instance
77
+
78
+ @classmethod
79
+ def _reset_instance(cls):
80
+ """Reset the singleton. For testing only."""
81
+ with cls._instance_lock:
82
+ if cls._instance is not None:
83
+ cls._instance.shutdown()
84
+ cls._instance = None
85
+ cls._instance_pid = None
86
+
87
+ def __init__(self, check_interval: float = 1.0, max_heartbeat_workers: int = 4):
88
+ self._tracked: Dict[str, LeaseInfo] = {}
89
+ self._lock = threading.Lock()
90
+ self._executor = ThreadPoolExecutor(
91
+ max_workers=max_heartbeat_workers,
92
+ thread_name_prefix="lease-heartbeat",
93
+ )
94
+ self._stop_event = threading.Event()
95
+ self._check_interval = check_interval
96
+ self._thread: Optional[threading.Thread] = None
97
+ self._started = False
98
+ self._start_lock = threading.Lock()
99
+
100
+ def _ensure_started(self) -> None:
101
+ """Lazily start the background thread on first track() call."""
102
+ if self._started:
103
+ return
104
+ with self._start_lock:
105
+ if not self._started:
106
+ self._thread = threading.Thread(
107
+ target=self._run, daemon=True, name="lease-manager",
108
+ )
109
+ self._thread.start()
110
+ self._started = True
111
+ logger.debug(
112
+ "LeaseManager started (check_interval=%.1fs)", self._check_interval,
113
+ )
114
+
115
+ def track(self, task_id: str, workflow_instance_id: str,
116
+ response_timeout_seconds: float, task_client: Any) -> None:
117
+ """Start tracking a task for lease extension heartbeats.
118
+
119
+ Thread-safe. Can be called from any worker thread or event loop.
120
+
121
+ Args:
122
+ task_id: Conductor task ID.
123
+ workflow_instance_id: Workflow instance this task belongs to.
124
+ response_timeout_seconds: The task's server-side response timeout.
125
+ task_client: A **sync** TaskResourceApi for sending heartbeat API calls.
126
+ """
127
+ interval = response_timeout_seconds * LEASE_EXTEND_DURATION_FACTOR
128
+ if interval < 1:
129
+ logger.debug(
130
+ "Skipping lease tracking for task %s (interval %.1fs too short)",
131
+ task_id, interval,
132
+ )
133
+ return
134
+
135
+ info = LeaseInfo(
136
+ task_id=task_id,
137
+ workflow_instance_id=workflow_instance_id,
138
+ response_timeout_seconds=response_timeout_seconds,
139
+ last_heartbeat_time=time.monotonic(),
140
+ interval_seconds=interval,
141
+ task_client=task_client,
142
+ )
143
+ with self._lock:
144
+ self._tracked[task_id] = info
145
+ self._ensure_started()
146
+ logger.debug(
147
+ "Tracking lease for task %s (timeout=%ss, heartbeat every %ss)",
148
+ task_id, response_timeout_seconds, interval,
149
+ )
150
+
151
+ def untrack(self, task_id: str) -> None:
152
+ """Stop tracking a task. Thread-safe."""
153
+ with self._lock:
154
+ removed = self._tracked.pop(task_id, None)
155
+ if removed is not None:
156
+ logger.debug("Untracked lease for task %s", task_id)
157
+
158
+ @property
159
+ def tracked_count(self) -> int:
160
+ """Number of currently tracked tasks."""
161
+ with self._lock:
162
+ return len(self._tracked)
163
+
164
+ # -- Background thread -----------------------------------------------------
165
+
166
+ def _run(self) -> None:
167
+ """Background loop — checks for due heartbeats at fixed intervals."""
168
+ while not self._stop_event.is_set():
169
+ try:
170
+ self._check_and_send()
171
+ except Exception as e:
172
+ logger.error("LeaseManager error: %s", e)
173
+ self._stop_event.wait(self._check_interval)
174
+
175
+ def _check_and_send(self) -> None:
176
+ """Find tasks with due heartbeats and dispatch to the thread pool."""
177
+ now = time.monotonic()
178
+ with self._lock:
179
+ due = [
180
+ info for info in self._tracked.values()
181
+ if now - info.last_heartbeat_time >= info.interval_seconds
182
+ ]
183
+ for info in due:
184
+ # Update timestamp immediately to prevent double-dispatch on next tick
185
+ info.last_heartbeat_time = time.monotonic()
186
+ self._executor.submit(self._send_heartbeat, info)
187
+
188
+ @staticmethod
189
+ def _send_heartbeat(info: LeaseInfo) -> None:
190
+ """Send a single lease extension heartbeat with retry.
191
+
192
+ Runs in a pool thread — blocking retries only block the pool thread,
193
+ never a poll loop.
194
+ """
195
+ result = TaskResult(
196
+ task_id=info.task_id,
197
+ workflow_instance_id=info.workflow_instance_id,
198
+ extend_lease=True,
199
+ )
200
+ for attempt in range(LEASE_EXTEND_RETRY_COUNT):
201
+ try:
202
+ info.task_client.update_task(body=result)
203
+ logger.debug("Extended lease for task %s", info.task_id)
204
+ return
205
+ except Exception as e:
206
+ if attempt < LEASE_EXTEND_RETRY_COUNT - 1:
207
+ time.sleep(0.5 * (attempt + 2))
208
+ else:
209
+ logger.error(
210
+ "Failed to extend lease for task %s after %d attempts: %s",
211
+ info.task_id, LEASE_EXTEND_RETRY_COUNT, e,
212
+ )
213
+
214
+ # -- Lifecycle -------------------------------------------------------------
215
+
216
+ def shutdown(self) -> None:
217
+ """Stop the background thread and thread pool."""
218
+ self._stop_event.set()
219
+ if self._started and self._thread is not None:
220
+ self._thread.join(timeout=5)
221
+ self._executor.shutdown(wait=False)
222
+ with self._lock:
223
+ self._tracked.clear()
224
+ logger.debug("LeaseManager shut down")
@@ -4,6 +4,7 @@ import importlib
4
4
  import inspect
5
5
  import logging
6
6
  import os
7
+ import signal
7
8
  import threading
8
9
  import time
9
10
  from multiprocessing import Process, freeze_support, Queue, set_start_method
@@ -17,7 +18,7 @@ from conductor.client.configuration.settings.metrics_settings import MetricsSett
17
18
  from conductor.client.event.task_runner_events import TaskRunnerEvent
18
19
  from conductor.client.event.sync_event_dispatcher import SyncEventDispatcher
19
20
  from conductor.client.event.sync_listener_register import register_task_runner_listener
20
- from conductor.client.telemetry.metrics_collector import MetricsCollector
21
+ from conductor.client.telemetry.metrics_collector_base import MetricsCollectorBase
21
22
  from conductor.client.telemetry.model.metric_documentation import MetricDocumentation
22
23
  from conductor.client.telemetry.model.metric_label import MetricLabel
23
24
  from conductor.client.telemetry.model.metric_name import MetricName
@@ -32,13 +33,24 @@ logger = logging.getLogger(
32
33
  )
33
34
 
34
35
  _decorated_functions = {}
36
+ _VALID_MP_START_METHODS = {"spawn", "fork", "forkserver"}
35
37
  _mp_fork_set = False
36
38
  if not _mp_fork_set:
37
39
  try:
38
- if platform == "win32":
39
- set_start_method("spawn")
40
- else:
41
- set_start_method("fork")
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"
46
+ _method = os.environ.get("CONDUCTOR_MP_START_METHOD", "").strip().lower() or _default_method
47
+ if _method not in _VALID_MP_START_METHODS:
48
+ logger.warning(
49
+ "Ignoring invalid CONDUCTOR_MP_START_METHOD=%r; falling back to %r",
50
+ _method, _default_method,
51
+ )
52
+ _method = _default_method
53
+ set_start_method(_method)
42
54
  _mp_fork_set = True
43
55
  except Exception as e:
44
56
  logger.info("error when setting multiprocessing.set_start_method - maybe the context is set %s", e.args)
@@ -52,6 +64,7 @@ def _run_sync_worker_process(
52
64
  event_listeners: Optional[List[Any]],
53
65
  ) -> None:
54
66
  """Process target: construct TaskRunner after fork/spawn and run forever."""
67
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
55
68
  task_runner = TaskRunner(worker, configuration, metrics_settings, event_listeners)
56
69
  task_runner.run()
57
70
 
@@ -63,6 +76,7 @@ def _run_async_worker_process(
63
76
  event_listeners: Optional[List[Any]],
64
77
  ) -> None:
65
78
  """Process target: construct AsyncTaskRunner after fork/spawn and run forever."""
79
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
66
80
  async_task_runner = AsyncTaskRunner(worker, configuration, metrics_settings, event_listeners)
67
81
  asyncio.run(async_task_runner.run())
68
82
 
@@ -70,7 +84,8 @@ def _run_async_worker_process(
70
84
  def register_decorated_fn(name: str, poll_interval: int, domain: str, worker_id: str, func,
71
85
  thread_count: int = 1, register_task_def: bool = False,
72
86
  poll_timeout: int = 100, lease_extend_enabled: bool = False, task_def: Optional['TaskDef'] = None,
73
- overwrite_task_def: bool = True, strict_schema: bool = False):
87
+ overwrite_task_def: bool = True, strict_schema: bool = False,
88
+ register_schema: Optional[bool] = None):
74
89
  logger.debug("decorated %s", name)
75
90
  _decorated_functions[(name, domain)] = {
76
91
  "func": func,
@@ -83,7 +98,8 @@ def register_decorated_fn(name: str, poll_interval: int, domain: str, worker_id:
83
98
  "lease_extend_enabled": lease_extend_enabled,
84
99
  "task_def": task_def,
85
100
  "overwrite_task_def": overwrite_task_def,
86
- "strict_schema": strict_schema
101
+ "strict_schema": strict_schema,
102
+ "register_schema": register_schema
87
103
  }
88
104
 
89
105
 
@@ -109,7 +125,8 @@ def get_registered_workers() -> List[Worker]:
109
125
  paused=False, # Always default to False, only env vars can set to True
110
126
  task_def_template=record.get("task_def"), # Optional TaskDef configuration
111
127
  overwrite_task_def=record.get("overwrite_task_def", True),
112
- strict_schema=record.get("strict_schema", False)
128
+ strict_schema=record.get("strict_schema", False),
129
+ register_schema=record.get("register_schema") if record.get("register_schema") is not None else False
113
130
  )
114
131
  workers.append(worker)
115
132
  return workers
@@ -146,6 +163,29 @@ class TaskHandler:
146
163
  - 10-100x better concurrency for I/O-bound workloads
147
164
  - Automatically detected - no configuration needed
148
165
 
166
+ Parameters:
167
+ scan_for_annotated_workers: When True (default), discovers workers decorated with
168
+ @worker_task that have already been imported into the current Python process.
169
+ This does NOT scan the filesystem — it only finds functions registered at
170
+ import time. Workers defined in modules that have not yet been imported will
171
+ be silently ignored.
172
+
173
+ To load workers from separate files, import them before creating TaskHandler:
174
+
175
+ import myapp.workers # must import first
176
+ with TaskHandler(configuration=config) as th:
177
+ th.start_processes()
178
+
179
+ Or use the import_modules parameter to have TaskHandler import them for you:
180
+
181
+ with TaskHandler(configuration=config,
182
+ import_modules=['myapp.workers']) as th:
183
+ th.start_processes()
184
+
185
+ import_modules: List of module dotted-path strings to import before scanning for
186
+ annotated workers (e.g. ['myapp.workers', 'myapp.other_workers']). Use this
187
+ instead of manual imports when scan_for_annotated_workers=True.
188
+
149
189
  Usage:
150
190
  # Default configuration
151
191
  handler = TaskHandler(configuration=config)
@@ -192,11 +232,11 @@ class TaskHandler:
192
232
  self._configuration = configuration
193
233
  self._metrics_settings = metrics_settings
194
234
 
195
- # Set prometheus multiprocess directory BEFORE any worker processes start
196
- # This must be done before prometheus_client is imported in worker processes
235
+ # Set PROMETHEUS_MULTIPROC_DIR BEFORE any worker processes start.
236
+ # MetricsSettings resolves the subdirectory eagerly at construction.
197
237
  if metrics_settings is not None:
198
- os.environ["PROMETHEUS_MULTIPROC_DIR"] = metrics_settings.directory
199
- logger.info(f"Set PROMETHEUS_MULTIPROC_DIR={metrics_settings.directory}")
238
+ os.environ["PROMETHEUS_MULTIPROC_DIR"] = metrics_settings.metrics_directory
239
+ logger.info(f"Set PROMETHEUS_MULTIPROC_DIR={metrics_settings.metrics_directory}")
200
240
 
201
241
  # Store event listeners to pass to each worker process
202
242
  self.event_listeners = event_listeners or []
@@ -228,9 +268,15 @@ class TaskHandler:
228
268
  'poll_timeout': record.get("poll_timeout", 100),
229
269
  'lease_extend_enabled': record.get("lease_extend_enabled", True),
230
270
  'overwrite_task_def': record.get("overwrite_task_def", True),
231
- 'strict_schema': record.get("strict_schema", False)
271
+ 'strict_schema': record.get("strict_schema", False),
272
+ 'register_schema': record.get("register_schema")
232
273
  }
233
274
 
275
+ # Apply global Configuration.register_schema as fallback when
276
+ # the decorator doesn't set it explicitly (None).
277
+ if code_config['register_schema'] is None and hasattr(configuration, 'register_schema') and configuration.register_schema is not None:
278
+ code_config['register_schema'] = configuration.register_schema
279
+
234
280
  # Resolve configuration with environment variable overrides
235
281
  resolved_config = resolve_worker_config(
236
282
  worker_name=task_def_name,
@@ -249,7 +295,8 @@ class TaskHandler:
249
295
  lease_extend_enabled=resolved_config['lease_extend_enabled'],
250
296
  task_def_template=record.get("task_def"), # Pass TaskDef configuration
251
297
  overwrite_task_def=resolved_config.get('overwrite_task_def', True),
252
- strict_schema=resolved_config.get('strict_schema', False))
298
+ strict_schema=resolved_config.get('strict_schema', False),
299
+ register_schema=resolved_config.get('register_schema', False))
253
300
  logger.debug("created worker with name=%s and domain=%s", task_def_name, resolved_config['domain'])
254
301
  workers.append(worker)
255
302
 
@@ -309,7 +356,7 @@ class TaskHandler:
309
356
  self.metrics_provider_process = None
310
357
  return
311
358
  self.metrics_provider_process = Process(
312
- target=MetricsCollector.provide_metrics,
359
+ target=MetricsCollectorBase.provide_metrics,
313
360
  args=(metrics_settings,)
314
361
  )
315
362
  logger.info("Created MetricsProvider process")
@@ -607,6 +654,7 @@ def _setup_logging_queue(configuration: Configuration):
607
654
 
608
655
  # This process performs the centralized logging
609
656
  def __logger_process(queue, log_level, logger_format=None):
657
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
610
658
  c_logger = logging.getLogger(
611
659
  Configuration.get_logging_formatted_name(
612
660
  __name__