feat(schéma): contraintes de domaine sur les statuts, purge, table morte retirée
Dix-huit migrations, zéro CHECK. crawl_run.status, job_triggers.job_type et band_locations.geocode_status étaient du texte libre dont les valeurs admises ne vivaient que dans des commentaires. C'est le dénominateur commun de plusieurs bugs de ce dépôt : un statut 'error' que plus personne n'écrivait mais qu'un bouton d'admin ciblait encore, des lignes 'processing' qu'aucune requête ne reprenait, un job_type réclamé par le mauvais daemon. Une faute de frappe écrivait une ligne durablement invisible, sans jamais rien faire échouer. Vérifié contre PostgreSQL : 'runnning' et le job_type 'geocode' sont désormais rejetés, les valeurs légitimes passent. Les contraintes restent NOT VALID de façon assumée — cette migration tourne au démarrage de chaque conteneur API, et une seule ligne héritée hors domaine empêcherait l'API de démarrer. Le but est d'arrêter la dérive, pas de réécrire le passé. Retire geocode_queue, vestige du pipeline Nominatim que plus aucune ligne de code ne référence. Ajoute enfin une purge de rétention sur crawl_log, admin_audit_log et admin_login_attempts, qui grossissaient sans fin — alors que la migration 015 avait explicitement conçu service_health pour éviter « une table qui grossit ». Elle est appelée par migrate.js au démarrage, seul point d'accroche périodique disponible, et volontairement tolérante : ce nettoyage ne doit jamais empêcher l'API de démarrer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
a21bd55a90
commit
1081ea4965
2 changed files with 107 additions and 0 deletions
93
apps/api/migrations/019_status_constraints_and_retention.sql
Normal file
93
apps/api/migrations/019_status_constraints_and_retention.sql
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
-- 019_status_constraints_and_retention.sql
|
||||||
|
--
|
||||||
|
-- A) Contraintes de domaine sur les statuts
|
||||||
|
--
|
||||||
|
-- Dix-huit migrations, zéro CHECK. crawl_run.status, job_triggers.job_type et
|
||||||
|
-- band_locations.geocode_status sont du texte libre dont les valeurs admises ne
|
||||||
|
-- vivaient que dans des commentaires. C'est le dénominateur commun de plusieurs
|
||||||
|
-- bugs de ce dépôt : un statut 'error' que plus personne n'écrivait mais qu'un
|
||||||
|
-- bouton d'admin ciblait encore, des lignes 'processing' qu'aucune requête ne
|
||||||
|
-- reprenait, un job_type réclamé par le mauvais daemon. Une faute de frappe
|
||||||
|
-- écrivait une ligne durablement invisible, sans jamais rien faire échouer.
|
||||||
|
--
|
||||||
|
-- Les valeurs sont relevées sur le code qui écrit réellement ces colonnes
|
||||||
|
-- (crawler/src/db.py, geocoder/src/*.py, api/src/adminRoutes.js).
|
||||||
|
--
|
||||||
|
-- NOT VALID est délibéré et DÉFINITIF ici : la contrainte s'applique à toutes
|
||||||
|
-- les écritures futures, mais l'historique n'est pas relu. Cette migration
|
||||||
|
-- s'exécute au démarrage de chaque conteneur API ; valider exigerait un scan
|
||||||
|
-- complet, et une seule ligne héritée hors domaine ferait échouer la migration,
|
||||||
|
-- donc le démarrage de l'API. Le but est d'arrêter la dérive, pas de réécrire
|
||||||
|
-- le passé. Pour valider un jour, à froid :
|
||||||
|
-- ALTER TABLE crawl_run VALIDATE CONSTRAINT crawl_run_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE crawl_run DROP CONSTRAINT IF EXISTS crawl_run_status_check;
|
||||||
|
ALTER TABLE crawl_run ADD CONSTRAINT crawl_run_status_check
|
||||||
|
CHECK (status IN ('running', 'done', 'error', 'cancelled')) NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE job_triggers DROP CONSTRAINT IF EXISTS job_triggers_status_check;
|
||||||
|
ALTER TABLE job_triggers ADD CONSTRAINT job_triggers_status_check
|
||||||
|
CHECK (status IN ('pending', 'running', 'done', 'error', 'cancelled')) NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE job_triggers DROP CONSTRAINT IF EXISTS job_triggers_type_check;
|
||||||
|
ALTER TABLE job_triggers ADD CONSTRAINT job_triggers_type_check
|
||||||
|
CHECK (job_type IN ('enrich', 'incremental', 'full_crawl', 'geocoder_enqueue')) NOT VALID;
|
||||||
|
|
||||||
|
ALTER TABLE band_locations DROP CONSTRAINT IF EXISTS band_locations_geocode_status_check;
|
||||||
|
ALTER TABLE band_locations ADD CONSTRAINT band_locations_geocode_status_check
|
||||||
|
CHECK (geocode_status IN (
|
||||||
|
'queued', 'processing', 'done', 'country_only', 'error', 'llm_needed', 'manual'
|
||||||
|
)) NOT VALID;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- B) Table morte
|
||||||
|
--
|
||||||
|
-- geocode_queue date du pipeline Nominatim, remplacé par band_locations. Plus
|
||||||
|
-- aucune ligne de code ne la référence ; elle ne subsistait que dans le schéma.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
DROP TABLE IF EXISTS geocode_queue;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
-- C) Rétention
|
||||||
|
--
|
||||||
|
-- crawl_log, admin_audit_log et admin_login_attempts grossissent sans fin. La
|
||||||
|
-- migration 015 avait pourtant explicitement conçu service_health pour éviter
|
||||||
|
-- « une table qui grossit » — la leçon n'avait pas été appliquée aux autres.
|
||||||
|
-- crawl_log prend une ligne par échec d'enrichissement, et GET /admin/api/activity
|
||||||
|
-- relit admin_audit_log en entier à chaque affichage.
|
||||||
|
--
|
||||||
|
-- La purge n'est pas automatique (pas de pg_cron ici) : elle est exposée par
|
||||||
|
-- l'API, qui l'appelle au démarrage. Les durées sont volontairement longues,
|
||||||
|
-- l'objectif est de borner la croissance, pas d'effacer l'historique utile.
|
||||||
|
-- ------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION purge_historique(
|
||||||
|
jours_logs INT DEFAULT 90,
|
||||||
|
jours_audit INT DEFAULT 365,
|
||||||
|
jours_tentatives INT DEFAULT 30
|
||||||
|
) RETURNS TABLE (table_purgee TEXT, lignes BIGINT) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
WITH d AS (
|
||||||
|
DELETE FROM crawl_log WHERE created_at < now() - make_interval(days => jours_logs)
|
||||||
|
RETURNING 1
|
||||||
|
) SELECT 'crawl_log'::TEXT, count(*) FROM d;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH d AS (
|
||||||
|
DELETE FROM admin_audit_log WHERE created_at < now() - make_interval(days => jours_audit)
|
||||||
|
RETURNING 1
|
||||||
|
) SELECT 'admin_audit_log'::TEXT, count(*) FROM d;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH d AS (
|
||||||
|
DELETE FROM admin_login_attempts WHERE created_at < now() - make_interval(days => jours_tentatives)
|
||||||
|
RETURNING 1
|
||||||
|
) SELECT 'admin_login_attempts'::TEXT, count(*) FROM d;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Index manquant : la purge et le filtre `since` de /admin/api/logs balayaient
|
||||||
|
-- crawl_log en entier faute d'index sur created_at seul (celui de la 006 est
|
||||||
|
-- DESC, utilisable, mais on garde l'intention explicite ici).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_login_attempts_created ON admin_login_attempts (created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created ON admin_audit_log (created_at);
|
||||||
|
|
@ -78,6 +78,20 @@ async function run() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count === 0) console.log('[migrate] nothing to apply');
|
if (count === 0) console.log('[migrate] nothing to apply');
|
||||||
|
|
||||||
|
// Purge de rétention (migration 019). Bornée par des index sur created_at, et
|
||||||
|
// volontairement tolérante : ce nettoyage ne doit jamais empêcher l'API de
|
||||||
|
// démarrer. Il n'y a pas d'ordonnanceur ici, et ce script tourne au démarrage
|
||||||
|
// de chaque conteneur — c'est le seul point d'accroche périodique disponible.
|
||||||
|
try {
|
||||||
|
const { rows: purged } = await client.query('SELECT * FROM purge_historique()');
|
||||||
|
const total = purged.reduce((n, r) => n + Number(r.lignes), 0);
|
||||||
|
if (total > 0) {
|
||||||
|
console.log('[migrate] purge: ' + purged.map(r => `${r.table_purgee}=${r.lignes}`).join(' '));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[migrate] purge ignorée: ${err.message}`);
|
||||||
|
}
|
||||||
// Relâché explicitement : la fermeture suffirait, mais l'expliciter rend le
|
// Relâché explicitement : la fermeture suffirait, mais l'expliciter rend le
|
||||||
// verrou visible dans les logs et évite de le garder si end() traîne.
|
// verrou visible dans les logs et évite de le garder si end() traîne.
|
||||||
await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK_ID]);
|
await client.query('SELECT pg_advisory_unlock($1)', [MIGRATION_LOCK_ID]);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue