feat(apps): archive vers Garage puis supprime les chunks anciens de reading

Nouveau module app.etl.reading_retention : pour chaque chunk de `reading` entièrement plus
vieux que APP_READING_RETENTION_DAYS (1095 jours), export CSV gzip reproductible vers Garage
(SSE-C, sha256 en métadonnées), relecture et comparaison, puis drop_chunks ciblé sur ce seul
chunk dans une transaction dédiée. --dry-run. Réglages APP_S3_* optionnels, jamais exigés par
l'API. DAG Airflow `retention` quotidien à 03h20. 44 tests unitaires sans réseau ni base,
tests d'intégrité du DAG, vérification --help dans l'image Airflow en CI. Docs 40-data et
20-backend.

Closes #36
This commit is contained in:
Johan LEROY
2026-09-24 10:36:09 +02:00
parent 0462dd01ba
commit fa815f49b6
10 changed files with 1292 additions and 14 deletions
+10
View File
@@ -104,6 +104,16 @@ démarre ne prouve rien sur la base, la première connexion réelle a lieu au pr
| `APP_TRUST_PROXY_HEADERS` | `false` | À vrai derrière un proxy, sinon le compteur par IP devient global |
| `APP_EXPOSE_API_DOCS` | déduit | Faux en `staging` et `prod` si non renseigné |
| `APP_METRICS_TOKEN` | absent | Si présent et non vide, `/metrics` exige `Authorization: Bearer`. Vide vaut absent |
| `APP_S3_ENDPOINT_URL` | absent | Endpoint S3 des archives ; `http://garage:3900` posé par Compose sur `airflow-scheduler`. Vide vaut absent |
| `APP_S3_REGION` | `garage` | Région déclarée au client S3 |
| `APP_S3_ACCESS_KEY` | absent | Identifiant de la clé Garage. Vide vaut absent |
| `APP_S3_SECRET_KEY` | absent | Secret de la clé Garage, `SecretStr`. Vide vaut absent |
| `APP_S3_BUCKET` | absent | Bucket des archives, `enervision-archives` en Compose. Vide vaut absent |
| `APP_S3_SSE_KEY` | absent | Base64 de 32 octets, clé SSE-C des archives, `SecretStr`. Vide vaut absent |
| `APP_READING_RETENTION_DAYS` | `1095` | Profondeur de `reading` en base chaude, 30 jours minimum |
L'API n'exige aucun des réglages `APP_S3_*` ni `APP_READING_RETENTION_DAYS` : seul
`app.etl.reading_retention` les réclame, et refuse de partir sans endpoint, clés et bucket.
Cinq gardes refusent de démarrer plutôt que de laisser passer une erreur silencieuse :
secret de moins de 32 caractères ou laissé à sa valeur d'exemple, `debug` en `staging` ou
+35 -9
View File
@@ -16,8 +16,9 @@ L'ingestion des **mesures** est implémentée pour les deux sources du MVP, le d
l'API Mock. Celle des **alertes** de l'API Mock, `/alerts`, reste à faire : voir
l'[ADR 0006](../adr/0006-moteur-de-regles-dans-le-backend.md). Les alertes `source='enervision'`,
elles, sont produites par la détection interne, désormais ordonnancée par le DAG Airflow `alertes`
(issue #116). L'orchestration de l'ingestion, les agrégats continus, la compression et la
rétention restent des cibles.
(issue #116). L'orchestration de l'ingestion, les agrégats continus et la compression restent
des cibles. La rétention de `reading` est faite : chaque chunk plus vieux que la borne est exporté
vers Garage puis supprimé (issue #36, section « Rétention et archivage » ci-dessous).
## Trois emplacements, trois rôles
@@ -27,7 +28,7 @@ au mauvais endroit ne s'exécute jamais, ou s'exécute deux fois.
| Emplacement | Contenu | Quand ça s'exécute |
|---|---|---|
| `db/init/` | Extensions, bases annexes | **Une seule fois**, à la première initialisation du conteneur, quand `PGDATA` est vide. Ne rejoue jamais |
| `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : rétention, compression | À la main, aujourd'hui vide |
| `db/migrations/` | SQL versionné qui ne découle pas du schéma applicatif : compression. La rétention de `reading` n'y est pas : une politique TimescaleDB ignorerait l'export, elle vit dans `apps/backend/app/etl/reading_retention.py`, ordonnancée par le DAG `retention` ([ADR 0019](../adr/0019-stockage-objet-garage-et-cycle-de-vie-des-mesures.md)) | À la main, aujourd'hui vide |
| `apps/backend/alembic/` | Le schéma exposé par l'API, et lui seul | `alembic upgrade head`, c'est `Base.metadata` qui fait foi |
Une hypertable relève des deux derniers : **Alembic crée la table, et le `create_hypertable()`
@@ -75,8 +76,9 @@ Les mécanismes d'ingestion sont maintenant implémentés pour les deux sources
Les traitements sont actuellement exécutables directement depuis le backend.
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus,
la compression et les politiques de rétention.
L'orchestration avec Apache Airflow reste une cible, tout comme les agrégats continus et la
compression. La rétention est faite : le DAG `retention` exporte chaque chunk de `reading` plus
vieux que `READING_RETENTION_DAYS` vers Garage, puis le supprime.
```mermaid
flowchart LR
@@ -91,7 +93,8 @@ flowchart LR
hy -.-> agg[("Agrégat continu")]
hy -.-> comp["Compression"]
hy -.-> ret["Rétention"]
hy --> ret["Rétention : export CSV gzip vers Garage, puis drop_chunks"]
ret --> garage[("Garage S3")]
agg -.-> backend["API FastAPI"]
agg -.-> graf["Grafana"]
@@ -104,6 +107,26 @@ Les flèches pointillées représentent les éléments encore prévus comme cibl
Les lectures de l'API et de Grafana viseront l'agrégat continu, pas la table brute : c'est tout
l'intérêt de TimescaleDB, et cela doit rester vrai quand les volumes augmenteront.
### Rétention et archivage (issue #36)
Statut : `Fait`.
`apps/backend/app/etl/reading_retention.py`, ordonnancé chaque nuit à 03:20 UTC par le DAG
`retention`, sélectionne dans `timescaledb_information.chunks` les chunks de `reading` dont
`range_end` est antérieur ou égal à `now() - READING_RETENTION_DAYS` : seul un chunk entièrement
plus vieux que la borne est éligible. Chaque chunk est lu via l'hypertable (`WHERE timestamp >=
range_start AND timestamp < range_end`), jamais via la table interne, sérialisé en CSV gzip
reproductible (jsonb et tableaux en JSON trié), puis écrit chiffré SSE-C sous la clé
`reading/<année>/reading_<début>_<fin>.csv.gz`, bornes UTC compactes. L'objet est relu et son
sha256 comparé à celui du corps envoyé ; en cas d'écart le chunk est conservé. Seulement alors
`drop_chunks('reading', older_than => range_end, newer_than => range_start)` supprime ce chunk et
lui seul, dans une transaction dédiée et courte : `drop_chunks` pose un verrou exclusif sur
`reading`, `site` et `dataset` jusqu'au COMMIT. Un objet déjà présent avec le même sha256 n'est pas
réécrit et un chunk supprimé n'est plus éligible : rejouer le DAG est sans effet, `--dry-run` liste
et mesure sans rien écrire. Le premier passage en production archive les chunks de janvier à
septembre 2023 ; la démo, ancrée au 31/12/2024, n'est pas touchée. Restauration manuelle :
télécharger l'objet avec la clé SSE-C, `gunzip`, `COPY` dans `reading` ; aucune commande fournie.
## Tables d'authentification
Statut : `Fait`.
@@ -239,8 +262,9 @@ colonne de temps : les index déclarés dans la révision le couvrent déjà.
devient ininterprétable dès le premier changement d'heure.
- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle
`timestamp` : c'est un nom de colonne, son type reste `timestamptz`.
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
elles ne découlent pas du schéma applicatif.
- **Les politiques de compression** vont dans `db/migrations/`, pas dans Alembic : elles ne
découlent pas du schéma applicatif. La rétention de `reading` est un traitement ETL
(`reading_retention.py`), pas une politique TimescaleDB : elle doit exporter avant de supprimer.
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
`alembic revision --autogenerate` ne le voit pas et génère un `drop` de sa table.
@@ -251,7 +275,9 @@ livrés : ce qui suit porte sur leur exploitation, plus sur leur forme.
- **Quelle granularité** conserver à long terme à l'ingestion : seconde, minute ou quart d'heure.
- **Quels agrégats continus** créer et sur quelles fenêtres.
- **Quelle profondeur de rétention** conserver en données brutes et à partir de quand compresser.
- **Quelle profondeur de rétention** : répondu par l'issue #36. Trois ans en base chaude par
défaut (`READING_RETENTION_DAYS`, 1095 jours) ; au-delà, les chunks sont archivés en CSV gzip
sur Garage, chiffrés SSE-C, puis supprimés. Reste ouvert : à partir de quand compresser.
- **Multi-tenant ou non** : un site appartient-il à un client et faut-il cloisonner les lectures.
## Modélisation détaillée des données