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
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# 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.rs` → `LocalBrokerMessage::Disconnected` → `disconnections_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-layer` → `caveat_write-durability-across-disconnect`.
|
||||
Reference in New Issue
Block a user