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.
- {cachekit-0.15.0 → cachekit-0.16.0}/Cargo.lock +1 -1
- {cachekit-0.15.0 → cachekit-0.16.0}/PKG-INFO +1 -1
- {cachekit-0.15.0 → cachekit-0.16.0}/pyproject.toml +1 -1
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/Cargo.toml +1 -1
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/__init__.py +1 -1
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/decorator.py +0 -6
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/nested.py +0 -2
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/encryption_wrapper.py +32 -4
- cachekit-0.15.0/src/cachekit/invalidation/__init__.py +0 -31
- cachekit-0.15.0/src/cachekit/invalidation/channel.py +0 -125
- cachekit-0.15.0/src/cachekit/invalidation/event.py +0 -232
- cachekit-0.15.0/src/cachekit/invalidation/redis_channel.py +0 -403
- {cachekit-0.15.0 → cachekit-0.16.0}/Cargo.toml +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/LICENSE +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/README.md +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/Makefile +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/README.md +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/TEST_EXPANSION_SUMMARY.md +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/src/lib.rs +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/src/python_bindings.rs +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/supply-chain/audits.toml +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/supply-chain/config.toml +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/supply-chain/imports.lock +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/rust/tsan_suppressions.txt +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/base.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/base_config.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/backend.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/client.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/config.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/error_handler.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/cachekitio/session.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/errors.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/file/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/file/backend.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/file/config.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/memcached/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/memcached/backend.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/memcached/config.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/memcached/error_handler.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/provider.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/backend.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/client.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/config.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/error_handler.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/backends/redis/provider.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/cache_handler.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/settings.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/singleton.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/config/validation.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/intent.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/local_wrapper.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/main.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/orchestrator.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/session.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/stats_context.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/tenant_context.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/utils/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/decorators/wrapper.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/di.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/hash_utils.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/health.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/hiredis_compat.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/imports.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/interop.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/key_generator.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/l1_cache.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/logging.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/monitoring/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/monitoring/correlation_tracking.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/monitoring/pool_monitor.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/monitoring/protocols.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/object_cache.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/py.typed +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/async_metrics.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/circuit_breaker.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/error_classification.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/load_control.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/metrics_collection.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/reliability/profiles.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/__init__.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/arrow_serializer.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/auto_serializer.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/base.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/interop_serializer.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/orjson_serializer.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/standard_serializer.py +0 -0
- {cachekit-0.15.0 → cachekit-0.16.0}/src/cachekit/serializers/wrapper.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cachekit"
|
|
7
|
-
version = "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"}
|
|
@@ -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
|
-
|
|
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
|
-
#
|
|
316
|
-
#
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|