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
@@ -0,0 +1,417 @@
|
|
1
|
+
"""Integration of durable request handling with API Gateway.
|
2
|
+
|
3
|
+
This module provides:
|
4
|
+
- Durable API Gateway with checkpointing
|
5
|
+
- Automatic request deduplication
|
6
|
+
- Event sourcing integration
|
7
|
+
- Backward compatibility with existing gateway
|
8
|
+
"""
|
9
|
+
|
10
|
+
import asyncio
|
11
|
+
import logging
|
12
|
+
from datetime import UTC, datetime
|
13
|
+
from typing import Any, Callable, Dict, List, Optional
|
14
|
+
|
15
|
+
from fastapi import HTTPException, Request, Response
|
16
|
+
from fastapi.responses import JSONResponse
|
17
|
+
|
18
|
+
from kailash.api.gateway import WorkflowAPIGateway
|
19
|
+
|
20
|
+
from .checkpoint_manager import CheckpointManager
|
21
|
+
from .deduplicator import RequestDeduplicator
|
22
|
+
from .durable_request import DurableRequest, RequestMetadata, RequestState
|
23
|
+
from .event_store import (
|
24
|
+
EventStore,
|
25
|
+
EventType,
|
26
|
+
performance_metrics_projection,
|
27
|
+
request_state_projection,
|
28
|
+
)
|
29
|
+
|
30
|
+
logger = logging.getLogger(__name__)
|
31
|
+
|
32
|
+
|
33
|
+
class DurableAPIGateway(WorkflowAPIGateway):
|
34
|
+
"""API Gateway with durable request handling.
|
35
|
+
|
36
|
+
Extends the standard gateway with:
|
37
|
+
- Request durability and checkpointing
|
38
|
+
- Automatic deduplication
|
39
|
+
- Event sourcing for audit trail
|
40
|
+
- Long-running request support
|
41
|
+
"""
|
42
|
+
|
43
|
+
def __init__(
|
44
|
+
self,
|
45
|
+
title: str = "Kailash Durable Workflow Gateway",
|
46
|
+
description: str = "Durable API for Kailash workflows",
|
47
|
+
version: str = "1.0.0",
|
48
|
+
max_workers: int = 10,
|
49
|
+
cors_origins: Optional[list[str]] = None,
|
50
|
+
# Durability configuration
|
51
|
+
enable_durability: bool = True,
|
52
|
+
checkpoint_manager: Optional[CheckpointManager] = None,
|
53
|
+
deduplicator: Optional[RequestDeduplicator] = None,
|
54
|
+
event_store: Optional[EventStore] = None,
|
55
|
+
durability_opt_in: bool = True, # If True, durability is opt-in per endpoint
|
56
|
+
):
|
57
|
+
"""Initialize durable API gateway."""
|
58
|
+
super().__init__(
|
59
|
+
title=title,
|
60
|
+
description=description,
|
61
|
+
version=version,
|
62
|
+
max_workers=max_workers,
|
63
|
+
cors_origins=cors_origins,
|
64
|
+
)
|
65
|
+
|
66
|
+
# Durability components
|
67
|
+
self.enable_durability = enable_durability
|
68
|
+
self.durability_opt_in = durability_opt_in
|
69
|
+
self.checkpoint_manager = checkpoint_manager or CheckpointManager()
|
70
|
+
self.deduplicator = deduplicator or RequestDeduplicator()
|
71
|
+
self.event_store = event_store or EventStore()
|
72
|
+
|
73
|
+
# Track active requests
|
74
|
+
self.active_requests: Dict[str, DurableRequest] = {}
|
75
|
+
|
76
|
+
# Register event projections
|
77
|
+
self.event_store.register_projection(
|
78
|
+
"request_states",
|
79
|
+
request_state_projection,
|
80
|
+
)
|
81
|
+
self.event_store.register_projection(
|
82
|
+
"performance_metrics",
|
83
|
+
performance_metrics_projection,
|
84
|
+
)
|
85
|
+
|
86
|
+
# Add durability middleware if enabled
|
87
|
+
if self.enable_durability:
|
88
|
+
self._add_durability_middleware()
|
89
|
+
|
90
|
+
# Register durability endpoints
|
91
|
+
self._register_durability_endpoints()
|
92
|
+
|
93
|
+
# Track background tasks
|
94
|
+
self._background_tasks: List[asyncio.Task] = []
|
95
|
+
|
96
|
+
def _add_durability_middleware(self):
|
97
|
+
"""Add middleware for durable request handling."""
|
98
|
+
|
99
|
+
@self.app.middleware("http")
|
100
|
+
async def durability_middleware(request: Request, call_next):
|
101
|
+
"""Process requests with durability support."""
|
102
|
+
# Check if durability is enabled for this endpoint
|
103
|
+
if not self._should_use_durability(request):
|
104
|
+
return await call_next(request)
|
105
|
+
|
106
|
+
# Extract request metadata
|
107
|
+
metadata = await self._extract_metadata(request)
|
108
|
+
|
109
|
+
# Check for duplicate request
|
110
|
+
duplicate_response = await self._check_duplicate(request, metadata)
|
111
|
+
if duplicate_response:
|
112
|
+
return duplicate_response
|
113
|
+
|
114
|
+
# Create durable request
|
115
|
+
durable_request = DurableRequest(
|
116
|
+
metadata=metadata,
|
117
|
+
checkpoint_manager=self.checkpoint_manager,
|
118
|
+
)
|
119
|
+
|
120
|
+
# Track active request
|
121
|
+
self.active_requests[durable_request.id] = durable_request
|
122
|
+
|
123
|
+
try:
|
124
|
+
# Record request creation
|
125
|
+
await self.event_store.append(
|
126
|
+
EventType.REQUEST_CREATED,
|
127
|
+
durable_request.id,
|
128
|
+
{
|
129
|
+
"method": metadata.method,
|
130
|
+
"path": metadata.path,
|
131
|
+
"idempotency_key": metadata.idempotency_key,
|
132
|
+
},
|
133
|
+
)
|
134
|
+
|
135
|
+
# Execute with durability
|
136
|
+
response = await self._execute_durable_request(
|
137
|
+
durable_request,
|
138
|
+
request,
|
139
|
+
call_next,
|
140
|
+
)
|
141
|
+
|
142
|
+
# Cache response for deduplication
|
143
|
+
await self._cache_response(request, metadata, response)
|
144
|
+
|
145
|
+
return response
|
146
|
+
|
147
|
+
finally:
|
148
|
+
# Clean up active request
|
149
|
+
del self.active_requests[durable_request.id]
|
150
|
+
|
151
|
+
def _should_use_durability(self, request: Request) -> bool:
|
152
|
+
"""Check if durability should be used for this request."""
|
153
|
+
if not self.enable_durability:
|
154
|
+
return False
|
155
|
+
|
156
|
+
if self.durability_opt_in:
|
157
|
+
# Check for durability header or query param
|
158
|
+
use_durability = (
|
159
|
+
request.headers.get("X-Durable-Request", "").lower() == "true"
|
160
|
+
or request.query_params.get("durable", "").lower() == "true"
|
161
|
+
)
|
162
|
+
return use_durability
|
163
|
+
|
164
|
+
# Durability enabled for all requests
|
165
|
+
return True
|
166
|
+
|
167
|
+
async def _extract_metadata(self, request: Request) -> RequestMetadata:
|
168
|
+
"""Extract metadata from HTTP request."""
|
169
|
+
# Get body if present
|
170
|
+
body = None
|
171
|
+
if request.method in ["POST", "PUT", "PATCH"]:
|
172
|
+
try:
|
173
|
+
body = await request.json()
|
174
|
+
except:
|
175
|
+
pass
|
176
|
+
|
177
|
+
# Extract user/tenant from headers or auth
|
178
|
+
user_id = request.headers.get("X-User-ID")
|
179
|
+
tenant_id = request.headers.get("X-Tenant-ID")
|
180
|
+
|
181
|
+
# Get idempotency key
|
182
|
+
idempotency_key = request.headers.get("Idempotency-Key") or request.headers.get(
|
183
|
+
"X-Idempotency-Key"
|
184
|
+
)
|
185
|
+
|
186
|
+
return RequestMetadata(
|
187
|
+
request_id=f"req_{request.headers.get('X-Request-ID', '')}",
|
188
|
+
method=request.method,
|
189
|
+
path=str(request.url.path),
|
190
|
+
headers=dict(request.headers),
|
191
|
+
query_params=dict(request.query_params),
|
192
|
+
body=body,
|
193
|
+
client_ip=request.client.host if request.client else "0.0.0.0",
|
194
|
+
user_id=user_id,
|
195
|
+
tenant_id=tenant_id,
|
196
|
+
idempotency_key=idempotency_key,
|
197
|
+
created_at=datetime.now(UTC),
|
198
|
+
updated_at=datetime.now(UTC),
|
199
|
+
)
|
200
|
+
|
201
|
+
async def _check_duplicate(
|
202
|
+
self,
|
203
|
+
request: Request,
|
204
|
+
metadata: RequestMetadata,
|
205
|
+
) -> Optional[Response]:
|
206
|
+
"""Check for duplicate request."""
|
207
|
+
duplicate = await self.deduplicator.check_duplicate(
|
208
|
+
method=metadata.method,
|
209
|
+
path=metadata.path,
|
210
|
+
query_params=metadata.query_params,
|
211
|
+
body=metadata.body,
|
212
|
+
headers=metadata.headers,
|
213
|
+
idempotency_key=metadata.idempotency_key,
|
214
|
+
)
|
215
|
+
|
216
|
+
if duplicate:
|
217
|
+
# Record deduplication hit
|
218
|
+
await self.event_store.append(
|
219
|
+
EventType.DEDUPLICATION_HIT,
|
220
|
+
metadata.request_id,
|
221
|
+
{
|
222
|
+
"cached_response": True,
|
223
|
+
"cache_age_seconds": duplicate["cache_age_seconds"],
|
224
|
+
},
|
225
|
+
)
|
226
|
+
|
227
|
+
return JSONResponse(
|
228
|
+
content=duplicate["data"],
|
229
|
+
status_code=duplicate["status_code"],
|
230
|
+
headers={
|
231
|
+
**duplicate["headers"],
|
232
|
+
"X-Cached-Response": "true",
|
233
|
+
"X-Cache-Age": str(duplicate["cache_age_seconds"]),
|
234
|
+
},
|
235
|
+
)
|
236
|
+
|
237
|
+
return None
|
238
|
+
|
239
|
+
async def _execute_durable_request(
|
240
|
+
self,
|
241
|
+
durable_request: DurableRequest,
|
242
|
+
request: Request,
|
243
|
+
call_next: Callable,
|
244
|
+
) -> Response:
|
245
|
+
"""Execute request with durability."""
|
246
|
+
try:
|
247
|
+
# Convert HTTP request to workflow request
|
248
|
+
# This is simplified - real implementation would parse the request
|
249
|
+
# and create appropriate workflow based on routing
|
250
|
+
|
251
|
+
# For now, just execute the request normally
|
252
|
+
response = await call_next(request)
|
253
|
+
|
254
|
+
# Record completion
|
255
|
+
await self.event_store.append(
|
256
|
+
EventType.REQUEST_COMPLETED,
|
257
|
+
durable_request.id,
|
258
|
+
{
|
259
|
+
"status_code": response.status_code,
|
260
|
+
"duration_ms": 0, # TODO: Track actual duration
|
261
|
+
},
|
262
|
+
)
|
263
|
+
|
264
|
+
return response
|
265
|
+
|
266
|
+
except Exception as e:
|
267
|
+
# Record failure
|
268
|
+
await self.event_store.append(
|
269
|
+
EventType.REQUEST_FAILED,
|
270
|
+
durable_request.id,
|
271
|
+
{
|
272
|
+
"error": str(e),
|
273
|
+
"error_type": type(e).__name__,
|
274
|
+
},
|
275
|
+
)
|
276
|
+
raise
|
277
|
+
|
278
|
+
async def _cache_response(
|
279
|
+
self,
|
280
|
+
request: Request,
|
281
|
+
metadata: RequestMetadata,
|
282
|
+
response: Response,
|
283
|
+
):
|
284
|
+
"""Cache response for deduplication."""
|
285
|
+
# Only cache successful responses
|
286
|
+
if response.status_code >= 400:
|
287
|
+
return
|
288
|
+
|
289
|
+
# Extract response data
|
290
|
+
response_data = {}
|
291
|
+
if hasattr(response, "body"):
|
292
|
+
try:
|
293
|
+
# Decode response body
|
294
|
+
import json
|
295
|
+
|
296
|
+
response_data = json.loads(response.body)
|
297
|
+
except:
|
298
|
+
pass
|
299
|
+
|
300
|
+
await self.deduplicator.cache_response(
|
301
|
+
method=metadata.method,
|
302
|
+
path=metadata.path,
|
303
|
+
query_params=metadata.query_params,
|
304
|
+
body=metadata.body,
|
305
|
+
headers=metadata.headers,
|
306
|
+
idempotency_key=metadata.idempotency_key,
|
307
|
+
response_data=response_data,
|
308
|
+
status_code=response.status_code,
|
309
|
+
response_headers=(
|
310
|
+
dict(response.headers) if hasattr(response, "headers") else {}
|
311
|
+
),
|
312
|
+
)
|
313
|
+
|
314
|
+
def _register_durability_endpoints(self):
|
315
|
+
"""Register durability-specific endpoints."""
|
316
|
+
|
317
|
+
@self.app.get("/durability/status")
|
318
|
+
async def durability_status():
|
319
|
+
"""Get durability system status."""
|
320
|
+
return {
|
321
|
+
"enabled": self.enable_durability,
|
322
|
+
"opt_in": self.durability_opt_in,
|
323
|
+
"active_requests": len(self.active_requests),
|
324
|
+
"checkpoint_stats": self.checkpoint_manager.get_stats(),
|
325
|
+
"deduplication_stats": self.deduplicator.get_stats(),
|
326
|
+
"event_store_stats": self.event_store.get_stats(),
|
327
|
+
}
|
328
|
+
|
329
|
+
@self.app.get("/durability/requests/{request_id}")
|
330
|
+
async def get_request_status(request_id: str):
|
331
|
+
"""Get status of a durable request."""
|
332
|
+
# Check active requests
|
333
|
+
if request_id in self.active_requests:
|
334
|
+
return self.active_requests[request_id].get_status()
|
335
|
+
|
336
|
+
# Check event store for historical data
|
337
|
+
events = await self.event_store.get_events(request_id)
|
338
|
+
if not events:
|
339
|
+
raise HTTPException(status_code=404, detail="Request not found")
|
340
|
+
|
341
|
+
# Build status from events
|
342
|
+
status = {
|
343
|
+
"request_id": request_id,
|
344
|
+
"events": len(events),
|
345
|
+
"first_event": events[0].timestamp.isoformat(),
|
346
|
+
"last_event": events[-1].timestamp.isoformat(),
|
347
|
+
"state": "unknown",
|
348
|
+
}
|
349
|
+
|
350
|
+
# Determine final state
|
351
|
+
for event in reversed(events):
|
352
|
+
if event.event_type == EventType.REQUEST_COMPLETED:
|
353
|
+
status["state"] = "completed"
|
354
|
+
break
|
355
|
+
elif event.event_type == EventType.REQUEST_FAILED:
|
356
|
+
status["state"] = "failed"
|
357
|
+
break
|
358
|
+
elif event.event_type == EventType.REQUEST_CANCELLED:
|
359
|
+
status["state"] = "cancelled"
|
360
|
+
break
|
361
|
+
|
362
|
+
return status
|
363
|
+
|
364
|
+
@self.app.get("/durability/requests/{request_id}/events")
|
365
|
+
async def get_request_events(request_id: str):
|
366
|
+
"""Get all events for a request."""
|
367
|
+
events = await self.event_store.get_events(request_id)
|
368
|
+
return {
|
369
|
+
"request_id": request_id,
|
370
|
+
"event_count": len(events),
|
371
|
+
"events": [e.to_dict() for e in events],
|
372
|
+
}
|
373
|
+
|
374
|
+
@self.app.post("/durability/requests/{request_id}/resume")
|
375
|
+
async def resume_request(request_id: str, checkpoint_id: Optional[str] = None):
|
376
|
+
"""Resume a failed or incomplete request."""
|
377
|
+
# TODO: Implement request resumption
|
378
|
+
return {
|
379
|
+
"status": "not_implemented",
|
380
|
+
"message": "Request resumption coming soon",
|
381
|
+
}
|
382
|
+
|
383
|
+
@self.app.delete("/durability/requests/{request_id}")
|
384
|
+
async def cancel_request(request_id: str):
|
385
|
+
"""Cancel an active request."""
|
386
|
+
if request_id not in self.active_requests:
|
387
|
+
raise HTTPException(status_code=404, detail="Active request not found")
|
388
|
+
|
389
|
+
durable_request = self.active_requests[request_id]
|
390
|
+
await durable_request.cancel()
|
391
|
+
|
392
|
+
return {"status": "cancelled", "request_id": request_id}
|
393
|
+
|
394
|
+
@self.app.get("/durability/projections/{name}")
|
395
|
+
async def get_projection(name: str):
|
396
|
+
"""Get current state of a projection."""
|
397
|
+
projection = self.event_store.get_projection(name)
|
398
|
+
if projection is None:
|
399
|
+
raise HTTPException(status_code=404, detail="Projection not found")
|
400
|
+
|
401
|
+
return {
|
402
|
+
"name": name,
|
403
|
+
"state": projection,
|
404
|
+
}
|
405
|
+
|
406
|
+
async def close(self):
|
407
|
+
"""Close the durable gateway and cleanup resources."""
|
408
|
+
await self.checkpoint_manager.close()
|
409
|
+
await self.deduplicator.close()
|
410
|
+
await self.event_store.close()
|
411
|
+
|
412
|
+
# Wait for active requests to complete
|
413
|
+
if self.active_requests:
|
414
|
+
logger.info(f"Waiting for {len(self.active_requests)} active requests")
|
415
|
+
# TODO: Implement graceful shutdown with timeout
|
416
|
+
|
417
|
+
# No parent close() method to call
|