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.
Files changed (65) hide show
  1. mic/cache/__init__.py +37 -0
  2. mic/cache/backends.py +237 -0
  3. mic/cache/cluster/__init__.py +35 -0
  4. mic/cache/cluster/consistent_hash.py +116 -0
  5. mic/cache/cluster/redis_cluster.py +191 -0
  6. mic/cache/revoke_blacklist.py +247 -0
  7. mic/datastorage/__init__.py +98 -0
  8. mic/datastorage/base.py +316 -0
  9. mic/datastorage/document/__init__.py +14 -0
  10. mic/datastorage/document/engine.py +166 -0
  11. mic/datastorage/document/in_memory.py +104 -0
  12. mic/datastorage/graph/__init__.py +12 -0
  13. mic/datastorage/graph/engine.py +131 -0
  14. mic/datastorage/keyvalue/__init__.py +11 -0
  15. mic/datastorage/keyvalue/in_memory.py +65 -0
  16. mic/datastorage/sql/__init__.py +10 -0
  17. mic/datastorage/sql/engine.py +152 -0
  18. mic/eventbus/__init__.py +70 -0
  19. mic/eventbus/base.py +54 -0
  20. mic/eventbus/in_memory.py +81 -0
  21. mic/eventbus/redis_streams.py +168 -0
  22. mic/idempotency/__init__.py +77 -0
  23. mic/idempotency/store.py +716 -0
  24. mic/locking/__init__.py +68 -0
  25. mic/locking/base.py +51 -0
  26. mic/locking/in_memory.py +64 -0
  27. mic/locking/lock_handle.py +26 -0
  28. mic/locking/redis_backend.py +123 -0
  29. mic/locking/scope.py +58 -0
  30. mic/outbox/__init__.py +111 -0
  31. mic/outbox/base.py +161 -0
  32. mic/outbox/dispatcher.py +345 -0
  33. mic/outbox/event.py +117 -0
  34. mic/outbox/in_memory.py +225 -0
  35. mic/outbox/sanitize.py +130 -0
  36. mic/outbox/sql.py +412 -0
  37. mic/py.typed +0 -0
  38. mic/queue/__init__.py +71 -0
  39. mic/queue/base.py +99 -0
  40. mic/queue/in_memory.py +87 -0
  41. mic/queue/kafka.py +120 -0
  42. mic/queue/nats.py +161 -0
  43. mic/read_models/__init__.py +64 -0
  44. mic/read_models/counter.py +78 -0
  45. mic/read_models/leaderboard.py +92 -0
  46. mic/read_models/membership_set.py +86 -0
  47. mic/read_models/timeline.py +97 -0
  48. mic/read_models/unique_count.py +73 -0
  49. mic/realtime/__init__.py +134 -0
  50. mic/realtime/auth.py +103 -0
  51. mic/realtime/backend.py +129 -0
  52. mic/realtime/endpoint.py +140 -0
  53. mic/realtime/redis_streams.py +141 -0
  54. mic/realtime/room.py +64 -0
  55. mic/response_cache/__init__.py +100 -0
  56. mic/response_cache/_stampede.py +90 -0
  57. mic/response_cache/_storage.py +109 -0
  58. mic/response_cache/middleware.py +289 -0
  59. mic/response_cache/rule.py +106 -0
  60. mic/shard/__init__.py +63 -0
  61. mic/shard/selector.py +145 -0
  62. mic/shard/session_factory.py +99 -0
  63. mic_datastore-0.1.0.dist-info/METADATA +91 -0
  64. mic_datastore-0.1.0.dist-info/RECORD +65 -0
  65. 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}"