Case study · aetherwx (ex maritime-atlas)

AetherWX — l'orchestre multi-source

Atlas live qui agrège 5 sources de données externes (AIS, météo NOAA + Météo-France, radar pluie, foudre) sur une carte time-scrubable. 14 services Docker, cluster GeoServer 3 replicas, alerts engine RabbitMQ topic. Le projet hommage / refonte du produit Neo de Campbell Scientific (où je bosse) mais en domaine maritime. 0 ligne de code humaine, ~23 commits en 2 jours.

Aperçu visuel

Une carte unique, dix bascules. Le slider temporel en bas pilote tout — vesselles passées/présentes, météo forecast +5j, radar pluie replay -2h. Voici les principales layers en isolation, puis l'animation des particules de vent (effet style windy.com).

Navires AIS live + trajets daily aggregés
Navires AIS live (2500+ positions) + trajets daily agrégés. Chaque couleur = catégorie de bateau (cargo bleu, tanker rose, pêche jaune, passenger vert).
SST — température mer NOAA OISST
SST (température de surface de la mer) — NOAA OISST quotidien, gradient bleu→cyan→vert→jaune→rouge (-2°C → 30°C).
Vent raster + flèches directionnelles
Vent (GFS) — raster magnitude + flèches directionnelles sample par 2×2 cellules grille. Sélecteur GFS / AROME inline.
Vagues hauteur + direction (WaveWatch III)
Vagues — NOAA WaveWatch III. Raster hauteur sig. + flèches direction primaire DIRPW. Land mask = pas de flèches sur la terre.
Radar pluie + foudre live
Radar pluie (RainViewer, XYZ tiles time-aware) + Foudre live (Blitzortung WSS, ~30 strikes/30s en bbox FR pendant un orage). Yellow halos = strikes <5min, ambre = <30min.
Panel alertes maritimes — Vent fort sur cargos / tankers
Alerts engine — règles RMQ qui croisent ais.positions + wind grid + lightning.strike. Ici : 17 alertes "Vent fort" sur cargos / tankers (MSC CALYPSO, MSC ALIYA, etc. à 10-16 m/s).
Particules vent style windy.com — 1500 particules canvas 2D, advectées par interpolation IDW sur les 4 plus proches voisins de la grille GFS. ~60fps desktop, couleurs Beaufort-like (bleu calme → rouge tempête).

Pourquoi ce projet

Au boulot je développe Neo (Campbell Scientific) pour les stations météo et aéroports — Angular + OpenLayers + cluster Docker Swarm de GeoServer + bus RabbitMQ pour synchroniser les replicas. J'adore le pattern services-qui-causent-entre-eux, et j'avais envie d'en refaire un dans un domaine totalement différent du météo aviation pour pas empiéter sur le taff. Maritime est venu naturellement : AIS gratuit (aisstream.io), modèles météo libres NOAA, et un panel de sources visualisables très large pour quelqu'un qui aime mettre des données sur des cartes.

Périmètre : bbox France métropole [-6, 41, 10, 51.5] — Manche + Atlantique + Méditerranée + Corse. Le tout sur un slider temporel qui pilote 11 layers (navires live + replay, météo raster, radar pluie, foudre) avec un engine d'alertes RMQ qui croise vessel + vent + foudre en temps réel.

Architecture — vue d'ensemble

Architecture
Loading diagram…
14 services Docker. Le frontend Angular OL n'attaque qu'un seul backend (GeoServer LB) — toute la complexité d'ingestion est en aval.

L'arête importante : le frontend ne tape qu'un seul backend public — l'alias DNS interne geoserver:8080 (qui est un Service K8s ClusterIP load-balancé sur les 2 replicas GeoServer synchronisés par Hazelcast). Toute l'ingestion (6 services), le bus RMQ, le moteur d'alertes, l'API d'auth/palettes, sont privés au réseau Docker. Le RainViewer est la seule source que le browser frappe directement (CORS open, tiles XYZ time-aware).

Multi-source ingestion — le détail par source

Architecture
Loading diagram…
Cinq pipelines indépendants, qui finissent tous par alimenter le même slider temporel côté frontend.

Les TTL différentiels sont standardisés :

  • 30j : vessel_positions, sst-daily (cohérent navigation)
  • 14j : alerts (analyse rétroactive incident)
  • 7j : lightning_strikes, weather GeoTIFFs, wind-arrows GeoJSON (forecasts deviennent obsolètes vite)

Chaque fetcher Python a une routine cleanup_old_files() en début de cycle (sprint 10b), TimescaleDB gère les hypertables côté DB.

Particularités intéressantes

  • Time slider globale [-7j, +5j] pilote 9 layers simultanément avec snap-to-latest (TIME=1970/cursor range) pour qu'aucune layer raster n'affiche jamais "rien" si le timestep exact n'existe pas.
  • Replay temporel des navires via SQL view paramétrée GeoServer vessels_at_time(at, window) — JDBC virtual table avec DISTINCT ON (mmsi) ORDER BY ts DESC, le frontend appelle WFS avec viewparams=at:ISO;window:300.
  • Palettes utilisateur (sprint 5 APEX) : auth JWT + chaque user peut créer jusqu'à 5 palettes SLD couleur custom → mirror automatique en styles GeoServer user_<id>_<slug> via REST POST/PUT, frontend injecte &STYLES=maritime:user_42_marine par user par layer.
  • Cluster GeoServer 2 replicas avec catalog partagé en Postgres via JDBCConfig (community extension), Hazelcast gs-hz-cluster pour synchroniser l'état in-memory entre replicas (GWC locks, tile seeds), LB nginx interne nommé geoserver (alias DNS) — rollout du single → cluster sans toucher aux 5+ services métier qui appellent http://geoserver:8080/.
  • Alerts engine souscrit ais.positions + lightning.strike, applique 2 règles (high-wind sur cargo/tanker dans zone >10 m/s, lightning à <10 km d'un navire actif), publie sur exchange alerts.maritime avec routing key <severity>.<kind>. Cooldown 30min par (mmsi, kind) pour éviter le spam.
  • Vent particules canvas 2D style windy.com / Earth Nullschool : 1500 particules advectées par interpolation IDW sur les 4 plus proches voisins du grid GFS, projection map.getPixelFromCoordinate, fade alpha 0.97 pour les trails. ~60fps desktop.
  • Exposition publique HTTPS via Cloudflare Tunnel sortant (le NAS appelle CF, pas l'inverse) — zéro port ouvert sur la box, SSL auto, CDN devant les WMS tiles. Domaine sladoire.dev chez Cloudflare Registrar (~12$/an).

Le process avec Claude Code

Démarrage en pair sur du dialogue : "je veux un truc avec OL + cluster GeoServer + RMQ comme au boulot, mais pas en météo aviation". Choix domaine maritime collectif, scope initial Bretagne, première étape scaffold sprint 1 en 2h. À partir de là, ~3 sprints/jour pendant 2 jours, le slider temporel ayant débloqué la vision (sprint 3.5) : "tout pilote le temps, on ajoute des sources qui ont du sens à timeshifter".

Pattern le plus impactant : multi-agent en background pour paralléliser des sprints indépendants. Sprints 9 (cluster GeoServer JDBCConfig) + 11 (AROME Météo-France) + un fix ol-companion 2e jaune→rouge dérivé ont été livrés en parallèle par 3 agents pendant que je codais sprint 8 (particules vent) en main — ~1h wall-clock pour 4 livrables. Ça demande de bien briefer chaque agent sur les fichiers à éviter (conflits possibles sur map.component.ts) et de git status avant commit, mais c'est une vraie multiplication.

Aussi : APEX pour le sprint 5 (auth + palettes user) qui était structurant et méritait l'analyze → plan → execute → validate détaillé. Les autres sprints courts (6/7/10) en main-coding direct. Le mix marche bien : APEX pour les gros chantiers, hand-code pour l'itération rapide. /save en fin de session pour capturer 7 memories techniques (pièges GeoServer, rioxarray orientation, IDW pour smooth, Drizzle vs Prisma, etc.).

Sprint Auth refonte — du minimal au full-featured

Le sprint 5 livrait un auth minimal (email + password + JWT 24h) suffisant pour le CRUD palettes user. Quelques sessions plus tard, besoin d'élargir : RBAC admin pour gérer les comptes, vérification email pour limiter le spam, Google OAuth en complément du mdp, suppression auto des comptes dormants. 5 phases livrées en une grosse session (~6h pair-coding), brique par brique, chaque phase poussée séparément.

  • P1 — Schema + RBAC + seed admin : ALTER TABLE users idempotent (5 colonnes ajoutées : username, role, email_verified_at, last_login_at, verification_token). Backfill username = email local-part avec suffix _N si collision via PL/pgSQL DO $$. @Roles('admin') decorator + RolesGuard NestJS (Reflector + SetMetadata). AdminSeedService OnModuleInit qui promote sylvain.ladoire@gmail.com en admin idempotent au boot — pas besoin de lancer un script manuel.
  • P2 — Vérification email Resend : SDK Resend (free 3000 mails/mois, signup sans CB). MailService avec mode dégradé "log only" si RESEND_API_KEY vide (utile en dev). Token UUID 24h, endpoint GET /auth/verify idempotent. Frontend register affiche un écran "vérifie ton email" + bouton "Renvoyer le mail".
  • P3 — Admin UI /admin/users : backend AdminUsersController protégé par @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('admin') au niveau classe. 3 endpoints : list / setRole / delete. Garde-fous serveur : un admin ne peut pas se rétrograder ni se supprimer (anti-brick). Frontend Angular avec table responsive + boutons "Promote", "Demote", "Suppr" + confirm + indicateur "tu" sur self.
  • P3.5 — Google OAuth (passport-google-oauth20) : GoogleStrategy NestJS + 2 endpoints GET /auth/google (302 vers Google) + GET /auth/google/callback (return + sign JWT). Le token est passé au frontend via URL fragment (#token=...) — les fragments ne sont PAS envoyés au serveur, moins de fuite logs vs query string. AuthService.loginOrCreateGoogleUser : merge by email si existe (update last_login_at), sinon create avec username généré + random password + email_verified_at = now (Google a déjà vérifié).
  • P4 — Cron dormants 3 mois : @nestjs/schedule v5 (compat Nest 11) avec @Cron(EVERY_DAY_AT_3AM, timeZone: 'Europe/Paris'). Critère COALESCE(last_login_at, created_at) < now - 90j — comptes jamais connectés sont aussi nettoyés. Admins jamais supprimés (anti-brick). Mode DORMANT_DRY_RUN=true pour audit avant prod.

Le pattern récurrent qui a marché : chaque phase = un commit isolé + push immédiat. Plus simple à reviewer, plus simple à revert si une phase casse. La phase Google OAuth a touché 13 fichiers (back + front) — un seul commit, mais clean dans son scope, et les autres phases déjà sécurisées avant.

Sprint Layer UX V2 — groupes, opacity, reset, forgot password

Suite du sprint Auth refonte. Sylvain ouvre la legend et constate 12 toggles à plat, sans hiérarchie ; il propose un regroupement par catégorie de data. Cinq phases livrées en 1 session de pair-coding (~6h), même pattern « 1 phase = 1 commit isolé » que l'auth refonte.

  • Phase A — Layer groups + opacity slider : refactor de la legend en 5 groupes (Info maritimes, Modèles océano, Modèles atmo, Radar, Foudre) + un placeholder "Satellite à venir". Chaque layer reçoit un slider d'opacité 0-100% visible quand le toggle est actif (`@if (showXXX())` dans le template). Bind sur layer.setOpacity() côté OL. Particules vent = opacity CSS sur le canvas overlay (signal séparé du rAF loop). Bouton « ↺ Réinitialiser » qui restore les defaults visibility/opacity + clear localStorage. Persist localStorage maritime.layer-prefs-v1 sur chaque change.
  • Phase B — Forgot/reset password (Resend) : termine l'auth refonte avec la P5 manquante. Schema password_reset_token + password_reset_expires_at. Token UUID v4 TTL 1h. AuthService.forgotPassword(email) renvoie un message générique (anti-énumération OWASP) quoi qu'il arrive. Bonus dans resetPassword : si l'email n'était pas vérifié, on valide automatiquement — l'user a prouvé qu'il contrôle l'email en cliquant le lien.
  • Phase C — Backend prefs étendu : user_layer_preferences gagne 2 colonnes visible BOOLEAN + opacity REAL (nullable — NULL = défaut app). Endpoints PUT /me/layer-state (single layer, pour debounce 500ms du slider) et PUT /me/layer-states (batch sync au login depuis le localStorage anonymous). Le layerKind accepte n'importe quel string (visible/opacity pour TOUTES les layers), tandis que la palette reste contrainte aux rasters VALID_LAYER_KINDS.

Architecture localStorage-first : Phase A persiste en localStorage pour les anonymous users, Phase C ajoute le sync DB pour les connected. Au login, le frontend merge le localStorage (state actuel browser) avec la DB (state cross-device). C'est l'inverse du pattern « DB first, localStorage cache » classique — ici l'anonymous a 100% des features sans backend, le DB devient une couche optionnelle pour la portabilité.

Pivots & lessons learned

  • Bug orientation GeoTIFF : 1h de débug sur SST avant de comprendre que xarray défaut lat ascending → rio.to_raster écrit Origin = coin sud-ouest. Fix sortby('lat', ascending=False) avant set_spatial_dims. Le même piège a frappé wind/waves (memory écrite).
  • NEAREST strategy silencieuse : sprint 4a wind/waves publiés mais invisibles côté WMS GetCapabilities. Cause : defaultValue.strategy=NEAREST sans referenceValue lève une ServiceException qui SKIP la layer silencieusement. Fix : utiliser MAXIMUM par défaut.
  • Drizzle > Prisma quand DB partagée : sprint 5 ajoutait 3 tables à une DB Postgres déjà peuplée d'hypertables TimescaleDB d'autres services. Prisma aurait introspect tout et risqué de drifter ; Drizzle SQL-first ne touche que ce qu'on déclare. Réutilisable si on ajoute du Nest à finance ou warhammer.
  • IDW smoothing pour particules : v1 sprint 8 utilisait nearestWind (snap discret) → trajectoires en zigzag visible aux frontières de cellule. v2 (8b sur feedback user "trop rapide + arrondir les angles") : interpolation IDW pondérée par 1/d² sur les 4 plus proches voisins + advectScale ÷ 4. Champ vectoriel C¹ continu, courbes naturelles.
  • Cluster GeoServer non-breaking : marathon 2026-05-24 passe de 1 à 2 replicas GeoServer synchronisés par Hazelcast K8s (plugin community gs-hz-cluster) — RBAC ServiceAccount + Role pod-reader, gossip TCP 5701 sur Service headless. JDBCConfig partagé en Postgres (CNPG pg-catalog) ; le cache GWC est posé sur un bucket SeaweedFS S3 partagé (maritime-gwc-tiles) pour zéro re-cache à froid lors d'un rolling restart. Le Service ClusterIP K8s geoserver:8080 load-balance, donc les 6 sidecars fetchers + l'API + le frontend n'ont aucune modif à faire — l'alias DNS K8s est l'arête stable. La 3ème replica est gardée en réserve pour la migration Hetzner CAX41 (32 Gi RAM, 8g heap × 3).
  • TTL différentiels alignés : audit a révélé que les retention TimescaleDB étaient OK (30j vessels, 7j lightning) mais que les fichiers disque GeoTIFF/GeoJSON s'accumulaient sans limite. Sprint 10b a ajouté cleanup_old_files() dans chaque fetcher Python (30j sst, 7j weather). Volumes stabilisés autour de 20 MB pour la météo.
  • Bonus pédagogique : Cloudflare Tunnel pour exposition publique. Le NAS reste tranquille sur le LAN sans port ouvert, mais https://maritime.sladoire.dev est accessible mondialement avec SSL + DDoS protection + CDN cache des tiles, 0€/mois (juste ~12$/an le domaine). Pattern réutilisable pour les autres apps NAS.
  • GeoServer community extensions : snapshot ≠ release. JDBCConfig + JDBCStore plantent en 404 sur SourceForge /extensions/ et sur build.geoserver.org/.../community-latest/. Première tentative avec le ZIP snapshot geoserver-2.28-SNAPSHOT-jdbcconfig-plugin.zipNoClassDefFoundError org/geoserver/util/SortedProperties (le snapshot référence une classe du master dev absente du runtime stable 2.28.2). Le bon chemin : repo.osgeo.org/repository/release/org/geoserver/community/ qui héberge les JARs release tagués alignés au runtime exact. Pour 2.28.3 : gs-jdbcconfig-2.28.3.jar (146 KB) + gs-jdbcstore-2.28.3.jar (61 KB). curl direct au build du Dockerfile, fini.
  • JDBC race condition au boot du cluster : détecté en cours de session quand un agent bg CANDHIS a redémarré les replicas en parallèle. JDBCConfig + JDBCStore lancent CREATE TABLE sans IF NOT EXISTS → gs-1 réussit (premier arrivé), gs-2 plante en relation "resources" already exists → context Spring fail → web app dégradée (REST 404). Fix dans cluster-bootstrap.sh : psql probe au boot (SELECT to_regclass('geoserver.object') IS NOT NULL), si tables présentes → sed sur initdb=true pour le mettre à false avant que GeoServer ne lise le fichier. Premier replica → init, replicas suivants → skip. Élégant + 0 modif au plugin upstream.
  • LB healthcheck 302 false-unhealthy : le LB nginx servait correctement les WMS (200 partout) mais le healthcheck wget -qO- /geoserver/web/ recevait 302 (redirect vers la login UI) → considéré erreur → "unhealthy" perpétuel → provisioner depend_on bloqué. Fix : --max-redirect=0 + accept exit code 8 (server-side redirect status code). Lesson : un healthcheck doit accepter TOUS les codes qui prouvent que le serveur répond, pas juste 200.
  • SeaweedFS server mode bundle vs MinIO maintenance mode. MinIO est entré en maintenance mode (déc 2025), donc SeaweedFS (chrislusf/seaweedfs:3.97) bundle master + volume + filer + S3 gateway dans 1 container via command: server -dir=/data -filer -s3 -s3.config=.... Le flag -filer est obligatoire (sans lui le S3 gateway attend en boucle). Credentials via JSON statique seaweedfs/s3.json. GeoWebCache tile cache vit maintenant dans le bucket maritime-gwc-tiles au lieu du disque local — testé avec aws s3 ls --endpoint-url=http://seaweedfs:8333.
  • Plugin Java maritime-gwc-init — la config GS durable cross-restart. Le vrai pivot architectural du sprint cluster-ready : les REST PUT GeoServer (blobstore, tile layer assignments) sont in-memory only — ils ne survivent pas au redémarrage du pod. L'approche durable ? Un module Maven (geoserver/maritime-gwc-init/) embarqué dans WEB-INF/lib de l'image Docker, avec un bean Spring @PostConstruct qui crée le S3 blobstore + assigne 16 layers GWC au boot, idempotent (check-before-create). Pattern repris de Neo au boulot : la config reproductible vit dans le code, pas dans l'état runtime. Résultat : le cluster rebuild complet en CI restaure exactement la configuration GWC en ~30 s de boot, sans script post-deploy ni intervention manuelle. L'isolation workspace aetherwx-sat (7 layers NASA) a suivi le même schéma : GetCap cold-start 250 ms vs 36 s avant split (le catalog JDBCConfig chargeait tous les layers en un seul GET /ows).
  • Plugin Java maritime-cascade-time-forward — fix un bug GeoTools. Sprint G65 (2026-05-27). Symptôme : l'animation cascade HRV/RSS/MTG/radar affichait toujours la même image quel que soit TIME. Cause racine trouvée en lisant gt-wms (538 + 215 lignes) : 0 occurrence du mot time case-insensitive. GeoTools ne forwarde tout simplement pas &TIME=... à l'upstream pour WMSLayerInfo cascade — par design, pas un bug de config. Approches échouées avant de trouver le bon point d'extension : REST PUT (500 UnsupportedOperationException), SQL UPDATE direct sur le blob XStream (silent no-op), HTTPClientFactory SPI (ordering FactoryRegistry non garanti), BeanPostProcessor sur ResourcePool (GS ne l'expose pas comme bean Spring direct). Solution finale : remplacer le champ privé ResourcePool.wmsCache (un SoftValueHashMap) via réflexion par une HashMap custom qui override put() pour auto-wrap le WebMapServer.httpClient. Couplé à un DispatcherCallback.operationDispatched() qui stash TIME du KVP dans un ThreadLocal et un décorateur HTTPClient.get(URL) qui réécrit l'URL upstream avec &TIME=.... 11 commits de marathon dont 4h coincées sur le pitfall "init() fire AVANT parse KVP donc request.getService() est null". Validation prod : HRV à 08:00/10:00/12:00/14:00 UTC → 4 md5 distincts (avant 1 seul). Le pattern « wrap cache via réflexion » est réutilisable pour toute interception HTTP outbound GS (auth headers, retry, rate limiting, mock pour tests).

GeoServer V3 — plugin WPS custom (2026-05-13)

Les rasters source météo / océano sont sur grille grossière : GFS 0.25° (~28km), OISST 0.25°, WW3 0.5°. Au rendu, on voit les pixels — pas joli, et les isolignes ras:Contour sortent en escaliers. La solution classique = densifier le raster côté serveur avant la color map. Mais il ne faut surtout pas pré-sampler les données (les rasters source doivent rester intacts pour GetFeatureInfo / WCS time series). Donc : densification rendering-side uniquement.

On a écrit un plugin Java GeoServer WPS qui expose deux processes custom :

  • idw:IDW — Inverse Distance Weighting raster densifier. Paralléle sur les rangées dst (IntStream.range().parallel()), fast paths pour p=1 (linear) et p=2 (squared, skip Math.pow), Math.fma pour les sommes pondérées, factory cache static final. ~150 lignes Java 17.
  • idw:IDWContour — appelle IDWProcess.execute() puis ContourProcess.process() de GeoTools en interne, retourne la FeatureCollection des isolignes lisses. Pourquoi pas un chaining SLD natif ? Voir bug ci-dessous.

Le bug GeoTools post-2.26.2 — débuggé avec Claude en miroir

Je connaissais le bug depuis un projet pro avec un autre Claude — il avait fini par trouver la régression dans les sources GeoTools, commit d'Andrea Aime (le mainteneur historique). En SLD avec rendering transformation, la syntaxe <Function name="parameter"><Literal>data</Literal></Function> sans valeur déclenche une auto-injection du coverage source côté pipeline de rendu. Sauf qu'à partir de GeoTools 32 (= GS 2.26.2), si le process bean Java ne contient AUCUNE des méthodes invertGridGeometry / invertQuery / customizeReadParams / clipOnRenderingArea, AnnotationDrivenProcessFactory.create() wrap le process en plain ProcessFunction au lieu de InvokeMethodRenderingProcess — l'auto-injection ne se déclenche plus, data arrive null, et l'execute() jette "Parameter data is missing but has min multiplicity > 0".

Fix dans le plugin : ajouter une méthode publique public GridGeometry invertGridGeometry(Query, GridGeometry) qui retourne le target tel quel. Sa simple existence détectée par réflexion suffit à déclencher le bon wrapping. Pas d'implements RenderingProcess requis — la réflexion sur le nom de méthode est plus permissive que l'interface elle-même.

Le chaining SLD reste cassé entre processes externes même avec le fix : ras:Contour avec un idw:IDW nested dedans → le inner ne reçoit toujours rien (GS n'auto-injecte que sur la transformation externe). D'où idw:IDWContour qui internalise les 2 étapes — un seul process, plus de chaining, plus de bug.

Le piège JDBCConfig — 1h perdue avant de comprendre

Le cluster GeoServer utilise JDBCConfig (catalog Postgres-backed) pour que les 2 replicas partagent le même état. Quand on PUT un SLD via REST API (PUT /rest/workspaces/<ws>/styles/<name>?raw=true), la DB est bien mise à jour, et un GET sur la même URL retourne la nouvelle version ✓. Mais le rendu continue d'utiliser l'ancienne version — pourquoi ?

Réponse : GeoServer charge les SLDs depuis le filesystem (workspaces/<ws>/styles/<name>.sld), pas depuis la DB. Les REST writes à JDBCConfig ne ré-écrivent pas les fichiers SLD sur disque — ils restent stales. POST /reload recharge le catalog depuis DB mais pas les fichiers SLD. Donc tant qu'on ne docker cp pas le SLD dans chaque replica, le rendu reste figé sur l'ancienne version — alors même que tous les indicateurs catalog disent "à jour".

Workaround durable : un script deploy-style.sh qui fait les 3 étapes (REST PUT + docker cp dans les N replicas + POST /reload). Idempotent, déployable en CI/CD. Le drift catalog ↔ disk n'est pas documenté côté GeoServer — leçon à graver dans la mémoire des projets cluster JDBCConfig.

Burst protection — control-flow extension

Le frontend OpenLayers tile par tuiles 256×256 — un pan de carte peut burst 30+ requêtes WMS GetMap en parallèle. Avec une rendering transformation coûteuse (IDW densify × Contour), le pool de threads Tomcat saturait → LB nginx en upstream timed out → cascade de 502 → GeoServer s'effondrait pendant 1-2 minutes. Solution : l'extension stable control-flow (Andrea Aime aussi, par contre celle-là est documentée). On a calibré pour quad-core × 2 replicas Hazelcast : ows.global=16, ows.wms.getmap=6, user=8, timeout=30s. Les requêtes excédentaires partent en file plutôt que de tuer le serveur.

Burst test validé : 30 requêtes WMS concurrentes → 30/30 réponses 200, P95 = 6s (sous le timeout 30s), zéro 502, zéro effondrement.