cachekit 0.6.1__tar.gz → 0.7.0__tar.gz

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 (92) hide show
  1. {cachekit-0.6.1 → cachekit-0.7.0}/Cargo.lock +1 -1
  2. {cachekit-0.6.1 → cachekit-0.7.0}/PKG-INFO +1 -1
  3. {cachekit-0.6.1 → cachekit-0.7.0}/pyproject.toml +1 -1
  4. {cachekit-0.6.1 → cachekit-0.7.0}/rust/Cargo.toml +1 -1
  5. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/__init__.py +1 -1
  6. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/backend.py +90 -22
  7. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/provider.py +26 -8
  8. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/cache_handler.py +23 -4
  9. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/decorator.py +7 -2
  10. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/nested.py +7 -2
  11. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/wrapper.py +66 -70
  12. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/key_generator.py +27 -2
  13. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/__init__.py +2 -1
  14. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/auto_serializer.py +32 -1
  15. {cachekit-0.6.1 → cachekit-0.7.0}/Cargo.toml +0 -0
  16. {cachekit-0.6.1 → cachekit-0.7.0}/LICENSE +0 -0
  17. {cachekit-0.6.1 → cachekit-0.7.0}/README.md +0 -0
  18. {cachekit-0.6.1 → cachekit-0.7.0}/rust/Makefile +0 -0
  19. {cachekit-0.6.1 → cachekit-0.7.0}/rust/README.md +0 -0
  20. {cachekit-0.6.1 → cachekit-0.7.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
  21. {cachekit-0.6.1 → cachekit-0.7.0}/rust/src/lib.rs +0 -0
  22. {cachekit-0.6.1 → cachekit-0.7.0}/rust/src/python_bindings.rs +0 -0
  23. {cachekit-0.6.1 → cachekit-0.7.0}/rust/supply-chain/audits.toml +0 -0
  24. {cachekit-0.6.1 → cachekit-0.7.0}/rust/supply-chain/config.toml +0 -0
  25. {cachekit-0.6.1 → cachekit-0.7.0}/rust/supply-chain/imports.lock +0 -0
  26. {cachekit-0.6.1 → cachekit-0.7.0}/rust/tsan_suppressions.txt +0 -0
  27. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/__init__.py +0 -0
  28. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/base.py +0 -0
  29. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/base_config.py +0 -0
  30. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
  31. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/client.py +0 -0
  32. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/config.py +0 -0
  33. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
  34. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/cachekitio/session.py +0 -0
  35. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/errors.py +0 -0
  36. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/file/__init__.py +0 -0
  37. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/file/backend.py +0 -0
  38. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/file/config.py +0 -0
  39. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/memcached/__init__.py +0 -0
  40. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/memcached/backend.py +0 -0
  41. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/memcached/config.py +0 -0
  42. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
  43. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/__init__.py +0 -0
  44. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/backend.py +0 -0
  45. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/client.py +0 -0
  46. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/config.py +0 -0
  47. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/error_handler.py +0 -0
  48. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/backends/redis/provider.py +0 -0
  49. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/__init__.py +0 -0
  50. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/settings.py +0 -0
  51. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/singleton.py +0 -0
  52. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/config/validation.py +0 -0
  53. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/__init__.py +0 -0
  54. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/intent.py +0 -0
  55. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/local_wrapper.py +0 -0
  56. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/main.py +0 -0
  57. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/orchestrator.py +0 -0
  58. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/session.py +0 -0
  59. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/stats_context.py +0 -0
  60. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/tenant_context.py +0 -0
  61. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/decorators/utils/__init__.py +0 -0
  62. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/di.py +0 -0
  63. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/hash_utils.py +0 -0
  64. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/health.py +0 -0
  65. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/hiredis_compat.py +0 -0
  66. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/imports.py +0 -0
  67. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/invalidation/__init__.py +0 -0
  68. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/invalidation/channel.py +0 -0
  69. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/invalidation/event.py +0 -0
  70. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/invalidation/redis_channel.py +0 -0
  71. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/l1_cache.py +0 -0
  72. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/logging.py +0 -0
  73. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/monitoring/__init__.py +0 -0
  74. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
  75. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/monitoring/pool_monitor.py +0 -0
  76. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/monitoring/protocols.py +0 -0
  77. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/object_cache.py +0 -0
  78. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/py.typed +0 -0
  79. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/__init__.py +0 -0
  80. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/adaptive_timeout.py +0 -0
  81. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/async_metrics.py +0 -0
  82. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
  83. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/error_classification.py +0 -0
  84. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/load_control.py +0 -0
  85. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/metrics_collection.py +0 -0
  86. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/reliability/profiles.py +0 -0
  87. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/arrow_serializer.py +0 -0
  88. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/base.py +0 -0
  89. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/encryption_wrapper.py +0 -0
  90. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
  91. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/standard_serializer.py +0 -0
  92. {cachekit-0.6.1 → cachekit-0.7.0}/src/cachekit/serializers/wrapper.py +0 -0
@@ -231,7 +231,7 @@ dependencies = [
231
231
 
232
232
  [[package]]
233
233
  name = "cachekit-rs"
234
- version = "0.6.1"
234
+ version = "0.7.0"
235
235
  dependencies = [
236
236
  "cachekit-core",
237
237
  "criterion",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachekit
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "cachekit"
7
- version = "0.6.1"
7
+ version = "0.7.0"
8
8
  description = "Production-ready Redis caching for Python with intelligent reliability features and Rust-powered performance"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "cachekit-rs"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  edition = "2021"
5
5
  authors = ["cachekit Contributors"]
6
6
  description = "High-performance storage engine for caching with compression and encryption"
@@ -68,7 +68,7 @@ Example Usage:
68
68
  ```
69
69
  """
70
70
 
71
- __version__ = "0.6.1"
71
+ __version__ = "0.7.0"
72
72
 
73
73
  from typing import Any, Callable, TypeVar
74
74
 
@@ -2,16 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
7
+ import math
8
+ import random
6
9
  import time
7
- from typing import TYPE_CHECKING, Any
10
+ from collections.abc import AsyncIterator
11
+ from contextlib import asynccontextmanager
12
+ from typing import TYPE_CHECKING, Any, Optional
13
+ from urllib.parse import quote
8
14
 
9
15
  import httpx
10
16
 
11
17
  from cachekit.backends.cachekitio.client import get_cached_async_http_client, get_sync_http_client
12
18
  from cachekit.backends.cachekitio.config import CachekitIOBackendConfig
13
19
  from cachekit.backends.cachekitio.error_handler import classify_http_error
14
- from cachekit.backends.errors import BackendError
20
+ from cachekit.backends.errors import BackendError, BackendErrorType
15
21
  from cachekit.decorators.stats_context import get_current_function_stats
16
22
  from cachekit.logging import get_structured_logger
17
23
 
@@ -544,42 +550,104 @@ class CachekitIOBackend:
544
550
 
545
551
  # ==================== LockableBackend Protocol ====================
546
552
 
547
- async def acquire_lock(self, lock_key: str, timeout: int = 5) -> str | None:
548
- """Acquire distributed lock.
553
+ async def _try_acquire_lock(self, lock_key: str, timeout: float) -> str | None:
554
+ """Single attempt at the SaaS lock endpoint. Returns lock_id, or None if held.
549
555
 
550
- Args:
551
- lock_key: Lock identifier
552
- timeout: Lock timeout in seconds
556
+ Non-finite (NaN / ±inf) or non-positive ``timeout`` is clamped to 1ms — without
557
+ the guard, ``int(NaN)`` / ``int(inf)`` would raise outside ``BackendError`` and
558
+ escape the wrapper's degrade-to-no-lock branch.
553
559
 
554
- Returns:
555
- Lock ID if acquired, None if failed
560
+ Raises:
561
+ BackendError: For AUTHENTICATION and PERMANENT failures (bad key, bad lock_key
562
+ format) — polling won't recover and the wrapper degrades to no-lock execution.
563
+ TRANSIENT/TIMEOUT/UNKNOWN are swallowed as None so the polling loop can retry.
556
564
  """
565
+ # Clamp non-positive / non-finite timeouts before the int conversion.
566
+ # int(NaN) / int(inf) raise ValueError/OverflowError that aren't BackendError, so
567
+ # they'd escape the wrapper's degrade-to-no-lock branch and crash the @cache.io call.
568
+ timeout_ms = max(1, int(timeout * 1000)) if math.isfinite(timeout) else 1
569
+ encoded_key = quote(lock_key, safe="")
557
570
  try:
558
- payload = json.dumps({"timeout_ms": timeout * 1000})
559
571
  response = await self._request_async(
560
572
  "POST",
561
- f"{lock_key}/lock",
562
- content=payload.encode(),
573
+ f"{encoded_key}/lock",
574
+ content=json.dumps({"timeout_ms": timeout_ms}).encode(),
563
575
  headers={"Content-Type": "application/json"},
564
576
  )
577
+ except BackendError as exc:
578
+ # Re-raise unrecoverable failures so the wrapper can log + degrade once,
579
+ # instead of burning the full blocking_timeout on billable retries.
580
+ if exc.error_type in (BackendErrorType.AUTHENTICATION, BackendErrorType.PERMANENT):
581
+ raise
582
+ return None
583
+
584
+ try:
565
585
  data = response.json()
566
- return data.get("lock_id")
567
- except BackendError:
586
+ lock_id = data.get("lock_id")
587
+ except (ValueError, AttributeError):
588
+ # Malformed body from the SaaS — treat as held (retry) rather than crashing
589
+ # the wrapper. Same failure class as the original issue #129 if we re-raised.
568
590
  return None
569
591
 
570
- async def release_lock(self, lock_key: str, lock_id: str) -> bool:
571
- """Release distributed lock.
592
+ return lock_id if isinstance(lock_id, str) else None
593
+
594
+ @asynccontextmanager
595
+ async def acquire_lock(
596
+ self,
597
+ key: str,
598
+ timeout: float,
599
+ blocking_timeout: Optional[float] = None,
600
+ ) -> AsyncIterator[bool]:
601
+ """Acquire distributed lock (LockableBackend protocol).
602
+
603
+ The SaaS endpoint returns immediately; client-side polling implements
604
+ ``blocking_timeout`` with proportional jitter (0.5×–1× the capped delay) to
605
+ avoid lockstep retries on concurrent waiters. Each retry is a billable SaaS
606
+ request — keep the cap tight.
572
607
 
573
608
  Args:
574
- lock_key: Lock identifier
575
- lock_id: Lock ID from acquire_lock
609
+ key: Lock key
610
+ timeout: Server-side hold duration before auto-release (seconds)
611
+ blocking_timeout: Max client-side wait to acquire (None = single attempt)
576
612
 
577
- Returns:
578
- True if released, False otherwise
613
+ Yields:
614
+ True if acquired, False if ``blocking_timeout`` elapsed without acquisition
615
+ """
616
+ lock_id: str | None = None
617
+ try:
618
+ lock_id = await self._try_acquire_lock(key, timeout)
619
+
620
+ if lock_id is None and blocking_timeout is not None:
621
+ deadline = time.monotonic() + blocking_timeout
622
+ delay = 0.05
623
+ while lock_id is None:
624
+ remaining = deadline - time.monotonic()
625
+ if remaining <= 0:
626
+ break
627
+ # Proportional jitter (not crypto): spread concurrent waiters, 0.5×–1× the capped delay.
628
+ jitter = 0.5 + random.random() * 0.5 # noqa: S311 — backoff jitter, not security
629
+ await asyncio.sleep(min(delay, remaining) * jitter)
630
+ lock_id = await self._try_acquire_lock(key, timeout)
631
+ delay = min(delay * 2, 0.5)
632
+
633
+ yield lock_id is not None
634
+ finally:
635
+ if lock_id is not None:
636
+ await self._release_lock(key, lock_id)
637
+
638
+ async def _release_lock(self, lock_key: str, lock_id: str) -> bool:
639
+ """Release distributed lock. Internal helper for ``acquire_lock``'s cleanup.
640
+
641
+ Best-effort: swallows ``BackendError`` and returns False so a release failure
642
+ inside ``__aexit__`` cannot mask the user's exception. The server-side ``timeout``
643
+ on the lock is the safety net if the DELETE never lands.
579
644
  """
645
+ # URL-encode both segments: lock_key is caller-controlled, lock_id is server-issued
646
+ # but the SaaS contract doesn't pin a charset.
647
+ encoded_key = quote(lock_key, safe="")
648
+ encoded_id = quote(lock_id, safe="")
580
649
  try:
581
- # DELETE /v1/cache/{key}/lock?lock_id=xxx
582
- await self._request_async("DELETE", f"{lock_key}/lock?lock_id={lock_id}")
650
+ await self._request_async("DELETE", f"{encoded_key}/lock?lock_id={encoded_id}")
583
651
  return True
584
652
  except BackendError:
585
653
  return False
@@ -102,32 +102,50 @@ class DefaultCacheClientProvider(CacheClientProvider):
102
102
 
103
103
 
104
104
  class DefaultBackendProvider(BackendProviderInterface):
105
- """Default backend provider using Redis backend.
105
+ """Default backend provider with env-based auto-detection.
106
106
 
107
- Creates RedisBackendProvider singleton with connection pooling.
108
- Delegates to RedisBackendProvider.get_backend() for per-request wrappers.
107
+ Priority order (first matching env var wins):
108
+ 1. CACHEKIT_API_KEY → CachekitIOBackend (SaaS)
109
+ 2. CACHEKIT_REDIS_URL or REDIS_URL → RedisBackend
109
110
 
110
111
  For single-tenant deployments (default), sets tenant_context to "default".
111
112
  For multi-tenant deployments, tenant_context must be set externally.
112
113
  """
113
114
 
114
115
  def __init__(self):
115
- self._provider = None
116
+ self._cachekitio_backend = None
117
+ self._redis_provider = None
116
118
 
117
119
  def get_backend(self):
118
- """Get per-request backend instance from singleton provider."""
119
- if self._provider is None:
120
+ """Get backend instance, auto-detected from environment on first call.
121
+
122
+ CachekitIO backends are stateless singletons (cached).
123
+ Redis backends are per-request tenant-scoped wrappers (not cached —
124
+ RedisBackendProvider.get_backend() reads tenant_context ContextVar).
125
+ """
126
+ import os
127
+
128
+ # Priority 1: CachekitIO SaaS backend (stateless, safe to cache)
129
+ if os.environ.get("CACHEKIT_API_KEY"):
130
+ if self._cachekitio_backend is None:
131
+ from cachekit.backends.cachekitio import CachekitIOBackend
132
+
133
+ self._cachekitio_backend = CachekitIOBackend()
134
+ return self._cachekitio_backend
135
+
136
+ # Priority 2: Redis backend (tenant-scoped, call provider each time)
137
+ if self._redis_provider is None:
120
138
  from cachekit.backends.redis.config import RedisBackendConfig
121
139
  from cachekit.backends.redis.provider import RedisBackendProvider, tenant_context
122
140
 
123
141
  redis_config = RedisBackendConfig.from_env()
124
- self._provider = RedisBackendProvider(redis_url=redis_config.redis_url)
142
+ self._redis_provider = RedisBackendProvider(redis_url=redis_config.redis_url)
125
143
 
126
144
  # Set default tenant for single-tenant mode (if not already set)
127
145
  if tenant_context.get() is None:
128
146
  tenant_context.set("default")
129
147
 
130
- return self._provider.get_backend()
148
+ return self._redis_provider.get_backend()
131
149
 
132
150
 
133
151
  __all__ = [
@@ -303,13 +303,25 @@ class CacheSerializationHandler:
303
303
  but extraction fails, ValueError propagates to caller (no fallback to shared key).
304
304
  """
305
305
  self.serializer_name = serializer_name
306
+ self.enable_integrity_checking = enable_integrity_checking
307
+ self._deployment_uuid_value: Optional[str] = None
308
+
309
+ # Auto-detect encryption from CACHEKIT_MASTER_KEY when not explicitly configured.
310
+ # This is the single convergence point for ALL backends and presets.
311
+ if not encryption and master_key is None and tenant_extractor is None:
312
+ from cachekit.config.singleton import get_settings
313
+
314
+ settings = get_settings()
315
+ if settings.master_key:
316
+ encryption = True
317
+ master_key = settings.master_key.get_secret_value()
318
+ single_tenant_mode = True
319
+
306
320
  self.encryption = encryption
307
321
  self.tenant_extractor = tenant_extractor
308
322
  self.single_tenant_mode = single_tenant_mode
309
323
  self.deployment_uuid = deployment_uuid
310
324
  self.master_key = master_key
311
- self.enable_integrity_checking = enable_integrity_checking
312
- self._deployment_uuid_value: Optional[str] = None
313
325
 
314
326
  # Extract string name for metadata storage (for protocol instances, use class name)
315
327
  if isinstance(serializer_name, str):
@@ -680,8 +692,9 @@ class CacheSerializationHandler:
680
692
  else:
681
693
  # Data is not encrypted - use base serializer directly (no cache_key needed)
682
694
  return base_serializer.deserialize(serialized_data, metadata)
683
- except ValueError:
684
- # cache_key missing for encrypted data - FAIL CLOSED (re-raise)
695
+ except (ValueError, SerializationError):
696
+ # ValueError: cache_key missing for encrypted data FAIL CLOSED
697
+ # SerializationError/EncryptionError: let the outer handler log and handle
685
698
  raise
686
699
  except Exception as e:
687
700
  get_logger().error(f"Deserialization failed with {self.serializer_name}: {e}")
@@ -806,6 +819,9 @@ class CacheOperationHandler:
806
819
  # Return a tuple (True, value) to distinguish from "no cache entry"
807
820
  return (True, deserialized)
808
821
  return None
822
+ except SerializationError as e:
823
+ get_logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
824
+ return None
809
825
  except Exception as e:
810
826
  get_logger().warning(f"Backend operation failed for get on {cache_key}: {e}")
811
827
  return None
@@ -836,6 +852,9 @@ class CacheOperationHandler:
836
852
  # Return a tuple (True, value) to distinguish from "no cache entry"
837
853
  return (True, deserialized)
838
854
  return None
855
+ except SerializationError as e:
856
+ get_logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
857
+ return None
839
858
  except Exception as e:
840
859
  get_logger().warning(f"Backend operation failed for get on {cache_key}: {e}")
841
860
  return None
@@ -285,7 +285,7 @@ class DecoratorConfig:
285
285
  "enable_prometheus_metrics": self.monitoring.enable_prometheus_metrics,
286
286
  # Encryption (flattened)
287
287
  "encryption": self.encryption.enabled,
288
- "master_key": self.encryption.master_key,
288
+ "master_key": "[REDACTED]" if self.encryption.master_key else None,
289
289
  "tenant_extractor": self.encryption.tenant_extractor,
290
290
  }
291
291
 
@@ -382,7 +382,7 @@ class DecoratorConfig:
382
382
  Use cases: PII, medical data, financial records, GDPR compliance
383
383
  Architecture: Both L1 and L2 store encrypted bytes (encrypt-at-rest everywhere)
384
384
 
385
- Note: Backend resolved from REDIS_URL env var, set_default_backend(), or explicit backend= kwarg
385
+ Note: Backend resolved from CACHEKIT_API_KEY, REDIS_URL, set_default_backend(), or explicit backend= kwarg
386
386
  Note: integrity_checking is forced to True (non-negotiable for security)
387
387
 
388
388
  Args:
@@ -539,6 +539,10 @@ class DecoratorConfig:
539
539
  CACHEKIT_API_KEY: API key for authentication (required)
540
540
  CACHEKIT_API_URL: API endpoint (default: https://api.cachekit.io)
541
541
 
542
+ Encryption: Set CACHEKIT_MASTER_KEY env var to enable automatic client-side
543
+ AES-256-GCM encryption — no code changes needed. Auto-detection happens in
544
+ CacheSerializationHandler and applies to ALL presets, not just .io().
545
+
542
546
  Args:
543
547
  **kwargs: Overrides (ttl, namespace, etc.)
544
548
 
@@ -572,6 +576,7 @@ class DecoratorConfig:
572
576
  backend = CachekitIOBackend()
573
577
 
574
578
  # Use production-grade settings with SaaS backend
579
+ # Encryption auto-detected from CACHEKIT_MASTER_KEY in CacheSerializationHandler
575
580
  return cls(
576
581
  backend=backend,
577
582
  integrity_checking=True,
@@ -7,7 +7,7 @@ timeout, backpressure, monitoring, encryption).
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- from dataclasses import dataclass
10
+ from dataclasses import dataclass, field
11
11
  from typing import Callable
12
12
 
13
13
  from .validation import ConfigurationError
@@ -285,6 +285,11 @@ class EncryptionConfig:
285
285
  NOTE: Per backend abstraction spec, encryption stores encrypted bytes in BOTH L1 and L2.
286
286
  L1 can be enabled with encryption (stores encrypted bytes, not plaintext).
287
287
 
288
+ Tenant mode is required: set single_tenant_mode=True for single-tenant or provide
289
+ a tenant_extractor callable for multi-tenant key isolation. @cache.secure() sets
290
+ single_tenant_mode automatically; if using EncryptionConfig directly (e.g. with
291
+ @cache.io), you must set it explicitly.
292
+
288
293
  Attributes:
289
294
  enabled: Enable client-side encryption (default: False)
290
295
  master_key: Hex-encoded master key for key derivation (required if enabled)
@@ -322,7 +327,7 @@ class EncryptionConfig:
322
327
  """
323
328
 
324
329
  enabled: bool = False
325
- master_key: str | None = None
330
+ master_key: str | None = field(default=None, repr=False)
326
331
  tenant_extractor: Callable[..., str] | None = None
327
332
  single_tenant_mode: bool = False
328
333
  deployment_uuid: str | None = None
@@ -20,7 +20,9 @@ from ..cache_handler import (
20
20
  )
21
21
  from ..key_generator import CacheKeyGenerator
22
22
  from ..l1_cache import get_l1_cache
23
+ from ..object_cache import ObjectCache
23
24
  from ..reliability import CircuitBreakerConfig
25
+ from ..serializers.base import SerializationError
24
26
 
25
27
  # Config import removed - using direct DecoratorConfig integration
26
28
  from .orchestrator import FeatureOrchestrator
@@ -479,6 +481,10 @@ def create_cache_wrapper(
479
481
  # FIX: Initialize L1 cache if enabled
480
482
  _l1_cache = get_l1_cache(namespace or "default") if l1_enabled else None
481
483
 
484
+ # L1-only mode: use ObjectCache for raw Python object storage (no serialization).
485
+ # This preserves types (tuples, sets, frozensets) that MessagePack would degrade.
486
+ _object_cache: ObjectCache | None = ObjectCache(max_entries=256) if _l1_only_mode else None
487
+
482
488
  # Create per-function statistics tracker with lazy session ID generation
483
489
  # Session ID format: "{process_uuid}:{module}.{function_name}"
484
490
  # Generated lazily on first use or regenerated after cache_clear()
@@ -569,41 +575,22 @@ def create_cache_wrapper(
569
575
  reset_current_function_stats(token)
570
576
  return func(*args, **kwargs)
571
577
 
572
- # L1-ONLY MODE: Skip backend initialization entirely
573
- # This is the fix for the sentinel problem: when backend=None is explicitly passed,
574
- # we should NOT try to get a backend from the provider
575
- if _l1_only_mode:
576
- # L1-only mode: Check L1 cache, execute function on miss, store in L1
577
- if _l1_cache and cache_key:
578
- l1_found, l1_bytes = _l1_cache.get(cache_key)
579
- if l1_found and l1_bytes:
580
- # L1 cache hit
581
- try:
582
- # Pass cache_key for AAD verification (required for encryption)
583
- l1_value = operation_handler.serialization_handler.deserialize_data(l1_bytes, cache_key=cache_key)
584
- _stats.record_l1_hit()
585
- reset_current_function_stats(token)
586
- return l1_value
587
- except Exception:
588
- # L1 deserialization failed - invalidate and continue
589
- _l1_cache.invalidate(cache_key)
578
+ # L1-ONLY MODE: Store raw Python objects (no serialization).
579
+ # Preserves types (tuples, sets, frozensets) that MessagePack would degrade.
580
+ if _l1_only_mode and _object_cache:
581
+ found, cached_value = _object_cache.get(cache_key)
582
+ if found:
583
+ _stats.record_l1_hit()
584
+ features.clear_correlation_id()
585
+ reset_current_function_stats(token)
586
+ return cached_value
590
587
 
591
- # L1 cache miss - execute function and store in L1
588
+ # Cache miss - execute function and store raw result
592
589
  _stats.record_miss()
593
590
  try:
594
591
  result = func(*args, **kwargs)
595
- # Serialize and store in L1
596
- try:
597
- # Pass cache_key for AAD binding (required for encryption)
598
- serialized_bytes = operation_handler.serialization_handler.serialize_data(
599
- result, args, kwargs, cache_key=cache_key
600
- )
601
- if _l1_cache and cache_key and serialized_bytes:
602
- _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl)
603
- _cached_keys.add(cache_key)
604
- except Exception as e:
605
- # Serialization/storage failed but function succeeded - log and return result
606
- logger().debug(f"L1-only mode: serialization/storage failed for {cache_key}: {e}")
592
+ _object_cache.put(cache_key, result, ttl=ttl if ttl is not None else 31536000)
593
+ _cached_keys.add(cache_key)
607
594
  return result
608
595
  finally:
609
596
  features.clear_correlation_id()
@@ -913,39 +900,20 @@ def create_cache_wrapper(
913
900
  )
914
901
  return await func(*args, **kwargs)
915
902
 
916
- # L1-ONLY MODE: Skip backend initialization entirely
917
- # This is the fix for the sentinel problem: when backend=None is explicitly passed,
918
- # we should NOT try to get a backend from the provider
919
- if _l1_only_mode:
920
- # L1-only mode: Check L1 cache, execute function on miss, store in L1
921
- if _l1_cache and cache_key:
922
- l1_found, l1_bytes = _l1_cache.get(cache_key)
923
- if l1_found and l1_bytes:
924
- # L1 cache hit
925
- try:
926
- # Pass cache_key for AAD verification (required for encryption)
927
- l1_value = operation_handler.serialization_handler.deserialize_data(l1_bytes, cache_key=cache_key)
928
- _stats.record_l1_hit()
929
- return l1_value
930
- except Exception:
931
- # L1 deserialization failed - invalidate and continue
932
- _l1_cache.invalidate(cache_key)
933
-
934
- # L1 cache miss - execute function and store in L1
903
+ # L1-ONLY MODE: Store raw Python objects (no serialization).
904
+ # Preserves types (tuples, sets, frozensets) that MessagePack would degrade.
905
+ if _l1_only_mode and _object_cache:
906
+ found, cached_value = _object_cache.get(cache_key)
907
+ if found:
908
+ _stats.record_l1_hit()
909
+ features.clear_correlation_id()
910
+ return cached_value
911
+
912
+ # Cache miss - execute function and store raw result
935
913
  _stats.record_miss()
936
914
  result = await func(*args, **kwargs)
937
- # Serialize and store in L1
938
- try:
939
- # Pass cache_key for AAD binding (required for encryption)
940
- serialized_bytes = operation_handler.serialization_handler.serialize_data(
941
- result, args, kwargs, cache_key=cache_key
942
- )
943
- if _l1_cache and cache_key and serialized_bytes:
944
- _l1_cache.put(cache_key, serialized_bytes, redis_ttl=ttl)
945
- _cached_keys.add(cache_key)
946
- except Exception as e:
947
- # Serialization/storage failed but function succeeded - log and return result
948
- logger().debug(f"L1-only mode: serialization/storage failed for {cache_key}: {e}")
915
+ _object_cache.put(cache_key, result, ttl=ttl if ttl is not None else 31536000)
916
+ _cached_keys.add(cache_key)
949
917
  return result
950
918
 
951
919
  # L1+L2 MODE: Original behavior with backend initialization
@@ -1066,8 +1034,21 @@ def create_cache_wrapper(
1066
1034
 
1067
1035
  return result
1068
1036
 
1037
+ except SerializationError as e:
1038
+ # Decrypt/integrity failure on L2 data — warn explicitly (fail-open: recompute)
1039
+ logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
1040
+ get_duration_ms = (time.perf_counter() - start_time) * 1000
1041
+ features.handle_cache_error(
1042
+ error=e,
1043
+ operation="cache_get_deserialize",
1044
+ cache_key=cache_key or "unknown",
1045
+ namespace=namespace or "default",
1046
+ duration_ms=get_duration_ms,
1047
+ correlation_id=correlation_id,
1048
+ )
1049
+
1069
1050
  except Exception as e:
1070
- # Redis error - record but continue to function execution
1051
+ # Backend/network error - record but continue to function execution
1071
1052
  get_duration_ms = (time.perf_counter() - start_time) * 1000
1072
1053
  features.handle_cache_error(
1073
1054
  error=e,
@@ -1113,6 +1094,8 @@ def create_cache_wrapper(
1113
1094
  _cached_keys.add(cache_key)
1114
1095
 
1115
1096
  return result
1097
+ except SerializationError as e:
1098
+ logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
1116
1099
  except Exception as e:
1117
1100
  # If double-check fails, continue to execute function
1118
1101
  _logger.debug("Double-check cache failed after lock acquisition: %s", e)
@@ -1139,6 +1122,8 @@ def create_cache_wrapper(
1139
1122
  _cached_keys.add(cache_key)
1140
1123
 
1141
1124
  return result
1125
+ except SerializationError as e:
1126
+ logger().warning(f"L2 cache decrypt/integrity failure for {cache_key}: {e}")
1142
1127
  except Exception:
1143
1128
  # Cache check failed - fall through to execute function
1144
1129
  logger().warning(
@@ -1314,7 +1299,9 @@ def create_cache_wrapper(
1314
1299
  # Snapshot prevents RuntimeError if another thread adds during iteration
1315
1300
  keys_snapshot = set(_cached_keys)
1316
1301
  for key in keys_snapshot:
1317
- if _l1_cache:
1302
+ if _object_cache:
1303
+ _object_cache.delete(key)
1304
+ elif _l1_cache:
1318
1305
  _l1_cache.invalidate(key)
1319
1306
  if _backend and not _l1_only_mode:
1320
1307
  invalidator.set_backend(_backend)
@@ -1329,7 +1316,9 @@ def create_cache_wrapper(
1329
1316
  # Single-key invalidation (specific args provided, or zero-param function)
1330
1317
  cache_key = operation_handler.get_cache_key(func, args, kwargs, namespace, integrity_checking)
1331
1318
 
1332
- if _l1_cache and cache_key:
1319
+ if _object_cache and cache_key:
1320
+ _object_cache.delete(cache_key)
1321
+ elif _l1_cache and cache_key:
1333
1322
  _l1_cache.invalidate(cache_key)
1334
1323
  _cached_keys.discard(cache_key)
1335
1324
 
@@ -1355,7 +1344,9 @@ def create_cache_wrapper(
1355
1344
  if not args and not kwargs and _func_has_params:
1356
1345
  keys_snapshot = set(_cached_keys)
1357
1346
  for key in keys_snapshot:
1358
- if _l1_cache:
1347
+ if _object_cache:
1348
+ _object_cache.delete(key)
1349
+ elif _l1_cache:
1359
1350
  _l1_cache.invalidate(key)
1360
1351
  if _backend and not _l1_only_mode:
1361
1352
  invalidator.set_backend(_backend)
@@ -1370,7 +1361,9 @@ def create_cache_wrapper(
1370
1361
  # Single-key invalidation (specific args provided, or zero-param function)
1371
1362
  cache_key = operation_handler.get_cache_key(func, args, kwargs, namespace, integrity_checking)
1372
1363
 
1373
- if _l1_cache and cache_key:
1364
+ if _object_cache and cache_key:
1365
+ _object_cache.delete(cache_key)
1366
+ elif _l1_cache and cache_key:
1374
1367
  _l1_cache.invalidate(cache_key)
1375
1368
  _cached_keys.discard(cache_key)
1376
1369
 
@@ -1432,9 +1425,12 @@ def create_cache_wrapper(
1432
1425
  def cache_clear() -> None:
1433
1426
  """Clear cache statistics and invalidate all cached entries."""
1434
1427
  _stats.clear()
1435
- # Also invalidate actual cache entries
1436
- if inspect.iscoroutinefunction(func):
1437
- raise TypeError("cache_clear() cannot clear cache for async functions. Use 'await fn.ainvalidate_cache()' instead.")
1428
+ # In L1-only mode, invalidation is synchronous (no backend I/O needed)
1429
+ # so cache_clear() works for both sync and async functions.
1430
+ if inspect.iscoroutinefunction(func) and not _l1_only_mode:
1431
+ raise TypeError(
1432
+ "cache_clear() cannot clear cache for async functions with a backend. Use 'await fn.ainvalidate_cache()' instead."
1433
+ )
1438
1434
  invalidate_cache()
1439
1435
 
1440
1436
  if inspect.iscoroutinefunction(func):
@@ -43,6 +43,12 @@ class CacheKeyGenerator:
43
43
  "local": "l", # Reference caching (no serialization)
44
44
  }
45
45
 
46
+ # Regex for chars allowed in the func component of a SaaS cache key.
47
+ # SaaS validates: /^[a-zA-Z0-9_.]{1,200}$/
48
+ _FUNC_ALLOWED_RE = __import__("re").compile(r"[^A-Za-z0-9_.]")
49
+ _DOUBLE_DOT_RE = __import__("re").compile(r"\.{2,}")
50
+ _FUNC_NAME_MAX = 200
51
+
46
52
  def __init__(self):
47
53
  """Initialize the key generator.
48
54
 
@@ -83,8 +89,9 @@ class CacheKeyGenerator:
83
89
  if namespace:
84
90
  key_parts.extend(["ns:", namespace, ":"])
85
91
 
86
- # Add function identifier (module + name) - single string operation
87
- key_parts.extend(["func:", func.__module__, ".", func.__qualname__, ":"])
92
+ # Add function identifier (module + name) sanitized for SaaS key format
93
+ func_name = self._sanitize_func_name(func.__module__, func.__qualname__)
94
+ key_parts.extend(["func:", func_name, ":"])
88
95
 
89
96
  # Generate args hash using Blake2b-256
90
97
  args_hash = self._blake2b_hash(args, kwargs)
@@ -347,3 +354,21 @@ class CacheKeyGenerator:
347
354
  normalized = f"{prefix}:{key_hash[:32]}"
348
355
 
349
356
  return normalized
357
+
358
+ @classmethod
359
+ def _sanitize_func_name(cls, module: str, qualname: str) -> str:
360
+ """Sanitize module.qualname for SaaS cache-key compliance.
361
+
362
+ The SaaS ``func`` component must match ``[a-zA-Z0-9_.]{1,200}``
363
+ and must not contain ``..``. Nested functions have qualnames like
364
+ ``outer.<locals>.inner`` and lambdas are ``<lambda>`` — the angle
365
+ brackets violate the regex.
366
+
367
+ This replaces every disallowed char with ``_``, collapses runs of
368
+ ``..`` into a single ``.``, and truncates to 200 chars. The mapping
369
+ is deterministic: same function → same key.
370
+ """
371
+ raw = f"{module}.{qualname}"
372
+ sanitized = cls._FUNC_ALLOWED_RE.sub("_", raw)
373
+ sanitized = cls._DOUBLE_DOT_RE.sub(".", sanitized)
374
+ return sanitized[: cls._FUNC_NAME_MAX]
@@ -52,6 +52,7 @@ _serializer_lock = Lock()
52
52
  # This allows passing enable_integrity_checking parameter during instantiation
53
53
  SERIALIZER_REGISTRY = {
54
54
  "auto": AutoSerializer, # Python-specific types (NumPy, pandas, datetime optimization)
55
+ "pythonic": AutoSerializer, # Alias — preserves Python types (tuples, sets, frozensets, datetime, UUID)
55
56
  "default": StandardSerializer, # Language-agnostic MessagePack for multi-language caches
56
57
  "std": StandardSerializer, # Explicit StandardSerializer alias
57
58
  "arrow": None, # Lazy-loaded: requires pyarrow from [data] extra
@@ -121,7 +122,7 @@ def get_serializer(name: str, enable_integrity_checking: bool = True) -> Seriali
121
122
  serializer_class = SERIALIZER_REGISTRY[name]
122
123
 
123
124
  # Instantiate with integrity checking configuration
124
- if name in ("default", "std", "auto", "arrow", "orjson"):
125
+ if name in ("default", "std", "auto", "pythonic", "arrow", "orjson"):
125
126
  # All core serializers use enable_integrity_checking parameter
126
127
  serializer = serializer_class(enable_integrity_checking=enable_integrity_checking)
127
128
  else:
@@ -9,6 +9,7 @@ Auto-detects and optimizes:
9
9
  - datetime/date/time (ISO-8601)
10
10
  - UUID (string representation)
11
11
  - set/frozenset (type-safe roundtrip)
12
+ - tuple (recursive type-safe roundtrip)
12
13
 
13
14
  Uses MessagePack as the default format with graceful degradation for optional dependencies.
14
15
 
@@ -73,7 +74,7 @@ ORM_ERROR_MESSAGE = (
73
74
 
74
75
  CUSTOM_CLASS_ERROR_MESSAGE = (
75
76
  "AutoSerializer does not support custom classes. "
76
- "Supported types: dict, list, str, int, float, bool, None, bytes, "
77
+ "Supported types: dict, list, tuple, str, int, float, bool, None, bytes, "
77
78
  "datetime, date, time, UUID, set, frozenset, NumPy arrays, pandas DataFrames.\n"
78
79
  "Options:\n"
79
80
  " 1. Convert to dict manually\n"
@@ -124,6 +125,26 @@ def _safe_hasattr(obj: Any, attr: str) -> bool:
124
125
  return False
125
126
 
126
127
 
128
+ def _wrap_tuples(obj: Any) -> Any:
129
+ """Recursively wrap tuples in type markers before msgpack encoding.
130
+
131
+ Msgpack natively serializes tuples as arrays (same as lists), so the
132
+ ``default`` callback is never called for them. This pre-processor
133
+ converts tuples to ``{"__tuple__": True, "value": [...]}`` markers
134
+ that ``_auto_object_hook`` restores on deserialization.
135
+
136
+ Only affects tuples — all other types pass through unchanged and are
137
+ handled by msgpack's ``default`` callback (``_auto_default``).
138
+ """
139
+ if isinstance(obj, tuple):
140
+ return {"__tuple__": True, "value": [_wrap_tuples(x) for x in obj]}
141
+ if isinstance(obj, list):
142
+ return [_wrap_tuples(x) for x in obj]
143
+ if isinstance(obj, dict):
144
+ return {k: _wrap_tuples(v) for k, v in obj.items()}
145
+ return obj
146
+
147
+
127
148
  def _auto_default(obj: Any) -> Any:
128
149
  """Custom encoder for types not natively supported by MessagePack.
129
150
 
@@ -226,6 +247,14 @@ def _auto_object_hook(obj: Any) -> Any:
226
247
  except (ValueError, TypeError) as e:
227
248
  raise SerializationError(f"Invalid UUID format in cached data: {value}") from e
228
249
 
250
+ if obj.get("__tuple__") is True:
251
+ if "value" not in obj:
252
+ raise SerializationError("Invalid tuple format: missing 'value' field in cached data")
253
+ value_list = obj["value"]
254
+ if not isinstance(value_list, list):
255
+ raise SerializationError(f"Invalid tuple format: expected list, got {type(value_list).__name__}")
256
+ return tuple(value_list)
257
+
229
258
  if obj.get("__set__") is True:
230
259
  if "value" not in obj:
231
260
  raise SerializationError("Invalid set format: missing 'value' field in cached data")
@@ -748,6 +777,8 @@ class AutoSerializer:
748
777
 
749
778
  def _serialize_msgpack(self, obj: Any) -> bytes:
750
779
  """Serialize general object with MessagePack."""
780
+ # Pre-process tuples into markers (msgpack natively flattens them to lists)
781
+ obj = _wrap_tuples(obj)
751
782
  msgpack_data = msgpack.packb(obj, **self._msgpack_pack_opts)
752
783
 
753
784
  if self.enable_integrity_checking:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes