Files
ng-eventually/docs/incidents/2026-07-14-write-loss-on-disconnect.md
T
Sylvain Duchesne 138d37c02f docs(incident): perte d'écriture sur mort de socket (SerializationError)
Post-mortem 2026-07-14 (ouvert) : symptôme + preuves Firefox verbatim, chaîne causale tracée (socket→Disconnected→reconnexion en TODO), réserve (i) perte-écriture vs (ii) réhydratation à froid, repro @data décisive (test de reconnexion existant faux-vert = lecture IndexedDB locale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-14 18:58:38 +02:00

5.7 KiB

Perte d'écriture lors d'une mort de socket (SerializationError)

Post-mortem — 2026-07-14 · Statut : OUVERT (non traité).

Une entité écrite juste avant une période d'inactivité peut être perdue silencieusement : l'écriture n'atteint jamais durablement le broker, et l'entité est absente à la reconnexion. Le compte / l'identité survit (pas de fork). Observé en conditions réelles (Festipod, Firefox) lors d'une pause après login/création.

Symptôme

  1. L'utilisateur se connecte, l'app crée une entité (un événement Festipod).
  2. Une période d'inactivité suit (idle, onglet en arrière-plan…).
  3. Le socket broker meurt spontanément avec SOCKET IS CLOSED Some(Left(SerializationError)).
  4. À la reconnexion, l'entité créée a disparu ; l'app relit son propre scope vide.

Preuves (VÉRIFIÉ — logs Firefox en direct, verbatim)

… REPLAY TOPIC NOT FOUND <topic> IN OVERLAY <overlay>
… NEED REPLAY true
… SENDING EVENTS FROM OUTBOX RETURNED: Err(TopicNotFound)
[user1][polyfill] resolveAccount(user1) → 1 record          ← le compte SURVIT (pas de fork)
[user1][polyfill] readScopeIndex(…) → 0 entities            ← mais le scope est VIDE
… set reçu: 0 objets Event (public)
… SOCKET IS CLOSED Some(Left(SerializationError)) [51, 3, 223, …]

Lecture (mécanisme plausible, non tranché) : l'écriture a été poussée dans l'outbox local, mais le socket est mort avant qu'elle ne soit flushée durablement dans le topic broker ; à la reconnexion, le replay de l'outbox échoue (Err(TopicNotFound)) parce que le topic n'a jamais été créé côté broker → l'événement est abandonné. Le compte, lui, avait déjà été résolu durablement (resolveAccount → 1 record) : il n'est ni perdu ni forké.

Réserve épistémique. Les preuves établissent le symptôme (perte + Err(TopicNotFound) + readScopeIndex → 0). Le mécanisme exact n'est pas tranché entre (i) perte à l'écriture (l'écriture n'atteint jamais durablement le broker) et (ii) échec de réhydratation à froid (l'écriture est sur le broker mais une session fraîche ne rouvre pas le scope propre). Le Err(TopicNotFound) sur le replay outbox penche pour (i) dans ce cas Firefox. Voir la repro @data ci-dessous, qui expose un symptôme voisin mais ne tranche pas (i) vs (ii).

Chaîne causale (TRACÉ — lecture du core NextGraph, à re-vérifier)

  • La SerializationError ferme le socket. Le core émet la déconnexion : broker.rsLocalBrokerMessage::Disconnecteddisconnections_sender.send(...) (≈ broker.rs:1051, à re-vérifier — numéro volatil, se repérer par le symbole).
  • Cette déconnexion est poussée aux abonnés via disconnections_subscribe(cb) (flux PUSH).
  • La reconnexion NextGraph est un // TODO non implémenté (≈ broker.rs:1051-1076) : rien ne rétablit le socket ni ne re-flushe l'outbox.
  • user_connect renvoie un instantané { server_id, server_ip, error, since } au moment de l'appel — pas un flux, inutilisable pour détecter une chute ultérieure.
  • Aucune API de confirmation de durabilité d'écriture : un appelant ne peut pas await la garantie qu'une écriture a atteint le broker.

Ce que le SDK expose mais ne consomme pas

disconnections_subscribe se déclenche sur cette panne — mais ni le polyfill (@ng-eventually/client) ni l'app consommateur ne s'y abonnent. Le signal existe, personne ne l'écoute ; côté app, aucun mécanisme ne re-tente ni n'avertit l'utilisateur.

Portée & non-reproduit

  • Observé Firefox uniquement à ce jour. Un test manuel sur un autre navigateur n'a pas déclenché la SerializationError ni ses conséquences.
  • Reproduction @data (Chromium, broker réel) — 2026-07-14, décisive. Le test de reconnexion @data existant (reconnexion-meme-identite) était faux-vert : il relisait les repos de A depuis l'IndexedDB local du profil persistant, jamais depuis le broker. Un lecteur réellement à froid (contexte non-persistant freshBrowser, même wallet/compte A, aucun état local — seedé du wallet capturé avant l'événement) lit 0 événement de A (BARRIER timed-out (8000ms), CONNECTION ESTABLISHED). Signature différente du cas Firefox (pas de mort de socket ; l'OUTBOX empty est celui du lecteur, trivialement vide) et ne tranche pas (i) vs (ii) — un barrier vide est compatible avec les deux. Établi en revanche : @data n'a jamais vérifié la durabilité broker des lectures propres de A, et la réhydratation à froid depuis le broker échoue. Repro : src/modules/event/features/reconnexion-froide-sans-local.feature (Festipod).
  • Pour trancher (i) vs (ii) : vérifier indépendamment que l'écriture de A atteint le broker — p.ex. un lecteur chaud / une seconde identité lit le doc public de l'événement (le scénario d'isolation deux-identités). S'il le voit → l'écriture est durable → le 0 du lecteur à froid est un (ii) (réhydratation). Sinon → (i).

Pistes de correction (non arbitré)

  1. Core — corriger la SerializationError et implémenter le TODO de reconnexion (rétablir le socket + re-flusher l'outbox).
  2. SDK / polyfill — consommer disconnections_subscribe → reconnexion + re-flush outbox comme mitigation, indépendamment du core.
  3. API de durabilité — exposer une confirmation qu'une écriture a atteint le broker, pour que l'appelant puisse l'await.

Liens

  • docs/nextgraph-current-state.md — état courant du core (déconnexion / reconnexion à cross-référencer ici).
  • Impact produit + caveat côté consommateur : concept Festipod data-layercaveat_write-durability-across-disconnect.