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 +138 -0
- hydict-0.5.0/README.md +128 -0
- hydict-0.5.0/pyproject.toml +15 -0
- hydict-0.5.0/pyproject.toml.orig +16 -0
- hydict-0.5.0/src/hydict/__init__.py +17 -0
- hydict-0.5.0/src/hydict/async_api/__init__.py +0 -0
- hydict-0.5.0/src/hydict/backends/__init__.py +9 -0
- hydict-0.5.0/src/hydict/backends/base.py +32 -0
- hydict-0.5.0/src/hydict/backends/memory.py +102 -0
- hydict-0.5.0/src/hydict/backends/remote.py +153 -0
- hydict-0.5.0/src/hydict/config/__init__.py +3 -0
- hydict-0.5.0/src/hydict/config/cache.py +10 -0
- hydict-0.5.0/src/hydict/config/redis.py +12 -0
- hydict-0.5.0/src/hydict/core/__init__.py +3 -0
- hydict-0.5.0/src/hydict/core/hdict.py +139 -0
- hydict-0.5.0/src/hydict/exceptions.py +14 -0
- hydict-0.5.0/src/hydict/invalidation/__init__.py +3 -0
- hydict-0.5.0/src/hydict/invalidation/invalidator.py +25 -0
- hydict-0.5.0/src/hydict/metrics/__init__.py +3 -0
- hydict-0.5.0/src/hydict/metrics/collector.py +18 -0
- hydict-0.5.0/src/hydict/serialization/__init__.py +3 -0
- hydict-0.5.0/src/hydict/serialization/serializer.py +10 -0
- hydict-0.5.0/src/hydict/stampede/__init__.py +3 -0
- hydict-0.5.0/src/hydict/stampede/protection.py +15 -0
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,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,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,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,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,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,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
|
+
|