hydict 0.5.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.
hydict-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.3
2
+ Name: hydict
3
+ Version: 0.5.0
4
+ Summary: hydict — a dict-compatible hierarchical cache for Python with local L1 memory and Redis/Valkey L2 storage.
5
+ Author: Veeresh Hanni
6
+ Author-email: Veeresh Hanni <veereshhanni347@gmail.com>
7
+ Requires-Dist: redis>=5.0
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+
11
+ # hydict
12
+
13
+ `hydict` is a dict-compatible hierarchical cache for Python. It uses local
14
+ in-memory storage as L1 and can use Redis or Valkey as L2.
15
+
16
+ ## Version 0.5.0
17
+
18
+ Version 0.5.0 is the V5 release. It includes the synchronous cache API,
19
+ invalidation, metrics, and per-key stampede protection. V5 release verification
20
+ recorded 82 passing tests.
21
+
22
+ ## Features
23
+
24
+ - Fast, local L1 in-memory cache
25
+ - Optional Redis/Valkey L2 cache
26
+ - L1 promotion after an L2 cache hit
27
+ - TTL support, including TTL preservation during L2-to-L1 promotion
28
+ - LRU eviction with an optional `max_entries` limit
29
+ - Redis connection pooling
30
+ - Thread-safe L1 operations
31
+ - Cache invalidation
32
+ - Cache statistics
33
+ - `get_or_set()` with per-key locking to reduce cache stampedes
34
+
35
+ ## Quick start
36
+
37
+ ```python
38
+ from hydict import HDict
39
+
40
+ cache = HDict(max_entries=100)
41
+
42
+ cache["name"] = "Veeresh"
43
+ assert cache["name"] == "Veeresh"
44
+ ```
45
+
46
+ Use `set()` when a value needs a TTL:
47
+
48
+ ```python
49
+ cache.set("session:1", {"user_id": 1}, ttl=60)
50
+ ```
51
+
52
+ ## Redis or Valkey backend
53
+
54
+ Pass `RedisConfig` to enable L2 storage. A compatible Redis or Valkey server
55
+ must be available at the configured address.
56
+
57
+ ```python
58
+ from hydict import HDict, RedisConfig
59
+
60
+ cache = HDict(
61
+ RedisConfig(
62
+ host="localhost",
63
+ port=6379,
64
+ max_connections=10,
65
+ )
66
+ )
67
+
68
+ cache.set("user:1", {"name": "Veeresh"}, ttl=300)
69
+ ```
70
+
71
+ Reads check L1 first. On an L1 miss, `HDict` checks L2 and promotes an L2 hit
72
+ into L1.
73
+
74
+ ## Loading missing values
75
+
76
+ `get_or_set()` returns an existing value when present. For a missing key, it
77
+ calls the loader, stores the returned value, and returns it. Concurrent callers
78
+ for the same key share a per-key lock, so only one loader call populates that
79
+ key at a time.
80
+
81
+ ```python
82
+ user = cache.get_or_set(
83
+ "user:1",
84
+ lambda: load_user_from_database(),
85
+ ttl=60,
86
+ )
87
+ ```
88
+
89
+ ## Invalidation and metrics
90
+
91
+ ```python
92
+ cache.invalidate("user:1")
93
+
94
+ print(cache.stats())
95
+ # {
96
+ # "l1_hits": 0,
97
+ # "l2_hits": 0,
98
+ # "total_hits": 0,
99
+ # "misses": 0,
100
+ # "loader_calls": 0,
101
+ # }
102
+ ```
103
+
104
+ `invalidate()` removes the key from L1 and, when configured, L2.
105
+
106
+ ## Development
107
+
108
+ Run the test suite:
109
+
110
+ ```powershell
111
+ python -m pip install -e .
112
+ pytest -q
113
+ ```
114
+
115
+ Redis integration tests are opt-in. Set `HYDICT_REDIS_INTEGRATION=1` before
116
+ running the integration test suite with a Redis server available.
117
+
118
+ ## Continuous integration and releases
119
+
120
+ GitHub Actions runs unit tests for pull requests and runs the complete
121
+ `pytest -q` suite against Redis 7 with `HYDICT_REDIS_INTEGRATION=1` for release
122
+ validation. Publishing runs only for version tags such as `v0.5.0`, and only
123
+ after both test jobs succeed. After a successful PyPI upload, the workflow also
124
+ creates a GitHub Release with generated notes and the wheel/source archive
125
+ attached.
126
+
127
+ To enable publishing, create the `PYPI_TOKEN` repository secret with a PyPI API
128
+ token. The workflow passes it to Twine without placing the token in the
129
+ repository or workflow logs.
130
+
131
+ ## Roadmap
132
+
133
+ The next planned release is `0.6.0` (V6), which will introduce an asynchronous
134
+ API for `asyncio` applications. Async support is not part of version 0.5.0.
135
+
136
+ See [docs/architecture.md](docs/architecture.md) for cache behavior and design
137
+ notes, the [developer guide](docs/developer-guide.md) for API and implementation
138
+ details, and [CHANGELOG.md](CHANGELOG.md) for release history.
hydict-0.5.0/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # hydict
2
+
3
+ `hydict` is a dict-compatible hierarchical cache for Python. It uses local
4
+ in-memory storage as L1 and can use Redis or Valkey as L2.
5
+
6
+ ## Version 0.5.0
7
+
8
+ Version 0.5.0 is the V5 release. It includes the synchronous cache API,
9
+ invalidation, metrics, and per-key stampede protection. V5 release verification
10
+ recorded 82 passing tests.
11
+
12
+ ## Features
13
+
14
+ - Fast, local L1 in-memory cache
15
+ - Optional Redis/Valkey L2 cache
16
+ - L1 promotion after an L2 cache hit
17
+ - TTL support, including TTL preservation during L2-to-L1 promotion
18
+ - LRU eviction with an optional `max_entries` limit
19
+ - Redis connection pooling
20
+ - Thread-safe L1 operations
21
+ - Cache invalidation
22
+ - Cache statistics
23
+ - `get_or_set()` with per-key locking to reduce cache stampedes
24
+
25
+ ## Quick start
26
+
27
+ ```python
28
+ from hydict import HDict
29
+
30
+ cache = HDict(max_entries=100)
31
+
32
+ cache["name"] = "Veeresh"
33
+ assert cache["name"] == "Veeresh"
34
+ ```
35
+
36
+ Use `set()` when a value needs a TTL:
37
+
38
+ ```python
39
+ cache.set("session:1", {"user_id": 1}, ttl=60)
40
+ ```
41
+
42
+ ## Redis or Valkey backend
43
+
44
+ Pass `RedisConfig` to enable L2 storage. A compatible Redis or Valkey server
45
+ must be available at the configured address.
46
+
47
+ ```python
48
+ from hydict import HDict, RedisConfig
49
+
50
+ cache = HDict(
51
+ RedisConfig(
52
+ host="localhost",
53
+ port=6379,
54
+ max_connections=10,
55
+ )
56
+ )
57
+
58
+ cache.set("user:1", {"name": "Veeresh"}, ttl=300)
59
+ ```
60
+
61
+ Reads check L1 first. On an L1 miss, `HDict` checks L2 and promotes an L2 hit
62
+ into L1.
63
+
64
+ ## Loading missing values
65
+
66
+ `get_or_set()` returns an existing value when present. For a missing key, it
67
+ calls the loader, stores the returned value, and returns it. Concurrent callers
68
+ for the same key share a per-key lock, so only one loader call populates that
69
+ key at a time.
70
+
71
+ ```python
72
+ user = cache.get_or_set(
73
+ "user:1",
74
+ lambda: load_user_from_database(),
75
+ ttl=60,
76
+ )
77
+ ```
78
+
79
+ ## Invalidation and metrics
80
+
81
+ ```python
82
+ cache.invalidate("user:1")
83
+
84
+ print(cache.stats())
85
+ # {
86
+ # "l1_hits": 0,
87
+ # "l2_hits": 0,
88
+ # "total_hits": 0,
89
+ # "misses": 0,
90
+ # "loader_calls": 0,
91
+ # }
92
+ ```
93
+
94
+ `invalidate()` removes the key from L1 and, when configured, L2.
95
+
96
+ ## Development
97
+
98
+ Run the test suite:
99
+
100
+ ```powershell
101
+ python -m pip install -e .
102
+ pytest -q
103
+ ```
104
+
105
+ Redis integration tests are opt-in. Set `HYDICT_REDIS_INTEGRATION=1` before
106
+ running the integration test suite with a Redis server available.
107
+
108
+ ## Continuous integration and releases
109
+
110
+ GitHub Actions runs unit tests for pull requests and runs the complete
111
+ `pytest -q` suite against Redis 7 with `HYDICT_REDIS_INTEGRATION=1` for release
112
+ validation. Publishing runs only for version tags such as `v0.5.0`, and only
113
+ after both test jobs succeed. After a successful PyPI upload, the workflow also
114
+ creates a GitHub Release with generated notes and the wheel/source archive
115
+ attached.
116
+
117
+ To enable publishing, create the `PYPI_TOKEN` repository secret with a PyPI API
118
+ token. The workflow passes it to Twine without placing the token in the
119
+ repository or workflow logs.
120
+
121
+ ## Roadmap
122
+
123
+ The next planned release is `0.6.0` (V6), which will introduce an asynchronous
124
+ API for `asyncio` applications. Async support is not part of version 0.5.0.
125
+
126
+ See [docs/architecture.md](docs/architecture.md) for cache behavior and design
127
+ notes, the [developer guide](docs/developer-guide.md) for API and implementation
128
+ details, and [CHANGELOG.md](CHANGELOG.md) for release history.
@@ -0,0 +1,15 @@
1
+ [project]
2
+ name = "hydict"
3
+ version = "0.5.0"
4
+ description = "hydict — a dict-compatible hierarchical cache for Python with local L1 memory and Redis/Valkey L2 storage."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = ["redis>=5.0"]
8
+
9
+ [[project.authors]]
10
+ name = "Veeresh Hanni"
11
+ email = "veereshhanni347@gmail.com"
12
+
13
+ [build-system]
14
+ requires = ["uv_build>=0.12.20,<0.13.0"]
15
+ build-backend = "uv_build"
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "hydict"
3
+ version = "0.5.0"
4
+ description = "hydict — a dict-compatible hierarchical cache for Python with local L1 memory and Redis/Valkey L2 storage."
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Veeresh Hanni", email = "veereshhanni347@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "redis>=5.0",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["uv_build>=0.12.20,<0.13.0"]
16
+ build-backend = "uv_build"
@@ -0,0 +1,17 @@
1
+ from .core import HDict
2
+ from .config import RedisConfig
3
+ from .exceptions import (
4
+ HydictError,
5
+ BackendError,
6
+ BackendConnectionError,
7
+ BackendOperationError,
8
+ )
9
+
10
+ __all__ = [
11
+ "HDict",
12
+ "RedisConfig",
13
+ "HydictError",
14
+ "BackendError",
15
+ "BackendConnectionError",
16
+ "BackendOperationError",
17
+ ]
File without changes
@@ -0,0 +1,9 @@
1
+ from .base import Backend
2
+ from .memory import MemoryBackend
3
+ from .remote import RemoteBackend
4
+
5
+ __all__ = [
6
+ "Backend",
7
+ "MemoryBackend",
8
+ "RemoteBackend",
9
+ ]
@@ -0,0 +1,32 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class Backend(ABC):
5
+
6
+ @abstractmethod
7
+ def set(self, key, value, ttl=None):
8
+ pass
9
+
10
+ @abstractmethod
11
+ def get(self, key):
12
+ pass
13
+
14
+ @abstractmethod
15
+ def get_with_ttl(self, key):
16
+ pass
17
+
18
+ @abstractmethod
19
+ def delete(self, key):
20
+ pass
21
+
22
+ @abstractmethod
23
+ def exists(self, key):
24
+ pass
25
+
26
+ @abstractmethod
27
+ def clear(self):
28
+ pass
29
+
30
+ @abstractmethod
31
+ def __len__(self):
32
+ pass
@@ -0,0 +1,102 @@
1
+ from collections import OrderedDict
2
+ import threading
3
+ import time
4
+
5
+ from .base import Backend
6
+
7
+ class MemoryBackend(Backend):
8
+
9
+ def __init__(self, max_entries=None):
10
+ self._data = OrderedDict()
11
+ self._expiry = {}
12
+ self.max_entries = max_entries
13
+ self._lock = threading.RLock()
14
+
15
+ def set(self, key, value, ttl=None):
16
+ with self._lock:
17
+ self._data[key] = value
18
+ self._data.move_to_end(key)
19
+
20
+ if ttl is None:
21
+ self._expiry.pop(key, None)
22
+ else:
23
+ self._expiry[key] = time.monotonic() + ttl
24
+
25
+ self._evict_if_needed()
26
+
27
+ def get(self, key):
28
+ with self._lock:
29
+ self._remove_if_expired(key)
30
+
31
+ value = self._data[key]
32
+
33
+ self._data.move_to_end(key)
34
+
35
+ return value
36
+
37
+ def get_with_ttl(self, key):
38
+ with self._lock:
39
+ self._remove_if_expired(key)
40
+
41
+ if key not in self._data:
42
+ raise KeyError(key)
43
+
44
+ expiry = self._expiry.get(key)
45
+
46
+ if expiry is None:
47
+ ttl = -1
48
+ else:
49
+ remaining = expiry - time.monotonic()
50
+
51
+ if remaining <= 0:
52
+ self._remove_if_expired(key)
53
+ raise KeyError(key)
54
+
55
+ ttl = max(0, int(remaining))
56
+
57
+ self._data.move_to_end(key)
58
+
59
+ return self._data[key], ttl
60
+
61
+ def delete(self, key):
62
+ with self._lock:
63
+ self._remove_if_expired(key)
64
+
65
+ del self._data[key]
66
+ self._expiry.pop(key, None)
67
+
68
+ def exists(self, key):
69
+ with self._lock:
70
+ self._remove_if_expired(key)
71
+ return key in self._data
72
+
73
+ def clear(self):
74
+ with self._lock:
75
+ self._data.clear()
76
+ self._expiry.clear()
77
+
78
+ def __len__(self):
79
+ with self._lock:
80
+ self._remove_expired()
81
+ return len(self._data)
82
+
83
+ def _evict_if_needed(self):
84
+ if self.max_entries is None:
85
+ return
86
+
87
+ while len(self._data) > self.max_entries:
88
+ oldest_key, _ = self._data.popitem(last=False)
89
+ self._expiry.pop(oldest_key, None)
90
+
91
+ def _remove_if_expired(self, key):
92
+ expiry = self._expiry.get(key)
93
+
94
+ if expiry is not None and time.monotonic() >= expiry:
95
+ self._data.pop(key, None)
96
+ self._expiry.pop(key, None)
97
+
98
+ def _remove_expired(self):
99
+ for key in list(self._expiry):
100
+ self._remove_if_expired(key)
101
+
102
+
@@ -0,0 +1,153 @@
1
+ import redis
2
+
3
+ from redis.exceptions import ConnectionError as RedisConnectionError
4
+ from redis.exceptions import RedisError
5
+
6
+ from ..config.redis import RedisConfig
7
+ from .base import Backend
8
+
9
+ from ..exceptions import (
10
+ BackendConnectionError,
11
+ BackendOperationError,
12
+ )
13
+
14
+ from ..serialization import Serializer
15
+
16
+ class RemoteBackend(Backend):
17
+
18
+ def __init__(self, config: RedisConfig):
19
+ if config.pool_timeout is None:
20
+ pool = redis.ConnectionPool(
21
+ host=config.host,
22
+ port=config.port,
23
+ db=config.db,
24
+ password=config.password,
25
+ decode_responses=config.decode_responses,
26
+ max_connections=config.max_connections,
27
+ )
28
+ else:
29
+ pool = redis.BlockingConnectionPool(
30
+ host=config.host,
31
+ port=config.port,
32
+ db=config.db,
33
+ password=config.password,
34
+ decode_responses=config.decode_responses,
35
+ max_connections=config.max_connections,
36
+ timeout=config.pool_timeout,
37
+ )
38
+
39
+ self.client = redis.Redis(connection_pool=pool)
40
+ self.serializer = Serializer()
41
+
42
+ def set(self, key, value, ttl=None):
43
+ try:
44
+ value = self.serializer.serialize(value)
45
+
46
+ if ttl is None:
47
+ self.client.set(key, value)
48
+ else:
49
+ self.client.set(key, value, ex=ttl)
50
+
51
+ except RedisConnectionError as exc:
52
+ raise BackendConnectionError(
53
+ "Remote backend connection failed"
54
+ ) from exc
55
+
56
+ except RedisError as exc:
57
+ raise BackendOperationError(
58
+ f"Failed to set key: {key}"
59
+ ) from exc
60
+
61
+ def get(self, key):
62
+ try:
63
+ value = self.client.get(key)
64
+
65
+ except RedisConnectionError as exc:
66
+ raise BackendConnectionError(
67
+ "Remote backend connection failed"
68
+ ) from exc
69
+
70
+ except RedisError as exc:
71
+ raise BackendOperationError(
72
+ f"Failed to get key: {key}"
73
+ ) from exc
74
+
75
+ if value is None:
76
+ raise KeyError(key)
77
+
78
+ return self.serializer.deserialize(value)
79
+
80
+ def delete(self, key):
81
+ try:
82
+ self.client.delete(key)
83
+
84
+ except RedisConnectionError as exc:
85
+ raise BackendConnectionError(
86
+ "Remote backend connection failed"
87
+ ) from exc
88
+
89
+ except RedisError as exc:
90
+ raise BackendOperationError(
91
+ f"Failed to delete key: {key}"
92
+ ) from exc
93
+
94
+ def exists(self, key):
95
+ try:
96
+ return self.client.exists(key) > 0
97
+
98
+ except RedisConnectionError as exc:
99
+ raise BackendConnectionError(
100
+ "Remote backend connection failed"
101
+ ) from exc
102
+
103
+ except RedisError as exc:
104
+ raise BackendOperationError(
105
+ f"Failed to check key: {key}"
106
+ ) from exc
107
+
108
+ def clear(self):
109
+ try:
110
+ self.client.flushdb()
111
+
112
+ except RedisConnectionError as exc:
113
+ raise BackendConnectionError(
114
+ "Remote backend connection failed"
115
+ ) from exc
116
+
117
+ except RedisError as exc:
118
+ raise BackendOperationError(
119
+ "Failed to clear remote backend"
120
+ ) from exc
121
+
122
+ def get_with_ttl(self, key):
123
+ try:
124
+ value = self.client.get(key)
125
+
126
+ if value is None:
127
+ raise KeyError(key)
128
+
129
+ ttl = self.client.ttl(key)
130
+
131
+ return self.serializer.deserialize(value), ttl
132
+
133
+ except RedisConnectionError as exc:
134
+ raise BackendConnectionError(
135
+ "Remote backend connection failed"
136
+ ) from exc
137
+
138
+ except RedisError as exc:
139
+ raise BackendOperationError(
140
+ f"Failed to get key: {key}"
141
+ ) from exc
142
+
143
+ def __len__(self):
144
+ try:
145
+ return self.client.dbsize()
146
+ except RedisConnectionError as exc:
147
+ raise BackendConnectionError(
148
+ "Remote backend connection failed"
149
+ ) from exc
150
+ except RedisError as exc:
151
+ raise BackendOperationError(
152
+ "Failed to get remote backend size"
153
+ ) from exc
@@ -0,0 +1,3 @@
1
+
2
+ from .redis import RedisConfig
3
+ __all__ = ["RedisConfig"]
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class CacheConfig:
6
+ host: str = "localhost"
7
+ port: int = 6379
8
+ db: int = 0
9
+ password: str | None = None
10
+ decode_responses: bool = True
@@ -0,0 +1,12 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class RedisConfig:
6
+ host: str = "localhost"
7
+ port: int = 6379
8
+ db: int = 0
9
+ password: str | None = None
10
+ decode_responses: bool = True
11
+ max_connections: int | None = None
12
+ pool_timeout: float | None = None
@@ -0,0 +1,3 @@
1
+ from .hdict import HDict
2
+
3
+ __all__ = ["HDict"]
@@ -0,0 +1,139 @@
1
+ from ..stampede.protection import StampedeProtection
2
+
3
+ from ..backends.memory import MemoryBackend
4
+ from ..backends.remote import RemoteBackend
5
+ from ..config.redis import RedisConfig
6
+ from ..invalidation import CacheInvalidator
7
+ from ..metrics import MetricsCollector
8
+
9
+ class HDict:
10
+ """
11
+ Thread-safe hybrid cache.
12
+
13
+ Thread-safety guarantees:
14
+ - Individual public operations are safe to call concurrently.
15
+ - The in-memory L1 backend protects its internal state with a lock.
16
+ - L1 and L2 operations are not an atomic transaction.
17
+ - A thread may observe another thread's update between L1 and L2 operations.
18
+ """
19
+ def __init__(self, config=None, max_entries=None):
20
+ self._memory = MemoryBackend(max_entries=max_entries)
21
+
22
+ self._remote = None
23
+
24
+ if config is not None:
25
+ self._remote = RemoteBackend(config)
26
+
27
+ self._invalidator = CacheInvalidator(
28
+ self._memory,
29
+ self._remote,
30
+ )
31
+ self._stampede = StampedeProtection()
32
+ self._metrics = MetricsCollector()
33
+
34
+ def __setitem__(self, key, value):
35
+ # Write to L1
36
+ self._memory.set(key, value)
37
+
38
+ # Write to L2
39
+ if self._remote is not None:
40
+ self._remote.set(key, value)
41
+
42
+ def __getitem__(self, key, _record_metrics=True):
43
+ if self._memory.exists(key):
44
+ if _record_metrics:
45
+ self._metrics.record_l1_hit()
46
+ return self._memory.get(key)
47
+
48
+ if self._remote is not None:
49
+ try:
50
+ value, ttl = self._remote.get_with_ttl(key)
51
+ except KeyError:
52
+ if _record_metrics:
53
+ self._metrics.record_miss()
54
+ raise
55
+
56
+ if _record_metrics:
57
+ self._metrics.record_l2_hit()
58
+
59
+ if ttl < 0:
60
+ self._memory.set(key, value)
61
+ else:
62
+ self._memory.set(key, value, ttl=ttl)
63
+
64
+ return value
65
+
66
+ if _record_metrics:
67
+ self._metrics.record_miss()
68
+
69
+ raise KeyError(key)
70
+
71
+ def __delitem__(self, key):
72
+ self._memory.delete(key)
73
+
74
+ if self._remote is not None:
75
+ self._remote.delete(key)
76
+
77
+ def __contains__(self, key):
78
+
79
+ if self._memory.exists(key):
80
+ return True
81
+
82
+ if self._remote is not None:
83
+ return self._remote.exists(key)
84
+
85
+ return False
86
+
87
+ def __len__(self):
88
+ return len(self._memory)
89
+
90
+ def get(self, key, default=None):
91
+ try:
92
+ return self[key]
93
+ except KeyError:
94
+ return default
95
+
96
+ def clear(self):
97
+ self._memory.clear()
98
+
99
+ if self._remote is not None:
100
+ self._remote.clear()
101
+
102
+ def set(self, key, value, ttl=None):
103
+ self._memory.set(key, value, ttl=ttl)
104
+
105
+ if self._remote is not None:
106
+ self._remote.set(key, value, ttl=ttl)
107
+
108
+ def invalidate(self, key):
109
+ self._invalidator.invalidate(key)
110
+
111
+ def get_or_set(self, key, loader, ttl=None):
112
+ try:
113
+ return self[key]
114
+ except KeyError:
115
+ pass
116
+
117
+ lock = self._stampede.get_lock(key)
118
+
119
+ with lock:
120
+ try:
121
+ return self.__getitem__(key, _record_metrics=False)
122
+ except KeyError:
123
+ pass
124
+
125
+ self._metrics.record_loader_call()
126
+
127
+ value = loader()
128
+ self.set(key, value, ttl=ttl)
129
+
130
+ return value
131
+
132
+ def stats(self):
133
+ return {
134
+ "l1_hits": self._metrics.l1_hits,
135
+ "l2_hits": self._metrics.l2_hits,
136
+ "total_hits": self._metrics.l1_hits + self._metrics.l2_hits,
137
+ "misses": self._metrics.misses,
138
+ "loader_calls": self._metrics.loader_calls,
139
+ }
@@ -0,0 +1,14 @@
1
+ class HydictError(Exception):
2
+ """Base exception for hydict."""
3
+
4
+
5
+ class BackendError(HydictError):
6
+ """Base exception for backend failures."""
7
+
8
+
9
+ class BackendConnectionError(BackendError):
10
+ """Raised when a backend cannot be reached."""
11
+
12
+
13
+ class BackendOperationError(BackendError):
14
+ """Raised when a backend operation fails."""
@@ -0,0 +1,3 @@
1
+ from .invalidator import CacheInvalidator
2
+
3
+ __all__ = ["CacheInvalidator"]
@@ -0,0 +1,25 @@
1
+ from hydict.backends import Backend
2
+
3
+
4
+ class CacheInvalidator:
5
+
6
+ def __init__(self, l1: Backend, l2: Backend | None = None):
7
+ self.l1 = l1
8
+ self.l2 = l2
9
+
10
+ def invalidate(self, key):
11
+ """
12
+ Remove a key from both L1 and L2.
13
+
14
+ Missing keys are ignored.
15
+ """
16
+ self._delete(self.l1, key)
17
+ if self.l2 is not None:
18
+ self._delete(self.l2, key)
19
+
20
+ @staticmethod
21
+ def _delete(backend: Backend, key):
22
+ try:
23
+ backend.delete(key)
24
+ except KeyError:
25
+ pass
@@ -0,0 +1,3 @@
1
+ from .collector import MetricsCollector
2
+
3
+ __all__ = ["MetricsCollector"]
@@ -0,0 +1,18 @@
1
+ class MetricsCollector:
2
+ def __init__(self):
3
+ self.l1_hits = 0
4
+ self.l2_hits = 0
5
+ self.misses = 0
6
+ self.loader_calls = 0
7
+
8
+ def record_l1_hit(self):
9
+ self.l1_hits += 1
10
+
11
+ def record_l2_hit(self):
12
+ self.l2_hits += 1
13
+
14
+ def record_miss(self):
15
+ self.misses += 1
16
+
17
+ def record_loader_call(self):
18
+ self.loader_calls += 1
@@ -0,0 +1,3 @@
1
+ from .serializer import Serializer
2
+
3
+ __all__ = ["Serializer"]
@@ -0,0 +1,10 @@
1
+ import json
2
+
3
+
4
+ class Serializer:
5
+
6
+ def serialize(self, value):
7
+ return json.dumps(value)
8
+
9
+ def deserialize(self, value):
10
+ return json.loads(value)
@@ -0,0 +1,3 @@
1
+ from .protection import StampedeProtection
2
+
3
+ __all__ = ["StampedeProtection"]
@@ -0,0 +1,15 @@
1
+ import threading
2
+
3
+ class StampedeProtection:
4
+
5
+ def __init__(self):
6
+ self._locks = {}
7
+ self._lock = threading.Lock()
8
+
9
+ def get_lock(self, key):
10
+ with self._lock:
11
+ if key not in self._locks:
12
+ self._locks[key] = threading.Lock()
13
+
14
+ return self._locks[key]
15
+