cachekit 0.15.0__tar.gz → 0.16.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 (93) hide show
  1. {cachekit-0.15.0 → cachekit-0.16.0}/Cargo.lock +1 -1
  2. {cachekit-0.15.0 → cachekit-0.16.0}/PKG-INFO +1 -1
  3. {cachekit-0.15.0 → cachekit-0.16.0}/pyproject.toml +1 -1
  4. {cachekit-0.15.0 → cachekit-0.16.0}/rust/Cargo.toml +1 -1
  5. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/__init__.py +1 -1
  6. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/decorator.py +0 -6
  7. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/nested.py +0 -2
  8. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/encryption_wrapper.py +32 -4
  9. cachekit-0.15.0/src/cachekit/invalidation/__init__.py +0 -31
  10. cachekit-0.15.0/src/cachekit/invalidation/channel.py +0 -125
  11. cachekit-0.15.0/src/cachekit/invalidation/event.py +0 -232
  12. cachekit-0.15.0/src/cachekit/invalidation/redis_channel.py +0 -403
  13. {cachekit-0.15.0 → cachekit-0.16.0}/Cargo.toml +0 -0
  14. {cachekit-0.15.0 → cachekit-0.16.0}/LICENSE +0 -0
  15. {cachekit-0.15.0 → cachekit-0.16.0}/README.md +0 -0
  16. {cachekit-0.15.0 → cachekit-0.16.0}/rust/Makefile +0 -0
  17. {cachekit-0.15.0 → cachekit-0.16.0}/rust/README.md +0 -0
  18. {cachekit-0.15.0 → cachekit-0.16.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
  19. {cachekit-0.15.0 → cachekit-0.16.0}/rust/src/lib.rs +0 -0
  20. {cachekit-0.15.0 → cachekit-0.16.0}/rust/src/python_bindings.rs +0 -0
  21. {cachekit-0.15.0 → cachekit-0.16.0}/rust/supply-chain/audits.toml +0 -0
  22. {cachekit-0.15.0 → cachekit-0.16.0}/rust/supply-chain/config.toml +0 -0
  23. {cachekit-0.15.0 → cachekit-0.16.0}/rust/supply-chain/imports.lock +0 -0
  24. {cachekit-0.15.0 → cachekit-0.16.0}/rust/tsan_suppressions.txt +0 -0
  25. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/__init__.py +0 -0
  26. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/base.py +0 -0
  27. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/base_config.py +0 -0
  28. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
  29. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/backend.py +0 -0
  30. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/client.py +0 -0
  31. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/config.py +0 -0
  32. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
  33. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/session.py +0 -0
  34. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/errors.py +0 -0
  35. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/file/__init__.py +0 -0
  36. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/file/backend.py +0 -0
  37. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/file/config.py +0 -0
  38. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/memcached/__init__.py +0 -0
  39. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/memcached/backend.py +0 -0
  40. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/memcached/config.py +0 -0
  41. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
  42. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/provider.py +0 -0
  43. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/__init__.py +0 -0
  44. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/backend.py +0 -0
  45. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/client.py +0 -0
  46. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/config.py +0 -0
  47. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/error_handler.py +0 -0
  48. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/provider.py +0 -0
  49. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/cache_handler.py +0 -0
  50. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/__init__.py +0 -0
  51. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/settings.py +0 -0
  52. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/singleton.py +0 -0
  53. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/validation.py +0 -0
  54. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/__init__.py +0 -0
  55. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/intent.py +0 -0
  56. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/local_wrapper.py +0 -0
  57. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/main.py +0 -0
  58. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/orchestrator.py +0 -0
  59. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/session.py +0 -0
  60. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/stats_context.py +0 -0
  61. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/tenant_context.py +0 -0
  62. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/utils/__init__.py +0 -0
  63. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/wrapper.py +0 -0
  64. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/di.py +0 -0
  65. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/hash_utils.py +0 -0
  66. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/health.py +0 -0
  67. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/hiredis_compat.py +0 -0
  68. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/imports.py +0 -0
  69. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/interop.py +0 -0
  70. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/key_generator.py +0 -0
  71. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/l1_cache.py +0 -0
  72. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/logging.py +0 -0
  73. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/monitoring/__init__.py +0 -0
  74. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
  75. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/monitoring/pool_monitor.py +0 -0
  76. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/monitoring/protocols.py +0 -0
  77. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/object_cache.py +0 -0
  78. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/py.typed +0 -0
  79. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/__init__.py +0 -0
  80. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/async_metrics.py +0 -0
  81. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
  82. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/error_classification.py +0 -0
  83. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/load_control.py +0 -0
  84. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/metrics_collection.py +0 -0
  85. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/profiles.py +0 -0
  86. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/__init__.py +0 -0
  87. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/arrow_serializer.py +0 -0
  88. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/auto_serializer.py +0 -0
  89. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/base.py +0 -0
  90. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/interop_serializer.py +0 -0
  91. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
  92. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/standard_serializer.py +0 -0
  93. {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/wrapper.py +0 -0
@@ -271,7 +271,7 @@ dependencies = [
271
271
 
272
272
  [[package]]
273
273
  name = "cachekit-rs"
274
- version = "0.15.0"
274
+ version = "0.16.0"
275
275
  dependencies = [
276
276
  "cachekit-core",
277
277
  "criterion",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cachekit
3
- Version: 0.15.0
3
+ Version: 0.16.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.15.0"
7
+ version = "0.16.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.15.0"
3
+ version = "0.16.0"
4
4
  edition = "2021"
5
5
  authors = ["cachekit Contributors"]
6
6
  description = "High-performance storage engine for caching with compression and encryption"
@@ -67,7 +67,7 @@ Example Usage:
67
67
  ```
68
68
  """
69
69
 
70
- __version__ = "0.15.0"
70
+ __version__ = "0.16.0"
71
71
 
72
72
  from collections.abc import Callable
73
73
  from typing import Any, TypeVar
@@ -337,7 +337,6 @@ class DecoratorConfig:
337
337
  l1=L1CacheConfig(
338
338
  enabled=True,
339
339
  swr_enabled=False,
340
- invalidation_enabled=False,
341
340
  namespace_index=False,
342
341
  ),
343
342
  circuit_breaker=CircuitBreakerConfig(enabled=False),
@@ -378,7 +377,6 @@ class DecoratorConfig:
378
377
  l1=L1CacheConfig(
379
378
  enabled=True,
380
379
  swr_enabled=True,
381
- invalidation_enabled=True,
382
380
  namespace_index=True,
383
381
  ),
384
382
  circuit_breaker=CircuitBreakerConfig(enabled=True),
@@ -444,7 +442,6 @@ class DecoratorConfig:
444
442
  l1=L1CacheConfig(
445
443
  enabled=True, # L1 stores encrypted bytes. Enabled: ~50ns hits vs 2-7ms Redis
446
444
  swr_enabled=True,
447
- invalidation_enabled=True,
448
445
  namespace_index=True,
449
446
  ),
450
447
  encryption=EncryptionConfig(
@@ -493,7 +490,6 @@ class DecoratorConfig:
493
490
  l1=L1CacheConfig(
494
491
  enabled=True,
495
492
  swr_enabled=True,
496
- invalidation_enabled=False,
497
493
  namespace_index=False,
498
494
  ),
499
495
  circuit_breaker=CircuitBreakerConfig(enabled=True),
@@ -534,7 +530,6 @@ class DecoratorConfig:
534
530
  l1=L1CacheConfig(
535
531
  enabled=True,
536
532
  swr_enabled=False,
537
- invalidation_enabled=False,
538
533
  namespace_index=False,
539
534
  ),
540
535
  circuit_breaker=CircuitBreakerConfig(enabled=False),
@@ -608,7 +603,6 @@ class DecoratorConfig:
608
603
  l1=L1CacheConfig(
609
604
  enabled=True,
610
605
  swr_enabled=True,
611
- invalidation_enabled=True,
612
606
  namespace_index=True,
613
607
  ),
614
608
  circuit_breaker=CircuitBreakerConfig(enabled=True),
@@ -30,7 +30,6 @@ class L1CacheConfig:
30
30
  task and sync functions via a daemon thread.
31
31
  swr_threshold_ratio: Fraction of TTL after which a hit triggers a background
32
32
  refresh, in (0.0, 1.0] (default: 0.5)
33
- invalidation_enabled: Enable invalidation event broadcasts (default: True)
34
33
  namespace_index: Enable fast namespace-based invalidation (default: True)
35
34
 
36
35
  Examples:
@@ -59,7 +58,6 @@ class L1CacheConfig:
59
58
  max_size_mb: int | None = None
60
59
  swr_enabled: bool = True
61
60
  swr_threshold_ratio: float = 0.5
62
- invalidation_enabled: bool = True
63
61
  namespace_index: bool = True
64
62
 
65
63
  def validate(self) -> None:
@@ -286,7 +286,11 @@ class EncryptionWrapper:
286
286
  Raises:
287
287
  ValueError: If cache_key is empty when data is encrypted
288
288
  TypeError: If cache_key is not a string
289
- EncryptionError: If decryption fails (including cache_key mismatch)
289
+ DecryptionAuthenticationError: If the entry claims plaintext
290
+ (``metadata.encrypted=False`` — this wrapper never returns
291
+ unauthenticated bytes), the tenant mismatches, or AES-GCM
292
+ authentication fails
293
+ EncryptionError: If deserialization fails after authenticated decryption
290
294
 
291
295
  Examples:
292
296
  Successful roundtrip with matching cache_key:
@@ -311,11 +315,35 @@ class EncryptionWrapper:
311
315
  Traceback (most recent call last):
312
316
  ...
313
317
  DecryptionAuthenticationError: Tenant mismatch: data encrypted for 'tenant-1', but current tenant is 'tenant-2'
318
+
319
+ An entry claiming plaintext is refused outright — the wrapper is
320
+ encryption-mandatory and fails closed on its own (LAB-271,
321
+ defense-in-depth behind the handler-level LAB-241 guard):
322
+
323
+ >>> from cachekit.serializers.base import SerializationFormat
324
+ >>> plain_meta = SerializationMetadata(serialization_format=SerializationFormat.MSGPACK)
325
+ >>> plain_meta.encrypted
326
+ False
327
+ >>> wrapper.deserialize(b"attacker-controlled bytes", plain_meta, cache_key="cart:user:42") # doctest: +IGNORE_EXCEPTION_DETAIL
328
+ Traceback (most recent call last):
329
+ ...
330
+ DecryptionAuthenticationError: EncryptionWrapper.deserialize received an entry claiming plaintext ...
314
331
  """
315
- # Handle unencrypted data (fallback case)
316
- # Check encrypted flag (orthogonal to format - encryption is a wrapper, not a format)
332
+ # SECURITY (LAB-271, defense-in-depth behind the LAB-241 handler guard):
333
+ # this wrapper is encryption-mandatory, so an entry claiming plaintext can
334
+ # never be legitimate input here. The frame header carrying this flag is
335
+ # NOT covered by the AES-GCM tag and may be forged; benign migration reads
336
+ # are converted to miss+evict by CacheSerializationHandler.deserialize_data
337
+ # before this wrapper is ever invoked. Reaching this branch means a caller
338
+ # bypassed that guard — fail closed rather than return unauthenticated bytes.
317
339
  if not metadata.encrypted:
318
- return self.serializer.deserialize(data, metadata)
340
+ raise DecryptionAuthenticationError(
341
+ "EncryptionWrapper.deserialize received an entry claiming plaintext "
342
+ "(metadata.encrypted=False). This wrapper is encryption-mandatory and "
343
+ "never returns unauthenticated bytes: the plaintext flag lives in the "
344
+ "unauthenticated frame header and may be forged. Route legitimate "
345
+ "plaintext reads through CacheSerializationHandler, not this wrapper."
346
+ )
319
347
 
320
348
  # SECURITY: Validate cache_key type and value for encrypted data
321
349
  if not isinstance(cache_key, str):
@@ -1,31 +0,0 @@
1
- """L1 cache invalidation system.
2
-
3
- This package provides cross-pod invalidation for L1 caches via messaging
4
- channels. The system is optional and degrades gracefully - L1 caches work
5
- without invalidation, relying on TTL expiry.
6
-
7
- Exports:
8
- InvalidationChannel: Protocol for invalidation broadcast
9
- InvalidationEvent: Immutable message for invalidation broadcast
10
- InvalidationLevel: Enum for invalidation scope
11
- InvalidationCallback: Type alias for event callback
12
- RedisInvalidationChannel: Redis Pub/Sub implementation of InvalidationChannel
13
- """
14
-
15
- from cachekit.invalidation.channel import (
16
- InvalidationCallback,
17
- InvalidationChannel,
18
- )
19
- from cachekit.invalidation.event import (
20
- InvalidationEvent,
21
- InvalidationLevel,
22
- )
23
- from cachekit.invalidation.redis_channel import RedisInvalidationChannel
24
-
25
- __all__ = [
26
- "InvalidationChannel",
27
- "InvalidationEvent",
28
- "InvalidationLevel",
29
- "InvalidationCallback",
30
- "RedisInvalidationChannel",
31
- ]
@@ -1,125 +0,0 @@
1
- """InvalidationChannel protocol for cross-pod cache invalidation.
2
-
3
- This module defines the protocol for broadcasting invalidation events across
4
- multiple L1 cache instances. Implementations may use Redis Pub/Sub, HTTP SSE,
5
- WebSockets, or other messaging systems.
6
-
7
- Protocol uses structural subtyping (PEP 544) - any class implementing these
8
- methods is considered a valid InvalidationChannel.
9
- """
10
-
11
- from __future__ import annotations
12
-
13
- from collections.abc import Callable
14
- from typing import TYPE_CHECKING, Any, Protocol
15
-
16
- if TYPE_CHECKING:
17
- pass
18
-
19
- # Type aliases for invalidation system
20
- InvalidationCallback = Callable[[Any], None]
21
- """Callback invoked when invalidation event is received.
22
-
23
- Args:
24
- event: The InvalidationEvent containing invalidation details
25
-
26
- Note:
27
- Callback must not raise exceptions. Exceptions are logged and suppressed
28
- to prevent breaking the invalidation listener.
29
- """
30
-
31
-
32
- class InvalidationChannel(Protocol):
33
- """Protocol for broadcasting cache invalidation events.
34
-
35
- This protocol defines the contract for invalidation channels that enable
36
- cross-pod L1 cache invalidation. All methods must be thread-safe.
37
-
38
- Design principles:
39
- - Fire-and-forget publishing (never raises, never blocks)
40
- - Multiple subscribers supported (fan-out)
41
- - Graceful degradation (cache works even if channel unavailable)
42
- - Idempotent lifecycle (start/stop can be called multiple times)
43
-
44
- Example:
45
- >>> from cachekit.invalidation import RedisInvalidationChannel
46
- >>> channel = RedisInvalidationChannel(redis_client) # doctest: +SKIP
47
- >>> channel.subscribe(lambda event: cache.handle_invalidation(event)) # doctest: +SKIP
48
- >>> channel.start() # doctest: +SKIP
49
- >>> channel.stop() # doctest: +SKIP
50
- """
51
-
52
- def publish(self, event: Any) -> None:
53
- """Publish invalidation event to all subscribers.
54
-
55
- This is a fire-and-forget operation that never raises exceptions
56
- and never blocks. Failures are logged but do not propagate.
57
-
58
- Args:
59
- event: The InvalidationEvent to broadcast
60
-
61
- Note:
62
- If channel is unavailable, event is dropped and logged. This is
63
- acceptable because L1 cache entries eventually expire via TTL.
64
- Cross-pod invalidation is a best-effort optimization.
65
- """
66
- ...
67
-
68
- def subscribe(self, callback: InvalidationCallback) -> None:
69
- """Register callback to receive invalidation events.
70
-
71
- Multiple callbacks may be registered. All callbacks are invoked
72
- when an event is received. Callbacks are invoked synchronously
73
- in the listener thread.
74
-
75
- Args:
76
- callback: Function to invoke when event is received
77
-
78
- Note:
79
- Callback exceptions are caught, logged, and suppressed to prevent
80
- breaking the listener. Callback should complete quickly (<10ms)
81
- to avoid blocking other subscribers.
82
- """
83
- ...
84
-
85
- def start(self) -> None:
86
- """Start the invalidation channel listener.
87
-
88
- This is idempotent - calling multiple times has no effect if already
89
- started. The listener runs in a background thread and automatically
90
- reconnects if connection is lost.
91
-
92
- Raises:
93
- RuntimeError: If channel fails to start (e.g., connection error)
94
-
95
- Note:
96
- Implementations should start a daemon thread so process can exit
97
- cleanly even if listener is running.
98
- """
99
- ...
100
-
101
- def stop(self) -> None:
102
- """Stop the invalidation channel listener.
103
-
104
- Blocks until listener thread terminates (max 5 seconds). This is
105
- idempotent - calling multiple times has no effect if already stopped.
106
-
107
- Note:
108
- After stop(), channel can be restarted via start(). Pending
109
- callbacks may still execute during shutdown.
110
- """
111
- ...
112
-
113
- def is_available(self) -> bool:
114
- """Check if channel is operational.
115
-
116
- Returns:
117
- True if channel is connected and ready to publish/receive events
118
- False if channel is disconnected or unavailable
119
-
120
- Note:
121
- This is a snapshot check. Channel may become unavailable
122
- immediately after returning True. Use for health checks and
123
- metrics, not for critical logic.
124
- """
125
- ...
@@ -1,232 +0,0 @@
1
- """InvalidationEvent dataclass for cross-pod cache invalidation messages.
2
-
3
- This module defines the immutable message format for broadcasting L1 cache
4
- invalidation events. Events are serialized to MessagePack for wire transport
5
- with strict security limits to prevent abuse.
6
-
7
- Security:
8
- - Max message size: 10KB (prevents memory exhaustion)
9
- - Max string length: 1024 characters
10
- - Max array/map size: 100 elements
11
- - Namespace/params_hash format validation via regex
12
- """
13
-
14
- from __future__ import annotations
15
-
16
- import re
17
- from dataclasses import dataclass
18
- from enum import Enum
19
- from typing import Any
20
-
21
- import msgpack
22
-
23
- # Security limits (non-negotiable)
24
- MAX_MESSAGE_SIZE = 10 * 1024 # 10KB max payload
25
- NAMESPACE_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,128}$")
26
- PARAMS_HASH_PATTERN = re.compile(r"^[a-f0-9]{64}$")
27
-
28
-
29
- class InvalidationLevel(Enum):
30
- """Invalidation scope level.
31
-
32
- - GLOBAL: Invalidate entire L1 cache across all pods
33
- - NAMESPACE: Invalidate all entries within a namespace
34
- - PARAMS: Invalidate specific cache key by params_hash
35
- """
36
-
37
- GLOBAL = "global"
38
- NAMESPACE = "namespace"
39
- PARAMS = "params"
40
-
41
-
42
- @dataclass(frozen=True)
43
- class InvalidationEvent:
44
- """Immutable invalidation event for cross-pod broadcast.
45
-
46
- This dataclass represents a single invalidation message that gets
47
- serialized to MessagePack and sent via InvalidationChannel (Redis Pub/Sub,
48
- HTTP SSE, etc.).
49
-
50
- Attributes:
51
- level: Invalidation scope (global, namespace, or params)
52
- namespace: Cache namespace (required for NAMESPACE level)
53
- params_hash: 64-char hex params hash (required for PARAMS level)
54
-
55
- Validation Rules:
56
- - NAMESPACE level: namespace must be provided and match NAMESPACE_PATTERN
57
- - PARAMS level: params_hash must be provided and match PARAMS_HASH_PATTERN (64-char hex)
58
- - GLOBAL level: namespace and params_hash must be None
59
-
60
- Serialization Format:
61
- MessagePack with compact keys to minimize wire size:
62
- - "l" -> level (str: "global", "namespace", "params")
63
- - "ns" -> namespace (str or None)
64
- - "ph" -> params_hash (str or None)
65
-
66
- Security:
67
- - from_bytes() enforces strict msgpack limits (max_bin_len, max_str_len, etc.)
68
- - Rejects payloads >10KB
69
- - Validates namespace/params_hash format via regex
70
-
71
- Examples:
72
- >>> # Global invalidation (clear all caches)
73
- >>> event = InvalidationEvent(level=InvalidationLevel.GLOBAL, namespace=None, params_hash=None)
74
- >>> data = event.to_bytes()
75
- >>> restored = InvalidationEvent.from_bytes(data)
76
- >>> restored == event
77
- True
78
-
79
- >>> # Namespace invalidation
80
- >>> event = InvalidationEvent(level=InvalidationLevel.NAMESPACE, namespace="user_cache", params_hash=None)
81
- >>> data = event.to_bytes()
82
- >>> len(data) < 100 # Compact serialization
83
- True
84
-
85
- >>> # Params invalidation
86
- >>> params_hash = "a" * 64 # Valid 64-char hex
87
- >>> event = InvalidationEvent(level=InvalidationLevel.PARAMS, namespace=None, params_hash=params_hash)
88
- >>> restored = InvalidationEvent.from_bytes(event.to_bytes())
89
- >>> restored.params_hash == params_hash
90
- True
91
-
92
- >>> # Validation errors
93
- >>> InvalidationEvent(level=InvalidationLevel.NAMESPACE, namespace=None, params_hash=None)
94
- Traceback (most recent call last):
95
- ValueError: NAMESPACE level requires namespace
96
-
97
- >>> InvalidationEvent(level=InvalidationLevel.PARAMS, namespace=None, params_hash="invalid")
98
- Traceback (most recent call last):
99
- ValueError: params_hash must be 64-character lowercase hex string
100
-
101
- >>> InvalidationEvent(level=InvalidationLevel.NAMESPACE, namespace="invalid space", params_hash=None)
102
- Traceback (most recent call last):
103
- ValueError: namespace must match pattern...
104
- """
105
-
106
- level: InvalidationLevel
107
- namespace: str | None
108
- params_hash: str | None
109
-
110
- def __post_init__(self) -> None:
111
- """Validate invalidation event after initialization.
112
-
113
- Raises:
114
- ValueError: If validation fails (missing required fields, invalid formats)
115
- """
116
- # NAMESPACE level requires namespace
117
- if self.level == InvalidationLevel.NAMESPACE:
118
- if self.namespace is None:
119
- raise ValueError("NAMESPACE level requires namespace")
120
- if not NAMESPACE_PATTERN.match(self.namespace):
121
- raise ValueError(
122
- f"namespace must match pattern {NAMESPACE_PATTERN.pattern} (alphanumeric, underscore, hyphen, 1-128 chars)"
123
- )
124
-
125
- # PARAMS level requires params_hash
126
- if self.level == InvalidationLevel.PARAMS:
127
- if self.params_hash is None:
128
- raise ValueError("PARAMS level requires params_hash")
129
- if not PARAMS_HASH_PATTERN.match(self.params_hash):
130
- raise ValueError("params_hash must be 64-character lowercase hex string")
131
-
132
- # GLOBAL level must have both fields as None
133
- if self.level == InvalidationLevel.GLOBAL:
134
- if self.namespace is not None or self.params_hash is not None:
135
- raise ValueError("GLOBAL level must have namespace=None and params_hash=None")
136
-
137
- def to_bytes(self) -> bytes:
138
- """Serialize event to MessagePack bytes.
139
-
140
- Uses compact keys to minimize wire size:
141
- - "l" -> level
142
- - "ns" -> namespace
143
- - "ph" -> params_hash
144
-
145
- Returns:
146
- MessagePack-encoded bytes (typically <100 bytes)
147
-
148
- Examples:
149
- >>> event = InvalidationEvent(level=InvalidationLevel.GLOBAL, namespace=None, params_hash=None)
150
- >>> data = event.to_bytes()
151
- >>> len(data) < 50 # Very compact
152
- True
153
- """
154
- payload: dict[str, Any] = {
155
- "l": self.level.value, # "global", "namespace", or "params"
156
- }
157
-
158
- if self.namespace is not None:
159
- payload["ns"] = self.namespace
160
-
161
- if self.params_hash is not None:
162
- payload["ph"] = self.params_hash
163
-
164
- return msgpack.packb(payload, use_bin_type=True) # type: ignore[return-value]
165
-
166
- @classmethod
167
- def from_bytes(cls, data: bytes) -> InvalidationEvent:
168
- """Deserialize InvalidationEvent from MessagePack bytes.
169
-
170
- Enforces strict security limits:
171
- - max_bin_len: 10KB (MAX_MESSAGE_SIZE)
172
- - max_str_len: 1024 characters
173
- - max_array_len: 100 elements
174
- - max_map_len: 100 keys
175
-
176
- Args:
177
- data: MessagePack-encoded bytes from to_bytes()
178
-
179
- Returns:
180
- Deserialized InvalidationEvent
181
-
182
- Raises:
183
- ValueError: If data is malformed, oversized, or fails validation
184
-
185
- Examples:
186
- >>> event = InvalidationEvent(level=InvalidationLevel.GLOBAL, namespace=None, params_hash=None)
187
- >>> data = event.to_bytes()
188
- >>> restored = InvalidationEvent.from_bytes(data)
189
- >>> restored == event
190
- True
191
-
192
- >>> # Security: reject oversized payload
193
- >>> huge_payload = msgpack.packb({"l": "global", "data": "x" * 20000})
194
- >>> InvalidationEvent.from_bytes(huge_payload) # doctest: +SKIP
195
- Traceback (most recent call last):
196
- ValueError: ...
197
- """
198
- try:
199
- # Enforce strict security limits
200
- payload = msgpack.unpackb(
201
- data,
202
- max_bin_len=MAX_MESSAGE_SIZE,
203
- max_str_len=1024,
204
- max_array_len=100,
205
- max_map_len=100,
206
- raw=False,
207
- use_list=True,
208
- )
209
- except (msgpack.exceptions.UnpackException, ValueError) as e:
210
- raise ValueError(f"Failed to deserialize InvalidationEvent: {e}") from e
211
-
212
- # Validate payload is a dict
213
- if not isinstance(payload, dict):
214
- raise ValueError(f"Invalid payload: expected dict, got {type(payload).__name__}")
215
-
216
- # Extract level (required)
217
- level_str = payload.get("l")
218
- if level_str is None:
219
- raise ValueError("Missing required field 'l' (level)")
220
-
221
- # Convert level string to enum
222
- try:
223
- level = InvalidationLevel(level_str)
224
- except ValueError as e:
225
- raise ValueError(f"Invalid level value '{level_str}': {e}") from e
226
-
227
- # Extract optional fields
228
- namespace = payload.get("ns")
229
- params_hash = payload.get("ph")
230
-
231
- # Construct and validate event (validation happens in __post_init__)
232
- return cls(level=level, namespace=namespace, params_hash=params_hash)
@@ -1,403 +0,0 @@
1
- """Redis Pub/Sub implementation of InvalidationChannel protocol.
2
-
3
- This module provides production-ready cross-pod L1 cache invalidation using
4
- Redis Pub/Sub messaging. Supports multiple subscribers with automatic
5
- reconnection and exponential backoff.
6
-
7
- IMPORTANT: Redis Pub/Sub is at-most-once delivery (fire-and-forget). Messages
8
- published while a subscriber is disconnected are lost. This is acceptable for
9
- cache invalidation because L1 entries eventually expire via TTL. Cross-pod
10
- invalidation is a best-effort optimization for reducing stale cache window.
11
-
12
- Architecture:
13
- - Dedicated PubSub connection (separate from main Redis client)
14
- - Daemon listener thread (name="cachekit-invalidation-listener")
15
- - threading.RLock for state protection
16
- - Exponential backoff reconnection (1.0s -> 2.0s -> 4.0s -> ... -> 30.0s max)
17
- - Optional metrics hooks via self._metrics attribute
18
- """
19
-
20
- from __future__ import annotations
21
-
22
- import logging
23
- import threading
24
- import time
25
- from typing import TYPE_CHECKING, Any, Optional
26
-
27
- import redis
28
-
29
- from cachekit.invalidation.event import InvalidationEvent
30
-
31
- if TYPE_CHECKING:
32
- from cachekit.invalidation.channel import InvalidationCallback
33
-
34
- logger = logging.getLogger(__name__)
35
-
36
- # Redis Pub/Sub channel name for invalidation events
37
- INVALIDATION_CHANNEL = "cachekit:invalidation"
38
-
39
- # Reconnection backoff parameters
40
- INITIAL_BACKOFF = 1.0 # Start with 1 second
41
- MAX_BACKOFF = 30.0 # Cap at 30 seconds
42
- BACKOFF_MULTIPLIER = 2.0 # Exponential backoff
43
-
44
-
45
- class RedisInvalidationChannel:
46
- """Redis Pub/Sub implementation of InvalidationChannel protocol.
47
-
48
- Provides cross-pod cache invalidation using Redis Pub/Sub messaging.
49
- Implements the InvalidationChannel protocol with automatic reconnection
50
- and exponential backoff.
51
-
52
- IMPORTANT: This implementation provides at-most-once delivery semantics.
53
- Messages may be lost during network partitions or subscriber restarts.
54
- This is acceptable for cache invalidation - entries expire via TTL anyway.
55
-
56
- Thread Safety:
57
- All public methods are thread-safe via threading.RLock.
58
-
59
- Examples:
60
- >>> from cachekit.invalidation import RedisInvalidationChannel, InvalidationEvent, InvalidationLevel
61
- >>> import redis
62
- >>> client = redis.Redis.from_url("redis://localhost:6379") # doctest: +SKIP
63
- >>> channel = RedisInvalidationChannel(client) # doctest: +SKIP
64
- >>> channel.subscribe(lambda event: print(f"Received: {event}")) # doctest: +SKIP
65
- >>> channel.start() # doctest: +SKIP
66
- >>> event = InvalidationEvent(level=InvalidationLevel.GLOBAL, namespace=None, params_hash=None) # doctest: +SKIP
67
- >>> channel.publish(event) # doctest: +SKIP
68
- >>> channel.stop() # doctest: +SKIP
69
- """
70
-
71
- def __init__(
72
- self,
73
- redis_client: redis.Redis,
74
- channel_name: str = INVALIDATION_CHANNEL,
75
- metrics: Optional[Any] = None,
76
- ):
77
- """Initialize RedisInvalidationChannel.
78
-
79
- Args:
80
- redis_client: Redis client instance (used to create dedicated PubSub connection)
81
- channel_name: Redis channel name for invalidation messages (default: "cachekit:invalidation")
82
- metrics: Optional metrics collector (must have inc() method)
83
-
84
- Note:
85
- Creates a dedicated PubSub connection separate from the main Redis client
86
- to avoid blocking cache operations.
87
- """
88
- self._redis_client = redis_client
89
- self._channel_name = channel_name
90
- self._metrics = metrics
91
-
92
- # State protected by RLock
93
- self._lock = threading.RLock()
94
- self._callbacks: list[InvalidationCallback] = []
95
- self._listener_thread: Optional[threading.Thread] = None
96
- self._running = False
97
- self._pubsub: Any = None # redis.client.PubSub type not exported
98
- self._backoff = INITIAL_BACKOFF
99
-
100
- def publish(self, event: Any) -> None:
101
- """Publish invalidation event to all subscribers.
102
-
103
- This is a fire-and-forget operation that never raises exceptions
104
- and never blocks. Failures are logged but do not propagate.
105
-
106
- Args:
107
- event: InvalidationEvent to broadcast
108
-
109
- Note:
110
- If Redis is unavailable, event is dropped and logged at CRITICAL level.
111
- This is acceptable because L1 cache entries eventually expire via TTL.
112
-
113
- Examples:
114
- >>> from cachekit.invalidation import InvalidationEvent, InvalidationLevel
115
- >>> channel = RedisInvalidationChannel(redis_client) # doctest: +SKIP
116
- >>> event = InvalidationEvent(level=InvalidationLevel.GLOBAL, namespace=None, params_hash=None) # doctest: +SKIP
117
- >>> channel.publish(event) # Never raises, never blocks # doctest: +SKIP
118
- """
119
- try:
120
- # Serialize event to bytes
121
- data = event.to_bytes()
122
-
123
- # Publish to Redis (returns subscriber_count)
124
- subscriber_count = self._redis_client.publish(self._channel_name, data)
125
-
126
- # Warn if no subscribers (message was lost)
127
- if subscriber_count == 0:
128
- logger.warning(
129
- "Published invalidation but no subscribers listening (message lost)",
130
- extra={"level": event.level.value, "namespace": event.namespace},
131
- )
132
- if self._metrics:
133
- self._metrics.inc("invalidation_no_subscribers_total")
134
-
135
- # Success metric
136
- if self._metrics:
137
- self._metrics.inc("invalidation_published_total")
138
-
139
- except Exception as e:
140
- # CRITICAL: Other pods will serve stale data until TTL expires
141
- logger.error(
142
- "CRITICAL: Failed to publish invalidation: %s (other pods will serve stale until TTL)",
143
- e,
144
- extra={"level": event.level.value, "namespace": event.namespace},
145
- )
146
- if self._metrics:
147
- self._metrics.inc("invalidation_publish_failed_total")
148
-
149
- def subscribe(self, callback: InvalidationCallback) -> None:
150
- """Register callback to receive invalidation events.
151
-
152
- Multiple callbacks may be registered. All callbacks are invoked
153
- when an event is received. Callbacks are invoked synchronously
154
- in the listener thread.
155
-
156
- Args:
157
- callback: Function to invoke when event is received
158
-
159
- Note:
160
- Callback exceptions are caught, logged, and suppressed to prevent
161
- breaking the listener. Callback should complete quickly (<10ms)
162
- to avoid blocking other subscribers.
163
-
164
- Examples:
165
- >>> def handle_invalidation(event): # doctest: +SKIP
166
- ... print(f"Cache invalidated: {event}")
167
- >>> channel.subscribe(handle_invalidation) # doctest: +SKIP
168
- """
169
- with self._lock:
170
- self._callbacks.append(callback)
171
-
172
- def start(self) -> None:
173
- """Start the invalidation channel listener.
174
-
175
- This is idempotent - calling multiple times has no effect if already
176
- started. The listener runs in a background thread and automatically
177
- reconnects if connection is lost.
178
-
179
- Raises:
180
- RuntimeError: If channel fails to start (e.g., connection error)
181
-
182
- Note:
183
- Starts a daemon thread so process can exit cleanly even if
184
- listener is running.
185
-
186
- Examples:
187
- >>> channel = RedisInvalidationChannel(redis_client) # doctest: +SKIP
188
- >>> channel.start() # Idempotent # doctest: +SKIP
189
- >>> channel.start() # No-op if already started # doctest: +SKIP
190
- """
191
- with self._lock:
192
- # Idempotent - no-op if already started
193
- if self._running:
194
- return
195
-
196
- try:
197
- # Create dedicated PubSub connection
198
- self._pubsub = self._redis_client.pubsub(ignore_subscribe_messages=True)
199
- self._pubsub.subscribe(self._channel_name)
200
-
201
- # Start daemon listener thread
202
- self._running = True
203
- self._listener_thread = threading.Thread(
204
- target=self._listen_loop,
205
- name="cachekit-invalidation-listener",
206
- daemon=True,
207
- )
208
- self._listener_thread.start()
209
-
210
- logger.info("Started invalidation channel listener on %s", self._channel_name)
211
-
212
- except Exception as e:
213
- # Cleanup on failure
214
- self._running = False
215
- if self._pubsub:
216
- try:
217
- self._pubsub.close()
218
- except Exception as close_err:
219
- logger.debug("Error closing PubSub during start failure cleanup: %s", close_err)
220
- self._pubsub = None
221
-
222
- raise RuntimeError(f"Failed to start invalidation channel: {e}") from e
223
-
224
- def stop(self) -> None:
225
- """Stop the invalidation channel listener.
226
-
227
- Blocks until listener thread terminates (max 5 seconds). This is
228
- idempotent - calling multiple times has no effect if already stopped.
229
-
230
- Note:
231
- After stop(), channel can be restarted via start(). Pending
232
- callbacks may still execute during shutdown.
233
-
234
- Examples:
235
- >>> channel.stop() # Blocks until listener exits # doctest: +SKIP
236
- >>> channel.stop() # No-op if already stopped # doctest: +SKIP
237
- """
238
- with self._lock:
239
- # Idempotent - no-op if already stopped
240
- if not self._running:
241
- return
242
-
243
- # Signal thread to stop
244
- self._running = False
245
-
246
- # Wait for thread to exit (max 5 seconds, without holding lock)
247
- if self._listener_thread and self._listener_thread.is_alive():
248
- self._listener_thread.join(timeout=5.0)
249
-
250
- # Cleanup PubSub connection
251
- with self._lock:
252
- if self._pubsub:
253
- try:
254
- self._pubsub.unsubscribe(self._channel_name)
255
- self._pubsub.close()
256
- except Exception as e:
257
- logger.debug("Error closing PubSub connection during shutdown: %s", e)
258
- finally:
259
- self._pubsub = None
260
-
261
- self._listener_thread = None
262
- self._backoff = INITIAL_BACKOFF # Reset backoff for next start
263
-
264
- logger.info("Stopped invalidation channel listener")
265
-
266
- def is_available(self) -> bool:
267
- """Check if channel is operational.
268
-
269
- Returns:
270
- True if channel is connected and ready to publish/receive events
271
- False if channel is disconnected or unavailable
272
-
273
- Note:
274
- This is a snapshot check. Channel may become unavailable
275
- immediately after returning True. Use for health checks and
276
- metrics, not for critical logic.
277
-
278
- Examples:
279
- >>> channel.start() # doctest: +SKIP
280
- >>> channel.is_available() # doctest: +SKIP
281
- True
282
- >>> channel.stop() # doctest: +SKIP
283
- >>> channel.is_available() # doctest: +SKIP
284
- False
285
- """
286
- with self._lock:
287
- return self._running and self._pubsub is not None
288
-
289
- def _listen_loop(self) -> None:
290
- """Background listener thread that receives and dispatches events.
291
-
292
- Runs until self._running is set to False. Implements exponential
293
- backoff reconnection on failure (1s -> 2s -> 4s -> ... -> 30s max).
294
-
295
- Note:
296
- This method runs in a daemon thread and should never raise exceptions.
297
- All errors are caught, logged, and trigger reconnection.
298
- """
299
- while self._running:
300
- try:
301
- # Get message from PubSub (blocking call)
302
- with self._lock:
303
- pubsub = self._pubsub
304
-
305
- if pubsub is None:
306
- # Shutting down, exit gracefully
307
- break
308
-
309
- # Listen for messages (timeout=1.0 allows checking self._running periodically)
310
- message = pubsub.get_message(timeout=1.0)
311
-
312
- if message is None:
313
- # Timeout, continue loop
314
- continue
315
-
316
- # Only process data messages (ignore subscribe/unsubscribe confirmations)
317
- if message["type"] != "message":
318
- continue
319
-
320
- # Deserialize and dispatch event
321
- try:
322
- data = message["data"]
323
-
324
- # Handle both str and bytes (Redis may decode based on decode_responses)
325
- if isinstance(data, str):
326
- data = data.encode("utf-8")
327
-
328
- event = InvalidationEvent.from_bytes(data)
329
-
330
- # Dispatch to all callbacks (suppress exceptions)
331
- with self._lock:
332
- callbacks = self._callbacks.copy()
333
-
334
- for callback in callbacks:
335
- try:
336
- callback(event)
337
- except Exception as e:
338
- logger.error("Callback raised exception during invalidation: %s", e, exc_info=True)
339
- if self._metrics:
340
- self._metrics.inc("invalidation_callback_error_total")
341
-
342
- # Success - reset backoff
343
- with self._lock:
344
- self._backoff = INITIAL_BACKOFF
345
-
346
- if self._metrics:
347
- self._metrics.inc("invalidation_received_total")
348
-
349
- except ValueError as e:
350
- # Malformed message - log and skip
351
- logger.warning("Received malformed invalidation message: %s", e)
352
- if self._metrics:
353
- self._metrics.inc("invalidation_malformed_total")
354
-
355
- except Exception as e:
356
- # Connection error or other failure - reconnect with backoff
357
- logger.error("Invalidation listener error: %s (will reconnect after %s seconds)", e, self._backoff)
358
-
359
- if self._metrics:
360
- self._metrics.inc("invalidation_listener_error_total")
361
-
362
- # Sleep before reconnection attempt (without holding lock)
363
- time.sleep(self._backoff)
364
-
365
- # Exponential backoff
366
- with self._lock:
367
- self._backoff = min(self._backoff * BACKOFF_MULTIPLIER, MAX_BACKOFF)
368
-
369
- # Attempt reconnection
370
- self._reconnect()
371
-
372
- def _reconnect(self) -> None:
373
- """Reconnect PubSub connection after failure.
374
-
375
- Closes existing connection and creates a new one. Used by listener
376
- thread during reconnection backoff loop.
377
-
378
- Note:
379
- This method is called from the listener thread and should never
380
- raise exceptions. All errors are logged and suppressed.
381
- """
382
- with self._lock:
383
- # Only reconnect if still running
384
- if not self._running:
385
- return
386
-
387
- # Close existing connection
388
- if self._pubsub:
389
- try:
390
- self._pubsub.close()
391
- except Exception as e:
392
- logger.debug("Error closing PubSub during reconnection: %s", e)
393
- finally:
394
- self._pubsub = None
395
-
396
- # Create new connection
397
- try:
398
- self._pubsub = self._redis_client.pubsub(ignore_subscribe_messages=True)
399
- self._pubsub.subscribe(self._channel_name)
400
- logger.info("Reconnected to invalidation channel")
401
- except Exception as e:
402
- logger.error("Failed to reconnect to invalidation channel: %s", e)
403
- # Leave _pubsub as None, will retry on next loop iteration
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes