Files
CollectFlow/src/features/admin/api/sync-settings.ts
T
Claude 0bd7ec9798 feat(admin): synchronisation nocturne des fournisseurs, paramétrable
Page /admin/synchronisation : choisir quels fournisseurs sont cadencés, pour
quelle source, et suivre l'avancement.

ORDRE DE PASSAGE. La file est « le plus anciennement synchronisé d'abord », en
ignorant ceux déjà traités depuis l'ouverture de la fenêtre. Il en découle,
sans curseur ni compteur de tour : la nuit 2 reprend là où la nuit 1 s'est
arrêtée, le tour recommence quand tout le monde est passé, et un fournisseur
en échec garde une date ancienne donc repasse en tête.

DEUX FILES. SQL (~43 s sur le plus gros) et Qlik (jusqu'à 8 min, sur un serveur
qui abandonne quand on le sollicite trop) ont leur propre activation et leur
propre rythme. Une extraction Qlik est intercalée tous les N fournisseurs SQL,
sinon elle ne démarrerait jamais avant que la file SQL soit vide. Un plafond
par nuit et un âge minimal évitent de refaire chaque nuit des agrégats
mensuels.

Détails qui comptent :
- La fenêtre signifie « ne plus DÉMARRER après l'heure de fin » : une
  extraction en cours n'est jamais coupée. Le passage par minuit est géré.
- L'horodatage est écrit même en cas d'échec, sans quoi le fournisseur fautif
  bloquerait la file derrière lui.
- Garde anti-boucle : l'avancement repose sur cette écriture, volontairement
  non bloquante ; si elle échoue, le round s'arrête au lieu de reprendre le
  même fournisseur indéfiniment.
- Un fournisseur sans article est désactivé automatiquement, motif affiché,
  réactivable — plutôt que réessayé chaque nuit.
- Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
  Qlik la nuit suivante sans décision explicite.

Déclenchement par minuteur interne (instrumentation.ts), battement d'une
minute. Aucune configuration côté hôte, se réarme au démarrage ; en
contrepartie rien ne tourne si l'application est arrêtée.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-07 07:28:11 +00:00

105 lines
3.8 KiB
TypeScript

/**
* CollectFlow — Réglages de la synchronisation nocturne.
*
* Rangés dans `data/.db-config.json`, comme les autres réglages de
* l'application (base, Qlik, API FF), pour qu'il n'y ait qu'un seul endroit à
* sauvegarder et à monter dans le conteneur.
*
* Module **serveur**, pas fichier d'actions : il exporte aussi des valeurs
* (les défauts, le type), ce que `"use server"` interdit.
*/
import "server-only";
import fs from "fs/promises";
import path from "path";
const DATA_DIR = path.join(process.cwd(), "data");
const CONFIG_FILE = path.join(DATA_DIR, ".db-config.json");
export interface SyncSettings {
/**
* Désactivé par défaut : un déploiement ne doit pas se mettre à solliciter
* Qlik et la base la nuit suivante sans que personne l'ait décidé.
*/
actif: boolean;
/** Heure d'ouverture de la fenêtre (0-23). */
heureDebut: number;
/** Heure de fermeture. Aucun fournisseur n'est **démarré** après. */
heureFin: number;
/**
* Nombre maximum d'extractions Qlik par nuit. Chacune peut prendre 8 minutes
* et le serveur abandonne quand il est trop sollicité : ce plafond est la
* principale protection contre une nuit entièrement passée sur Qlik.
*/
qlikParNuit: number;
/** Une extraction Qlik est intercalée tous les N fournisseurs SQL. */
sqlParQlik: number;
/**
* Âge minimal des données Qlik avant de les refaire. Ce sont des agrégats
* mensuels : les rafraîchir toutes les nuits coûterait cher pour rien.
*/
qlikMinJours: number;
}
export const SYNC_SETTINGS_DEFAUT: SyncSettings = {
actif: false,
heureDebut: 0,
heureFin: 5,
qlikParNuit: 6,
sqlParQlik: 20,
qlikMinJours: 7,
};
function borne(v: unknown, min: number, max: number, defaut: number): number {
const n = Number(v);
if (!Number.isFinite(n)) return defaut;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
/** Normalise ce qui vient du fichier : il est éditable à la main. */
function normaliser(brut: Partial<SyncSettings> | undefined): SyncSettings {
const d = SYNC_SETTINGS_DEFAUT;
const s: SyncSettings = {
actif: Boolean(brut?.actif),
heureDebut: borne(brut?.heureDebut, 0, 23, d.heureDebut),
heureFin: borne(brut?.heureFin, 0, 23, d.heureFin),
qlikParNuit: borne(brut?.qlikParNuit, 0, 200, d.qlikParNuit),
sqlParQlik: borne(brut?.sqlParQlik, 1, 500, d.sqlParQlik),
qlikMinJours: borne(brut?.qlikMinJours, 0, 365, d.qlikMinJours),
};
// Une fenêtre de durée nulle ne s'ouvrirait jamais : on revient au défaut
// plutôt que de laisser le planificateur muet sans explication.
if (s.heureDebut === s.heureFin) {
s.heureDebut = d.heureDebut;
s.heureFin = d.heureFin;
}
return s;
}
export async function readSyncSettings(): Promise<SyncSettings> {
try {
const raw = await fs.readFile(CONFIG_FILE, "utf-8");
const cfg = JSON.parse(raw) as { syncNuit?: Partial<SyncSettings> };
return normaliser(cfg?.syncNuit);
} catch {
return { ...SYNC_SETTINGS_DEFAUT };
}
}
export async function saveSyncSettings(patch: Partial<SyncSettings>): Promise<SyncSettings> {
let existant: Record<string, unknown> = {};
try {
existant = JSON.parse(await fs.readFile(CONFIG_FILE, "utf-8"));
} catch {
// Pas encore de fichier : on le crée avec les seuls réglages de synchro.
}
const courant = normaliser((existant.syncNuit as Partial<SyncSettings>) ?? undefined);
const suivant = normaliser({ ...courant, ...patch });
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(CONFIG_FILE, JSON.stringify({ ...existant, syncNuit: suivant }, null, 2));
console.log(`[sync-nuit] réglages enregistrés : ${JSON.stringify(suivant)}`);
return suivant;
}