Skip to content

Commit 9aca688

Browse files
committed
tests
1 parent 2cbce6a commit 9aca688

File tree

5 files changed

+416
-10
lines changed

5 files changed

+416
-10
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- RedisCache now supports pluggable serializers with built-ins for `pickle` (default) and `json`, plus custom `dumps`/`loads` implementations.
1212
- `HybridCache.from_redis` helper for a one-liner L1 (in-memory) + L2 (Redis) setup.
13+
- `HybridCache` now supports `l2_ttl` parameter for independent L2 TTL control. Defaults to `l1_ttl * 2` if not specified.
1314
- `__version__` attribute exposed in the main module for version checking.
14-
- Comprehensive test coverage for BGCache lambda cache factory pattern.
15+
- Comprehensive test coverage for BGCache lambda cache factory pattern and HybridCache l2_ttl behavior.
1516
- Documentation example for using lambda cache factories with BGCache (lazy Redis connection initialization).
1617

1718
## [0.1.4] - 2025-12-12

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,18 @@ from advanced_caching import HybridCache, InMemCache, RedisCache
157157
158158
l1 = InMemCache()
159159
l2 = RedisCache(client, prefix="app:")
160+
# l2_ttl defaults to l1_ttl * 2 if not specified
160161
hybrid = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60)
162+
163+
# Explicit l2_ttl for longer L2 persistence
164+
hybrid_long_l2 = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60, l2_ttl=3600)
161165
```
162166

167+
**TTL behavior:**
168+
- `l1_ttl`: How long data stays in fast L1 memory cache
169+
- `l2_ttl`: How long data persists in L2 (Redis). Defaults to `l1_ttl * 2`
170+
- When data expires from L1 but exists in L2, it's automatically repopulated to L1
171+
163172
#### With BGCache using lambda factory
164173
165174
For lazy initialization (e.g., deferred Redis connection):
@@ -180,7 +189,8 @@ def get_redis_cache():
180189
cache=lambda: HybridCache(
181190
l1_cache=InMemCache(),
182191
l2_cache=get_redis_cache(),
183-
l1_ttl=86400
192+
l1_ttl=3600,
193+
l2_ttl=86400 # L2 persists longer than L1
184194
)
185195
)
186196
def load_config_map() -> dict[str, dict]:
@@ -254,9 +264,9 @@ assert validate_cache_storage(cache)
254264
* `BGCache.register_loader(key, interval_seconds, ttl=None, run_immediately=True)`
255265
* Storages:
256266
257-
* `InMemCache`
258-
* `RedisCache`
259-
* `HybridCache`
267+
* `InMemCache()`
268+
* `RedisCache(redis_client, prefix="", serializer="pickle"|"json"|custom)`
269+
* `HybridCache(l1_cache, l2_cache, l1_ttl=60, l2_ttl=None)` - `l2_ttl` defaults to `l1_ttl * 2`
260270
* Utilities:
261271
262272
* `CacheEntry`

src/advanced_caching/storage.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ def __init__(
478478
l1_cache: CacheStorage | None = None,
479479
l2_cache: CacheStorage | None = None,
480480
l1_ttl: int = 60,
481+
l2_ttl: int | None = None,
481482
):
482483
"""
483484
Initialize hybrid cache.
@@ -486,12 +487,14 @@ def __init__(
486487
l1_cache: L1 cache (memory), defaults to InMemCache
487488
l2_cache: L2 cache (distributed), required
488489
l1_ttl: TTL for L1 cache in seconds
490+
l2_ttl: TTL for L2 cache in seconds, defaults to l1_ttl * 2
489491
"""
490492
self.l1 = l1_cache if l1_cache is not None else InMemCache()
491493
if l2_cache is None:
492494
raise ValueError("l2_cache is required for HybridCache")
493495
self.l2 = l2_cache
494496
self.l1_ttl = l1_ttl
497+
self.l2_ttl = l2_ttl if l2_ttl is not None else l1_ttl * 2
495498

496499
def get(self, key: str) -> Any | None:
497500
"""Get value, checking L1 then L2."""
@@ -511,7 +514,8 @@ def get(self, key: str) -> Any | None:
511514
def set(self, key: str, value: Any, ttl: int = 0) -> None:
512515
"""Set value in both L1 and L2."""
513516
self.l1.set(key, value, min(ttl, self.l1_ttl) if ttl > 0 else self.l1_ttl)
514-
self.l2.set(key, value, ttl)
517+
l2_ttl = min(ttl, self.l2_ttl) if ttl > 0 else self.l2_ttl
518+
self.l2.set(key, value, l2_ttl)
515519

516520
def get_entry(self, key: str) -> CacheEntry | None:
517521
"""Get raw entry preferring L1, falling back to L2 and repopulating L1."""
@@ -555,19 +559,22 @@ def exists(self, key: str) -> bool:
555559

556560
def set_if_not_exists(self, key: str, value: Any, ttl: int) -> bool:
557561
"""Atomic set if not exists (L2 only for consistency)."""
558-
success = self.l2.set_if_not_exists(key, value, ttl)
562+
l2_ttl = min(ttl, self.l2_ttl) if ttl > 0 else self.l2_ttl
563+
success = self.l2.set_if_not_exists(key, value, l2_ttl)
559564
if success:
560565
self.l1.set(key, value, min(ttl, self.l1_ttl) if ttl > 0 else self.l1_ttl)
561566
return success
562567

563568
def set_entry(self, key: str, entry: CacheEntry, ttl: int | None = None) -> None:
564-
"""Store raw entry in both layers, respecting L1 TTL."""
569+
"""Store raw entry in both layers, respecting L1 and L2 TTL."""
565570
ttl = ttl if ttl is not None else max(int(entry.fresh_until - time.time()), 0)
566571

567572
l1_ttl = min(ttl, self.l1_ttl) if ttl > 0 else self.l1_ttl
573+
l2_ttl = min(ttl, self.l2_ttl) if ttl > 0 else self.l2_ttl
574+
568575
self.l1.set_entry(key, entry, ttl=l1_ttl)
569576

570577
if hasattr(self.l2, "set_entry"):
571-
self.l2.set_entry(key, entry, ttl=ttl) # type: ignore[attr-defined]
578+
self.l2.set_entry(key, entry, ttl=l2_ttl) # type: ignore[attr-defined]
572579
else:
573-
self.l2.set(key, entry.value, ttl)
580+
self.l2.set(key, entry.value, l2_ttl)

tests/test_correctness.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,127 @@ def f(*, x: int) -> int:
721721
assert calls["n"] == 1
722722

723723

724+
class TestHybridCache:
725+
"""Test HybridCache L1+L2 behavior with l2_ttl."""
726+
727+
def test_l2_ttl_defaults_to_l1_ttl_times_2(self):
728+
"""Test that l2_ttl defaults to l1_ttl * 2."""
729+
l1 = InMemCache()
730+
l2 = InMemCache()
731+
732+
cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60)
733+
assert cache.l1_ttl == 60
734+
assert cache.l2_ttl == 120
735+
736+
def test_l2_ttl_explicit_value(self):
737+
"""Test that explicit l2_ttl is respected."""
738+
l1 = InMemCache()
739+
l2 = InMemCache()
740+
741+
cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=60, l2_ttl=300)
742+
assert cache.l1_ttl == 60
743+
assert cache.l2_ttl == 300
744+
745+
def test_set_respects_l2_ttl(self):
746+
"""Test that set() uses l2_ttl for L2 cache."""
747+
l1 = InMemCache()
748+
l2 = InMemCache()
749+
750+
# Set l1_ttl=1, l2_ttl=10
751+
cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10)
752+
753+
cache.set("key1", "value1", ttl=100)
754+
755+
# Both should have the value immediately
756+
assert cache.get("key1") == "value1"
757+
assert l1.get("key1") == "value1"
758+
assert l2.get("key1") == "value1"
759+
760+
# Wait for L1 to expire (l1_ttl=1)
761+
time.sleep(1.2)
762+
763+
# L1 should be expired, but L2 should still have it
764+
assert l1.get("key1") is None
765+
assert l2.get("key1") == "value1"
766+
767+
# HybridCache should fetch from L2 and repopulate L1
768+
assert cache.get("key1") == "value1"
769+
assert l1.get("key1") == "value1" # L1 repopulated
770+
771+
def test_set_entry_respects_l2_ttl(self):
772+
"""Test that set_entry() uses l2_ttl for L2 cache."""
773+
from advanced_caching.storage import CacheEntry
774+
775+
l1 = InMemCache()
776+
l2 = InMemCache()
777+
778+
cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10)
779+
780+
now = time.time()
781+
entry = CacheEntry(value="test_value", fresh_until=now + 100, created_at=now)
782+
783+
cache.set_entry("key2", entry, ttl=100)
784+
785+
# Both should have the entry (using get() which checks freshness)
786+
assert cache.get("key2") == "test_value"
787+
assert l1.get("key2") == "test_value"
788+
assert l2.get("key2") == "test_value"
789+
790+
# Wait for L1 to expire (l1_ttl=1)
791+
time.sleep(1.2)
792+
793+
# L1 expired (get() returns None for expired), L2 should still have it
794+
assert l1.get("key2") is None
795+
assert l2.get("key2") == "test_value"
796+
797+
# HybridCache should fetch from L2
798+
assert cache.get("key2") == "test_value"
799+
800+
def test_set_if_not_exists_respects_l2_ttl(self):
801+
"""Test that set_if_not_exists() uses l2_ttl for L2 cache."""
802+
l1 = InMemCache()
803+
l2 = InMemCache()
804+
805+
cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=1, l2_ttl=10)
806+
807+
# First set should succeed
808+
assert cache.set_if_not_exists("key3", "value3", ttl=100) is True
809+
assert cache.get("key3") == "value3"
810+
811+
# Second set should fail (key exists)
812+
assert cache.set_if_not_exists("key3", "value3_new", ttl=100) is False
813+
assert cache.get("key3") == "value3"
814+
815+
# Wait for L1 to expire
816+
time.sleep(1.2)
817+
818+
# L2 should still have it, so set_if_not_exists should fail
819+
assert cache.set_if_not_exists("key3", "value3_new", ttl=100) is False
820+
821+
# Value should still be original from L2
822+
assert cache.get("key3") == "value3"
823+
824+
def test_l2_ttl_with_zero_ttl_in_set(self):
825+
"""Test that l2_ttl is used when ttl=0 is passed to set()."""
826+
l1 = InMemCache()
827+
l2 = InMemCache()
828+
829+
cache = HybridCache(l1_cache=l1, l2_cache=l2, l1_ttl=2, l2_ttl=5)
830+
831+
# Set with ttl=0 should use l1_ttl and l2_ttl defaults
832+
cache.set("key4", "value4", ttl=0)
833+
834+
assert cache.get("key4") == "value4"
835+
836+
# Wait for L1 to expire
837+
time.sleep(2.2)
838+
839+
# L1 expired, but L2 should still have it (l2_ttl=5)
840+
assert l1.get("key4") is None
841+
assert l2.get("key4") == "value4"
842+
assert cache.get("key4") == "value4"
843+
844+
724845
class TestNoCachingWhenZero:
725846
"""Ensure ttl/interval_seconds == 0 disables caching/background behavior."""
726847

0 commit comments

Comments
 (0)