fix+perf: corrections de revue (currentEcheance, dayOfYear, SWR, last-good, doc apikey)
- vigilance: currentEcheance basée sur productDatetime (jour calme renvoyait J1 à tort)
- normales: dayOfYear extrait en Europe/Paris pour 'now' (UTC mélangeait les jours après minuit)
- meteofrance-auth + CLAUDE.md: header `apikey:` documenté correctement (pas Authorization Bearer)
- cache: SWR — envelope {v, fu}, hard TTL = ttl*6, refresh background avec lock anti-stampede
- vigilance: snapshot last-good (TTL 30j) écrit à chaque fetch, fallback final si MF+ODS KO
- vigilance: nettoyage variable url morte dans fetchOpendatasoft
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e72f25b33d
commit
290f9be7b9
5 changed files with 201 additions and 45 deletions
|
|
@ -19,11 +19,36 @@ function getClient(): Redis {
|
|||
return client;
|
||||
}
|
||||
|
||||
// Envelope SWR : `v` = valeur, `fu` = freshUntil (timestamp ms).
|
||||
// L'entrée Valkey expire via TTL `hardTtl` (= ratio fresh × STALE_RATIO),
|
||||
// `fu` indique la limite "fraîche" en deçà de laquelle aucun refresh n'est tenté.
|
||||
interface SwrEnvelope<T> {
|
||||
v: T;
|
||||
fu: number;
|
||||
}
|
||||
|
||||
// Combien de temps on est prêt à servir une valeur stale après freshUntil,
|
||||
// en multiple du TTL frais. 6× = ex. 5 min frais → on garde 30 min en cache et
|
||||
// refresh en background quand on dépasse 5 min.
|
||||
const STALE_RATIO = 6;
|
||||
|
||||
// Locks in-process pour éviter le stampede en refresh background : N requêtes
|
||||
// concurrentes sur la même clé qui dépasse freshUntil → 1 seul fetch.
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
function isEnvelope<T>(v: unknown): v is SwrEnvelope<T> {
|
||||
return !!v && typeof v === 'object' && 'v' in (v as object) && 'fu' in (v as object);
|
||||
}
|
||||
|
||||
export async function cacheGet<T>(key: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await getClient().get(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
const parsed = JSON.parse(raw);
|
||||
// Compat : ancienne shape (valeur brute sans envelope) → on la traite comme
|
||||
// une valeur fraîche pour la durée résiduelle du TTL natif Valkey.
|
||||
if (isEnvelope<T>(parsed)) return parsed.v;
|
||||
return parsed as T;
|
||||
} catch (err) {
|
||||
console.warn('[cache] get failed for', key, (err as Error).message);
|
||||
return null;
|
||||
|
|
@ -32,22 +57,76 @@ export async function cacheGet<T>(key: string): Promise<T | null> {
|
|||
|
||||
export async function cacheSet<T>(key: string, value: T, ttlSec: number): Promise<void> {
|
||||
try {
|
||||
await getClient().set(key, JSON.stringify(value), 'EX', ttlSec);
|
||||
const env: SwrEnvelope<T> = { v: value, fu: Date.now() + ttlSec * 1000 };
|
||||
await getClient().set(key, JSON.stringify(env), 'EX', ttlSec * STALE_RATIO);
|
||||
} catch (err) {
|
||||
console.warn('[cache] set failed for', key, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stale-while-revalidate.
|
||||
*
|
||||
* - Cache miss : fetch synchrone, set, retourne.
|
||||
* - Cache hit + fresh (now < freshUntil) : retourne immédiatement.
|
||||
* - Cache hit + stale : retourne la valeur stale + déclenche un refresh
|
||||
* background (lock in-process pour éviter le stampede). Si le fetch
|
||||
* background échoue, on log mais on conserve la valeur stale en cache
|
||||
* jusqu'à l'expiration `hardTtl` (= ttlSec × STALE_RATIO).
|
||||
*
|
||||
* Effet : les visiteurs ne paient jamais la latence d'un refetch tant qu'on
|
||||
* a une valeur même stale. Sur Vigilance (5 min fresh, 30 min hard), un
|
||||
* blip MF de 25 min reste invisible pour l'utilisateur.
|
||||
*/
|
||||
export async function cacheOrFetch<T>(
|
||||
key: string,
|
||||
ttlSec: number,
|
||||
fetcher: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cached = await cacheGet<T>(key);
|
||||
if (cached !== null) return cached;
|
||||
const fresh = await fetcher();
|
||||
await cacheSet(key, fresh, ttlSec);
|
||||
return fresh;
|
||||
const raw = await rawGet(key);
|
||||
if (raw === null) {
|
||||
return doFetchAndSet(key, ttlSec, fetcher);
|
||||
}
|
||||
if (!isEnvelope<T>(raw)) {
|
||||
// Ancienne shape : on la sert + on refresh en background pour migrer.
|
||||
triggerBackgroundRefresh(key, ttlSec, fetcher);
|
||||
return raw as T;
|
||||
}
|
||||
if (Date.now() < raw.fu) return raw.v;
|
||||
triggerBackgroundRefresh(key, ttlSec, fetcher);
|
||||
return raw.v;
|
||||
}
|
||||
|
||||
async function rawGet(key: string): Promise<unknown | null> {
|
||||
try {
|
||||
const raw = await getClient().get(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.warn('[cache] get failed for', key, (err as Error).message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doFetchAndSet<T>(key: string, ttlSec: number, fetcher: () => Promise<T>): Promise<T> {
|
||||
const existing = inflight.get(key) as Promise<T> | undefined;
|
||||
if (existing) return existing;
|
||||
const p = (async () => {
|
||||
const fresh = await fetcher();
|
||||
await cacheSet(key, fresh, ttlSec);
|
||||
return fresh;
|
||||
})().finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
function triggerBackgroundRefresh<T>(key: string, ttlSec: number, fetcher: () => Promise<T>): void {
|
||||
if (inflight.has(key)) return;
|
||||
void doFetchAndSet(key, ttlSec, fetcher).catch((err) => {
|
||||
console.warn('[cache] background refresh failed for', key, (err as Error).message);
|
||||
});
|
||||
}
|
||||
|
||||
export async function pingCache(): Promise<boolean> {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
// sans flow refresh) et (b) API Key permanente / longue durée. On utilise (b).
|
||||
//
|
||||
// Configuration via env :
|
||||
// METEOFRANCE_API_KEY → clé permanente, envoyée en header `Authorization: Bearer <key>`
|
||||
// METEOFRANCE_API_KEY → clé permanente, envoyée en header `apikey: <key>`
|
||||
// (PAS `Authorization: Bearer` — c'est le format des
|
||||
// tokens OAuth2 courts, pas des API Keys du portail).
|
||||
//
|
||||
// Quand la clé approche de l'expiration (cf. duration choisie à la création),
|
||||
// régénérer côté portail puis mettre à jour le vault `Infra/Météo France API`,
|
||||
|
|
|
|||
|
|
@ -27,12 +27,19 @@ export interface DailyNormale {
|
|||
}
|
||||
|
||||
// Day-of-year en convention "leap calendar" (1..366), aligné sur le calendrier bissextile.
|
||||
// Les années non-bissextiles : on bascule simplement le 1er mars au doy 60 si pas 29 fév.
|
||||
// Mais pour matcher l'indexation du JSON (1..366), on utilise une convention stable.
|
||||
// Extraction Y/M/D en Europe/Paris pour rester cohérent avec :
|
||||
// - les pages SSR qui formatent en Europe/Paris (todayLabel, productDate),
|
||||
// - les obs climato YYYY-MM-DD (parsées en UTC midnight, donc Paris == J ou J-1
|
||||
// selon offset DST, mais ça représente toujours la "journée Paris" du jour
|
||||
// suivant — assez proche pour le lookup normale).
|
||||
// - `new Date()` (now) : en UTC, getUTC* renvoie hier entre 00h et 01h/02h Paris.
|
||||
// Toujours utiliser le jour Paris-local.
|
||||
export function dayOfYear(date: Date): number {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = date.getUTCMonth() + 1;
|
||||
const d = date.getUTCDate();
|
||||
// sv-SE → format "YYYY-MM-DD"
|
||||
const iso = date.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });
|
||||
const y = parseInt(iso.slice(0, 4), 10);
|
||||
const m = parseInt(iso.slice(5, 7), 10);
|
||||
const d = parseInt(iso.slice(8, 10), 10);
|
||||
const cumulNonLeap = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||||
const cumulLeap = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335];
|
||||
const isLeap = (y % 4 === 0 && y % 100 !== 0) || (y % 400 === 0);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { cacheOrFetch } from './cache';
|
||||
import { cacheOrFetch, cacheGet, cacheSet } from './cache';
|
||||
import type { ColorId, PhenomenonId } from './phenomena';
|
||||
import { COLORS, PHENOMENA } from './phenomena';
|
||||
import { fetchMF, hasMeteoFranceCredentials } from './meteofrance-auth';
|
||||
|
|
@ -23,9 +23,18 @@ const OPENDATASOFT_BASE =
|
|||
'https://public.opendatasoft.com/api/explore/v2.1/catalog/datasets/weatherref-france-vigilance-meteo-departement/records';
|
||||
|
||||
const CACHE_KEY = 'vigilance:snapshot';
|
||||
// Snapshot longue durée, écrit à chaque fetch réussi (peu importe le provider).
|
||||
// Sert de filet de sécurité quand cache short-TTL expiré + tous providers KO.
|
||||
// 30 jours : large, mais c'est la valeur la plus récente qu'on ait obtenue ;
|
||||
// le visiteur voit `productDatetime` qui révèle l'ancienneté du bulletin.
|
||||
const CACHE_KEY_LAST_GOOD = 'vigilance:snapshot:last-good';
|
||||
const LAST_GOOD_TTL = 30 * 24 * 60 * 60;
|
||||
|
||||
async function persistLastGood(snap: VigilanceSnapshot): Promise<void> {
|
||||
await cacheSet(CACHE_KEY_LAST_GOOD, snap, LAST_GOOD_TTL);
|
||||
}
|
||||
|
||||
async function fetchOpendatasoft(): Promise<VigilanceSnapshot> {
|
||||
const url = `${OPENDATASOFT_BASE}?limit=100&offset=0`;
|
||||
const all: VigilanceAlert[] = [];
|
||||
let offset = 0;
|
||||
let total = Infinity;
|
||||
|
|
@ -175,40 +184,69 @@ function pickProvider(): { name: string; fn: () => Promise<VigilanceSnapshot> }
|
|||
return { name: 'opendatasoft', fn: fetchOpendatasoft };
|
||||
}
|
||||
|
||||
async function fetchAndPersist(fn: () => Promise<VigilanceSnapshot>): Promise<VigilanceSnapshot> {
|
||||
const snap = await fn();
|
||||
// Best-effort : on n'attend pas, et on n'échoue pas si Valkey est down.
|
||||
void persistLastGood(snap);
|
||||
return snap;
|
||||
}
|
||||
|
||||
export async function getVigilanceSnapshot(): Promise<VigilanceSnapshot> {
|
||||
const ttl = parseInt(process.env.VIGILANCE_CACHE_TTL ?? '900', 10);
|
||||
const provider = pickProvider();
|
||||
try {
|
||||
return await cacheOrFetch(CACHE_KEY, ttl, provider.fn);
|
||||
return await cacheOrFetch(CACHE_KEY, ttl, () => fetchAndPersist(provider.fn));
|
||||
} catch (e) {
|
||||
// Fallback automatique vers l'autre provider en cas d'erreur (token expiré, MF API down, etc.)
|
||||
if (provider.name === 'meteofrance') {
|
||||
console.warn('[vigilance] MF failed, falling back to opendatasoft:', (e as Error).message);
|
||||
return cacheOrFetch(CACHE_KEY + ':fallback', ttl, fetchOpendatasoft);
|
||||
try {
|
||||
return await cacheOrFetch(CACHE_KEY + ':fallback', ttl, () => fetchAndPersist(fetchOpendatasoft));
|
||||
} catch (e2) {
|
||||
const lastGood = await cacheGet<VigilanceSnapshot>(CACHE_KEY_LAST_GOOD);
|
||||
if (lastGood) {
|
||||
console.warn('[vigilance] both providers failed, serving last-good from', lastGood.fetchedAt);
|
||||
return lastGood;
|
||||
}
|
||||
throw e2;
|
||||
}
|
||||
}
|
||||
// provider == opendatasoft (uniquement) : pas d'autre provider, on tente :last-good
|
||||
const lastGood = await cacheGet<VigilanceSnapshot>(CACHE_KEY_LAST_GOOD);
|
||||
if (lastGood) {
|
||||
console.warn('[vigilance] opendatasoft failed, serving last-good from', lastGood.fetchedAt);
|
||||
return lastGood;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine quelle echeance du bulletin couvre l'instant présent.
|
||||
* Détermine quelle echeance du bulletin couvre "aujourd'hui réel" pour le visiteur.
|
||||
*
|
||||
* Le bulletin Météo France publié à 16h Paris reste valide jusqu'au prochain
|
||||
* bulletin (~6h Paris le lendemain). Pendant la nuit (de minuit à 6h), on se
|
||||
* retrouve techniquement dans le J+1 du bulletin, qui correspond pour
|
||||
* l'utilisateur à "aujourd'hui réel".
|
||||
* Le bulletin Météo France publié à ~16h Paris reste valide jusqu'au prochain
|
||||
* bulletin (~6h Paris le lendemain). Entre minuit et la publication du suivant,
|
||||
* la journée calendaire de `productDatetime` (= "J" du bulletin) correspond à
|
||||
* "hier" pour le visiteur — on doit alors basculer sur J1.
|
||||
*
|
||||
* Logique : si l'instant présent est avant la fin du J, on utilise J ; sinon J1.
|
||||
* Comparaison strictement sur le jour calendaire en Europe/Paris :
|
||||
* - si jour(now) == jour(productDatetime) → echeance = 'J'
|
||||
* - sinon (bulletin de la veille servi avant la prochaine publication) → 'J1'
|
||||
*
|
||||
* ⚠ Ne pas se baser sur `endTime` des alertes filtrées par echeance J :
|
||||
* en jour calme (toutes alertes vert), aucune alerte J n'est présente dans
|
||||
* le snapshot → l'ancienne impl renvoyait J1 par défaut et l'UI affichait
|
||||
* "demain" partout. Corrigé en s'appuyant sur productDatetime.
|
||||
*/
|
||||
function parisDateKey(d: Date): string {
|
||||
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Paris' });
|
||||
}
|
||||
|
||||
export function currentEcheance(snapshot: VigilanceSnapshot): 'J' | 'J1' {
|
||||
const now = new Date().toISOString();
|
||||
const jEnd = snapshot.alerts
|
||||
.filter((a) => a.echeance === 'J')
|
||||
.map((a) => a.endTime)
|
||||
.sort()
|
||||
.slice(-1)[0];
|
||||
if (jEnd && now < jEnd) return 'J';
|
||||
return 'J1';
|
||||
if (!snapshot.productDatetime) return 'J';
|
||||
const bulletinDay = parisDateKey(new Date(snapshot.productDatetime));
|
||||
const nowDay = parisDateKey(new Date());
|
||||
return nowDay === bulletinDay ? 'J' : 'J1';
|
||||
}
|
||||
|
||||
export function maxColorByDepartement(snapshot: VigilanceSnapshot, echeance: 'J' | 'J1' = 'J'): Map<string, ColorId> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue