kailash 0.5.0__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.
Files changed (57) hide show
  1. kailash/__init__.py +1 -1
  2. kailash/client/__init__.py +12 -0
  3. kailash/client/enhanced_client.py +306 -0
  4. kailash/core/actors/__init__.py +16 -0
  5. kailash/core/actors/connection_actor.py +566 -0
  6. kailash/core/actors/supervisor.py +364 -0
  7. kailash/edge/__init__.py +16 -0
  8. kailash/edge/compliance.py +834 -0
  9. kailash/edge/discovery.py +659 -0
  10. kailash/edge/location.py +582 -0
  11. kailash/gateway/__init__.py +33 -0
  12. kailash/gateway/api.py +289 -0
  13. kailash/gateway/enhanced_gateway.py +357 -0
  14. kailash/gateway/resource_resolver.py +217 -0
  15. kailash/gateway/security.py +227 -0
  16. kailash/middleware/auth/models.py +2 -2
  17. kailash/middleware/database/base_models.py +1 -7
  18. kailash/middleware/gateway/__init__.py +22 -0
  19. kailash/middleware/gateway/checkpoint_manager.py +398 -0
  20. kailash/middleware/gateway/deduplicator.py +382 -0
  21. kailash/middleware/gateway/durable_gateway.py +417 -0
  22. kailash/middleware/gateway/durable_request.py +498 -0
  23. kailash/middleware/gateway/event_store.py +459 -0
  24. kailash/nodes/admin/permission_check.py +817 -33
  25. kailash/nodes/admin/role_management.py +1242 -108
  26. kailash/nodes/admin/schema_manager.py +438 -0
  27. kailash/nodes/admin/user_management.py +1124 -1582
  28. kailash/nodes/code/__init__.py +8 -1
  29. kailash/nodes/code/async_python.py +1035 -0
  30. kailash/nodes/code/python.py +1 -0
  31. kailash/nodes/data/async_sql.py +9 -3
  32. kailash/nodes/data/sql.py +20 -11
  33. kailash/nodes/data/workflow_connection_pool.py +643 -0
  34. kailash/nodes/rag/__init__.py +1 -4
  35. kailash/resources/__init__.py +40 -0
  36. kailash/resources/factory.py +533 -0
  37. kailash/resources/health.py +319 -0
  38. kailash/resources/reference.py +288 -0
  39. kailash/resources/registry.py +392 -0
  40. kailash/runtime/async_local.py +711 -302
  41. kailash/testing/__init__.py +34 -0
  42. kailash/testing/async_test_case.py +353 -0
  43. kailash/testing/async_utils.py +345 -0
  44. kailash/testing/fixtures.py +458 -0
  45. kailash/testing/mock_registry.py +495 -0
  46. kailash/workflow/__init__.py +8 -0
  47. kailash/workflow/async_builder.py +621 -0
  48. kailash/workflow/async_patterns.py +766 -0
  49. kailash/workflow/cyclic_runner.py +107 -16
  50. kailash/workflow/graph.py +7 -2
  51. kailash/workflow/resilience.py +11 -1
  52. {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/METADATA +7 -4
  53. {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/RECORD +57 -22
  54. {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/WHEEL +0 -0
  55. {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/entry_points.txt +0 -0
  56. {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/licenses/LICENSE +0 -0
  57. {kailash-0.5.0.dist-info → kailash-0.6.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,382 @@
1
+ """Request deduplication with fingerprinting and caching.
2
+
3
+ This module provides:
4
+ - Request fingerprinting for deduplication
5
+ - Idempotency key support
6
+ - Time-window based detection
7
+ - Result caching for duplicate requests
8
+ """
9
+
10
+ import asyncio
11
+ import datetime as dt
12
+ import hashlib
13
+ import json
14
+ import logging
15
+ import time
16
+ from collections import OrderedDict
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timedelta
19
+ from typing import Any, Dict, Optional, Set, Tuple
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ @dataclass
25
+ class CachedResponse:
26
+ """Cached response for duplicate request."""
27
+
28
+ request_fingerprint: str
29
+ idempotency_key: Optional[str]
30
+ response_data: Dict[str, Any]
31
+ status_code: int
32
+ headers: Dict[str, str]
33
+ created_at: datetime
34
+ request_count: int = 1
35
+ last_accessed: datetime = None
36
+
37
+ def __post_init__(self):
38
+ if not self.last_accessed:
39
+ self.last_accessed = self.created_at
40
+
41
+ def is_expired(self, ttl_seconds: int) -> bool:
42
+ """Check if cached response is expired."""
43
+ # Handle both timezone-aware and naive datetimes
44
+ created_at = self.created_at
45
+ if created_at.tzinfo is None:
46
+ # Assume naive datetime is UTC
47
+ created_at = created_at.replace(tzinfo=dt.UTC)
48
+
49
+ age = (datetime.now(dt.UTC) - created_at).total_seconds()
50
+ return age > ttl_seconds
51
+
52
+ def to_response(self) -> Dict[str, Any]:
53
+ """Convert to response format."""
54
+ return {
55
+ "data": self.response_data,
56
+ "status_code": self.status_code,
57
+ "headers": self.headers,
58
+ "cached": True,
59
+ "cache_age_seconds": (
60
+ datetime.now(dt.UTC)
61
+ - (
62
+ self.created_at.replace(tzinfo=dt.UTC)
63
+ if self.created_at.tzinfo is None
64
+ else self.created_at
65
+ )
66
+ ).total_seconds(),
67
+ }
68
+
69
+
70
+ class RequestFingerprinter:
71
+ """Creates unique fingerprints for requests."""
72
+
73
+ @staticmethod
74
+ def create_fingerprint(
75
+ method: str,
76
+ path: str,
77
+ query_params: Dict[str, str],
78
+ body: Optional[Dict[str, Any]] = None,
79
+ headers: Optional[Dict[str, str]] = None,
80
+ include_headers: Optional[Set[str]] = None,
81
+ ) -> str:
82
+ """Create a unique fingerprint for the request."""
83
+ # Build fingerprint components
84
+ components = {
85
+ "method": method.upper(),
86
+ "path": path,
87
+ "query": RequestFingerprinter._normalize_params(query_params),
88
+ }
89
+
90
+ # Include body if present
91
+ if body:
92
+ components["body"] = RequestFingerprinter._normalize_body(body)
93
+
94
+ # Include specific headers if requested
95
+ if headers and include_headers:
96
+ header_values = {
97
+ k: v
98
+ for k, v in headers.items()
99
+ if k.lower() in {h.lower() for h in include_headers}
100
+ }
101
+ if header_values:
102
+ components["headers"] = header_values
103
+
104
+ # Create stable JSON representation
105
+ fingerprint_data = json.dumps(components, sort_keys=True)
106
+
107
+ # Create hash
108
+ return hashlib.sha256(fingerprint_data.encode()).hexdigest()
109
+
110
+ @staticmethod
111
+ def _normalize_params(params: Dict[str, str]) -> Dict[str, str]:
112
+ """Normalize query parameters."""
113
+ # Sort by key and handle empty values
114
+ return {k: v for k, v in sorted(params.items()) if v is not None and v != ""}
115
+
116
+ @staticmethod
117
+ def _normalize_body(body: Dict[str, Any]) -> Dict[str, Any]:
118
+ """Normalize request body."""
119
+
120
+ # Remove null values and sort keys
121
+ def clean_dict(d):
122
+ if isinstance(d, dict):
123
+ return {k: clean_dict(v) for k, v in sorted(d.items()) if v is not None}
124
+ elif isinstance(d, list):
125
+ return [clean_dict(item) for item in d]
126
+ else:
127
+ return d
128
+
129
+ return clean_dict(body)
130
+
131
+
132
+ class RequestDeduplicator:
133
+ """Deduplicates requests using fingerprinting and idempotency keys."""
134
+
135
+ def __init__(
136
+ self,
137
+ ttl_seconds: int = 3600, # 1 hour default
138
+ max_cache_size: int = 10000,
139
+ include_headers: Optional[Set[str]] = None,
140
+ storage_backend: Optional[Any] = None, # For persistent storage
141
+ ):
142
+ """Initialize deduplicator."""
143
+ self.ttl_seconds = ttl_seconds
144
+ self.max_cache_size = max_cache_size
145
+ self.include_headers = include_headers or set()
146
+ self.storage_backend = storage_backend
147
+
148
+ # In-memory cache (LRU)
149
+ self._cache: OrderedDict[str, CachedResponse] = OrderedDict()
150
+ self._idempotency_cache: Dict[str, str] = {} # idempotency_key -> fingerprint
151
+ self._lock = asyncio.Lock()
152
+
153
+ # Metrics
154
+ self.hit_count = 0
155
+ self.miss_count = 0
156
+ self.eviction_count = 0
157
+
158
+ # Start cleanup task
159
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
160
+
161
+ async def check_duplicate(
162
+ self,
163
+ method: str,
164
+ path: str,
165
+ query_params: Dict[str, str],
166
+ body: Optional[Dict[str, Any]] = None,
167
+ headers: Optional[Dict[str, str]] = None,
168
+ idempotency_key: Optional[str] = None,
169
+ ) -> Optional[Dict[str, Any]]:
170
+ """Check if request is a duplicate and return cached response."""
171
+ # Get request fingerprint
172
+ fingerprint = RequestFingerprinter.create_fingerprint(
173
+ method=method,
174
+ path=path,
175
+ query_params=query_params,
176
+ body=body,
177
+ headers=headers,
178
+ include_headers=self.include_headers,
179
+ )
180
+
181
+ async with self._lock:
182
+ # Check idempotency key first
183
+ if idempotency_key:
184
+ if idempotency_key in self._idempotency_cache:
185
+ cached_fingerprint = self._idempotency_cache[idempotency_key]
186
+ if cached_fingerprint != fingerprint:
187
+ # Same idempotency key but different request
188
+ raise ValueError(
189
+ f"Idempotency key {idempotency_key} used with different request"
190
+ )
191
+ fingerprint = cached_fingerprint
192
+
193
+ # Check cache
194
+ if fingerprint in self._cache:
195
+ cached = self._cache[fingerprint]
196
+
197
+ # Check if expired
198
+ if cached.is_expired(self.ttl_seconds):
199
+ # Remove expired entry
200
+ del self._cache[fingerprint]
201
+ if cached.idempotency_key:
202
+ del self._idempotency_cache[cached.idempotency_key]
203
+ self.miss_count += 1
204
+ return None
205
+
206
+ # Move to end (LRU)
207
+ self._cache.move_to_end(fingerprint)
208
+
209
+ # Update access info
210
+ cached.request_count += 1
211
+ cached.last_accessed = datetime.now(dt.UTC)
212
+
213
+ self.hit_count += 1
214
+
215
+ logger.info(
216
+ f"Duplicate request detected: {method} {path} "
217
+ f"(fingerprint: {fingerprint[:8]}..., count: {cached.request_count})"
218
+ )
219
+
220
+ return cached.to_response()
221
+
222
+ # Check persistent storage if available
223
+ if self.storage_backend:
224
+ stored = await self._check_storage(fingerprint)
225
+ if stored:
226
+ # Add to cache
227
+ self._add_to_cache(fingerprint, stored, idempotency_key)
228
+ self.hit_count += 1
229
+ return stored.to_response()
230
+
231
+ self.miss_count += 1
232
+ return None
233
+
234
+ async def cache_response(
235
+ self,
236
+ method: str,
237
+ path: str,
238
+ query_params: Dict[str, str],
239
+ body: Optional[Dict[str, Any]],
240
+ headers: Optional[Dict[str, str]],
241
+ idempotency_key: Optional[str],
242
+ response_data: Dict[str, Any],
243
+ status_code: int = 200,
244
+ response_headers: Optional[Dict[str, str]] = None,
245
+ ) -> None:
246
+ """Cache a response for deduplication."""
247
+ # Only cache successful responses by default
248
+ if status_code >= 400:
249
+ return
250
+
251
+ fingerprint = RequestFingerprinter.create_fingerprint(
252
+ method=method,
253
+ path=path,
254
+ query_params=query_params,
255
+ body=body,
256
+ headers=headers,
257
+ include_headers=self.include_headers,
258
+ )
259
+
260
+ cached_response = CachedResponse(
261
+ request_fingerprint=fingerprint,
262
+ idempotency_key=idempotency_key,
263
+ response_data=response_data,
264
+ status_code=status_code,
265
+ headers=response_headers or {},
266
+ created_at=datetime.now(dt.UTC),
267
+ )
268
+
269
+ async with self._lock:
270
+ self._add_to_cache(fingerprint, cached_response, idempotency_key)
271
+
272
+ # Store in persistent storage if available
273
+ if self.storage_backend:
274
+ asyncio.create_task(self._store_response(fingerprint, cached_response))
275
+
276
+ logger.debug(
277
+ f"Cached response for {method} {path} (fingerprint: {fingerprint[:8]}...)"
278
+ )
279
+
280
+ def _add_to_cache(
281
+ self,
282
+ fingerprint: str,
283
+ response: CachedResponse,
284
+ idempotency_key: Optional[str],
285
+ ) -> None:
286
+ """Add response to cache with LRU eviction."""
287
+ # Evict oldest if at capacity
288
+ while len(self._cache) >= self.max_cache_size:
289
+ oldest_key, oldest_value = self._cache.popitem(last=False)
290
+ if oldest_value.idempotency_key:
291
+ del self._idempotency_cache[oldest_value.idempotency_key]
292
+ self.eviction_count += 1
293
+ logger.debug(f"Evicted cached response: {oldest_key[:8]}...")
294
+
295
+ # Add to cache
296
+ self._cache[fingerprint] = response
297
+
298
+ # Add idempotency mapping
299
+ if idempotency_key:
300
+ self._idempotency_cache[idempotency_key] = fingerprint
301
+
302
+ async def _check_storage(self, fingerprint: str) -> Optional[CachedResponse]:
303
+ """Check persistent storage for cached response."""
304
+ try:
305
+ data = await self.storage_backend.get(f"dedup:{fingerprint}")
306
+ if data:
307
+ return CachedResponse(**data)
308
+ except Exception as e:
309
+ logger.error(f"Failed to check storage for {fingerprint}: {e}")
310
+ return None
311
+
312
+ async def _store_response(self, fingerprint: str, response: CachedResponse) -> None:
313
+ """Store response in persistent storage."""
314
+ try:
315
+ data = {
316
+ "request_fingerprint": response.request_fingerprint,
317
+ "idempotency_key": response.idempotency_key,
318
+ "response_data": response.response_data,
319
+ "status_code": response.status_code,
320
+ "headers": response.headers,
321
+ "created_at": response.created_at.isoformat(),
322
+ "request_count": response.request_count,
323
+ "last_accessed": response.last_accessed.isoformat(),
324
+ }
325
+ await self.storage_backend.set(
326
+ f"dedup:{fingerprint}",
327
+ data,
328
+ ttl=self.ttl_seconds,
329
+ )
330
+ except Exception as e:
331
+ logger.error(f"Failed to store response for {fingerprint}: {e}")
332
+
333
+ async def _cleanup_loop(self) -> None:
334
+ """Periodically clean up expired entries."""
335
+ while True:
336
+ try:
337
+ await asyncio.sleep(300) # Every 5 minutes
338
+ await self._cleanup_expired()
339
+ except asyncio.CancelledError:
340
+ break
341
+ except Exception as e:
342
+ logger.error(f"Cleanup error: {e}")
343
+
344
+ async def _cleanup_expired(self) -> None:
345
+ """Remove expired cache entries."""
346
+ async with self._lock:
347
+ expired_keys = []
348
+
349
+ for fingerprint, cached in self._cache.items():
350
+ if cached.is_expired(self.ttl_seconds):
351
+ expired_keys.append(fingerprint)
352
+
353
+ for key in expired_keys:
354
+ cached = self._cache.pop(key)
355
+ if cached.idempotency_key:
356
+ self._idempotency_cache.pop(cached.idempotency_key, None)
357
+
358
+ if expired_keys:
359
+ logger.info(f"Cleaned up {len(expired_keys)} expired cache entries")
360
+
361
+ def get_stats(self) -> Dict[str, Any]:
362
+ """Get deduplication statistics."""
363
+ total_requests = self.hit_count + self.miss_count
364
+ hit_rate = self.hit_count / total_requests if total_requests > 0 else 0.0
365
+
366
+ return {
367
+ "hit_count": self.hit_count,
368
+ "miss_count": self.miss_count,
369
+ "hit_rate": hit_rate,
370
+ "cache_size": len(self._cache),
371
+ "eviction_count": self.eviction_count,
372
+ "idempotency_keys": len(self._idempotency_cache),
373
+ "ttl_seconds": self.ttl_seconds,
374
+ }
375
+
376
+ async def close(self) -> None:
377
+ """Close deduplicator and cleanup."""
378
+ self._cleanup_task.cancel()
379
+ try:
380
+ await self._cleanup_task
381
+ except asyncio.CancelledError:
382
+ pass