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.
- mic_datastore-0.1.0/.gitignore +28 -0
- mic_datastore-0.1.0/PKG-INFO +91 -0
- mic_datastore-0.1.0/README.md +47 -0
- mic_datastore-0.1.0/mic/cache/__init__.py +37 -0
- mic_datastore-0.1.0/mic/cache/backends.py +237 -0
- mic_datastore-0.1.0/mic/cache/cluster/__init__.py +35 -0
- mic_datastore-0.1.0/mic/cache/cluster/consistent_hash.py +116 -0
- mic_datastore-0.1.0/mic/cache/cluster/redis_cluster.py +191 -0
- mic_datastore-0.1.0/mic/cache/revoke_blacklist.py +247 -0
- mic_datastore-0.1.0/mic/datastorage/__init__.py +98 -0
- mic_datastore-0.1.0/mic/datastorage/base.py +316 -0
- mic_datastore-0.1.0/mic/datastorage/document/__init__.py +14 -0
- mic_datastore-0.1.0/mic/datastorage/document/engine.py +166 -0
- mic_datastore-0.1.0/mic/datastorage/document/in_memory.py +104 -0
- mic_datastore-0.1.0/mic/datastorage/graph/__init__.py +12 -0
- mic_datastore-0.1.0/mic/datastorage/graph/engine.py +131 -0
- mic_datastore-0.1.0/mic/datastorage/keyvalue/__init__.py +11 -0
- mic_datastore-0.1.0/mic/datastorage/keyvalue/in_memory.py +65 -0
- mic_datastore-0.1.0/mic/datastorage/sql/__init__.py +10 -0
- mic_datastore-0.1.0/mic/datastorage/sql/engine.py +152 -0
- mic_datastore-0.1.0/mic/eventbus/__init__.py +70 -0
- mic_datastore-0.1.0/mic/eventbus/base.py +54 -0
- mic_datastore-0.1.0/mic/eventbus/in_memory.py +81 -0
- mic_datastore-0.1.0/mic/eventbus/redis_streams.py +168 -0
- mic_datastore-0.1.0/mic/idempotency/__init__.py +77 -0
- mic_datastore-0.1.0/mic/idempotency/store.py +716 -0
- mic_datastore-0.1.0/mic/locking/__init__.py +68 -0
- mic_datastore-0.1.0/mic/locking/base.py +51 -0
- mic_datastore-0.1.0/mic/locking/in_memory.py +64 -0
- mic_datastore-0.1.0/mic/locking/lock_handle.py +26 -0
- mic_datastore-0.1.0/mic/locking/redis_backend.py +123 -0
- mic_datastore-0.1.0/mic/locking/scope.py +58 -0
- mic_datastore-0.1.0/mic/outbox/__init__.py +111 -0
- mic_datastore-0.1.0/mic/outbox/base.py +161 -0
- mic_datastore-0.1.0/mic/outbox/dispatcher.py +345 -0
- mic_datastore-0.1.0/mic/outbox/event.py +117 -0
- mic_datastore-0.1.0/mic/outbox/in_memory.py +225 -0
- mic_datastore-0.1.0/mic/outbox/sanitize.py +130 -0
- mic_datastore-0.1.0/mic/outbox/sql.py +412 -0
- mic_datastore-0.1.0/mic/py.typed +0 -0
- mic_datastore-0.1.0/mic/queue/__init__.py +71 -0
- mic_datastore-0.1.0/mic/queue/base.py +99 -0
- mic_datastore-0.1.0/mic/queue/in_memory.py +87 -0
- mic_datastore-0.1.0/mic/queue/kafka.py +120 -0
- mic_datastore-0.1.0/mic/queue/nats.py +161 -0
- mic_datastore-0.1.0/mic/read_models/__init__.py +64 -0
- mic_datastore-0.1.0/mic/read_models/counter.py +78 -0
- mic_datastore-0.1.0/mic/read_models/leaderboard.py +92 -0
- mic_datastore-0.1.0/mic/read_models/membership_set.py +86 -0
- mic_datastore-0.1.0/mic/read_models/timeline.py +97 -0
- mic_datastore-0.1.0/mic/read_models/unique_count.py +73 -0
- mic_datastore-0.1.0/mic/realtime/__init__.py +134 -0
- mic_datastore-0.1.0/mic/realtime/auth.py +103 -0
- mic_datastore-0.1.0/mic/realtime/backend.py +129 -0
- mic_datastore-0.1.0/mic/realtime/endpoint.py +140 -0
- mic_datastore-0.1.0/mic/realtime/redis_streams.py +141 -0
- mic_datastore-0.1.0/mic/realtime/room.py +64 -0
- mic_datastore-0.1.0/mic/response_cache/__init__.py +100 -0
- mic_datastore-0.1.0/mic/response_cache/_stampede.py +90 -0
- mic_datastore-0.1.0/mic/response_cache/_storage.py +109 -0
- mic_datastore-0.1.0/mic/response_cache/middleware.py +289 -0
- mic_datastore-0.1.0/mic/response_cache/rule.py +106 -0
- mic_datastore-0.1.0/mic/shard/__init__.py +63 -0
- mic_datastore-0.1.0/mic/shard/selector.py +145 -0
- mic_datastore-0.1.0/mic/shard/session_factory.py +99 -0
- 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]
|