kailash 0.4.2__py3-none-any.whl → 0.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.
- kailash/middleware/database/repositories.py +3 -1
- kailash/nodes/admin/audit_log.py +364 -6
- kailash/nodes/admin/user_management.py +1006 -20
- kailash/nodes/api/http.py +95 -71
- kailash/nodes/base.py +281 -164
- kailash/nodes/base_async.py +30 -31
- kailash/nodes/data/async_sql.py +3 -22
- kailash/utils/resource_manager.py +420 -0
- kailash/workflow/builder.py +93 -10
- kailash/workflow/cyclic_runner.py +4 -25
- {kailash-0.4.2.dist-info → kailash-0.5.0.dist-info}/METADATA +6 -4
- {kailash-0.4.2.dist-info → kailash-0.5.0.dist-info}/RECORD +16 -15
- {kailash-0.4.2.dist-info → kailash-0.5.0.dist-info}/WHEEL +0 -0
- {kailash-0.4.2.dist-info → kailash-0.5.0.dist-info}/entry_points.txt +0 -0
- {kailash-0.4.2.dist-info → kailash-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.4.2.dist-info → kailash-0.5.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,420 @@
|
|
1
|
+
"""Resource management utilities for the Kailash SDK.
|
2
|
+
|
3
|
+
This module provides context managers and utilities for efficient resource
|
4
|
+
management across the SDK, ensuring proper cleanup and preventing memory leaks.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import asyncio
|
8
|
+
import logging
|
9
|
+
import threading
|
10
|
+
import weakref
|
11
|
+
from collections import defaultdict
|
12
|
+
from contextlib import asynccontextmanager, contextmanager
|
13
|
+
from datetime import UTC, datetime
|
14
|
+
from typing import Any, Callable, Dict, Generic, Optional, Set, TypeVar
|
15
|
+
|
16
|
+
logger = logging.getLogger(__name__)
|
17
|
+
|
18
|
+
T = TypeVar("T")
|
19
|
+
|
20
|
+
|
21
|
+
class ResourcePool(Generic[T]):
|
22
|
+
"""Generic resource pool for connection pooling and resource reuse.
|
23
|
+
|
24
|
+
This class provides a thread-safe pool for managing expensive resources
|
25
|
+
like database connections, HTTP clients, etc.
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(
|
29
|
+
self,
|
30
|
+
factory: Callable[[], T],
|
31
|
+
max_size: int = 10,
|
32
|
+
timeout: float = 30.0,
|
33
|
+
cleanup: Optional[Callable[[T], None]] = None,
|
34
|
+
):
|
35
|
+
"""Initialize the resource pool.
|
36
|
+
|
37
|
+
Args:
|
38
|
+
factory: Function to create new resources
|
39
|
+
max_size: Maximum pool size
|
40
|
+
timeout: Timeout for acquiring resources
|
41
|
+
cleanup: Optional cleanup function for resources
|
42
|
+
"""
|
43
|
+
self._factory = factory
|
44
|
+
self._max_size = max_size
|
45
|
+
self._timeout = timeout
|
46
|
+
self._cleanup = cleanup
|
47
|
+
|
48
|
+
self._pool: list[T] = []
|
49
|
+
self._in_use: Set[T] = set()
|
50
|
+
self._lock = threading.Lock()
|
51
|
+
self._semaphore = threading.Semaphore(max_size)
|
52
|
+
self._created_count = 0
|
53
|
+
|
54
|
+
@contextmanager
|
55
|
+
def acquire(self):
|
56
|
+
"""Acquire a resource from the pool.
|
57
|
+
|
58
|
+
Yields:
|
59
|
+
Resource instance
|
60
|
+
|
61
|
+
Raises:
|
62
|
+
TimeoutError: If resource cannot be acquired within timeout
|
63
|
+
"""
|
64
|
+
if not self._semaphore.acquire(timeout=self._timeout):
|
65
|
+
raise TimeoutError(f"Failed to acquire resource within {self._timeout}s")
|
66
|
+
|
67
|
+
resource = None
|
68
|
+
try:
|
69
|
+
with self._lock:
|
70
|
+
# Try to get from pool
|
71
|
+
if self._pool:
|
72
|
+
resource = self._pool.pop()
|
73
|
+
else:
|
74
|
+
# Create new resource if under limit
|
75
|
+
if self._created_count < self._max_size:
|
76
|
+
resource = self._factory()
|
77
|
+
self._created_count += 1
|
78
|
+
else:
|
79
|
+
raise RuntimeError("Pool exhausted")
|
80
|
+
|
81
|
+
self._in_use.add(resource)
|
82
|
+
|
83
|
+
yield resource
|
84
|
+
|
85
|
+
finally:
|
86
|
+
if resource is not None:
|
87
|
+
with self._lock:
|
88
|
+
self._in_use.discard(resource)
|
89
|
+
self._pool.append(resource)
|
90
|
+
self._semaphore.release()
|
91
|
+
|
92
|
+
def cleanup_all(self):
|
93
|
+
"""Clean up all resources in the pool."""
|
94
|
+
with self._lock:
|
95
|
+
# Clean up pooled resources
|
96
|
+
for resource in self._pool:
|
97
|
+
if self._cleanup:
|
98
|
+
try:
|
99
|
+
self._cleanup(resource)
|
100
|
+
except Exception as e:
|
101
|
+
logger.error(f"Error cleaning up resource: {e}")
|
102
|
+
|
103
|
+
# Clean up in-use resources (best effort)
|
104
|
+
for resource in self._in_use:
|
105
|
+
if self._cleanup:
|
106
|
+
try:
|
107
|
+
self._cleanup(resource)
|
108
|
+
except Exception as e:
|
109
|
+
logger.error(f"Error cleaning up in-use resource: {e}")
|
110
|
+
|
111
|
+
self._pool.clear()
|
112
|
+
self._in_use.clear()
|
113
|
+
self._created_count = 0
|
114
|
+
|
115
|
+
|
116
|
+
class AsyncResourcePool(Generic[T]):
|
117
|
+
"""Async version of ResourcePool for async resources."""
|
118
|
+
|
119
|
+
def __init__(
|
120
|
+
self,
|
121
|
+
factory: Callable[[], T],
|
122
|
+
max_size: int = 10,
|
123
|
+
timeout: float = 30.0,
|
124
|
+
cleanup: Optional[Callable[[T], Any]] = None,
|
125
|
+
):
|
126
|
+
"""Initialize the async resource pool.
|
127
|
+
|
128
|
+
Args:
|
129
|
+
factory: Async function to create new resources
|
130
|
+
max_size: Maximum pool size
|
131
|
+
timeout: Timeout for acquiring resources
|
132
|
+
cleanup: Optional async cleanup function
|
133
|
+
"""
|
134
|
+
self._factory = factory
|
135
|
+
self._max_size = max_size
|
136
|
+
self._timeout = timeout
|
137
|
+
self._cleanup = cleanup
|
138
|
+
|
139
|
+
self._pool: list[T] = []
|
140
|
+
self._in_use: Set[T] = set()
|
141
|
+
self._lock = asyncio.Lock()
|
142
|
+
self._semaphore = asyncio.Semaphore(max_size)
|
143
|
+
self._created_count = 0
|
144
|
+
|
145
|
+
@asynccontextmanager
|
146
|
+
async def acquire(self):
|
147
|
+
"""Acquire a resource from the pool asynchronously.
|
148
|
+
|
149
|
+
Yields:
|
150
|
+
Resource instance
|
151
|
+
|
152
|
+
Raises:
|
153
|
+
TimeoutError: If resource cannot be acquired within timeout
|
154
|
+
"""
|
155
|
+
try:
|
156
|
+
await asyncio.wait_for(self._semaphore.acquire(), timeout=self._timeout)
|
157
|
+
except asyncio.TimeoutError:
|
158
|
+
raise TimeoutError(f"Failed to acquire resource within {self._timeout}s")
|
159
|
+
|
160
|
+
resource = None
|
161
|
+
try:
|
162
|
+
async with self._lock:
|
163
|
+
# Try to get from pool
|
164
|
+
if self._pool:
|
165
|
+
resource = self._pool.pop()
|
166
|
+
else:
|
167
|
+
# Create new resource if under limit
|
168
|
+
if self._created_count < self._max_size:
|
169
|
+
if asyncio.iscoroutinefunction(self._factory):
|
170
|
+
resource = await self._factory()
|
171
|
+
else:
|
172
|
+
resource = self._factory()
|
173
|
+
self._created_count += 1
|
174
|
+
else:
|
175
|
+
raise RuntimeError("Pool exhausted")
|
176
|
+
|
177
|
+
self._in_use.add(resource)
|
178
|
+
|
179
|
+
yield resource
|
180
|
+
|
181
|
+
finally:
|
182
|
+
if resource is not None:
|
183
|
+
async with self._lock:
|
184
|
+
self._in_use.discard(resource)
|
185
|
+
self._pool.append(resource)
|
186
|
+
self._semaphore.release()
|
187
|
+
|
188
|
+
async def cleanup_all(self):
|
189
|
+
"""Clean up all resources in the pool asynchronously."""
|
190
|
+
async with self._lock:
|
191
|
+
# Clean up pooled resources
|
192
|
+
for resource in self._pool:
|
193
|
+
if self._cleanup:
|
194
|
+
try:
|
195
|
+
if asyncio.iscoroutinefunction(self._cleanup):
|
196
|
+
await self._cleanup(resource)
|
197
|
+
else:
|
198
|
+
self._cleanup(resource)
|
199
|
+
except Exception as e:
|
200
|
+
logger.error(f"Error cleaning up resource: {e}")
|
201
|
+
|
202
|
+
# Clean up in-use resources (best effort)
|
203
|
+
for resource in self._in_use:
|
204
|
+
if self._cleanup:
|
205
|
+
try:
|
206
|
+
if asyncio.iscoroutinefunction(self._cleanup):
|
207
|
+
await self._cleanup(resource)
|
208
|
+
else:
|
209
|
+
self._cleanup(resource)
|
210
|
+
except Exception as e:
|
211
|
+
logger.error(f"Error cleaning up in-use resource: {e}")
|
212
|
+
|
213
|
+
self._pool.clear()
|
214
|
+
self._in_use.clear()
|
215
|
+
self._created_count = 0
|
216
|
+
|
217
|
+
|
218
|
+
class ResourceTracker:
|
219
|
+
"""Track and manage resources across the SDK to prevent leaks."""
|
220
|
+
|
221
|
+
def __init__(self):
|
222
|
+
self._resources: Dict[str, weakref.WeakSet] = defaultdict(weakref.WeakSet)
|
223
|
+
self._metrics: Dict[str, Dict[str, Any]] = defaultdict(dict)
|
224
|
+
self._lock = threading.Lock()
|
225
|
+
|
226
|
+
def register(self, resource_type: str, resource: Any):
|
227
|
+
"""Register a resource for tracking.
|
228
|
+
|
229
|
+
Args:
|
230
|
+
resource_type: Type/category of resource
|
231
|
+
resource: Resource instance to track
|
232
|
+
"""
|
233
|
+
with self._lock:
|
234
|
+
self._resources[resource_type].add(resource)
|
235
|
+
|
236
|
+
# Update metrics
|
237
|
+
if resource_type not in self._metrics:
|
238
|
+
self._metrics[resource_type] = {
|
239
|
+
"created": 0,
|
240
|
+
"active": 0,
|
241
|
+
"peak": 0,
|
242
|
+
"last_created": None,
|
243
|
+
}
|
244
|
+
|
245
|
+
self._metrics[resource_type]["created"] += 1
|
246
|
+
self._metrics[resource_type]["active"] = len(self._resources[resource_type])
|
247
|
+
self._metrics[resource_type]["peak"] = max(
|
248
|
+
self._metrics[resource_type]["peak"],
|
249
|
+
self._metrics[resource_type]["active"],
|
250
|
+
)
|
251
|
+
self._metrics[resource_type]["last_created"] = datetime.now(UTC)
|
252
|
+
|
253
|
+
def get_metrics(self) -> Dict[str, Dict[str, Any]]:
|
254
|
+
"""Get current resource metrics.
|
255
|
+
|
256
|
+
Returns:
|
257
|
+
Dictionary of metrics by resource type
|
258
|
+
"""
|
259
|
+
with self._lock:
|
260
|
+
# Update active counts
|
261
|
+
for resource_type in self._metrics:
|
262
|
+
self._metrics[resource_type]["active"] = len(
|
263
|
+
self._resources[resource_type]
|
264
|
+
)
|
265
|
+
|
266
|
+
return dict(self._metrics)
|
267
|
+
|
268
|
+
def get_active_resources(
|
269
|
+
self, resource_type: Optional[str] = None
|
270
|
+
) -> Dict[str, int]:
|
271
|
+
"""Get count of active resources.
|
272
|
+
|
273
|
+
Args:
|
274
|
+
resource_type: Optional filter by type
|
275
|
+
|
276
|
+
Returns:
|
277
|
+
Dictionary of resource type to active count
|
278
|
+
"""
|
279
|
+
with self._lock:
|
280
|
+
if resource_type:
|
281
|
+
return {resource_type: len(self._resources.get(resource_type, set()))}
|
282
|
+
else:
|
283
|
+
return {
|
284
|
+
rtype: len(resources)
|
285
|
+
for rtype, resources in self._resources.items()
|
286
|
+
}
|
287
|
+
|
288
|
+
|
289
|
+
# Global resource tracker instance
|
290
|
+
_resource_tracker = ResourceTracker()
|
291
|
+
|
292
|
+
|
293
|
+
def get_resource_tracker() -> ResourceTracker:
|
294
|
+
"""Get the global resource tracker instance."""
|
295
|
+
return _resource_tracker
|
296
|
+
|
297
|
+
|
298
|
+
@contextmanager
|
299
|
+
def managed_resource(
|
300
|
+
resource_type: str, resource: Any, cleanup: Optional[Callable] = None
|
301
|
+
):
|
302
|
+
"""Context manager for tracking and cleaning up resources.
|
303
|
+
|
304
|
+
Args:
|
305
|
+
resource_type: Type/category of resource
|
306
|
+
resource: Resource instance
|
307
|
+
cleanup: Optional cleanup function
|
308
|
+
|
309
|
+
Yields:
|
310
|
+
The resource instance
|
311
|
+
"""
|
312
|
+
_resource_tracker.register(resource_type, resource)
|
313
|
+
|
314
|
+
try:
|
315
|
+
yield resource
|
316
|
+
finally:
|
317
|
+
if cleanup:
|
318
|
+
try:
|
319
|
+
cleanup(resource)
|
320
|
+
except Exception as e:
|
321
|
+
logger.error(f"Error cleaning up {resource_type}: {e}")
|
322
|
+
|
323
|
+
|
324
|
+
@asynccontextmanager
|
325
|
+
async def async_managed_resource(
|
326
|
+
resource_type: str, resource: Any, cleanup: Optional[Callable] = None
|
327
|
+
):
|
328
|
+
"""Async context manager for tracking and cleaning up resources.
|
329
|
+
|
330
|
+
Args:
|
331
|
+
resource_type: Type/category of resource
|
332
|
+
resource: Resource instance
|
333
|
+
cleanup: Optional async cleanup function
|
334
|
+
|
335
|
+
Yields:
|
336
|
+
The resource instance
|
337
|
+
"""
|
338
|
+
_resource_tracker.register(resource_type, resource)
|
339
|
+
|
340
|
+
try:
|
341
|
+
yield resource
|
342
|
+
finally:
|
343
|
+
if cleanup:
|
344
|
+
try:
|
345
|
+
if asyncio.iscoroutinefunction(cleanup):
|
346
|
+
await cleanup(resource)
|
347
|
+
else:
|
348
|
+
cleanup(resource)
|
349
|
+
except Exception as e:
|
350
|
+
logger.error(f"Error cleaning up {resource_type}: {e}")
|
351
|
+
|
352
|
+
|
353
|
+
class ConcurrencyLimiter:
|
354
|
+
"""Limit concurrent operations to prevent resource exhaustion."""
|
355
|
+
|
356
|
+
def __init__(self, max_concurrent: int = 10):
|
357
|
+
"""Initialize the concurrency limiter.
|
358
|
+
|
359
|
+
Args:
|
360
|
+
max_concurrent: Maximum concurrent operations
|
361
|
+
"""
|
362
|
+
self._semaphore = threading.Semaphore(max_concurrent)
|
363
|
+
self._active = 0
|
364
|
+
self._peak = 0
|
365
|
+
self._lock = threading.Lock()
|
366
|
+
|
367
|
+
@contextmanager
|
368
|
+
def limit(self):
|
369
|
+
"""Context manager to limit concurrency."""
|
370
|
+
self._semaphore.acquire()
|
371
|
+
with self._lock:
|
372
|
+
self._active += 1
|
373
|
+
self._peak = max(self._peak, self._active)
|
374
|
+
|
375
|
+
try:
|
376
|
+
yield
|
377
|
+
finally:
|
378
|
+
with self._lock:
|
379
|
+
self._active -= 1
|
380
|
+
self._semaphore.release()
|
381
|
+
|
382
|
+
def get_stats(self) -> Dict[str, int]:
|
383
|
+
"""Get concurrency statistics."""
|
384
|
+
with self._lock:
|
385
|
+
return {"active": self._active, "peak": self._peak}
|
386
|
+
|
387
|
+
|
388
|
+
class AsyncConcurrencyLimiter:
|
389
|
+
"""Async version of ConcurrencyLimiter."""
|
390
|
+
|
391
|
+
def __init__(self, max_concurrent: int = 10):
|
392
|
+
"""Initialize the async concurrency limiter.
|
393
|
+
|
394
|
+
Args:
|
395
|
+
max_concurrent: Maximum concurrent operations
|
396
|
+
"""
|
397
|
+
self._semaphore = asyncio.Semaphore(max_concurrent)
|
398
|
+
self._active = 0
|
399
|
+
self._peak = 0
|
400
|
+
self._lock = asyncio.Lock()
|
401
|
+
|
402
|
+
@asynccontextmanager
|
403
|
+
async def limit(self):
|
404
|
+
"""Async context manager to limit concurrency."""
|
405
|
+
await self._semaphore.acquire()
|
406
|
+
async with self._lock:
|
407
|
+
self._active += 1
|
408
|
+
self._peak = max(self._peak, self._active)
|
409
|
+
|
410
|
+
try:
|
411
|
+
yield
|
412
|
+
finally:
|
413
|
+
async with self._lock:
|
414
|
+
self._active -= 1
|
415
|
+
self._semaphore.release()
|
416
|
+
|
417
|
+
async def get_stats(self) -> Dict[str, int]:
|
418
|
+
"""Get concurrency statistics."""
|
419
|
+
async with self._lock:
|
420
|
+
return {"active": self._active, "peak": self._peak}
|
kailash/workflow/builder.py
CHANGED
@@ -21,7 +21,7 @@ class WorkflowBuilder:
|
|
21
21
|
|
22
22
|
def add_node(
|
23
23
|
self,
|
24
|
-
node_type: str,
|
24
|
+
node_type: str | type | Any,
|
25
25
|
node_id: str | None = None,
|
26
26
|
config: dict[str, Any] | None = None,
|
27
27
|
) -> str:
|
@@ -29,9 +29,9 @@ class WorkflowBuilder:
|
|
29
29
|
Add a node to the workflow.
|
30
30
|
|
31
31
|
Args:
|
32
|
-
node_type: Node type name
|
32
|
+
node_type: Node type name (string), Node class, or Node instance
|
33
33
|
node_id: Unique identifier for this node (auto-generated if not provided)
|
34
|
-
config: Configuration for the node
|
34
|
+
config: Configuration for the node (ignored if node_type is an instance)
|
35
35
|
|
36
36
|
Returns:
|
37
37
|
Node ID (useful for method chaining)
|
@@ -48,11 +48,80 @@ class WorkflowBuilder:
|
|
48
48
|
f"Node ID '{node_id}' already exists in workflow"
|
49
49
|
)
|
50
50
|
|
51
|
-
|
51
|
+
# Import Node here to avoid circular imports
|
52
|
+
from kailash.nodes.base import Node
|
53
|
+
|
54
|
+
# Handle different input types
|
55
|
+
if isinstance(node_type, str):
|
56
|
+
# String node type name
|
57
|
+
self.nodes[node_id] = {"type": node_type, "config": config or {}}
|
58
|
+
type_name = node_type
|
59
|
+
elif isinstance(node_type, type) and issubclass(node_type, Node):
|
60
|
+
# Node class
|
61
|
+
self.nodes[node_id] = {
|
62
|
+
"type": node_type.__name__,
|
63
|
+
"config": config or {},
|
64
|
+
"class": node_type,
|
65
|
+
}
|
66
|
+
type_name = node_type.__name__
|
67
|
+
elif hasattr(node_type, "__class__") and issubclass(node_type.__class__, Node):
|
68
|
+
# Node instance
|
69
|
+
self.nodes[node_id] = {
|
70
|
+
"instance": node_type,
|
71
|
+
"type": node_type.__class__.__name__,
|
72
|
+
}
|
73
|
+
type_name = node_type.__class__.__name__
|
74
|
+
else:
|
75
|
+
raise WorkflowValidationError(
|
76
|
+
f"Invalid node type: {type(node_type)}. "
|
77
|
+
"Expected: str (node type name), Node class, or Node instance"
|
78
|
+
)
|
52
79
|
|
53
|
-
logger.info(f"Added node '{node_id}' of type '{
|
80
|
+
logger.info(f"Added node '{node_id}' of type '{type_name}'")
|
54
81
|
return node_id
|
55
82
|
|
83
|
+
def add_node_instance(self, node_instance: Any, node_id: str | None = None) -> str:
|
84
|
+
"""
|
85
|
+
Add a node instance to the workflow.
|
86
|
+
|
87
|
+
This is a convenience method for adding pre-configured node instances.
|
88
|
+
|
89
|
+
Args:
|
90
|
+
node_instance: Pre-configured node instance
|
91
|
+
node_id: Unique identifier for this node (auto-generated if not provided)
|
92
|
+
|
93
|
+
Returns:
|
94
|
+
Node ID
|
95
|
+
|
96
|
+
Raises:
|
97
|
+
WorkflowValidationError: If node_id is already used or instance is invalid
|
98
|
+
"""
|
99
|
+
return self.add_node(node_instance, node_id)
|
100
|
+
|
101
|
+
def add_node_type(
|
102
|
+
self,
|
103
|
+
node_type: str,
|
104
|
+
node_id: str | None = None,
|
105
|
+
config: dict[str, Any] | None = None,
|
106
|
+
) -> str:
|
107
|
+
"""
|
108
|
+
Add a node by type name to the workflow.
|
109
|
+
|
110
|
+
This is the original string-based method, provided for clarity and backward compatibility.
|
111
|
+
|
112
|
+
Args:
|
113
|
+
node_type: Node type name as string
|
114
|
+
node_id: Unique identifier for this node (auto-generated if not provided)
|
115
|
+
config: Configuration for the node
|
116
|
+
|
117
|
+
Returns:
|
118
|
+
Node ID
|
119
|
+
|
120
|
+
Raises:
|
121
|
+
WorkflowValidationError: If node_id is already used
|
122
|
+
"""
|
123
|
+
return self.add_node(node_type, node_id, config)
|
124
|
+
|
56
125
|
def add_connection(
|
57
126
|
self, from_node: str, from_output: str, to_node: str, to_input: str
|
58
127
|
) -> None:
|
@@ -149,11 +218,25 @@ class WorkflowBuilder:
|
|
149
218
|
# Add nodes to workflow
|
150
219
|
for node_id, node_info in self.nodes.items():
|
151
220
|
try:
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
221
|
+
if "instance" in node_info:
|
222
|
+
# Node instance was provided
|
223
|
+
workflow.add_node(
|
224
|
+
node_id=node_id, node_or_type=node_info["instance"]
|
225
|
+
)
|
226
|
+
elif "class" in node_info:
|
227
|
+
# Node class was provided
|
228
|
+
node_class = node_info["class"]
|
229
|
+
node_config = node_info.get("config", {})
|
230
|
+
workflow.add_node(
|
231
|
+
node_id=node_id, node_or_type=node_class, **node_config
|
232
|
+
)
|
233
|
+
else:
|
234
|
+
# String node type
|
235
|
+
node_type = node_info["type"]
|
236
|
+
node_config = node_info.get("config", {})
|
237
|
+
workflow.add_node(
|
238
|
+
node_id=node_id, node_or_type=node_type, **node_config
|
239
|
+
)
|
157
240
|
except Exception as e:
|
158
241
|
raise WorkflowValidationError(
|
159
242
|
f"Failed to add node '{node_id}' to workflow: {e}"
|
@@ -516,12 +516,11 @@ class CyclicWorkflowExecutor:
|
|
516
516
|
f"Cycle {cycle_id} iteration now at {cycle_state.iteration} (after update)"
|
517
517
|
)
|
518
518
|
|
519
|
-
# Check max iterations
|
520
|
-
|
521
|
-
|
522
|
-
):
|
519
|
+
# Check max iterations - loop_count represents actual iterations executed
|
520
|
+
max_iterations = cycle_config.get("max_iterations", float("inf"))
|
521
|
+
if loop_count >= max_iterations:
|
523
522
|
logger.info(
|
524
|
-
f"Cycle {cycle_id} reached max iterations: {
|
523
|
+
f"Cycle {cycle_id} reached max iterations: {loop_count}/{max_iterations}"
|
525
524
|
)
|
526
525
|
should_terminate = True
|
527
526
|
|
@@ -643,9 +642,6 @@ class CyclicWorkflowExecutor:
|
|
643
642
|
if is_cycle_edge and is_cycle_iteration and previous_iteration_results:
|
644
643
|
# For cycle edges after first iteration, use previous iteration results
|
645
644
|
pred_output = previous_iteration_results.get(pred)
|
646
|
-
logger.debug(
|
647
|
-
f"Using previous iteration result for {pred} -> {node_id}: {type(pred_output)} keys={list(pred_output.keys()) if isinstance(pred_output, dict) else 'not dict'}"
|
648
|
-
)
|
649
645
|
elif pred in state.node_outputs:
|
650
646
|
# For non-cycle edges or first iteration, use normal state
|
651
647
|
pred_output = state.node_outputs[pred]
|
@@ -658,10 +654,6 @@ class CyclicWorkflowExecutor:
|
|
658
654
|
|
659
655
|
# Apply mapping
|
660
656
|
mapping = edge_data.get("mapping", {})
|
661
|
-
if is_cycle_edge and is_cycle_iteration:
|
662
|
-
logger.debug(
|
663
|
-
f"Applying cycle mapping: {mapping} from {pred} to {node_id}"
|
664
|
-
)
|
665
657
|
for src_key, dst_key in mapping.items():
|
666
658
|
# Handle nested output access
|
667
659
|
if "." in src_key:
|
@@ -677,10 +669,6 @@ class CyclicWorkflowExecutor:
|
|
677
669
|
inputs[dst_key] = value
|
678
670
|
elif isinstance(pred_output, dict) and src_key in pred_output:
|
679
671
|
inputs[dst_key] = pred_output[src_key]
|
680
|
-
if is_cycle_edge and is_cycle_iteration:
|
681
|
-
logger.debug(
|
682
|
-
f"Mapped {src_key}={pred_output[src_key]} to {dst_key}"
|
683
|
-
)
|
684
672
|
elif src_key == "output":
|
685
673
|
# Default output mapping
|
686
674
|
inputs[dst_key] = pred_output
|
@@ -706,10 +694,6 @@ class CyclicWorkflowExecutor:
|
|
706
694
|
# Recursively filter None values from context to avoid security validation errors
|
707
695
|
context = self._filter_none_values(context)
|
708
696
|
|
709
|
-
# Debug inputs before merging
|
710
|
-
if cycle_state and cycle_state.iteration > 0:
|
711
|
-
logger.debug(f"Inputs gathered from connections: {inputs}")
|
712
|
-
|
713
697
|
# Merge node config with inputs
|
714
698
|
# Order: config < initial_parameters < connection inputs
|
715
699
|
merged_inputs = {**node.config}
|
@@ -769,11 +753,6 @@ class CyclicWorkflowExecutor:
|
|
769
753
|
logger.debug(
|
770
754
|
f"Executing node: {node_id} (iteration: {cycle_state.iteration if cycle_state else 'N/A'})"
|
771
755
|
)
|
772
|
-
logger.debug(f"Node inputs: {list(merged_inputs.keys())}")
|
773
|
-
if cycle_state:
|
774
|
-
logger.debug(
|
775
|
-
f"Input values - value: {merged_inputs.get('value')}, counter: {merged_inputs.get('counter')}"
|
776
|
-
)
|
777
756
|
|
778
757
|
try:
|
779
758
|
with collector.collect(node_id=node_id) as metrics_context:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: kailash
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.5.0
|
4
4
|
Summary: Python SDK for the Kailash container-node architecture
|
5
5
|
Home-page: https://github.com/integrum/kailash-python-sdk
|
6
6
|
Author: Integrum
|
@@ -120,12 +120,13 @@ Dynamic: requires-python
|
|
120
120
|
- 🏭 **Session 067 Enhancements**: Business workflow templates, data lineage tracking, automatic credential rotation
|
121
121
|
- 🔄 **Zero-Downtime Operations**: Automatic credential rotation with enterprise notifications and audit trails
|
122
122
|
- 🌉 **Enterprise Middleware (v0.4.0)**: Production-ready middleware architecture with real-time agent-frontend communication, dynamic workflows, and AI chat integration
|
123
|
+
- ⚡ **Performance Revolution (v0.5.0)**: 10-100x faster parameter resolution, clear async/sync separation, automatic resource management
|
123
124
|
|
124
125
|
## 🏗️ Project Architecture
|
125
126
|
|
126
127
|
The Kailash project is organized into three distinct layers:
|
127
128
|
|
128
|
-
### Core Architecture (v0.
|
129
|
+
### Core Architecture (v0.5.0)
|
129
130
|
```
|
130
131
|
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
131
132
|
│ Frontend │ │ Middleware │ │ Kailash Core │
|
@@ -150,9 +151,10 @@ kailash_python_sdk/
|
|
150
151
|
1. **SDK Layer** (`src/kailash/`) - The core framework providing:
|
151
152
|
- Nodes: Reusable computational units (100+ built-in)
|
152
153
|
- Workflows: DAG-based orchestration with cyclic support
|
153
|
-
- Runtime: Unified execution engine
|
154
|
-
- Middleware: Enterprise communication layer (
|
154
|
+
- Runtime: Unified execution engine with optimized async/sync separation (v0.5.0)
|
155
|
+
- Middleware: Enterprise communication layer (v0.4.0)
|
155
156
|
- Security: RBAC/ABAC access control with audit logging
|
157
|
+
- Performance: LRU parameter caching, automatic resource pooling (NEW in v0.5.0)
|
156
158
|
|
157
159
|
2. **Application Layer** (`apps/`) - Complete applications including:
|
158
160
|
- User Management System (Django++ capabilities)
|