mic-datastore 0.1.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.
Files changed (66) hide show
  1. mic_datastore-0.1.0/.gitignore +28 -0
  2. mic_datastore-0.1.0/PKG-INFO +91 -0
  3. mic_datastore-0.1.0/README.md +47 -0
  4. mic_datastore-0.1.0/mic/cache/__init__.py +37 -0
  5. mic_datastore-0.1.0/mic/cache/backends.py +237 -0
  6. mic_datastore-0.1.0/mic/cache/cluster/__init__.py +35 -0
  7. mic_datastore-0.1.0/mic/cache/cluster/consistent_hash.py +116 -0
  8. mic_datastore-0.1.0/mic/cache/cluster/redis_cluster.py +191 -0
  9. mic_datastore-0.1.0/mic/cache/revoke_blacklist.py +247 -0
  10. mic_datastore-0.1.0/mic/datastorage/__init__.py +98 -0
  11. mic_datastore-0.1.0/mic/datastorage/base.py +316 -0
  12. mic_datastore-0.1.0/mic/datastorage/document/__init__.py +14 -0
  13. mic_datastore-0.1.0/mic/datastorage/document/engine.py +166 -0
  14. mic_datastore-0.1.0/mic/datastorage/document/in_memory.py +104 -0
  15. mic_datastore-0.1.0/mic/datastorage/graph/__init__.py +12 -0
  16. mic_datastore-0.1.0/mic/datastorage/graph/engine.py +131 -0
  17. mic_datastore-0.1.0/mic/datastorage/keyvalue/__init__.py +11 -0
  18. mic_datastore-0.1.0/mic/datastorage/keyvalue/in_memory.py +65 -0
  19. mic_datastore-0.1.0/mic/datastorage/sql/__init__.py +10 -0
  20. mic_datastore-0.1.0/mic/datastorage/sql/engine.py +152 -0
  21. mic_datastore-0.1.0/mic/eventbus/__init__.py +70 -0
  22. mic_datastore-0.1.0/mic/eventbus/base.py +54 -0
  23. mic_datastore-0.1.0/mic/eventbus/in_memory.py +81 -0
  24. mic_datastore-0.1.0/mic/eventbus/redis_streams.py +168 -0
  25. mic_datastore-0.1.0/mic/idempotency/__init__.py +77 -0
  26. mic_datastore-0.1.0/mic/idempotency/store.py +716 -0
  27. mic_datastore-0.1.0/mic/locking/__init__.py +68 -0
  28. mic_datastore-0.1.0/mic/locking/base.py +51 -0
  29. mic_datastore-0.1.0/mic/locking/in_memory.py +64 -0
  30. mic_datastore-0.1.0/mic/locking/lock_handle.py +26 -0
  31. mic_datastore-0.1.0/mic/locking/redis_backend.py +123 -0
  32. mic_datastore-0.1.0/mic/locking/scope.py +58 -0
  33. mic_datastore-0.1.0/mic/outbox/__init__.py +111 -0
  34. mic_datastore-0.1.0/mic/outbox/base.py +161 -0
  35. mic_datastore-0.1.0/mic/outbox/dispatcher.py +345 -0
  36. mic_datastore-0.1.0/mic/outbox/event.py +117 -0
  37. mic_datastore-0.1.0/mic/outbox/in_memory.py +225 -0
  38. mic_datastore-0.1.0/mic/outbox/sanitize.py +130 -0
  39. mic_datastore-0.1.0/mic/outbox/sql.py +412 -0
  40. mic_datastore-0.1.0/mic/py.typed +0 -0
  41. mic_datastore-0.1.0/mic/queue/__init__.py +71 -0
  42. mic_datastore-0.1.0/mic/queue/base.py +99 -0
  43. mic_datastore-0.1.0/mic/queue/in_memory.py +87 -0
  44. mic_datastore-0.1.0/mic/queue/kafka.py +120 -0
  45. mic_datastore-0.1.0/mic/queue/nats.py +161 -0
  46. mic_datastore-0.1.0/mic/read_models/__init__.py +64 -0
  47. mic_datastore-0.1.0/mic/read_models/counter.py +78 -0
  48. mic_datastore-0.1.0/mic/read_models/leaderboard.py +92 -0
  49. mic_datastore-0.1.0/mic/read_models/membership_set.py +86 -0
  50. mic_datastore-0.1.0/mic/read_models/timeline.py +97 -0
  51. mic_datastore-0.1.0/mic/read_models/unique_count.py +73 -0
  52. mic_datastore-0.1.0/mic/realtime/__init__.py +134 -0
  53. mic_datastore-0.1.0/mic/realtime/auth.py +103 -0
  54. mic_datastore-0.1.0/mic/realtime/backend.py +129 -0
  55. mic_datastore-0.1.0/mic/realtime/endpoint.py +140 -0
  56. mic_datastore-0.1.0/mic/realtime/redis_streams.py +141 -0
  57. mic_datastore-0.1.0/mic/realtime/room.py +64 -0
  58. mic_datastore-0.1.0/mic/response_cache/__init__.py +100 -0
  59. mic_datastore-0.1.0/mic/response_cache/_stampede.py +90 -0
  60. mic_datastore-0.1.0/mic/response_cache/_storage.py +109 -0
  61. mic_datastore-0.1.0/mic/response_cache/middleware.py +289 -0
  62. mic_datastore-0.1.0/mic/response_cache/rule.py +106 -0
  63. mic_datastore-0.1.0/mic/shard/__init__.py +63 -0
  64. mic_datastore-0.1.0/mic/shard/selector.py +145 -0
  65. mic_datastore-0.1.0/mic/shard/session_factory.py +99 -0
  66. mic_datastore-0.1.0/pyproject.toml +54 -0
@@ -0,0 +1,28 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ .mypy_cache/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+
9
+ # Build / packaging
10
+ dist/
11
+ build/
12
+ *.egg-info/
13
+ .coverage
14
+ coverage.xml
15
+
16
+ # OS / Editor
17
+ .DS_Store
18
+ Thumbs.db
19
+ .idea/
20
+ .vscode/
21
+ *.swp
22
+ *.bak
23
+
24
+ # Reports / temp
25
+ reports/
26
+ .benchmarks/
27
+ .mutmut-cache/
28
+ .wily/
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: mic-datastore
3
+ Version: 0.1.0
4
+ Summary: MIC — persistance & messaging (datastorage, cache, shard, locking, outbox, eventbus, queue, idempotency, response_cache, read_models, realtime).
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.14
7
+ Requires-Dist: mic-core
8
+ Provides-Extra: document
9
+ Requires-Dist: pymongo<5,>=4.10; extra == 'document'
10
+ Provides-Extra: graph
11
+ Requires-Dist: neo4j<6,>=5.27; extra == 'graph'
12
+ Provides-Extra: kafka
13
+ Requires-Dist: aiokafka<1,>=0.12; extra == 'kafka'
14
+ Provides-Extra: messaging
15
+ Requires-Dist: aiokafka<1,>=0.12; extra == 'messaging'
16
+ Requires-Dist: nats-py<3,>=2.7; extra == 'messaging'
17
+ Provides-Extra: mongo
18
+ Requires-Dist: pymongo<5,>=4.10; extra == 'mongo'
19
+ Provides-Extra: mysql
20
+ Requires-Dist: alembic<2,>=1.13; extra == 'mysql'
21
+ Requires-Dist: pymysql<2,>=1.1; extra == 'mysql'
22
+ Requires-Dist: sqlmodel<0.1,>=0.0.38; extra == 'mysql'
23
+ Provides-Extra: nats
24
+ Requires-Dist: nats-py<3,>=2.7; extra == 'nats'
25
+ Provides-Extra: neo4j
26
+ Requires-Dist: neo4j<6,>=5.27; extra == 'neo4j'
27
+ Provides-Extra: oracle
28
+ Requires-Dist: alembic<2,>=1.13; extra == 'oracle'
29
+ Requires-Dist: oracledb<3,>=2.5; extra == 'oracle'
30
+ Requires-Dist: sqlmodel<0.1,>=0.0.38; extra == 'oracle'
31
+ Provides-Extra: postgres
32
+ Requires-Dist: alembic<2,>=1.13; extra == 'postgres'
33
+ Requires-Dist: psycopg[binary]<4,>=3.2; extra == 'postgres'
34
+ Requires-Dist: sqlmodel<0.1,>=0.0.38; extra == 'postgres'
35
+ Provides-Extra: redis
36
+ Requires-Dist: redis<7,>=5.2; extra == 'redis'
37
+ Provides-Extra: sql
38
+ Requires-Dist: alembic<2,>=1.13; extra == 'sql'
39
+ Requires-Dist: sqlmodel<0.1,>=0.0.38; extra == 'sql'
40
+ Provides-Extra: ws
41
+ Requires-Dist: mic-core[auth]; extra == 'ws'
42
+ Requires-Dist: redis<7,>=5.2; extra == 'ws'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # mic-datastore
46
+
47
+ Briques de **persistance & messaging** du framework MIC. Dépend de `mic-core`
48
+ (DAG `platform → data → core` — jamais l'inverse).
49
+
50
+ ## Modules
51
+
52
+ | Module | Rôle |
53
+ | -- | -- |
54
+ | `mic.datastorage` | Backends de données : `SqlDataStorage`, `DocumentDataStorage` (MongoDB), `GraphDataStorage` (Neo4j), variantes in-memory |
55
+ | `mic.cache` | Cache (in-memory / Redis) + `RevokeBlacklist` (révocation JWT) |
56
+ | `mic.shard` | Sélection de shard / session factory |
57
+ | `mic.locking` | Verrous distribués (in-memory / Redis) |
58
+ | `mic.outbox` | Pattern transactional outbox + dispatcher |
59
+ | `mic.eventbus` | Pub/sub synchrone (in-memory / Redis Streams) |
60
+ | `mic.queue` | Event bus async durable (NATS JetStream / Kafka) |
61
+ | `mic.idempotency` | Store d'idempotence |
62
+ | `mic.response_cache` | Middleware de cache de réponses HTTP |
63
+ | `mic.read_models` | Read-models (compteurs, leaderboards, timelines, HLL) |
64
+ | `mic.realtime` | WebSocket realtime (backend Redis Streams) |
65
+
66
+ Namespace **PEP 420** (pas de `mic/__init__.py`).
67
+
68
+ ## Installation
69
+
70
+ ```bash
71
+ pip install "mic-datastore[postgres,redis]"
72
+ ```
73
+
74
+ ## Extras
75
+
76
+ | Extra | Tire |
77
+ | -- | -- |
78
+ | `sql` | sqlmodel + alembic |
79
+ | `postgres` / `mysql` / `oracle` | `sql` + driver (psycopg / PyMySQL / oracledb) |
80
+ | `mongo` (alias `document`) | pymongo |
81
+ | `neo4j` (alias `graph`) | neo4j |
82
+ | `redis` | redis |
83
+ | `nats` / `kafka` | nats-py / aiokafka |
84
+ | `messaging` | `nats` + `kafka` |
85
+ | `ws` | `redis` + `mic-core[auth]` |
86
+
87
+ ## Versionnage
88
+
89
+ Brique volatile — reste en `0.x` plus longtemps que `mic-core`. Contraindre
90
+ avec `>=0.1,<1`. Release : `make release-pkg PKG=data VERSION=x.y.z` → tag
91
+ `data-vX.Y.Z` (cf. [`../../RELEASING.md`](../../RELEASING.md)).
@@ -0,0 +1,47 @@
1
+ # mic-datastore
2
+
3
+ Briques de **persistance & messaging** du framework MIC. Dépend de `mic-core`
4
+ (DAG `platform → data → core` — jamais l'inverse).
5
+
6
+ ## Modules
7
+
8
+ | Module | Rôle |
9
+ | -- | -- |
10
+ | `mic.datastorage` | Backends de données : `SqlDataStorage`, `DocumentDataStorage` (MongoDB), `GraphDataStorage` (Neo4j), variantes in-memory |
11
+ | `mic.cache` | Cache (in-memory / Redis) + `RevokeBlacklist` (révocation JWT) |
12
+ | `mic.shard` | Sélection de shard / session factory |
13
+ | `mic.locking` | Verrous distribués (in-memory / Redis) |
14
+ | `mic.outbox` | Pattern transactional outbox + dispatcher |
15
+ | `mic.eventbus` | Pub/sub synchrone (in-memory / Redis Streams) |
16
+ | `mic.queue` | Event bus async durable (NATS JetStream / Kafka) |
17
+ | `mic.idempotency` | Store d'idempotence |
18
+ | `mic.response_cache` | Middleware de cache de réponses HTTP |
19
+ | `mic.read_models` | Read-models (compteurs, leaderboards, timelines, HLL) |
20
+ | `mic.realtime` | WebSocket realtime (backend Redis Streams) |
21
+
22
+ Namespace **PEP 420** (pas de `mic/__init__.py`).
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ pip install "mic-datastore[postgres,redis]"
28
+ ```
29
+
30
+ ## Extras
31
+
32
+ | Extra | Tire |
33
+ | -- | -- |
34
+ | `sql` | sqlmodel + alembic |
35
+ | `postgres` / `mysql` / `oracle` | `sql` + driver (psycopg / PyMySQL / oracledb) |
36
+ | `mongo` (alias `document`) | pymongo |
37
+ | `neo4j` (alias `graph`) | neo4j |
38
+ | `redis` | redis |
39
+ | `nats` / `kafka` | nats-py / aiokafka |
40
+ | `messaging` | `nats` + `kafka` |
41
+ | `ws` | `redis` + `mic-core[auth]` |
42
+
43
+ ## Versionnage
44
+
45
+ Brique volatile — reste en `0.x` plus longtemps que `mic-core`. Contraindre
46
+ avec `>=0.1,<1`. Release : `make release-pkg PKG=data VERSION=x.y.z` → tag
47
+ `data-vX.Y.Z` (cf. [`../../RELEASING.md`](../../RELEASING.md)).
@@ -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
+ ]
@@ -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]