kailash 0.4.2__py3-none-any.whl → 0.6.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.
- kailash/__init__.py +1 -1
- kailash/client/__init__.py +12 -0
- kailash/client/enhanced_client.py +306 -0
- kailash/core/actors/__init__.py +16 -0
- kailash/core/actors/connection_actor.py +566 -0
- kailash/core/actors/supervisor.py +364 -0
- kailash/edge/__init__.py +16 -0
- kailash/edge/compliance.py +834 -0
- kailash/edge/discovery.py +659 -0
- kailash/edge/location.py +582 -0
- kailash/gateway/__init__.py +33 -0
- kailash/gateway/api.py +289 -0
- kailash/gateway/enhanced_gateway.py +357 -0
- kailash/gateway/resource_resolver.py +217 -0
- kailash/gateway/security.py +227 -0
- kailash/middleware/auth/models.py +2 -2
- kailash/middleware/database/base_models.py +1 -7
- kailash/middleware/database/repositories.py +3 -1
- kailash/middleware/gateway/__init__.py +22 -0
- kailash/middleware/gateway/checkpoint_manager.py +398 -0
- kailash/middleware/gateway/deduplicator.py +382 -0
- kailash/middleware/gateway/durable_gateway.py +417 -0
- kailash/middleware/gateway/durable_request.py +498 -0
- kailash/middleware/gateway/event_store.py +459 -0
- kailash/nodes/admin/audit_log.py +364 -6
- kailash/nodes/admin/permission_check.py +817 -33
- kailash/nodes/admin/role_management.py +1242 -108
- kailash/nodes/admin/schema_manager.py +438 -0
- kailash/nodes/admin/user_management.py +1209 -681
- kailash/nodes/api/http.py +95 -71
- kailash/nodes/base.py +281 -164
- kailash/nodes/base_async.py +30 -31
- kailash/nodes/code/__init__.py +8 -1
- kailash/nodes/code/async_python.py +1035 -0
- kailash/nodes/code/python.py +1 -0
- kailash/nodes/data/async_sql.py +12 -25
- kailash/nodes/data/sql.py +20 -11
- kailash/nodes/data/workflow_connection_pool.py +643 -0
- kailash/nodes/rag/__init__.py +1 -4
- kailash/resources/__init__.py +40 -0
- kailash/resources/factory.py +533 -0
- kailash/resources/health.py +319 -0
- kailash/resources/reference.py +288 -0
- kailash/resources/registry.py +392 -0
- kailash/runtime/async_local.py +711 -302
- kailash/testing/__init__.py +34 -0
- kailash/testing/async_test_case.py +353 -0
- kailash/testing/async_utils.py +345 -0
- kailash/testing/fixtures.py +458 -0
- kailash/testing/mock_registry.py +495 -0
- kailash/utils/resource_manager.py +420 -0
- kailash/workflow/__init__.py +8 -0
- kailash/workflow/async_builder.py +621 -0
- kailash/workflow/async_patterns.py +766 -0
- kailash/workflow/builder.py +93 -10
- kailash/workflow/cyclic_runner.py +111 -41
- kailash/workflow/graph.py +7 -2
- kailash/workflow/resilience.py +11 -1
- {kailash-0.4.2.dist-info → kailash-0.6.0.dist-info}/METADATA +12 -7
- {kailash-0.4.2.dist-info → kailash-0.6.0.dist-info}/RECORD +64 -28
- {kailash-0.4.2.dist-info → kailash-0.6.0.dist-info}/WHEEL +0 -0
- {kailash-0.4.2.dist-info → kailash-0.6.0.dist-info}/entry_points.txt +0 -0
- {kailash-0.4.2.dist-info → kailash-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.4.2.dist-info → kailash-0.6.0.dist-info}/top_level.txt +0 -0
kailash/runtime/async_local.py
CHANGED
@@ -1,388 +1,797 @@
|
|
1
|
-
"""
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
1
|
+
"""
|
2
|
+
Unified Async Runtime for Kailash Workflows.
|
3
|
+
|
4
|
+
This module provides the AsyncLocalRuntime, a specialized async-first runtime that
|
5
|
+
extends LocalRuntime with advanced concurrent execution, workflow optimization,
|
6
|
+
and integrated resource management.
|
7
|
+
|
8
|
+
Key Features:
|
9
|
+
- Native async/await execution with concurrent node processing
|
10
|
+
- Workflow analysis and optimization for parallel execution
|
11
|
+
- Integrated ResourceRegistry support
|
12
|
+
- Advanced execution context and tracking
|
13
|
+
- Performance profiling and metrics
|
14
|
+
- Circuit breaker patterns for resilient execution
|
10
15
|
"""
|
11
16
|
|
17
|
+
import asyncio
|
12
18
|
import logging
|
19
|
+
import time
|
20
|
+
import weakref
|
21
|
+
from concurrent.futures import ThreadPoolExecutor
|
22
|
+
from dataclasses import dataclass, field
|
13
23
|
from datetime import UTC, datetime
|
14
|
-
from typing import Any
|
24
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
15
25
|
|
16
26
|
import networkx as nx
|
17
27
|
|
18
|
-
from kailash.
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
28
|
+
from kailash.nodes.base import Node
|
29
|
+
from kailash.nodes.base_async import AsyncNode
|
30
|
+
from kailash.resources import ResourceRegistry
|
31
|
+
from kailash.runtime.local import LocalRuntime
|
32
|
+
from kailash.sdk_exceptions import RuntimeExecutionError, WorkflowExecutionError
|
23
33
|
from kailash.tracking import TaskManager, TaskStatus
|
24
|
-
from kailash.workflow.graph import Workflow
|
25
34
|
|
26
35
|
logger = logging.getLogger(__name__)
|
27
36
|
|
28
37
|
|
29
|
-
|
30
|
-
|
38
|
+
@dataclass
|
39
|
+
class ExecutionLevel:
|
40
|
+
"""Represents a level of nodes that can execute concurrently."""
|
41
|
+
|
42
|
+
level: int
|
43
|
+
nodes: Set[str] = field(default_factory=set)
|
44
|
+
dependencies_satisfied: Set[str] = field(default_factory=set)
|
45
|
+
|
46
|
+
|
47
|
+
@dataclass
|
48
|
+
class ExecutionPlan:
|
49
|
+
"""Execution plan for optimized workflow execution."""
|
50
|
+
|
51
|
+
workflow_id: str
|
52
|
+
async_nodes: Set[str] = field(default_factory=set)
|
53
|
+
sync_nodes: Set[str] = field(default_factory=set)
|
54
|
+
execution_levels: List[ExecutionLevel] = field(default_factory=list)
|
55
|
+
required_resources: Set[str] = field(default_factory=set)
|
56
|
+
estimated_duration: float = 0.0
|
57
|
+
max_concurrent_nodes: int = 1
|
58
|
+
|
59
|
+
@property
|
60
|
+
def is_fully_async(self) -> bool:
|
61
|
+
"""Check if workflow contains only async nodes."""
|
62
|
+
return len(self.sync_nodes) == 0 and len(self.async_nodes) > 0
|
63
|
+
|
64
|
+
@property
|
65
|
+
def has_async_nodes(self) -> bool:
|
66
|
+
"""Check if workflow contains any async nodes."""
|
67
|
+
return len(self.async_nodes) > 0
|
68
|
+
|
69
|
+
@property
|
70
|
+
def can_parallelize(self) -> bool:
|
71
|
+
"""Check if workflow can benefit from parallelization."""
|
72
|
+
return self.max_concurrent_nodes > 1
|
73
|
+
|
74
|
+
|
75
|
+
@dataclass
|
76
|
+
class ExecutionMetrics:
|
77
|
+
"""Metrics collected during execution."""
|
78
|
+
|
79
|
+
total_duration: float = 0.0
|
80
|
+
node_durations: Dict[str, float] = field(default_factory=dict)
|
81
|
+
concurrent_executions: int = 0
|
82
|
+
resource_access_count: Dict[str, int] = field(default_factory=dict)
|
83
|
+
error_count: int = 0
|
84
|
+
retry_count: int = 0
|
85
|
+
|
86
|
+
|
87
|
+
class ExecutionContext:
|
88
|
+
"""Context passed through workflow execution with resource access."""
|
89
|
+
|
90
|
+
def __init__(self, resource_registry: Optional[ResourceRegistry] = None):
|
91
|
+
self.resource_registry = resource_registry
|
92
|
+
self.variables: Dict[str, Any] = {}
|
93
|
+
self.metrics = ExecutionMetrics()
|
94
|
+
self.start_time = time.time()
|
95
|
+
self._weak_refs: Dict[str, weakref.ref] = {}
|
96
|
+
|
97
|
+
def set_variable(self, key: str, value: Any) -> None:
|
98
|
+
"""Set a context variable accessible to all nodes."""
|
99
|
+
self.variables[key] = value
|
100
|
+
|
101
|
+
def get_variable(self, key: str, default=None) -> Any:
|
102
|
+
"""Get a context variable."""
|
103
|
+
return self.variables.get(key, default)
|
104
|
+
|
105
|
+
async def get_resource(self, name: str) -> Any:
|
106
|
+
"""Get resource from registry."""
|
107
|
+
if not self.resource_registry:
|
108
|
+
raise RuntimeError("No resource registry available in execution context")
|
31
109
|
|
32
|
-
|
33
|
-
|
34
|
-
|
110
|
+
# Track resource access
|
111
|
+
self.metrics.resource_access_count[name] = (
|
112
|
+
self.metrics.resource_access_count.get(name, 0) + 1
|
113
|
+
)
|
114
|
+
|
115
|
+
return await self.resource_registry.get_resource(name)
|
116
|
+
|
117
|
+
|
118
|
+
class WorkflowAnalyzer:
|
119
|
+
"""Analyzes workflows for optimization opportunities."""
|
120
|
+
|
121
|
+
def __init__(self, enable_profiling: bool = True):
|
122
|
+
self.enable_profiling = enable_profiling
|
123
|
+
self._analysis_cache: Dict[str, ExecutionPlan] = {}
|
124
|
+
|
125
|
+
def analyze(self, workflow) -> ExecutionPlan:
|
126
|
+
"""Analyze workflow and create execution plan."""
|
127
|
+
workflow_id = (
|
128
|
+
workflow.workflow_id
|
129
|
+
if hasattr(workflow, "workflow_id")
|
130
|
+
else str(id(workflow))
|
131
|
+
)
|
132
|
+
|
133
|
+
# Check cache first
|
134
|
+
if workflow_id in self._analysis_cache:
|
135
|
+
return self._analysis_cache[workflow_id]
|
136
|
+
|
137
|
+
plan = ExecutionPlan(workflow_id=workflow_id)
|
138
|
+
|
139
|
+
# Identify node types
|
140
|
+
for node_id, node_instance in workflow._node_instances.items():
|
141
|
+
if isinstance(node_instance, AsyncNode):
|
142
|
+
plan.async_nodes.add(node_id)
|
143
|
+
else:
|
144
|
+
plan.sync_nodes.add(node_id)
|
35
145
|
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
146
|
+
# Identify resource requirements
|
147
|
+
plan.required_resources = self._identify_resources(workflow)
|
148
|
+
|
149
|
+
# Compute execution levels for parallelization
|
150
|
+
plan.execution_levels = self._compute_execution_levels(workflow)
|
151
|
+
|
152
|
+
# Calculate max concurrent nodes
|
153
|
+
plan.max_concurrent_nodes = (
|
154
|
+
max(len(level.nodes) for level in plan.execution_levels)
|
155
|
+
if plan.execution_levels
|
156
|
+
else 1
|
157
|
+
)
|
158
|
+
|
159
|
+
# Estimate execution duration (simplified)
|
160
|
+
plan.estimated_duration = self._estimate_duration(workflow, plan)
|
161
|
+
|
162
|
+
# Cache the plan
|
163
|
+
self._analysis_cache[workflow_id] = plan
|
164
|
+
|
165
|
+
logger.debug(
|
166
|
+
f"Workflow analysis complete: {len(plan.async_nodes)} async nodes, "
|
167
|
+
f"{len(plan.sync_nodes)} sync nodes, "
|
168
|
+
f"{len(plan.execution_levels)} execution levels"
|
169
|
+
)
|
170
|
+
|
171
|
+
return plan
|
172
|
+
|
173
|
+
def _compute_execution_levels(self, workflow) -> List[ExecutionLevel]:
|
174
|
+
"""Compute execution levels for parallel execution."""
|
175
|
+
levels = []
|
176
|
+
remaining_nodes = set(workflow._node_instances.keys())
|
177
|
+
completed_nodes = set()
|
178
|
+
level_num = 0
|
179
|
+
|
180
|
+
while remaining_nodes:
|
181
|
+
current_level = ExecutionLevel(level=level_num)
|
182
|
+
|
183
|
+
# Find nodes that can execute at this level
|
184
|
+
for node_id in list(remaining_nodes):
|
185
|
+
# Check if all dependencies are satisfied
|
186
|
+
dependencies = set(workflow.graph.predecessors(node_id))
|
187
|
+
if dependencies.issubset(completed_nodes):
|
188
|
+
current_level.nodes.add(node_id)
|
189
|
+
current_level.dependencies_satisfied.update(dependencies)
|
190
|
+
|
191
|
+
if not current_level.nodes:
|
192
|
+
# No nodes can execute - likely a dependency cycle
|
193
|
+
logger.warning(
|
194
|
+
f"No executable nodes at level {level_num}, remaining: {remaining_nodes}"
|
195
|
+
)
|
196
|
+
break
|
197
|
+
|
198
|
+
levels.append(current_level)
|
199
|
+
completed_nodes.update(current_level.nodes)
|
200
|
+
remaining_nodes -= current_level.nodes
|
201
|
+
level_num += 1
|
202
|
+
|
203
|
+
return levels
|
204
|
+
|
205
|
+
def _identify_resources(self, workflow) -> Set[str]:
|
206
|
+
"""Identify required resources from workflow metadata."""
|
207
|
+
resources = set()
|
208
|
+
|
209
|
+
# Check workflow-level metadata
|
210
|
+
if hasattr(workflow, "metadata") and workflow.metadata:
|
211
|
+
workflow_resources = workflow.metadata.get("required_resources", [])
|
212
|
+
resources.update(workflow_resources)
|
213
|
+
|
214
|
+
# Check node-level metadata
|
215
|
+
for node_id, node_instance in workflow._node_instances.items():
|
216
|
+
if hasattr(node_instance, "config") and isinstance(
|
217
|
+
node_instance.config, dict
|
218
|
+
):
|
219
|
+
node_resources = node_instance.config.get("required_resources", [])
|
220
|
+
resources.update(node_resources)
|
221
|
+
|
222
|
+
return resources
|
223
|
+
|
224
|
+
def _estimate_duration(self, workflow, plan: ExecutionPlan) -> float:
|
225
|
+
"""Estimate workflow execution duration."""
|
226
|
+
# Simplified estimation based on node count and type
|
227
|
+
base_duration_per_node = 0.1 # 100ms per node
|
228
|
+
async_multiplier = 0.5 # Async nodes are typically faster
|
229
|
+
sync_multiplier = 1.0
|
230
|
+
|
231
|
+
async_duration = (
|
232
|
+
len(plan.async_nodes) * base_duration_per_node * async_multiplier
|
233
|
+
)
|
234
|
+
sync_duration = len(plan.sync_nodes) * base_duration_per_node * sync_multiplier
|
235
|
+
|
236
|
+
# Account for parallelization
|
237
|
+
if plan.execution_levels:
|
238
|
+
# Use the longest level as bottleneck
|
239
|
+
max_level_size = max(len(level.nodes) for level in plan.execution_levels)
|
240
|
+
parallelization_factor = (
|
241
|
+
max_level_size / len(plan.execution_levels)
|
242
|
+
if plan.execution_levels
|
243
|
+
else 1
|
244
|
+
)
|
245
|
+
else:
|
246
|
+
parallelization_factor = 1
|
247
|
+
|
248
|
+
return (async_duration + sync_duration) * parallelization_factor
|
249
|
+
|
250
|
+
|
251
|
+
class AsyncExecutionTracker:
|
252
|
+
"""Tracks async execution state and results."""
|
253
|
+
|
254
|
+
def __init__(self, workflow, context: ExecutionContext):
|
255
|
+
self.workflow = workflow
|
256
|
+
self.context = context
|
257
|
+
self.results: Dict[str, Any] = {}
|
258
|
+
self.node_outputs: Dict[str, Any] = {}
|
259
|
+
self.errors: Dict[str, Exception] = {}
|
260
|
+
self.execution_times: Dict[str, float] = {}
|
261
|
+
self._locks: Dict[str, asyncio.Lock] = {}
|
262
|
+
|
263
|
+
def get_lock(self, node_id: str) -> asyncio.Lock:
|
264
|
+
"""Get or create a lock for a node."""
|
265
|
+
if node_id not in self._locks:
|
266
|
+
self._locks[node_id] = asyncio.Lock()
|
267
|
+
return self._locks[node_id]
|
268
|
+
|
269
|
+
async def record_result(
|
270
|
+
self, node_id: str, result: Any, execution_time: float
|
271
|
+
) -> None:
|
272
|
+
"""Record execution result for a node."""
|
273
|
+
async with self.get_lock(node_id):
|
274
|
+
self.results[node_id] = result
|
275
|
+
self.node_outputs[node_id] = result
|
276
|
+
self.execution_times[node_id] = execution_time
|
277
|
+
self.context.metrics.node_durations[node_id] = execution_time
|
278
|
+
|
279
|
+
async def record_error(self, node_id: str, error: Exception) -> None:
|
280
|
+
"""Record execution error for a node."""
|
281
|
+
async with self.get_lock(node_id):
|
282
|
+
self.errors[node_id] = error
|
283
|
+
self.context.metrics.error_count += 1
|
284
|
+
|
285
|
+
def get_result(self) -> Dict[str, Any]:
|
286
|
+
"""Get final execution results."""
|
287
|
+
return {
|
288
|
+
"results": self.results.copy(),
|
289
|
+
"errors": {node_id: str(error) for node_id, error in self.errors.items()},
|
290
|
+
"execution_times": self.execution_times.copy(),
|
291
|
+
"total_duration": time.time() - self.context.start_time,
|
292
|
+
"metrics": self.context.metrics,
|
293
|
+
}
|
294
|
+
|
295
|
+
|
296
|
+
class AsyncLocalRuntime(LocalRuntime):
|
297
|
+
"""
|
298
|
+
Async-optimized runtime for Kailash workflows.
|
299
|
+
|
300
|
+
Extends LocalRuntime with advanced async execution capabilities:
|
301
|
+
- Concurrent node execution where possible
|
302
|
+
- Integrated ResourceRegistry support
|
303
|
+
- Workflow analysis and optimization
|
304
|
+
- Advanced performance tracking
|
305
|
+
- Circuit breaker patterns
|
306
|
+
|
307
|
+
Example:
|
308
|
+
```python
|
309
|
+
from kailash.resources import ResourceRegistry, DatabasePoolFactory
|
310
|
+
from kailash.runtime.async_local import AsyncLocalRuntime
|
311
|
+
|
312
|
+
# Setup resources
|
313
|
+
registry = ResourceRegistry()
|
314
|
+
registry.register_factory("db", DatabasePoolFactory(...))
|
315
|
+
|
316
|
+
# Create async runtime
|
317
|
+
runtime = AsyncLocalRuntime(
|
318
|
+
resource_registry=registry,
|
319
|
+
max_concurrent_nodes=10,
|
320
|
+
enable_analysis=True
|
321
|
+
)
|
41
322
|
|
42
|
-
|
43
|
-
|
44
|
-
|
323
|
+
# Execute workflow
|
324
|
+
result = await runtime.execute_workflow_async(workflow, inputs)
|
325
|
+
```
|
45
326
|
"""
|
46
327
|
|
47
|
-
def __init__(
|
48
|
-
|
328
|
+
def __init__(
|
329
|
+
self,
|
330
|
+
resource_registry: Optional[ResourceRegistry] = None,
|
331
|
+
max_concurrent_nodes: int = 10,
|
332
|
+
enable_analysis: bool = True,
|
333
|
+
enable_profiling: bool = True,
|
334
|
+
thread_pool_size: int = 4,
|
335
|
+
**kwargs,
|
336
|
+
):
|
337
|
+
"""
|
338
|
+
Initialize AsyncLocalRuntime.
|
49
339
|
|
50
340
|
Args:
|
51
|
-
|
52
|
-
|
341
|
+
resource_registry: Optional ResourceRegistry for resource management
|
342
|
+
max_concurrent_nodes: Maximum number of nodes to execute concurrently
|
343
|
+
enable_analysis: Whether to analyze workflows for optimization
|
344
|
+
enable_profiling: Whether to collect detailed performance metrics
|
345
|
+
thread_pool_size: Size of thread pool for sync node execution
|
346
|
+
**kwargs: Additional arguments passed to LocalRuntime
|
53
347
|
"""
|
54
|
-
|
55
|
-
|
56
|
-
|
348
|
+
# Ensure async is enabled
|
349
|
+
kwargs["enable_async"] = True
|
350
|
+
super().__init__(**kwargs)
|
351
|
+
|
352
|
+
self.resource_registry = resource_registry
|
353
|
+
self.max_concurrent_nodes = max_concurrent_nodes
|
354
|
+
self.enable_analysis = enable_analysis
|
355
|
+
self.enable_profiling = enable_profiling
|
356
|
+
|
357
|
+
# Workflow analyzer
|
358
|
+
self.analyzer = (
|
359
|
+
WorkflowAnalyzer(enable_profiling=enable_profiling)
|
360
|
+
if enable_analysis
|
361
|
+
else None
|
362
|
+
)
|
57
363
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
364
|
+
# Thread pool for sync node execution
|
365
|
+
self.thread_pool = ThreadPoolExecutor(max_workers=thread_pool_size)
|
366
|
+
|
367
|
+
# Execution semaphore for concurrency control
|
368
|
+
self.execution_semaphore = asyncio.Semaphore(max_concurrent_nodes)
|
62
369
|
|
63
|
-
|
370
|
+
logger.info(
|
371
|
+
f"AsyncLocalRuntime initialized with max_concurrent_nodes={max_concurrent_nodes}"
|
372
|
+
)
|
373
|
+
|
374
|
+
async def execute_workflow_async(
|
64
375
|
self,
|
65
|
-
workflow
|
66
|
-
|
67
|
-
|
68
|
-
) ->
|
69
|
-
"""
|
376
|
+
workflow,
|
377
|
+
inputs: Dict[str, Any],
|
378
|
+
context: Optional[ExecutionContext] = None,
|
379
|
+
) -> Dict[str, Any]:
|
380
|
+
"""
|
381
|
+
Execute workflow with native async support.
|
382
|
+
|
383
|
+
This method provides first-class async execution with:
|
384
|
+
- Concurrent node execution where dependencies allow
|
385
|
+
- Integrated resource management
|
386
|
+
- Performance optimization based on workflow analysis
|
387
|
+
- Advanced error handling and recovery
|
70
388
|
|
71
389
|
Args:
|
72
390
|
workflow: Workflow to execute
|
73
|
-
|
74
|
-
|
391
|
+
inputs: Input data for the workflow
|
392
|
+
context: Optional execution context
|
75
393
|
|
76
394
|
Returns:
|
77
|
-
|
395
|
+
Dictionary containing execution results and metrics
|
78
396
|
|
79
397
|
Raises:
|
80
|
-
|
81
|
-
WorkflowValidationError: If workflow is invalid
|
398
|
+
WorkflowExecutionError: If execution fails
|
82
399
|
"""
|
83
|
-
|
84
|
-
raise RuntimeExecutionError("No workflow provided")
|
400
|
+
start_time = time.time()
|
85
401
|
|
86
|
-
|
402
|
+
# Create execution context
|
403
|
+
if context is None:
|
404
|
+
context = ExecutionContext(resource_registry=self.resource_registry)
|
405
|
+
|
406
|
+
# Add inputs to context
|
407
|
+
context.variables.update(inputs)
|
87
408
|
|
88
409
|
try:
|
89
|
-
#
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
410
|
+
# Analyze workflow if enabled
|
411
|
+
execution_plan = None
|
412
|
+
if self.analyzer:
|
413
|
+
execution_plan = self.analyzer.analyze(workflow)
|
414
|
+
logger.info(
|
415
|
+
f"Execution plan: {execution_plan.max_concurrent_nodes} max concurrent, "
|
416
|
+
f"{len(execution_plan.execution_levels)} levels"
|
417
|
+
)
|
418
|
+
|
419
|
+
# Choose execution strategy based on analysis
|
420
|
+
if execution_plan and execution_plan.is_fully_async:
|
421
|
+
result = await self._execute_fully_async_workflow(
|
422
|
+
workflow, context, execution_plan
|
423
|
+
)
|
424
|
+
elif execution_plan and execution_plan.has_async_nodes:
|
425
|
+
result = await self._execute_mixed_workflow(
|
426
|
+
workflow, context, execution_plan
|
427
|
+
)
|
428
|
+
else:
|
429
|
+
result = await self._execute_sync_workflow(workflow, context)
|
430
|
+
|
431
|
+
# Update total execution time
|
432
|
+
total_time = time.time() - start_time
|
433
|
+
context.metrics.total_duration = total_time
|
434
|
+
|
435
|
+
logger.info(f"Workflow execution completed in {total_time:.2f}s")
|
436
|
+
|
437
|
+
return result
|
114
438
|
|
115
|
-
# Mark run as completed
|
116
|
-
if task_manager and run_id:
|
117
|
-
try:
|
118
|
-
task_manager.update_run_status(run_id, "completed")
|
119
|
-
except Exception as e:
|
120
|
-
self.logger.warning(f"Failed to update run status: {e}")
|
121
|
-
|
122
|
-
return results, run_id
|
123
|
-
|
124
|
-
except WorkflowValidationError:
|
125
|
-
# Re-raise validation errors as-is
|
126
|
-
if task_manager and run_id:
|
127
|
-
try:
|
128
|
-
task_manager.update_run_status(
|
129
|
-
run_id, "failed", error="Validation failed"
|
130
|
-
)
|
131
|
-
except Exception:
|
132
|
-
pass
|
133
|
-
raise
|
134
439
|
except Exception as e:
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
task_manager.update_run_status(run_id, "failed", error=str(e))
|
139
|
-
except Exception:
|
140
|
-
pass
|
141
|
-
|
142
|
-
# Wrap other errors in RuntimeExecutionError
|
143
|
-
raise RuntimeExecutionError(
|
144
|
-
f"Async workflow execution failed: {type(e).__name__}: {e}"
|
145
|
-
) from e
|
440
|
+
logger.error(f"Workflow execution failed: {e}")
|
441
|
+
context.metrics.error_count += 1
|
442
|
+
raise WorkflowExecutionError(f"Async execution failed: {e}") from e
|
146
443
|
|
147
|
-
async def
|
148
|
-
self,
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
parameters: dict[str, dict[str, Any]],
|
153
|
-
) -> dict[str, Any]:
|
154
|
-
"""Execute the workflow nodes asynchronously.
|
444
|
+
async def _execute_fully_async_workflow(
|
445
|
+
self, workflow, context: ExecutionContext, execution_plan: ExecutionPlan
|
446
|
+
) -> Dict[str, Any]:
|
447
|
+
"""Execute fully async workflow with maximum concurrency."""
|
448
|
+
logger.debug("Executing fully async workflow with concurrent levels")
|
155
449
|
|
156
|
-
|
157
|
-
workflow: Workflow to execute
|
158
|
-
task_manager: Task manager for tracking
|
159
|
-
run_id: Run ID for tracking
|
160
|
-
parameters: Parameter overrides
|
450
|
+
tracker = AsyncExecutionTracker(workflow, context)
|
161
451
|
|
162
|
-
|
163
|
-
|
452
|
+
# Execute by levels to respect dependencies
|
453
|
+
for level in execution_plan.execution_levels:
|
454
|
+
if not level.nodes:
|
455
|
+
continue
|
456
|
+
|
457
|
+
logger.debug(f"Executing level {level.level} with {len(level.nodes)} nodes")
|
458
|
+
|
459
|
+
# Create tasks for all nodes in this level
|
460
|
+
tasks = []
|
461
|
+
for node_id in level.nodes:
|
462
|
+
task = self._execute_node_async(workflow, node_id, tracker, context)
|
463
|
+
tasks.append(task)
|
464
|
+
|
465
|
+
# Execute all tasks in this level concurrently
|
466
|
+
try:
|
467
|
+
await asyncio.gather(*tasks, return_exceptions=False)
|
468
|
+
except Exception as e:
|
469
|
+
logger.error(f"Level {level.level} execution failed: {e}")
|
470
|
+
raise
|
471
|
+
|
472
|
+
return tracker.get_result()
|
473
|
+
|
474
|
+
async def _execute_mixed_workflow(
|
475
|
+
self, workflow, context: ExecutionContext, execution_plan: ExecutionPlan
|
476
|
+
) -> Dict[str, Any]:
|
477
|
+
"""Execute workflow with mixed sync/async nodes."""
|
478
|
+
logger.debug("Executing mixed workflow with sync/async optimization")
|
479
|
+
|
480
|
+
tracker = AsyncExecutionTracker(workflow, context)
|
481
|
+
|
482
|
+
# Execute by levels, handling sync/async appropriately
|
483
|
+
for level in execution_plan.execution_levels:
|
484
|
+
if not level.nodes:
|
485
|
+
continue
|
486
|
+
|
487
|
+
# Separate sync and async nodes in this level
|
488
|
+
async_nodes = [n for n in level.nodes if n in execution_plan.async_nodes]
|
489
|
+
sync_nodes = [n for n in level.nodes if n in execution_plan.sync_nodes]
|
490
|
+
|
491
|
+
tasks = []
|
492
|
+
|
493
|
+
# Add async node tasks
|
494
|
+
for node_id in async_nodes:
|
495
|
+
task = self._execute_node_async(workflow, node_id, tracker, context)
|
496
|
+
tasks.append(task)
|
497
|
+
|
498
|
+
# Add sync node tasks (wrapped in thread pool)
|
499
|
+
for node_id in sync_nodes:
|
500
|
+
task = self._execute_sync_node_async(
|
501
|
+
workflow, node_id, tracker, context
|
502
|
+
)
|
503
|
+
tasks.append(task)
|
504
|
+
|
505
|
+
# Execute all tasks in this level concurrently
|
506
|
+
if tasks:
|
507
|
+
await asyncio.gather(*tasks, return_exceptions=False)
|
508
|
+
|
509
|
+
return tracker.get_result()
|
510
|
+
|
511
|
+
async def _execute_sync_workflow(
|
512
|
+
self, workflow, context: ExecutionContext
|
513
|
+
) -> Dict[str, Any]:
|
514
|
+
"""Execute sync-only workflow in thread pool."""
|
515
|
+
logger.debug("Executing sync-only workflow")
|
516
|
+
|
517
|
+
# Use parent's sync execution but wrap in async
|
518
|
+
loop = asyncio.get_event_loop()
|
519
|
+
|
520
|
+
def sync_execute():
|
521
|
+
# Convert context back to inputs for sync execution
|
522
|
+
return self._execute_sync_workflow_internal(workflow, context.variables)
|
523
|
+
|
524
|
+
result = await loop.run_in_executor(self.thread_pool, sync_execute)
|
525
|
+
|
526
|
+
# Wrap result in expected format
|
527
|
+
return {
|
528
|
+
"results": result,
|
529
|
+
"errors": {},
|
530
|
+
"execution_times": {},
|
531
|
+
"total_duration": time.time() - context.start_time,
|
532
|
+
"metrics": context.metrics,
|
533
|
+
}
|
534
|
+
|
535
|
+
def _execute_sync_workflow_internal(
|
536
|
+
self, workflow, inputs: Dict[str, Any]
|
537
|
+
) -> Dict[str, Any]:
|
538
|
+
"""Internal sync workflow execution."""
|
539
|
+
# Use parent's synchronous execution logic
|
540
|
+
# This is a simplified version - in practice, you'd call the parent's method
|
541
|
+
results = {}
|
164
542
|
|
165
|
-
Raises:
|
166
|
-
WorkflowExecutionError: If execution fails
|
167
|
-
"""
|
168
|
-
# Get execution order
|
169
543
|
try:
|
170
544
|
execution_order = list(nx.topological_sort(workflow.graph))
|
171
|
-
self.logger.info(f"Determined execution order: {execution_order}")
|
172
545
|
except nx.NetworkXError as e:
|
173
546
|
raise WorkflowExecutionError(
|
174
547
|
f"Failed to determine execution order: {e}"
|
175
548
|
) from e
|
176
549
|
|
177
|
-
# Initialize results storage
|
178
|
-
results = {}
|
179
550
|
node_outputs = {}
|
180
|
-
failed_nodes = []
|
181
551
|
|
182
|
-
# Execute each node
|
183
552
|
for node_id in execution_order:
|
184
|
-
self.logger.info(f"Executing node: {node_id}")
|
185
|
-
|
186
|
-
# Get node instance
|
187
553
|
node_instance = workflow._node_instances.get(node_id)
|
188
554
|
if not node_instance:
|
189
|
-
raise WorkflowExecutionError(
|
190
|
-
f"Node instance '{node_id}' not found in workflow"
|
191
|
-
)
|
555
|
+
raise WorkflowExecutionError(f"Node instance '{node_id}' not found")
|
192
556
|
|
193
|
-
#
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
task = task_manager.create_task(
|
198
|
-
run_id=run_id,
|
199
|
-
node_id=node_id,
|
200
|
-
node_type=node_instance.__class__.__name__,
|
201
|
-
started_at=datetime.now(UTC),
|
202
|
-
)
|
203
|
-
except Exception as e:
|
204
|
-
self.logger.warning(
|
205
|
-
f"Failed to create task for node '{node_id}': {e}"
|
206
|
-
)
|
557
|
+
# Prepare inputs (simplified)
|
558
|
+
node_inputs = self._prepare_sync_node_inputs(
|
559
|
+
workflow, node_id, node_outputs, inputs
|
560
|
+
)
|
207
561
|
|
562
|
+
# Execute node
|
208
563
|
try:
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
node_id=node_id,
|
213
|
-
node_instance=node_instance,
|
214
|
-
node_outputs=node_outputs,
|
215
|
-
parameters=parameters.get(node_id, {}),
|
216
|
-
)
|
217
|
-
|
218
|
-
if self.debug:
|
219
|
-
self.logger.debug(f"Node {node_id} inputs: {inputs}")
|
220
|
-
|
221
|
-
# Update task status
|
222
|
-
if task:
|
223
|
-
task.update_status(TaskStatus.RUNNING)
|
224
|
-
|
225
|
-
# Execute node - check if it supports async execution
|
226
|
-
start_time = datetime.now(UTC)
|
227
|
-
|
228
|
-
if hasattr(node_instance, "execute_async"):
|
229
|
-
# Use async execution if available
|
230
|
-
outputs = await node_instance.execute_async(**inputs)
|
231
|
-
else:
|
232
|
-
# Fall back to synchronous execution using execute()
|
233
|
-
# This ensures proper validation and error handling
|
234
|
-
outputs = node_instance.execute(**inputs)
|
235
|
-
|
236
|
-
execution_time = (datetime.now(UTC) - start_time).total_seconds()
|
237
|
-
|
238
|
-
# Store outputs
|
239
|
-
node_outputs[node_id] = outputs
|
240
|
-
results[node_id] = outputs
|
241
|
-
|
242
|
-
if self.debug:
|
243
|
-
self.logger.debug(f"Node {node_id} outputs: {outputs}")
|
244
|
-
|
245
|
-
# Update task status
|
246
|
-
if task:
|
247
|
-
task.update_status(
|
248
|
-
TaskStatus.COMPLETED,
|
249
|
-
result=outputs,
|
250
|
-
ended_at=datetime.now(UTC),
|
251
|
-
metadata={"execution_time": execution_time},
|
252
|
-
)
|
253
|
-
|
254
|
-
self.logger.info(
|
255
|
-
f"Node {node_id} completed successfully in {execution_time:.3f}s"
|
256
|
-
)
|
257
|
-
|
564
|
+
result = node_instance.execute(**node_inputs)
|
565
|
+
results[node_id] = result
|
566
|
+
node_outputs[node_id] = result
|
258
567
|
except Exception as e:
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
# Update task status
|
263
|
-
if task:
|
264
|
-
task.update_status(
|
265
|
-
TaskStatus.FAILED,
|
266
|
-
error=str(e),
|
267
|
-
ended_at=datetime.now(UTC),
|
268
|
-
)
|
568
|
+
raise WorkflowExecutionError(
|
569
|
+
f"Node '{node_id}' execution failed: {e}"
|
570
|
+
) from e
|
269
571
|
|
270
|
-
|
271
|
-
if self._should_stop_on_error(workflow, node_id):
|
272
|
-
error_msg = f"Node '{node_id}' failed: {e}"
|
273
|
-
if len(failed_nodes) > 1:
|
274
|
-
error_msg += f" (Previously failed nodes: {failed_nodes[:-1]})"
|
572
|
+
return results
|
275
573
|
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
574
|
+
def _prepare_sync_node_inputs(
|
575
|
+
self,
|
576
|
+
workflow,
|
577
|
+
node_id: str,
|
578
|
+
node_outputs: Dict[str, Any],
|
579
|
+
context_inputs: Dict[str, Any],
|
580
|
+
) -> Dict[str, Any]:
|
581
|
+
"""Prepare inputs for sync node execution."""
|
582
|
+
# Simplified input preparation
|
583
|
+
inputs = context_inputs.copy()
|
584
|
+
|
585
|
+
# Add outputs from predecessor nodes
|
586
|
+
for predecessor in workflow.graph.predecessors(node_id):
|
587
|
+
if predecessor in node_outputs:
|
588
|
+
inputs[f"{predecessor}_output"] = node_outputs[predecessor]
|
284
589
|
|
285
|
-
return
|
590
|
+
return inputs
|
286
591
|
|
287
|
-
def
|
592
|
+
async def _execute_node_async(
|
288
593
|
self,
|
289
|
-
workflow
|
594
|
+
workflow,
|
290
595
|
node_id: str,
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
596
|
+
tracker: AsyncExecutionTracker,
|
597
|
+
context: ExecutionContext,
|
598
|
+
) -> None:
|
599
|
+
"""Execute a single async node."""
|
600
|
+
start_time = time.time()
|
296
601
|
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
parameters: Parameter overrides
|
602
|
+
async with self.execution_semaphore:
|
603
|
+
try:
|
604
|
+
node_instance = workflow._node_instances.get(node_id)
|
605
|
+
if not node_instance:
|
606
|
+
raise WorkflowExecutionError(f"Node instance '{node_id}' not found")
|
303
607
|
|
304
|
-
|
305
|
-
|
608
|
+
# Prepare inputs
|
609
|
+
inputs = await self._prepare_async_node_inputs(
|
610
|
+
workflow, node_id, tracker, context
|
611
|
+
)
|
306
612
|
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
613
|
+
# Execute async node
|
614
|
+
if isinstance(node_instance, AsyncNode):
|
615
|
+
# Pass resource registry if available
|
616
|
+
if context.resource_registry:
|
617
|
+
result = await node_instance.async_run(
|
618
|
+
resource_registry=context.resource_registry, **inputs
|
619
|
+
)
|
620
|
+
else:
|
621
|
+
result = await node_instance.async_run(**inputs)
|
622
|
+
else:
|
623
|
+
# Shouldn't happen in fully async workflow, but handle gracefully
|
624
|
+
result = await self._execute_sync_node_in_thread(
|
625
|
+
node_instance, inputs
|
626
|
+
)
|
311
627
|
|
312
|
-
|
313
|
-
|
628
|
+
execution_time = time.time() - start_time
|
629
|
+
await tracker.record_result(node_id, result, execution_time)
|
314
630
|
|
315
|
-
|
316
|
-
for edge in workflow.graph.in_edges(node_id, data=True):
|
317
|
-
source_node_id = edge[0]
|
318
|
-
mapping = edge[2].get("mapping", {})
|
631
|
+
logger.debug(f"Node '{node_id}' completed in {execution_time:.2f}s")
|
319
632
|
|
320
|
-
|
321
|
-
|
633
|
+
except Exception as e:
|
634
|
+
execution_time = time.time() - start_time
|
635
|
+
await tracker.record_error(node_id, e)
|
636
|
+
logger.error(
|
637
|
+
f"Node '{node_id}' failed after {execution_time:.2f}s: {e}"
|
638
|
+
)
|
639
|
+
raise WorkflowExecutionError(
|
640
|
+
f"Node '{node_id}' execution failed: {e}"
|
641
|
+
) from e
|
322
642
|
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
643
|
+
async def _execute_sync_node_async(
|
644
|
+
self,
|
645
|
+
workflow,
|
646
|
+
node_id: str,
|
647
|
+
tracker: AsyncExecutionTracker,
|
648
|
+
context: ExecutionContext,
|
649
|
+
) -> None:
|
650
|
+
"""Execute a sync node in thread pool."""
|
651
|
+
start_time = time.time()
|
328
652
|
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
f"Source output '{source_key}' not found in node '{source_node_id}'. "
|
335
|
-
f"Available outputs: {list(source_outputs.keys())}"
|
336
|
-
)
|
653
|
+
async with self.execution_semaphore:
|
654
|
+
try:
|
655
|
+
node_instance = workflow._node_instances.get(node_id)
|
656
|
+
if not node_instance:
|
657
|
+
raise WorkflowExecutionError(f"Node instance '{node_id}' not found")
|
337
658
|
|
338
|
-
|
339
|
-
|
659
|
+
# Prepare inputs
|
660
|
+
inputs = await self._prepare_async_node_inputs(
|
661
|
+
workflow, node_id, tracker, context
|
662
|
+
)
|
340
663
|
|
341
|
-
|
664
|
+
# Execute sync node in thread pool
|
665
|
+
result = await self._execute_sync_node_in_thread(node_instance, inputs)
|
342
666
|
|
343
|
-
|
344
|
-
|
667
|
+
execution_time = time.time() - start_time
|
668
|
+
await tracker.record_result(node_id, result, execution_time)
|
345
669
|
|
346
|
-
|
347
|
-
|
348
|
-
|
670
|
+
logger.debug(
|
671
|
+
f"Sync node '{node_id}' completed in {execution_time:.2f}s"
|
672
|
+
)
|
349
673
|
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
674
|
+
except Exception as e:
|
675
|
+
execution_time = time.time() - start_time
|
676
|
+
await tracker.record_error(node_id, e)
|
677
|
+
logger.error(
|
678
|
+
f"Sync node '{node_id}' failed after {execution_time:.2f}s: {e}"
|
679
|
+
)
|
680
|
+
raise WorkflowExecutionError(
|
681
|
+
f"Sync node '{node_id}' execution failed: {e}"
|
682
|
+
) from e
|
355
683
|
|
356
|
-
|
357
|
-
|
358
|
-
|
684
|
+
async def _execute_sync_node_in_thread(
|
685
|
+
self, node_instance: Node, inputs: Dict[str, Any]
|
686
|
+
) -> Any:
|
687
|
+
"""Execute sync node in thread pool."""
|
688
|
+
loop = asyncio.get_event_loop()
|
359
689
|
|
690
|
+
def execute_sync():
|
691
|
+
return node_instance.execute(**inputs)
|
360
692
|
|
361
|
-
|
362
|
-
from kailash.runtime.local import LocalRuntime # noqa: E402
|
693
|
+
return await loop.run_in_executor(self.thread_pool, execute_sync)
|
363
694
|
|
364
|
-
|
365
|
-
|
695
|
+
async def _prepare_async_node_inputs(
|
696
|
+
self,
|
697
|
+
workflow,
|
698
|
+
node_id: str,
|
699
|
+
tracker: AsyncExecutionTracker,
|
700
|
+
context: ExecutionContext,
|
701
|
+
) -> Dict[str, Any]:
|
702
|
+
"""Prepare inputs for async node execution."""
|
703
|
+
inputs = context.variables.copy()
|
704
|
+
|
705
|
+
# Add outputs from predecessor nodes
|
706
|
+
for predecessor in workflow.graph.predecessors(node_id):
|
707
|
+
if predecessor in tracker.node_outputs:
|
708
|
+
# Use the actual connection mapping if available
|
709
|
+
edge_data = workflow.graph.get_edge_data(predecessor, node_id)
|
710
|
+
if edge_data and "mapping" in edge_data:
|
711
|
+
# Handle new graph format with mapping
|
712
|
+
mapping = edge_data["mapping"]
|
713
|
+
source_data = tracker.node_outputs[predecessor]
|
714
|
+
|
715
|
+
for source_path, target_param in mapping.items():
|
716
|
+
if source_path != "result" and isinstance(source_data, dict):
|
717
|
+
# Navigate the path (e.g., "result.data")
|
718
|
+
path_parts = source_path.split(".")
|
719
|
+
current_data = source_data
|
720
|
+
for part in path_parts:
|
721
|
+
if (
|
722
|
+
isinstance(current_data, dict)
|
723
|
+
and part in current_data
|
724
|
+
):
|
725
|
+
current_data = current_data[part]
|
726
|
+
else:
|
727
|
+
current_data = None
|
728
|
+
break
|
729
|
+
inputs[target_param] = current_data
|
730
|
+
else:
|
731
|
+
# Source path is 'result' or source_data is not a dict
|
732
|
+
if source_path == "result":
|
733
|
+
inputs[target_param] = source_data
|
734
|
+
elif (
|
735
|
+
isinstance(source_data, dict)
|
736
|
+
and source_path in source_data
|
737
|
+
):
|
738
|
+
inputs[target_param] = source_data[source_path]
|
739
|
+
else:
|
740
|
+
inputs[target_param] = source_data
|
741
|
+
elif edge_data and "connections" in edge_data:
|
742
|
+
# Handle legacy connection format
|
743
|
+
connections = edge_data["connections"]
|
744
|
+
for connection in connections:
|
745
|
+
source_path = connection.get("source_path", "result")
|
746
|
+
target_param = connection.get(
|
747
|
+
"target_param", f"{predecessor}_output"
|
748
|
+
)
|
366
749
|
|
750
|
+
# Extract data using source path
|
751
|
+
source_data = tracker.node_outputs[predecessor]
|
752
|
+
if source_path != "result" and isinstance(source_data, dict):
|
753
|
+
# Navigate the path (e.g., "result.data")
|
754
|
+
path_parts = source_path.split(".")
|
755
|
+
current_data = source_data
|
756
|
+
for part in path_parts:
|
757
|
+
if (
|
758
|
+
isinstance(current_data, dict)
|
759
|
+
and part in current_data
|
760
|
+
):
|
761
|
+
current_data = current_data[part]
|
762
|
+
else:
|
763
|
+
current_data = None
|
764
|
+
break
|
765
|
+
inputs[target_param] = current_data
|
766
|
+
else:
|
767
|
+
inputs[target_param] = source_data
|
768
|
+
else:
|
769
|
+
# Default behavior - use predecessor output directly
|
770
|
+
inputs[f"{predecessor}_output"] = tracker.node_outputs[predecessor]
|
367
771
|
|
368
|
-
|
369
|
-
class AsyncLocalRuntimeCompat(LocalRuntime):
|
370
|
-
"""Backward compatibility wrapper for AsyncLocalRuntime.
|
772
|
+
return inputs
|
371
773
|
|
372
|
-
|
373
|
-
|
374
|
-
|
774
|
+
async def cleanup(self) -> None:
|
775
|
+
"""Clean up runtime resources."""
|
776
|
+
logger.info("Cleaning up AsyncLocalRuntime")
|
375
777
|
|
376
|
-
|
377
|
-
|
378
|
-
super().__init__(
|
379
|
-
debug=debug, enable_async=True, max_concurrency=max_concurrency, **kwargs
|
380
|
-
)
|
778
|
+
# Clean up thread pool
|
779
|
+
self.thread_pool.shutdown(wait=True)
|
381
780
|
|
382
|
-
|
383
|
-
|
384
|
-
|
781
|
+
# Clean up resource registry if owned
|
782
|
+
if self.resource_registry:
|
783
|
+
await self.resource_registry.cleanup()
|
385
784
|
|
785
|
+
logger.info("AsyncLocalRuntime cleanup complete")
|
386
786
|
|
387
|
-
|
388
|
-
|
787
|
+
def __del__(self):
|
788
|
+
"""Cleanup on deletion."""
|
789
|
+
try:
|
790
|
+
# Schedule cleanup if event loop is available
|
791
|
+
loop = asyncio.get_event_loop()
|
792
|
+
if loop.is_running():
|
793
|
+
loop.create_task(self.cleanup())
|
794
|
+
except Exception:
|
795
|
+
# If no event loop, just shutdown thread pool
|
796
|
+
if hasattr(self, "thread_pool"):
|
797
|
+
self.thread_pool.shutdown(wait=False)
|