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.
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.
5 critères de rejet appliqués séquentiellement. Toutes les positions rejetées sont stockées avec quality_flag='filtered'.
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.
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).
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.
Steps 10 et 1 exécutent AVANT le pre-filtre pour toute position. Steps 2-9 uniquement si la position passe le filtre.
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é.
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.
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.
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.
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.
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.
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.
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.
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.
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 |
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.
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.
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.
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).
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.
Décision D-stale-cascade. La contrainte unique (vehicle_id) WHERE ended_at IS NULL bloque les futurs INSERT si un stop orphelin existe.
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.
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.
>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).
Le moteur tourne dans deux Workers Cloudflare distincts avec un budget strict de mémoire, CPU, et latence.
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.
| 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. |
| 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 |
| Scenario | Durée cible | Notes |
|---|---|---|
| Batch normal (5 véhicules, aucun événement) | ~100-200 ms | Positions insert + distance + métriques trip |
| Batch charge (5 véhicules, tous événements) | ~200-400 ms | Toutes les machines d'état actives |
| Pire cas (10 véhicules, 500 positions) | ~1-2 s | Bien dans les 30s Worker |
| Segment record (rare, à l'arrivée waypoint) | +5-10 ms sync | 1 INSERT + 5 requêtes par plage temporelle. Géométrie construite en async (waitUntil). |
| Objectif p95 | < 500 ms/véhicule | Tous les objectifs supposent fetch() en FRA |
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.
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