← Retour à l'index

8. Robustesse et Cas Particuliers

15 cas couvrant tout ce qui peut mal tourner dans le pipeline de traitement GPS : positions corrompues, doublons, races concurrentes, limites matérielles. Chaque cas documente la menace, la garde, et la conséquence si la garde est absente.

A. Rejet et Filtrage des Positions

Trois barrières successives éliminent les positions invalides avant qu'elles n'atteignent les machines d'état. Chaque position rejetée est stockée avec son quality_flag — aucune donnée n'est perdue.

Diagramme 1 Arbre de décision du pre-filtre (Step 0) Position entrante lat = 0 ET lng = 0 ? filtered (Null Island) non timestamp > now + 1h ? filtered (futur) non timestamp < now - 30j ? filtered (ancien) non vitesse > 300 km/h ? filtered (implausible) non |lat| > 90 ou |lng| > 180 ? filtered (hors limites) non Passe au Step 2 Note: lat=0 XOR lng=0 est valide (équateur, méridien de Greenwich) Seuils non configurables (codés en dur)

5 critères de rejet appliqués séquentiellement. Toutes les positions rejetées sont stockées avec quality_flag='filtered'.

R1 Pre-filtre : 5 critères de rejet

Step 0 applique 5 tests : coordonnées nulles (lat=0 AND lng=0), timestamp futur (>1h), timestamp ancien (>30j), vitesse >300 km/h, coordonnées hors limites (|lat|>90 ou |lng|>180).

Résultat : quality_flag='filtered', stocké dans Active Data, steps 2-9 ignorés.

R2 Rate limiting : 500 positions/device/batch

Positions triées par device_timestamp. Les N premières sont traitées, l'excès est stocké quality_flag='rate_limited'. Compteur counters.positions_rate_limited incrémenté.

Protège le budget CPU du Worker contre un batch Flespi malformé. Par device, par invocation (pas par jour).

R3 Positions hors ordre (late)

Si device_timestamp ≤ lastAccepted.deviceTimestamp : quality_flag='late', steps 3-9 ignorés. Évite un delta de temps négatif dans le calcul de vitesse et la corruption des timers.

Cause : retry Flespi, reordering réseau. Steps 1 (gap) et 10 (liveness) exécutent normalement.

Ordre d'exécution du pipeline

Diagramme 2 Ordre d'exécution : quelles étapes pour quelles positions TOUTE POSITION Step 10 lastMessageAt Step 1 detectGap Step 0 preFilter REJETE INSERT filtered ACCEPTEE passe Step 2 Step 3 Step 4 Step 5 6-8b Step 9 classify stop trip distance metrics+ lastAccepted Exécuté pour les positions qui passent le pre-filtre Point de décision (pre-filtre) Rejet (stocké, pas traité)

Steps 10 et 1 exécutent AVANT le pre-filtre pour toute position. Steps 2-9 uniquement si la position passe le filtre.

B. Vivacité et Fast Path

Le moteur distingue "traceur vivant" de "GPS valide". Un traceur qui envoie des coordonnées zero est vivant (son timer de gap se reset), mais son GPS n'est pas valide (les machines d'état sont ignorées). Les messages bufferises suivent un chemin accéléré.

R4 Vivacité deux niveaux

Steps 1 (gap) et 10 (lastMessageAt) exécutent avant le pre-filtre, pour toute position reçue. GPS poubelle ≠ traceur mort.

Sans cette regle : des positions zero-coordonnées supprimeraient le signal de vivacité → fausses ouvertures de gap.

R5 Fast path messages bufferises

Si message_buffered=true OU age>15 min : seuls classify (step 2) et distance (step 5) exécutent. Stop, waypoint, corridor, fuel, events ignorés.

Empêche les entités fantômes : un dump de 200 positions d'une zone morte ne doit pas ouvrir 15 arrêts et 3 déviations.

R6 Classification de vitesse

4 plages : Normal 0-90 km/h, Élevée 90-120 (journalisée), Excès >120 (événement), Implausible >300 (rejetée par preFilter).

Le seuil d'excès est configurable par tenant (défaut 120). Le seuil implausible (300) est codé en dur.

Chemin accéléré pour positions bufferisees

Diagramme 3 Fast path : étapes exécutées (vert) vs ignorées (gris) pour positions bufferisees LIVE BUFFERED 10+1 0 2 3 4 5 6 7-8b 9 10+1 0 2 3 4 5 6 7-8b 9 stop trip metrics waypoint+ Exécuté Ignoré (évite les entités fantômes) Condition : message_buffered = true OU age > 15 minutes à la réception

Le fast path préserve la distance et la classification mais empêche la création d'arrêts, trajets, et déviations fantômes à partir de données historiques.

C. Idempotence et Multi-Tenant

Le consumer traite des batches qui mélangent des devices de plusieurs tenants et peut recevoir des retries. Deux mécanismes garantissent la cohérence : déduplication par contrainte unique et gate isFirstDelivery.

R7 Retry idempotent

Dedup via UNIQUE(device_id, device_timestamp) + ON CONFLICT DO NOTHING. Gate isFirstDelivery : si des doublons partiels sont détectés, toutes les mutations dérivées sont ignorées.

Blast radius limité : un batch de métriques non accumulé, auto-correctif au batch suivant.

R8 Multi-tenant dans un batch

Flespi mélange les tenants dans un seul webhook. Le consumer groupe par tenantId après batchResolveDevices, puis appelle loadBatchContext une fois par tenant.

Queries per-tenant (#8 waypoints, #11 config) exécutent une fois par tenant ; queries per-vehicle (#1-7, #9-10, #12) une fois par groupe.

R9 Même véhicule deux fois dans un batch

Après chaque processVehicleBatch, le contexte partagé est mis à jour avec le nouvel état du véhicule. Le 2ème appel pour le même véhicule utilise l'état en mémoire frais.

13 champs synchronisés : openTrips, openStops, openGaps, activeVisits, sequencePosition, deviations, tripAccumulator, stationarySince, lastAccepted, lastMessageAt, fuelState, activeFuelEvent, fuelLevelHistory.

D. Race Conditions Cron / Moteur

Le cron exécute toutes les 2 minutes. Le moteur exécute à chaque batch de positions. Ils peuvent modifier les mêmes entités simultanément. Trois races identifiées, toutes résolues par des gardes SQL.

Race Scenario Garde SQL Perdant Conséquence
Race 1 Trip close concurrent (cron stale 48h + engine stop) WHERE state='open' Le 2ème écrivain : UPDATE affecte 0 lignes No-op, aucune perte
Race 2 Trip metric UPDATE vs cron close WHERE id=$tripId AND state='open' Engine si cron commit d'abord Perte marginale des métriques du dernier batch (acceptable)
Race 3 Gap création concurrente UPDATE-then-INSERT + buffer 60s Le cron ouvre un gap que le moteur ferme Gap fermé proprement par le moteur

R10 Race 1 : Trip close concurrent

Le cron ferme un trip stale (>48h) au même moment où le moteur détecte un arrêt. WHERE state='open' sur tous les UPDATE trips : le premier écrivain gagne, le second est un no-op.

Décision D-trip-metric-where. Le cron cascade obligatoirement les stops et déviations orphelins.

R11 Race 2 : Metric UPDATE pendant trip close

UPDATE trips SET distance_m=$total WHERE id=$tripId AND state='open'. Si le cron a déjà fermé le trip, 0 lignes affectées. Perte marginale des métriques du batch en cours.

Décision D-trip-metric-where. Auto-correctif : les métriques finales étaient dans un batch de fermeture.

R12 Race 3 : Gap création concurrente

Le moteur fait toujours UPDATE ... WHERE state='open' RETURNING id avant INSERT. Si un gap cron existe, il est fermé. Buffer de 60s dans la requête cron empêche l'ouverture prématurée.

Décision D-gap-race. Sans le buffer : un gap zombie bloquerait les futures créations de gap via la contrainte unique.

Chronologie de la Race 3 : gap cron vs arrivée de données

Diagramme 4 Race 3 : Buffer de 60s contre les gaps zombies temps T0 dernier message T0 + gap_threshold T0 + gap + 60s seuil cron T1 données arrivent Buffer 60s MOTEUR Pas de données (silence GPS) Traite batch UPDATE gap SET state='closed' CRON check dans le buffer → PAS de gap check si silence: INSERT gap

Le buffer de 60s garantit que la requête cron ne lit que des last_message_at commités et stables. Les transactions moteur complètent en <2s (p95 < 500ms/véhicule).

E. Cascade et Immutabilité

Deux décisions architecturales qui préviennent la dégradation permanente des véhicules : la cascade obligatoire à la fermeture de trip stale, et l'immutabilité des positions.

Cascade de fermeture de trip stale

Diagramme 5 Décision D-stale-cascade : fermeture obligatoire des entités orphelines UPDATE trip SET state='closed', close_cause='stale' UPDATE stops SET state='closed' WHERE vehicle_id=$vid AND ended_at IS NULL UPDATE deviations SET state='abandoned' WHERE vehicle_id=$vid AND mission_id=$mid AND state='active' Sans cascade : l'arrêt orphelin bloque insertStop() → dégradation permanente du véhicule

Décision D-stale-cascade. La contrainte unique (vehicle_id) WHERE ended_at IS NULL bloque les futurs INSERT si un stop orphelin existe.

R13 Cascade stale trip

Fermeture trip stale (>48h) → DOIT fermer les stops ouverts (ended_at IS NULL) + abandonner les déviations actives. Ordre : trip → stop → déviation.

Décision D-stale-cascade. Sans cascade : le véhicule dégradé en mode raw-insert-only indéfiniment, sans récupération possible sauf intervention manuelle en base.

R14 Immutabilité des positions (Pattern C)

Tout le contexte (quality_flag, corridor_km, segment_km, is_inside_corridor) est calculé AVANT l'INSERT. Un seul write par position, aucun UPDATE jamais.

Décision D-position-immutability. Élimine le WAL bloat (~67% en moins), les dead tuples VACUUM, et le gonflement d'index des updates.

R15 Disjoncteur capteur carburant

>20 événements fuel/heure/véhicule → machine d'état fuel suspendue, événement sensor_fault_suspected émis. Seuil configurable (fuelEventRateLimitPerHour).

Décision stress test R6. Un capteur oscillant peut générer 100+ événements/heure. Le seuil de 20/h est généreux pour le réel (5-6 arrêts/h inhabituels mais possibles).

F. Contraintes Infrastructure CF Worker

Le moteur tourne dans deux Workers Cloudflare distincts avec un budget strict de mémoire, CPU, et latence.

Architecture deux Workers

Diagramme 6 Décision D-worker-split : edge API + engine près de la DB FRA (Francfort) — près de la DB WNAM (Amérique Nord Ouest) korido-api edge Worker (HTTP) Flespi CF Queue korido-engine queue() handler env.SELF.fetch() ~150ms hop one-time korido-engine fetch() handler (FRA) Supabase Frankfurt ~13ms/query R2 Archive fire-and-forget Sans split : ~314ms/query depuis WNAM = pipeline à 1.6s Avec split : ~13ms/query depuis FRA = pipeline à ~215ms

Le queue() handler atterrit en WNAM ; env.SELF.fetch() redirige vers le fetch() handler en FRA (placement: aws:eu-central-1). Coût unique de ~150ms amorti sur tout le batch.

Limites matérielles

Contrainte Valeur Impact moteur
Mémoire 128 MB par invocation État pur ~650-850 octets/véhicule. 100 véhicules = ~80 KB. Non limitant.
CPU time 30s (Standard) Engine ~0.11ms/position à 50 waypoints. 500 positions = 55ms. Marge >40x.
Bundle size 10 MB (CI gate: 8 MB) par Worker korido-engine : @korido/fleet ~15 KB + processeurs V2 ~10 KB.
Queue batch 10 messages max 1 message = 1 batch Flespi (~1-100 positions). 10 messages = 10-1000 positions.
Max concurrency 1 (queue positions) Traitement série = pas de contention DB. Choix de conception.
Connexions DB max: 1 par invocation Toutes les queries + transaction sur une seule connexion Hyperdrive.
Sous-requêtes 10 000 par invocation 100 véhicules x 8 queries = 800. Grande marge.
Message queue 128 KB max Enforce par groupBySize() dans le service d'ingestion.

Budget mémoire par véhicule

Composant Taille
Champs existants (lastAccepted, stop, stationarySince)~200 octets
Nouveaux (trip, tripAccumulator, gap, deviation, outsideCount)~300 octets
Visites waypoints actives (0-2 typique)~100 octets
État carburant (fuelState, activeFuelEvent)~50 octets
Total par véhicule~650-850 octets
Élément additionnel Budget
fuelLevelHistory (60 entries x 50 véhicules)~150 KB
Turf.js (boolean-point-in-polygon + point-to-line-distance)~10 KB bundle
Polygone corridor en mémoire (200-400 coords)~2-8 KB/mission
Total pour 50 véhicules< 250 KB

Durées de transaction cibles

Scenario Durée cible Notes
Batch normal (5 véhicules, aucun événement)~100-200 msPositions insert + distance + métriques trip
Batch charge (5 véhicules, tous événements)~200-400 msToutes les machines d'état actives
Pire cas (10 véhicules, 500 positions)~1-2 sBien dans les 30s Worker
Segment record (rare, à l'arrivée waypoint)+5-10 ms sync1 INSERT + 5 requêtes par plage temporelle. Géométrie construite en async (waitUntil).
Objectif p95< 500 ms/véhiculeTous les objectifs supposent fetch() en FRA

R2 Cold Storage

Toutes les positions (y compris jitter/spike/rate_limited) sont archivées en NDJSON gzippé vers R2 après le commit de la transaction. Chemin : r2://korido-archive/positions/{yyyy}/{mm}/{dd}/{vehicle_id}.ndjson.gz. Écrit en fire-and-forget — un échec d'archive ne bloque jamais l'ingestion. Coût : ~0.72$/an pour 50 véhicules (~3-4 GB/an gzippé). Permet un replay complet si l'algorithme du moteur change.


Synthèse : Tableau des Gardes

Résumé des 15 cas avec la menace, la garde appliquee, et le résultat en cas de garde absente.

# Cas Garde Sans garde
R1 Pre-filtre (5 critères) preFilter step 0, quality_flag='filtered' Coords nulles corrompent les calculs de distance et stop
R2 Rate limiting 500/device/batch Sort + truncate, quality_flag='rate_limited' Un batch malformé consomme tout le CPU Worker
R3 Positions hors ordre quality_flag='late', skip steps 3-9 Delta temps négatif → division par zero, vitesse impossible
R4 Vivacité deux niveaux Steps 1+10 avant preFilter GPS poubelle supprime le signal de vivacité → faux gaps
R5 Fast path buffered Skip steps 3-4, 6-8b si buffered/age>15min 200 positions de zone morte → 15 faux arrêts, 3 fausses déviations
R6 Classification vitesse 4 plages, >300 rejeté par preFilter Vitesse implausible traitée comme réelle → faux speeding
R7 Retry idempotent UNIQUE + ON CONFLICT + isFirstDelivery Doublons → double comptage distance, events en double
R8 Multi-tenant dans batch Groupement par tenantId, loadBatchContext par tenant Config tenant A appliquee aux véhicules tenant B
R9 Même véhicule 2x dans batch Mise à jour contexte en mémoire après chaque processVehicleBatch 2ème traitement voit un état stale → doublons d'entités
R10 Race trip close concurrent WHERE state='open' Trip fermé deux fois ou écrit après fermeture
R11 Race metric UPDATE WHERE id=$tripId AND state='open' Métriques écrites sur un trip déjà fermé
R12 Race gap création UPDATE-then-INSERT + buffer 60s cron Gap zombie bloque futures créations via contrainte unique
R13 Cascade stale trip Close stops + abandon déviations dans le même SQL Stop orphelin bloque insertStop() → dégradation permanente
R14 Immutabilité positions Pattern C : tout calculé avant INSERT, zero UPDATE WAL bloat +67%, VACUUM overhead, index gonflement
R15 Disjoncteur capteur fuel >20 events/h → suspension + sensor_fault_suspected Capteur oscillant génère 100+ events/h, noie les alertes réelles

Source : docs/brainstorm/engine-evolution/engine-v2/ — pipeline.md, async-ops.md, config.md, décisions.md, io-boundary.md