kailash 0.5.0__py3-none-any.whl → 0.6.1__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/access_control/__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/adaptive_pool_controller.py +630 -0
- kailash/core/actors/connection_actor.py +566 -0
- kailash/core/actors/supervisor.py +364 -0
- kailash/core/ml/__init__.py +1 -0
- kailash/core/ml/query_patterns.py +544 -0
- kailash/core/monitoring/__init__.py +19 -0
- kailash/core/monitoring/connection_metrics.py +488 -0
- kailash/core/optimization/__init__.py +1 -0
- kailash/core/resilience/__init__.py +17 -0
- kailash/core/resilience/circuit_breaker.py +382 -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/access_control.py +6 -6
- kailash/middleware/auth/models.py +2 -2
- kailash/middleware/communication/ai_chat.py +7 -7
- kailash/middleware/communication/api_gateway.py +5 -15
- kailash/middleware/database/base_models.py +1 -7
- 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 +499 -0
- kailash/middleware/mcp/enhanced_server.py +2 -2
- 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 +1124 -1582
- 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 +9 -3
- kailash/nodes/data/query_pipeline.py +641 -0
- kailash/nodes/data/query_router.py +895 -0
- kailash/nodes/data/sql.py +20 -11
- kailash/nodes/data/workflow_connection_pool.py +1071 -0
- kailash/nodes/monitoring/__init__.py +3 -5
- kailash/nodes/monitoring/connection_dashboard.py +822 -0
- kailash/nodes/rag/__init__.py +2 -7
- 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/workflow/__init__.py +8 -0
- kailash/workflow/async_builder.py +621 -0
- kailash/workflow/async_patterns.py +766 -0
- kailash/workflow/cyclic_runner.py +107 -16
- kailash/workflow/graph.py +7 -2
- kailash/workflow/resilience.py +11 -1
- {kailash-0.5.0.dist-info → kailash-0.6.1.dist-info}/METADATA +19 -4
- {kailash-0.5.0.dist-info → kailash-0.6.1.dist-info}/RECORD +74 -28
- {kailash-0.5.0.dist-info → kailash-0.6.1.dist-info}/WHEEL +0 -0
- {kailash-0.5.0.dist-info → kailash-0.6.1.dist-info}/entry_points.txt +0 -0
- {kailash-0.5.0.dist-info → kailash-0.6.1.dist-info}/licenses/LICENSE +0 -0
- {kailash-0.5.0.dist-info → kailash-0.6.1.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
|