Metadata-Version: 2.5
Name: mic-datastore
Version: 0.1.28
Summary: MIC — persistance, messaging et tâches périodiques (datastorage, cache, locking, outbox, queue, scheduling, realtime).
Project-URL: Homepage, https://mic.uman.social/
Project-URL: Documentation, https://mic.uman.social/
Project-URL: Repository, https://gitlab.com/code-idea/code-libs/python/mic/mic-struct
Project-URL: Issues, https://gitlab.com/code-idea/code-libs/python/mic/mic-struct/-/issues
Project-URL: Changelog, https://gitlab.com/code-idea/code-libs/python/mic/mic-struct/-/blob/master/CHANGELOG.md
Project-URL: Copyright, http://uman.social
Author: UMAN
License-Expression: MIT
Requires-Python: >=3.14
Requires-Dist: mic-core
Provides-Extra: document
Requires-Dist: pymongo<5,>=4.18.2; extra == 'document'
Provides-Extra: event-replay
Requires-Dist: clickhouse-connect<2,>=1.9; extra == 'event-replay'
Requires-Dist: mic-core[event-replay]; extra == 'event-replay'
Provides-Extra: graph
Requires-Dist: neo4j<7,>=6.3; extra == 'graph'
Provides-Extra: kafka
Requires-Dist: aiokafka<1,>=0.12; extra == 'kafka'
Provides-Extra: messaging
Requires-Dist: aiokafka<1,>=0.12; extra == 'messaging'
Requires-Dist: nats-py<3,>=2.7; extra == 'messaging'
Provides-Extra: mongo
Requires-Dist: pymongo<5,>=4.18.2; extra == 'mongo'
Provides-Extra: mysql
Requires-Dist: alembic<2,>=1.13; extra == 'mysql'
Requires-Dist: pymysql<2,>=1.1; extra == 'mysql'
Requires-Dist: sqlmodel<0.1,>=0.0.38; extra == 'mysql'
Provides-Extra: nats
Requires-Dist: nats-py<3,>=2.7; extra == 'nats'
Provides-Extra: neo4j
Requires-Dist: neo4j<7,>=6.3; extra == 'neo4j'
Provides-Extra: oracle
Requires-Dist: alembic<2,>=1.13; extra == 'oracle'
Requires-Dist: oracledb<27,>=26; extra == 'oracle'
Requires-Dist: sqlmodel<0.1,>=0.0.38; extra == 'oracle'
Provides-Extra: postgres
Requires-Dist: alembic<2,>=1.13; extra == 'postgres'
Requires-Dist: psycopg[binary]<4,>=3.2; extra == 'postgres'
Requires-Dist: sqlmodel<0.1,>=0.0.38; extra == 'postgres'
Provides-Extra: redis
Requires-Dist: redis<9,>=5.2; extra == 'redis'
Provides-Extra: sql
Requires-Dist: alembic<2,>=1.13; extra == 'sql'
Requires-Dist: sqlmodel<0.1,>=0.0.38; extra == 'sql'
Provides-Extra: ws
Requires-Dist: mic-core[auth]; extra == 'ws'
Requires-Dist: redis<9,>=5.2; extra == 'ws'
Provides-Extra: ws-oidc
Requires-Dist: mic-core[auth]; extra == 'ws-oidc'
Requires-Dist: mic-core[oidc]; extra == 'ws-oidc'
Requires-Dist: redis<9,>=5.2; extra == 'ws-oidc'
Description-Content-Type: text/markdown

# mic-datastore

Briques de **persistance & messaging** du framework MIC. Dépend de `mic-core`
(DAG `platform → data → core` — jamais l'inverse).

La page [Hors périmètre][out-of-scope] distingue les adaptateurs livrés par MIC
des bases, brokers, moteurs et politiques que l'application ou son
infrastructure doit exploiter. Le guide [Quitter MIC][leaving-mic] décrit les
stratégies de conservation, migration, drainage ou reconstruction des états.

[out-of-scope]: https://gitlab.com/code-idea/code-libs/python/mic/mic-struct/-/blob/develop/docs/guides/out-of-scope.md
[leaving-mic]: https://gitlab.com/code-idea/code-libs/python/mic/mic-struct/-/blob/develop/docs/guides/leaving-mic.md

## Prérequis Python

`mic-datastore` exige Python 3.14 ou une version ultérieure. CPython 3.14 est
la cible actuellement qualifiée par un gate CI bloquant ; Python 3.13 et les
versions antérieures ne sont pas pris en charge. Consultez la
[politique Python][python-runtime-policy] avant une adoption sur un hébergement
dont l'environnement d'exécution ou l'image de base n'est pas sous votre contrôle.

[python-runtime-policy]: https://gitlab.com/code-idea/code-libs/python/mic/mic-struct/-/blob/develop/docs/guides/python-runtime-policy.md

## Modules

| Module | Rôle |
| -- | -- |
| `mic.datastorage` | Backends de données : `SqlDataStorage`, `DocumentDataStorage` (MongoDB), `GraphDataStorage` (Neo4j), variantes in-memory |
| `mic.cache` | Cache (in-memory / Redis) + `RevokeBlacklist` (révocation JWT) |
| `mic.shard` | Sélection de shard / session factory |
| `mic.locking` | Verrous distribués (in-memory / Redis) |
| `mic.outbox` | Pattern transactional outbox + dispatcher |
| `mic.eventbus` | Pub/sub synchrone (in-memory / Redis Streams) |
| `mic.queue` | Event bus async durable (NATS JetStream / Kafka) |
| `mic.idempotency` | Store d'idempotence |
| `mic.response_cache` | Middleware de cache de réponses HTTP |
| `mic.read_models` | Read-models (compteurs, leaderboards, timelines, HLL) |
| `mic.realtime` | WebSocket realtime (backend Redis Streams) |
| `mic.scheduling` | Tâches périodiques : verrou, checkpoint, suivi et alerte de silence |

Namespace **PEP 420** (pas de `mic/__init__.py`).

## Porte SQL explicite

`RelationalStore` et `QuerySpec` couvrent le CRUD portable. Lorsqu'un agrégat
exige des contraintes, verrous, CTE ou requêtes propres au moteur, MIC autorise
une descente explicite via la session fournie par `SqlDataStorage`.

Le guide [Porte SQL explicite][sql-escape-hatch] fixe les garanties à conserver :
même session et même transaction, paramètres liés, portée d'organisation
imposée, erreurs métier stables, observabilité partagée, migrations Alembic et
test sur le dialecte réel.

[sql-escape-hatch]: https://gitlab.com/code-idea/code-libs/python/mic/mic-struct/-/blob/develop/docs/guides/sql-escape-hatch.md

## Ordre de l'outbox

`OutboxDispatcher` et `AsyncOutboxDispatcher` peuvent préserver l'ordre de
chaque `(aggregate_type, aggregate_id)` avec
`preserve_aggregate_order=True`. Une tête en retry ou sous lease bloque ses
propres successeurs, tandis que les autres agrégats continuent en parallèle.
En multi-worker, combinez cette option avec `use_claim=True`.

Le guide [Ordre et rejeu de l'outbox][outbox-ordering-replay] couvre également
la DLQ, les priorités, le rejeu temporel et les headers d'idempotence.

[outbox-ordering-replay]: https://gitlab.com/code-idea/code-libs/python/mic/mic-struct/-/blob/develop/docs/guides/outbox-ordering-and-replay.md

## `mic.realtime` — WebSocket depuis un navigateur

Un navigateur ne peut pas poser d'en-tête `Authorization` sur l'upgrade
WebSocket, et un jeton en query string finit dans les journaux d'accès : le
client passe son jeton en **sous-protocole** et propose en plus un
sous-protocole applicatif, que le serveur doit renvoyer dans l'`accept` (sinon
le navigateur ferme la connexion).

```js
const ws = new WebSocket(url, ["app.v1", `bearer.${jwt}`]);
ws.onclose = (e) => {
  if (e.code === 4401) { /* jeton expiré/invalide : se reconnecter avec un jeton neuf */ }
  if (e.code === 4403) { /* interdit : ne pas réessayer */ }
};
```

```python
from mic.auth import OidcTokenVerifier
from mic.realtime import OidcWebSocketAuthenticator, make_websocket_endpoint

def is_staff(claims):  # emplacement des rôles propre au fournisseur : voir ci-dessous
    return "staff" in claims.extra.get("roles", [])

endpoint = make_websocket_endpoint(
    room=room,
    authenticator=OidcWebSocketAuthenticator(  # extra [ws-oidc]
        verifier=OidcTokenVerifier(
            issuer=..., audience=..., jwks_url=...,
            algorithms=("RS256",),  # défaut ; ex. ("ES256",) selon le fournisseur
        ),
        authorize=is_staff,  # False → 4403
    ),
    subprotocol="app.v1",  # renvoyé seulement si proposé ; jamais `bearer.<jwt>`
    allowed_origins=["https://app.example.org"],  # anti-CSWSH (même hôte toujours admis)
)

async def handler(ws, **params):
    ws.auth.sub  # claims vérifiés renvoyés par l'authenticator
```

Rien n'est propre à un fournisseur : la vérification est de l'OIDC standard
(signature via le JWKS, `iss` et `aud` exacts, `exp`), et les claims non
standard arrivent tels quels dans `claims.extra`. Seul `authorize` sait où
lire les droits, qui varient d'un fournisseur à l'autre, par exemple :

| Fournisseur | Rôles dans le jeton d'accès |
| -- | -- |
| Keycloak | `realm_access.roles`, `resource_access.<client>.roles` |
| Microsoft Entra ID | `roles` (rôles d'application) |
| Okta | `groups` (claim à ajouter au serveur d'autorisation) |
| Auth0 | claim à espace de noms ajouté par une Action (ex. `https://app.example.org/roles`) |
| ZITADEL | `urn:zitadel:iam:org:project:roles` (objet indexé par rôle) |

`OidcTokenVerifier(algorithms=...)` accepte tout algorithme asymétrique
(`ASYMMETRIC_ALGORITHMS` : RS, PS, ES, EdDSA) ; `HS*` et `none` sont refusés.

| Situation | Fermeture |
| -- | -- |
| Jeton absent / invalide | 4401 |
| `authorize` refuse, ou `Origin` hors `allowed_origins` | 4403 |
| Claim `exp` atteint (`close_on_expiry=True`, défaut) | 4401 |

Les refus **acceptent puis ferment aussitôt** (aucune frame métier) : un refus
au handshake devient un HTTP 403 dont le navigateur ne voit qu'un 1006, sans
pouvoir distinguer 4401 de 4403. `refuse_before_accept=True` rétablit le refus
au handshake. Authenticators livrés : `OidcWebSocketAuthenticator` (async,
JWKS vérifié hors de la boucle d'événements, jeton en sous-protocole seul par
défaut) et `ServiceAuthWebSocketAuthenticator` (sync, jetons de service) ; pour
un cas maison, `AsyncWebSocketAuthenticator` + `extract_bearer_token(scope,
query_param=None)`.

`RedisStreamsBackend(client=redis, maxlen=1_000)` plafonne chaque stream de
room via `XADD MAXLEN ~` (défaut `DEFAULT_MAXLEN = 1000`, trim approximatif ;
`maxlen=None` = pas de plafond).

## `mic.scheduling` — tâches périodiques observables

Le planificateur de la plateforme (CronJob Kubernetes, systemd timer, boucle du
service) appelle `run_due()` ; MIC porte les garanties qui doivent rester
identiques quel que soit ce planificateur : exclusion distribuée, reprise,
état d'exécution et détection du silence.

```python
from mic.locking import RedisDistributedLock
from mic.scheduling import (
    PeriodicTaskRunner,
    RedisSchedulingStore,
    SilenceMonitor,
    TaskContext,
    TaskResult,
    periodic,
)

@periodic(
    name="collect:k8s",
    every="15m",
    lock="collect:k8s",
    timeout="10m",
    silence_after="35m",
)
def collect(ctx: TaskContext) -> TaskResult:
    cursor = ctx.checkpoint
    processed, next_cursor = collect_batch(cursor)
    ctx.raise_if_timed_out()  # point d'arrêt sûr entre deux lots
    return TaskResult(processed=processed, checkpoint=next_cursor)

store = RedisSchedulingStore(redis_url="redis://redis:6379/0")
runner = PeriodicTaskRunner(
    lock_backend=RedisDistributedLock(redis_url="redis://redis:6379/0"),
    store=store,
    metrics=metrics_backend,  # MetricsBackend optionnel
)
runner.run_due(collect)

alerts = SilenceMonitor(store=store, metrics=metrics_backend).check([collect])
```

Une tâche en échec ne fait jamais avancer son checkpoint et son exception est
propagée au superviseur. Le timeout est une **échéance coopérative** : le runner
mesure tout dépassement au retour et `TaskContext.raise_if_timed_out()` permet
de l'interrompre entre deux lots. Il ne tue pas un thread Python arbitraire ;
un arrêt forcé reste la responsabilité du superviseur de processus ou du
`activeDeadlineSeconds` d'un CronJob. `SilenceMonitor` doit vivre dans un
processus de supervision distinct si l'on veut détecter la disparition totale
du worker.

Métriques émises via `MetricsBackend` : `mic_scheduling_runs_total`
(`task`, `status`), `mic_scheduling_duration_seconds`,
`mic_scheduling_last_processed`, `mic_scheduling_last_success_timestamp_seconds`
et `mic_scheduling_silent`.

## Installation

```bash
pip install "mic-datastore[postgres,redis]"
```

## Extras

| Extra | Tire |
| -- | -- |
| `sql` | sqlmodel + alembic |
| `postgres` / `mysql` / `oracle` | `sql` + driver (psycopg / PyMySQL / oracledb) |
| `mongo` (alias `document`) | pymongo |
| `neo4j` (alias `graph`) | neo4j |
| `redis` | redis |
| `nats` / `kafka` | nats-py / aiokafka |
| `messaging` | `nats` + `kafka` |
| `ws` | `redis` + `mic-core[auth]` |

## Versionnage

Brique volatile — reste en `0.x` plus longtemps que `mic-core`. Contraindre
avec `>=0.1,<1`. Release : `make release-pkg PKG=data VERSION=x.y.z` → tag
`data-vX.Y.Z` (cf. [`../../RELEASING.md`](../../RELEASING.md)).
