mic-datastore 0.1.0__py3-none-any.whl
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.
- mic/cache/__init__.py +37 -0
- mic/cache/backends.py +237 -0
- mic/cache/cluster/__init__.py +35 -0
- mic/cache/cluster/consistent_hash.py +116 -0
- mic/cache/cluster/redis_cluster.py +191 -0
- mic/cache/revoke_blacklist.py +247 -0
- mic/datastorage/__init__.py +98 -0
- mic/datastorage/base.py +316 -0
- mic/datastorage/document/__init__.py +14 -0
- mic/datastorage/document/engine.py +166 -0
- mic/datastorage/document/in_memory.py +104 -0
- mic/datastorage/graph/__init__.py +12 -0
- mic/datastorage/graph/engine.py +131 -0
- mic/datastorage/keyvalue/__init__.py +11 -0
- mic/datastorage/keyvalue/in_memory.py +65 -0
- mic/datastorage/sql/__init__.py +10 -0
- mic/datastorage/sql/engine.py +152 -0
- mic/eventbus/__init__.py +70 -0
- mic/eventbus/base.py +54 -0
- mic/eventbus/in_memory.py +81 -0
- mic/eventbus/redis_streams.py +168 -0
- mic/idempotency/__init__.py +77 -0
- mic/idempotency/store.py +716 -0
- mic/locking/__init__.py +68 -0
- mic/locking/base.py +51 -0
- mic/locking/in_memory.py +64 -0
- mic/locking/lock_handle.py +26 -0
- mic/locking/redis_backend.py +123 -0
- mic/locking/scope.py +58 -0
- mic/outbox/__init__.py +111 -0
- mic/outbox/base.py +161 -0
- mic/outbox/dispatcher.py +345 -0
- mic/outbox/event.py +117 -0
- mic/outbox/in_memory.py +225 -0
- mic/outbox/sanitize.py +130 -0
- mic/outbox/sql.py +412 -0
- mic/py.typed +0 -0
- mic/queue/__init__.py +71 -0
- mic/queue/base.py +99 -0
- mic/queue/in_memory.py +87 -0
- mic/queue/kafka.py +120 -0
- mic/queue/nats.py +161 -0
- mic/read_models/__init__.py +64 -0
- mic/read_models/counter.py +78 -0
- mic/read_models/leaderboard.py +92 -0
- mic/read_models/membership_set.py +86 -0
- mic/read_models/timeline.py +97 -0
- mic/read_models/unique_count.py +73 -0
- mic/realtime/__init__.py +134 -0
- mic/realtime/auth.py +103 -0
- mic/realtime/backend.py +129 -0
- mic/realtime/endpoint.py +140 -0
- mic/realtime/redis_streams.py +141 -0
- mic/realtime/room.py +64 -0
- mic/response_cache/__init__.py +100 -0
- mic/response_cache/_stampede.py +90 -0
- mic/response_cache/_storage.py +109 -0
- mic/response_cache/middleware.py +289 -0
- mic/response_cache/rule.py +106 -0
- mic/shard/__init__.py +63 -0
- mic/shard/selector.py +145 -0
- mic/shard/session_factory.py +99 -0
- mic_datastore-0.1.0.dist-info/METADATA +91 -0
- mic_datastore-0.1.0.dist-info/RECORD +65 -0
- mic_datastore-0.1.0.dist-info/WHEEL +4 -0
mic/cache/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Cache — abstraction key-value avec TTL pour patterns transverses.
|
|
2
|
+
|
|
3
|
+
Trois implémentations :
|
|
4
|
+
- ``InMemoryCache`` : dict en mémoire, no-op TTL hors process. Pour
|
|
5
|
+
tests + dev mono-process.
|
|
6
|
+
- ``RedisCache`` (extra ``[redis]``) : Redis via ``redis-py``, TTL
|
|
7
|
+
en secondes côté Redis. Pour prod multi-replicas.
|
|
8
|
+
- ``CacheBackend`` : ABC commun.
|
|
9
|
+
|
|
10
|
+
Cas d'usage primaires :
|
|
11
|
+
- **Idempotency-Key** : déduplication des requêtes POST retentées
|
|
12
|
+
par le client (cf. ``mic.client``).
|
|
13
|
+
- **Rate limiting** : compteur sliding window (à venir v1.2).
|
|
14
|
+
- **Distributed lock** : SET NX EX (à venir v1.2).
|
|
15
|
+
- **Cache-aside** : memoization de queries lentes côté domain
|
|
16
|
+
service (consumer-side, pas dans la lib).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from mic.cache.backends import (
|
|
20
|
+
CacheBackend,
|
|
21
|
+
CacheError,
|
|
22
|
+
InMemoryCache,
|
|
23
|
+
RedisCache,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# RevokeBlacklist — révocation de JWT court-TTL adossée à un CacheBackend.
|
|
27
|
+
# Réside ici (tier data/cache) et non dans mic.auth : c'est un besoin
|
|
28
|
+
# *stateful*, pas de la cryptographie de jeton (cf. ADR-0001, split mic-datastore).
|
|
29
|
+
from mic.cache.revoke_blacklist import RevokeBlacklist
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"CacheBackend",
|
|
33
|
+
"CacheError",
|
|
34
|
+
"InMemoryCache",
|
|
35
|
+
"RedisCache",
|
|
36
|
+
"RevokeBlacklist",
|
|
37
|
+
]
|
mic/cache/backends.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Backends de cache : ABC + InMemory + Redis.
|
|
2
|
+
|
|
3
|
+
Convention :
|
|
4
|
+
- API simple : ``get(key)``, ``set(key, value, ttl_seconds)``,
|
|
5
|
+
``delete(key)``, ``set_if_absent(key, value, ttl_seconds)``.
|
|
6
|
+
- Valeurs sont des **bytes** (sérialisation JSON / pickle laissée au
|
|
7
|
+
consumer — pas d'opinion ici).
|
|
8
|
+
- ``ttl_seconds`` est obligatoire sur ``set`` pour empêcher les
|
|
9
|
+
fuites mémoire silencieuses (Redis : EXPIRE après SET ; InMemory :
|
|
10
|
+
expiration paresseuse à la lecture).
|
|
11
|
+
- Les erreurs réseau / Redis bubblent en ``CacheError`` (et non un
|
|
12
|
+
``redis.RedisError`` qui ferait fuiter le SDK).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CacheError(Exception):
|
|
24
|
+
"""Erreur cache (réseau, sérialisation, backend down)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CacheBackend(ABC):
|
|
28
|
+
"""Contract minimal d'un backend de cache."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def get(self, key: str) -> bytes | None:
|
|
32
|
+
"""Retourne la valeur (bytes) ou None si absente / expirée."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def set(self, key: str, value: bytes, *, ttl_seconds: int) -> None:
|
|
36
|
+
"""Set avec TTL obligatoire (≥1)."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def set_if_absent(self, key: str, value: bytes, *, ttl_seconds: int) -> bool:
|
|
40
|
+
"""SET NX : retourne True si insertion, False si la key existait."""
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def delete(self, key: str) -> None:
|
|
44
|
+
"""Supprime. No-op si la key n'existe pas."""
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def clear(self) -> None:
|
|
48
|
+
"""Vide le cache (utile en tests, dangereux en prod)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# InMemoryCache : dict + expiration paresseuse
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class InMemoryCache(CacheBackend):
|
|
57
|
+
"""Dict en mémoire avec expiration paresseuse à la lecture.
|
|
58
|
+
|
|
59
|
+
Pour tests + dev mono-process. NE PAS utiliser en prod multi-replicas
|
|
60
|
+
— chaque replica aurait son propre dict.
|
|
61
|
+
|
|
62
|
+
``time_fn`` injectable (default ``time.monotonic``) pour pouvoir
|
|
63
|
+
avancer l'horloge dans les tests sans ``time.sleep``.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, *, time_fn: Callable[[], float] = time.monotonic) -> None:
|
|
67
|
+
self._store: dict[str, tuple[bytes, float]] = {}
|
|
68
|
+
self._time_fn = time_fn
|
|
69
|
+
|
|
70
|
+
def _is_expired(self, expiry: float) -> bool:
|
|
71
|
+
return self._time_fn() >= expiry
|
|
72
|
+
|
|
73
|
+
def get(self, key: str) -> bytes | None:
|
|
74
|
+
entry = self._store.get(key)
|
|
75
|
+
if entry is None:
|
|
76
|
+
return None
|
|
77
|
+
value, expiry = entry
|
|
78
|
+
if self._is_expired(expiry):
|
|
79
|
+
del self._store[key]
|
|
80
|
+
return None
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
def set(self, key: str, value: bytes, *, ttl_seconds: int) -> None:
|
|
84
|
+
if ttl_seconds < 1:
|
|
85
|
+
raise CacheError(f"ttl_seconds must be >= 1, got {ttl_seconds}")
|
|
86
|
+
self._store[key] = (value, self._time_fn() + ttl_seconds)
|
|
87
|
+
|
|
88
|
+
def set_if_absent(self, key: str, value: bytes, *, ttl_seconds: int) -> bool:
|
|
89
|
+
if ttl_seconds < 1:
|
|
90
|
+
raise CacheError(f"ttl_seconds must be >= 1, got {ttl_seconds}")
|
|
91
|
+
# Lire (avec expiration paresseuse) avant de check
|
|
92
|
+
if self.get(key) is not None:
|
|
93
|
+
return False
|
|
94
|
+
self._store[key] = (value, self._time_fn() + ttl_seconds)
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def delete(self, key: str) -> None:
|
|
98
|
+
self._store.pop(key, None)
|
|
99
|
+
|
|
100
|
+
def clear(self) -> None:
|
|
101
|
+
self._store.clear()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# RedisCache : redis-py (extra [redis])
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class RedisCache(CacheBackend):
|
|
110
|
+
"""Cache Redis via ``redis-py``.
|
|
111
|
+
|
|
112
|
+
Requiert l'extra ``[redis]``. ``client`` peut être injecté
|
|
113
|
+
(utile pour les tests qui mockent) ou construit depuis ``url``.
|
|
114
|
+
|
|
115
|
+
Toutes les méthodes catchent les erreurs Redis (réseau, timeout)
|
|
116
|
+
et les re-raisent en ``CacheError`` — le consumer ne voit pas le
|
|
117
|
+
SDK sous-jacent.
|
|
118
|
+
|
|
119
|
+
## Mode Redis Cluster (opt-in)
|
|
120
|
+
|
|
121
|
+
Par défaut (``cluster=False``), le client est un Redis **standalone**
|
|
122
|
+
(``redis.Redis`` — single-node ou derrière Sentinel). Passer
|
|
123
|
+
``cluster=True`` construit un ``redis.cluster.RedisCluster`` à la
|
|
124
|
+
place : le client découvre la topologie multi-node depuis l'``url``
|
|
125
|
+
de bootstrap et route automatiquement chaque key vers son shard via
|
|
126
|
+
les redirections MOVED/ASK.
|
|
127
|
+
|
|
128
|
+
Le ``RedisCache`` reste mono-key sur toutes ses opérations
|
|
129
|
+
(``get`` / ``set`` / ``set_if_absent`` / ``delete``), donc il n'y a
|
|
130
|
+
**jamais** de risque ``CROSSSLOT`` — aucune commande ne touche plus
|
|
131
|
+
d'une key à la fois. La seule opération sensible au cluster est
|
|
132
|
+
``clear()`` (FLUSHDB), géré ci-dessous.
|
|
133
|
+
|
|
134
|
+
Cette option est l'escape-hatch "minimal switch" pour un cache
|
|
135
|
+
cache-aside simple. Pour les patterns multi-shards avancés (hash
|
|
136
|
+
tags, fan-out par node, bootstrap multi-node explicite), préférer
|
|
137
|
+
:class:`mic.cache.cluster.RedisClusterCache`, qui expose
|
|
138
|
+
``startup_nodes`` + ``hash_tagged()``.
|
|
139
|
+
|
|
140
|
+
>>> RedisCache(url="redis://node-1:6379", cluster=True) # doctest: +SKIP
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
url: str | None = None,
|
|
147
|
+
client: Any = None,
|
|
148
|
+
decode_responses: bool = False,
|
|
149
|
+
cluster: bool = False,
|
|
150
|
+
) -> None:
|
|
151
|
+
#: Mémorisé pour router ``clear()`` (FLUSHDB) correctement : en
|
|
152
|
+
#: cluster le flush n'est pas un broadcast, il faut viser tous
|
|
153
|
+
#: les masters. Un client injecté est traité comme cluster ssi
|
|
154
|
+
#: ``cluster=True`` a été passé explicitement.
|
|
155
|
+
self._cluster = cluster
|
|
156
|
+
if client is not None:
|
|
157
|
+
self._client = client
|
|
158
|
+
else:
|
|
159
|
+
if not url:
|
|
160
|
+
raise CacheError("RedisCache: must pass either ``url`` or ``client``")
|
|
161
|
+
try:
|
|
162
|
+
import redis # noqa: PLC0415 — optional dep guard
|
|
163
|
+
except ImportError as exc:
|
|
164
|
+
raise CacheError(
|
|
165
|
+
"redis-py not installed. " "Install with: pip install 'mic-struct[redis]'"
|
|
166
|
+
) from exc
|
|
167
|
+
if cluster:
|
|
168
|
+
# ``import redis`` ne charge pas le sous-module ``redis.cluster`` :
|
|
169
|
+
# on l'importe explicitement (couvert par le même dep guard).
|
|
170
|
+
from redis.cluster import RedisCluster # noqa: PLC0415
|
|
171
|
+
|
|
172
|
+
self._client = RedisCluster.from_url(url, decode_responses=decode_responses)
|
|
173
|
+
else:
|
|
174
|
+
self._client = redis.Redis.from_url(url, decode_responses=decode_responses)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def native(self) -> Any:
|
|
178
|
+
"""Le ``redis.Redis`` natif — escape-hatch pour les commandes
|
|
179
|
+
Redis non couvertes par l'API ``CacheBackend`` (PUB/SUB, scripts
|
|
180
|
+
Lua, pipelines, INFO, …). Aligné avec le pattern ``.native`` de
|
|
181
|
+
:mod:`mic.datastorage` (DocumentDataStorage / GraphDataStorage).
|
|
182
|
+
"""
|
|
183
|
+
return self._client
|
|
184
|
+
|
|
185
|
+
def _wrap_errors(self, op: str, fn: Callable[[], Any]) -> Any:
|
|
186
|
+
try:
|
|
187
|
+
return fn()
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
raise CacheError(f"Redis {op} failed: {exc}") from exc
|
|
190
|
+
|
|
191
|
+
def get(self, key: str) -> bytes | None:
|
|
192
|
+
result = self._wrap_errors("GET", lambda: self._client.get(key))
|
|
193
|
+
if result is None:
|
|
194
|
+
return None
|
|
195
|
+
if isinstance(result, str):
|
|
196
|
+
return result.encode()
|
|
197
|
+
return bytes(result)
|
|
198
|
+
|
|
199
|
+
def set(self, key: str, value: bytes, *, ttl_seconds: int) -> None:
|
|
200
|
+
if ttl_seconds < 1:
|
|
201
|
+
raise CacheError(f"ttl_seconds must be >= 1, got {ttl_seconds}")
|
|
202
|
+
self._wrap_errors(
|
|
203
|
+
"SET",
|
|
204
|
+
lambda: self._client.set(key, value, ex=ttl_seconds),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def set_if_absent(self, key: str, value: bytes, *, ttl_seconds: int) -> bool:
|
|
208
|
+
if ttl_seconds < 1:
|
|
209
|
+
raise CacheError(f"ttl_seconds must be >= 1, got {ttl_seconds}")
|
|
210
|
+
# SET NX EX = atomic
|
|
211
|
+
result = self._wrap_errors(
|
|
212
|
+
"SET NX EX",
|
|
213
|
+
lambda: self._client.set(key, value, ex=ttl_seconds, nx=True),
|
|
214
|
+
)
|
|
215
|
+
return bool(result)
|
|
216
|
+
|
|
217
|
+
def delete(self, key: str) -> None:
|
|
218
|
+
self._wrap_errors("DEL", lambda: self._client.delete(key))
|
|
219
|
+
|
|
220
|
+
def clear(self) -> None:
|
|
221
|
+
"""Vide le cache (FLUSHDB). Dangereux en prod, utile en tests.
|
|
222
|
+
|
|
223
|
+
En mode cluster, ``FLUSHDB`` n'est **pas** un broadcast : par
|
|
224
|
+
défaut redis-py ne le route que vers le master "par défaut", ce
|
|
225
|
+
qui laisserait les autres shards intacts. On vise donc
|
|
226
|
+
explicitement ``RedisCluster.ALL_NODES`` pour flusher tous les
|
|
227
|
+
masters. En standalone, l'appel reste un simple ``FLUSHDB``.
|
|
228
|
+
"""
|
|
229
|
+
if self._cluster:
|
|
230
|
+
from redis.cluster import RedisCluster # noqa: PLC0415 — optional dep guard
|
|
231
|
+
|
|
232
|
+
self._wrap_errors(
|
|
233
|
+
"FLUSHDB ALL_NODES",
|
|
234
|
+
lambda: self._client.flushdb(target_nodes=RedisCluster.ALL_NODES),
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
self._wrap_errors("FLUSHDB", self._client.flushdb)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Redis Cluster / Valkey Cluster wrapper — sharding self-hosté multi-shards.
|
|
2
|
+
|
|
3
|
+
Cf. ADR-0010 (docs/decisions/) — à 3M concurrent users, le
|
|
4
|
+
cache distribué doit tenir >100k ops/s, ce qu'un Redis single-node
|
|
5
|
+
ne fait pas. La solution canon est **Valkey Cluster** (fork BSD de
|
|
6
|
+
Redis, gouvernance Linux Foundation neutre) ou Redis Cluster OSS.
|
|
7
|
+
|
|
8
|
+
Briques publiques :
|
|
9
|
+
|
|
10
|
+
- :class:`RedisClusterCache` — implémente :class:`mic.cache.CacheBackend`
|
|
11
|
+
par-dessus ``redis.cluster.RedisCluster``. Compatible avec Valkey
|
|
12
|
+
(wire-protocol identique).
|
|
13
|
+
- :class:`ConsistentHashRing` — primitive de consistent hashing pour
|
|
14
|
+
les use-cases hors-Redis (ex: sharding application-level vers N
|
|
15
|
+
instances de service, fan-out vers M backends).
|
|
16
|
+
|
|
17
|
+
Pour les opérations multi-keys atomiques (MGET, pipeline), utiliser
|
|
18
|
+
les **hash tags** Redis : ``{tag}:key1`` et ``{tag}:key2`` tomberont
|
|
19
|
+
sur le même slot (et donc le même shard), permettant un MGET en
|
|
20
|
+
1 round-trip. Le wrapper expose ``RedisClusterCache.with_hash_tag(tag)``
|
|
21
|
+
pour ce pattern.
|
|
22
|
+
|
|
23
|
+
L'extra ``mic-struct[redis]`` (qui installe ``redis>=5.2``) suffit —
|
|
24
|
+
``RedisCluster`` est natif depuis redis-py 4.x.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from mic.cache.cluster.consistent_hash import ConsistentHashRing
|
|
30
|
+
from mic.cache.cluster.redis_cluster import RedisClusterCache
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ConsistentHashRing",
|
|
34
|
+
"RedisClusterCache",
|
|
35
|
+
]
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Consistent hashing ring — primitive pour le sharding non-Redis.
|
|
2
|
+
|
|
3
|
+
Pour Redis Cluster / Valkey Cluster, **ne pas utiliser** : Redis fait
|
|
4
|
+
son propre sharding via ses 16 384 hash slots. Cette brique sert :
|
|
5
|
+
|
|
6
|
+
- au sharding application-level (fan-out vers N replicas d'un service
|
|
7
|
+
qui n'expose pas de cluster natif) ;
|
|
8
|
+
- au routing déterministe vers M backends (ex: dispatcher de jobs
|
|
9
|
+
vers M workers où chaque worker possède un sous-ensemble) ;
|
|
10
|
+
- au sharding Postgres si on n'utilise pas Citus (chaque shard
|
|
11
|
+
reçoit les rows dont ``hash(user_id)`` tombe dans son range).
|
|
12
|
+
|
|
13
|
+
Algorithme : ring de virtuels nodes (``virtual_nodes`` par node
|
|
14
|
+
physique, default 150) avec hash MD5. Donne une distribution
|
|
15
|
+
quasi-uniforme et minimise la redistribution lors d'un add/remove
|
|
16
|
+
node (~ 1/N keys re-mappées au lieu de toutes en hash naïf).
|
|
17
|
+
|
|
18
|
+
L'API est délibérément **read-only** post-construction. Pour modifier
|
|
19
|
+
le ring (add/remove node), construire un nouveau ring — c'est plus
|
|
20
|
+
simple à raisonner concurremment.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
from bisect import bisect_right
|
|
27
|
+
from collections.abc import Iterable
|
|
28
|
+
|
|
29
|
+
#: Nombre de virtuels nodes par node physique. 150 est le sweet spot
|
|
30
|
+
#: documenté Cassandra/DynamoDB : la variance de distribution chute
|
|
31
|
+
#: à <5 % au-delà, gain marginal au prix d'une mémoire ring plus
|
|
32
|
+
#: grosse.
|
|
33
|
+
DEFAULT_VIRTUAL_NODES = 150
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _hash(key: str) -> int:
|
|
37
|
+
"""Hash MD5 → entier 64-bit unsigned.
|
|
38
|
+
|
|
39
|
+
MD5 est cryptographiquement cassé pour les signatures, mais reste
|
|
40
|
+
un excellent hash uniforme pour le routing — c'est l'usage canon
|
|
41
|
+
Cassandra/Riak/etc. SHA-1 / SHA-256 sont plus lents pour aucun
|
|
42
|
+
bénéfice ici.
|
|
43
|
+
"""
|
|
44
|
+
return int.from_bytes(hashlib.md5(key.encode(), usedforsecurity=False).digest()[:8])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConsistentHashRing:
|
|
48
|
+
"""Ring de consistent hashing pour le routing déterministe.
|
|
49
|
+
|
|
50
|
+
Construction :
|
|
51
|
+
|
|
52
|
+
>>> ring = ConsistentHashRing(nodes=("worker-1", "worker-2", "worker-3"))
|
|
53
|
+
>>> ring.node_for("user:42")
|
|
54
|
+
'worker-2'
|
|
55
|
+
|
|
56
|
+
Le ring est immutable : pour ajouter / retirer un node, construire
|
|
57
|
+
un nouveau ring. Les ré-affectations seront limitées à
|
|
58
|
+
``~1/(N+1)`` des keys (vs **toutes** les keys avec un hash naïf
|
|
59
|
+
``hash(key) % N``).
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
nodes: itérable des identifiants de nodes (chaînes uniques).
|
|
63
|
+
virtual_nodes: virtuels nodes par node physique (default 150).
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: si ``nodes`` est vide ou contient des doublons,
|
|
67
|
+
ou si ``virtual_nodes`` < 1.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
nodes: Iterable[str],
|
|
74
|
+
virtual_nodes: int = DEFAULT_VIRTUAL_NODES,
|
|
75
|
+
) -> None:
|
|
76
|
+
node_tuple = tuple(nodes)
|
|
77
|
+
if not node_tuple:
|
|
78
|
+
raise ValueError("ConsistentHashRing(nodes=...) must contain at least one node")
|
|
79
|
+
if len(set(node_tuple)) != len(node_tuple):
|
|
80
|
+
raise ValueError("ConsistentHashRing(nodes=...) must not contain duplicates")
|
|
81
|
+
if virtual_nodes < 1:
|
|
82
|
+
raise ValueError("virtual_nodes must be >= 1")
|
|
83
|
+
|
|
84
|
+
self._nodes = node_tuple
|
|
85
|
+
self._virtual_nodes = virtual_nodes
|
|
86
|
+
|
|
87
|
+
# Construit le ring : pour chaque node physique, on insère
|
|
88
|
+
# ``virtual_nodes`` points sur le ring. La key est triée par
|
|
89
|
+
# hash pour permettre un bisect O(log V·N) au routing.
|
|
90
|
+
ring: list[tuple[int, str]] = []
|
|
91
|
+
for node in node_tuple:
|
|
92
|
+
for vnode_index in range(virtual_nodes):
|
|
93
|
+
hash_value = _hash(f"{node}#{vnode_index}")
|
|
94
|
+
ring.append((hash_value, node))
|
|
95
|
+
ring.sort()
|
|
96
|
+
self._hashes: tuple[int, ...] = tuple(h for h, _ in ring)
|
|
97
|
+
self._owners: tuple[str, ...] = tuple(n for _, n in ring)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def nodes(self) -> tuple[str, ...]:
|
|
101
|
+
"""Liste des nodes physiques (ordre d'insertion préservé)."""
|
|
102
|
+
return self._nodes
|
|
103
|
+
|
|
104
|
+
def node_for(self, key: str) -> str:
|
|
105
|
+
"""Retourne le node responsable de cette key.
|
|
106
|
+
|
|
107
|
+
Algorithme : on hash la key, on cherche le premier point du
|
|
108
|
+
ring ≥ ce hash (wrap-around vers le début si on dépasse).
|
|
109
|
+
"""
|
|
110
|
+
key_hash = _hash(key)
|
|
111
|
+
# bisect_right : index du premier élément > key_hash. Si
|
|
112
|
+
# key_hash dépasse le dernier point du ring, on wrap au début.
|
|
113
|
+
index = bisect_right(self._hashes, key_hash)
|
|
114
|
+
if index == len(self._hashes):
|
|
115
|
+
index = 0
|
|
116
|
+
return self._owners[index]
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""``RedisClusterCache`` — ``CacheBackend`` au-dessus de Redis Cluster / Valkey.
|
|
2
|
+
|
|
3
|
+
Le ``redis-py`` (≥ 5.2) embarque nativement ``RedisCluster`` — pas
|
|
4
|
+
besoin de ``redis-py-cluster``. La même classe pilote indifféremment
|
|
5
|
+
**Redis Cluster OSS** et **Valkey Cluster** (le wire-protocol est
|
|
6
|
+
identique).
|
|
7
|
+
|
|
8
|
+
Restrictions cluster (par rapport à un Redis single-node) :
|
|
9
|
+
|
|
10
|
+
- **Pas de transactions multi-key** sauf si toutes les keys sont sur
|
|
11
|
+
le même slot. Utiliser les **hash tags** ``{tag}:key`` pour forcer
|
|
12
|
+
des keys liées (ex: ``{user:42}:profile`` et ``{user:42}:counters``)
|
|
13
|
+
sur le même slot.
|
|
14
|
+
- **Pas de SELECT db** (Redis Cluster n'a qu'une DB 0). Le namespace
|
|
15
|
+
doit passer par un préfixe de key (``svc:env:bucket:key``).
|
|
16
|
+
- **Pas de FLUSHALL trivial** : le wrapper itère sur tous les nodes
|
|
17
|
+
pour faire FLUSHDB par node (utilisé en tests uniquement).
|
|
18
|
+
|
|
19
|
+
Pour les patterns multi-shards (lecture en éventail vers tous les
|
|
20
|
+
shards), redis-py expose ``cluster_nodes()`` et permet d'instancier
|
|
21
|
+
un client par node — pas dans le scope de cette brique, à ajouter
|
|
22
|
+
si un consumer en a besoin.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable, Iterable
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from mic.cache.backends import CacheBackend, CacheError
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RedisClusterCache(CacheBackend):
|
|
34
|
+
"""Cache distribué multi-shards via Redis Cluster / Valkey Cluster.
|
|
35
|
+
|
|
36
|
+
Implémente :class:`mic.cache.CacheBackend` — les consumers qui
|
|
37
|
+
utilisent déjà ``RedisCache`` peuvent migrer en changeant
|
|
38
|
+
uniquement la construction du backend (pas d'appel-site à
|
|
39
|
+
modifier).
|
|
40
|
+
|
|
41
|
+
Construction :
|
|
42
|
+
|
|
43
|
+
>>> from mic.cache.cluster import RedisClusterCache # doctest: +SKIP
|
|
44
|
+
>>> cache = RedisClusterCache( # doctest: +SKIP
|
|
45
|
+
... startup_nodes=[
|
|
46
|
+
... ("redis-node-1.svc.cluster.local", 6379),
|
|
47
|
+
... ("redis-node-2.svc.cluster.local", 6379),
|
|
48
|
+
... ("redis-node-3.svc.cluster.local", 6379),
|
|
49
|
+
... ],
|
|
50
|
+
... )
|
|
51
|
+
>>> cache.set("key", b"value", ttl_seconds=60) # doctest: +SKIP
|
|
52
|
+
|
|
53
|
+
L'argument ``startup_nodes`` peut contenir 1 seul node — le client
|
|
54
|
+
découvre le reste de la topologie via ``CLUSTER NODES``. Donner
|
|
55
|
+
plusieurs nodes améliore la résilience au bootstrap (si un node
|
|
56
|
+
est down).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
startup_nodes: Iterable[tuple[str, int]] | None = None,
|
|
63
|
+
client: Any = None,
|
|
64
|
+
decode_responses: bool = False,
|
|
65
|
+
require_full_coverage: bool = True,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Construit le cache.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
startup_nodes: liste de tuples ``(host, port)`` pour le
|
|
71
|
+
bootstrap. Le client découvre le reste via
|
|
72
|
+
``CLUSTER NODES``.
|
|
73
|
+
client: ``RedisCluster`` pré-construit (utile pour les
|
|
74
|
+
tests qui mockent). Si fourni, ``startup_nodes`` est
|
|
75
|
+
ignoré.
|
|
76
|
+
decode_responses: si True, les valeurs lues sont décodées
|
|
77
|
+
en str. Default False (bytes), aligné avec
|
|
78
|
+
:class:`RedisCache`.
|
|
79
|
+
require_full_coverage: si True (default), le client refuse
|
|
80
|
+
de booter si un slot du cluster est non-couvert.
|
|
81
|
+
Mettre à False uniquement pour des opérations de
|
|
82
|
+
maintenance (rebalance en cours).
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
CacheError: si ``redis-py`` n'est pas installé, ou si
|
|
86
|
+
ni ``startup_nodes`` ni ``client`` n'est fourni.
|
|
87
|
+
"""
|
|
88
|
+
if client is not None:
|
|
89
|
+
self._client = client
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if not startup_nodes:
|
|
93
|
+
raise CacheError("RedisClusterCache: must pass either ``startup_nodes`` or ``client``")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
from redis.cluster import ( # noqa: PLC0415 — optional dep guard
|
|
97
|
+
ClusterNode,
|
|
98
|
+
RedisCluster,
|
|
99
|
+
)
|
|
100
|
+
except ImportError as exc:
|
|
101
|
+
raise CacheError(
|
|
102
|
+
"redis-py not installed. Install with: pip install 'mic-struct[redis]'"
|
|
103
|
+
) from exc
|
|
104
|
+
|
|
105
|
+
nodes = [ClusterNode(host=host, port=port) for host, port in startup_nodes] # type: ignore[no-untyped-call]
|
|
106
|
+
self._client = RedisCluster(
|
|
107
|
+
startup_nodes=nodes,
|
|
108
|
+
decode_responses=decode_responses,
|
|
109
|
+
require_full_coverage=require_full_coverage,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def native(self) -> Any:
|
|
114
|
+
"""Le ``redis.cluster.RedisCluster`` natif — escape-hatch pour les
|
|
115
|
+
opérations spécifiques cluster (CLUSTER NODES, KEYSLOT, fan-out
|
|
116
|
+
manuel par node, …). Aligné avec ``.native`` de :mod:`mic.datastorage`.
|
|
117
|
+
"""
|
|
118
|
+
return self._client
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _wrap_errors(op: str, fn: Callable[[], Any]) -> Any:
|
|
122
|
+
try:
|
|
123
|
+
return fn()
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
raise CacheError(f"RedisCluster {op} failed: {exc}") from exc
|
|
126
|
+
|
|
127
|
+
def get(self, key: str) -> bytes | None:
|
|
128
|
+
result = self._wrap_errors("GET", lambda: self._client.get(key))
|
|
129
|
+
if result is None:
|
|
130
|
+
return None
|
|
131
|
+
if isinstance(result, str):
|
|
132
|
+
return result.encode()
|
|
133
|
+
return bytes(result)
|
|
134
|
+
|
|
135
|
+
def set(self, key: str, value: bytes, *, ttl_seconds: int) -> None:
|
|
136
|
+
if ttl_seconds < 1:
|
|
137
|
+
raise CacheError(f"ttl_seconds must be >= 1, got {ttl_seconds}")
|
|
138
|
+
self._wrap_errors(
|
|
139
|
+
"SET",
|
|
140
|
+
lambda: self._client.set(key, value, ex=ttl_seconds),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def set_if_absent(self, key: str, value: bytes, *, ttl_seconds: int) -> bool:
|
|
144
|
+
if ttl_seconds < 1:
|
|
145
|
+
raise CacheError(f"ttl_seconds must be >= 1, got {ttl_seconds}")
|
|
146
|
+
result = self._wrap_errors(
|
|
147
|
+
"SET NX EX",
|
|
148
|
+
lambda: self._client.set(key, value, ex=ttl_seconds, nx=True),
|
|
149
|
+
)
|
|
150
|
+
return bool(result)
|
|
151
|
+
|
|
152
|
+
def delete(self, key: str) -> None:
|
|
153
|
+
self._wrap_errors("DEL", lambda: self._client.delete(key))
|
|
154
|
+
|
|
155
|
+
def clear(self) -> None:
|
|
156
|
+
"""Vide TOUS les shards. **Tests uniquement** — JAMAIS en prod.
|
|
157
|
+
|
|
158
|
+
FLUSHDB n'est pas un broadcast Redis Cluster, il faut le
|
|
159
|
+
router à chaque master node. ``redis-py`` le fait via
|
|
160
|
+
``flushdb(target_nodes=ALL_NODES)``.
|
|
161
|
+
"""
|
|
162
|
+
from redis.cluster import RedisCluster # noqa: PLC0415
|
|
163
|
+
|
|
164
|
+
self._wrap_errors(
|
|
165
|
+
"FLUSHDB ALL_NODES",
|
|
166
|
+
lambda: self._client.flushdb(target_nodes=RedisCluster.ALL_NODES),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------
|
|
170
|
+
# Helpers spécifiques cluster
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def hash_tagged(tag: str, key: str) -> str:
|
|
175
|
+
"""Forge une key qui force le slot via hash tag Redis.
|
|
176
|
+
|
|
177
|
+
Pattern Redis Cluster : si une key contient ``{tag}``, le slot
|
|
178
|
+
est calculé sur ``tag`` uniquement — toutes les keys partageant
|
|
179
|
+
le même tag tombent sur le même slot, donc le même shard.
|
|
180
|
+
Permet les opérations multi-key (MGET, MSET, pipeline,
|
|
181
|
+
transactions) qui sinon échouent en cluster.
|
|
182
|
+
|
|
183
|
+
>>> RedisClusterCache.hash_tagged("user:42", "profile")
|
|
184
|
+
'{user:42}:profile'
|
|
185
|
+
|
|
186
|
+
Convention : utiliser ``hash_tagged(tenant_id, ...)``
|
|
187
|
+
pour grouper toutes les keys d'un tenant — permet de migrer
|
|
188
|
+
un tenant entier vers un shard dédié au moment d'un
|
|
189
|
+
rebalance.
|
|
190
|
+
"""
|
|
191
|
+
return f"{{{tag}}}:{key}"
|