← Journal technique

backend

Une synchro hors-ligne vraiment sûre à rejouer

Une synchro hors-ligne vraiment sûre à rejouer

J'ai construit ça pour du matériel qui perd la connectivité mais doit quand même fonctionner — une borne, un terminal de comptoir. L'appareil met les actions en file localement et les rejoue une fois de retour en ligne.

Mettre en file est trivial. Rejouer de façon sûre, c'est là qu'est la vraie conception, parce qu'un endpoint de rejeu naïf a deux modes de défaillance :

  • Traitement en double. L'appareil réessaie après une réponse perdue et envoie le même événement deux fois.
  • Conflits périmés. L'appareil a mis en file une action contre un état qui a changé depuis — il veut « appeler » un ticket qu'un autre appareil a déjà annulé.

Deux couches d'idempotence

Couche un — déduplication par ID brut. Chaque événement porte un ID généré par le client. Vérifie s'il a déjà été traité avant de faire quoi que ce soit d'autre. Ça attrape le simple cas « envoyé deux fois parce qu'aucune réponse n'est revenue ».

Couche deux — idempotence sémantique, par type d'événement. La vérification d'ID manque les événements logiquement conflictuels qui ne partagent pas d'ID. Ça a besoin d'une validation propre au type : un événement de création vérifie si quelque chose avec la même clé naturelle existe déjà ; un événement de transition vérifie si l'état courant permet réellement le mouvement. C'est la couche qui arrête la vraie corruption, pas juste les lignes en double.

Les vérifications de chaque événement, son application, et son enregistrement d'audit vont dans une seule transaction — pour que je ne puisse pas me retrouver avec un événement appliqué mais jamais enregistré (retraité à la synchro suivante), ni enregistré mais jamais appliqué.

await prisma.$transaction(async (tx) => {
  const existing = await tx.device_events.findUnique({ where: { event_id } });
  if (existing) return { status: "duplicate" };

  const check = await checkSemanticIdempotency(event, tx);
  if (!check.canProceed) return { status: "rejected", reason: check.reason };

  await applyEvent(event, tx);
  await tx.device_events.create({ data: { event_id, ... } });
  return { status: "accepted" };
}, { timeout: 10000 });

Ne renvoie pas réussite/échec pour le lot

Un appareil qui rejoue sa file a besoin de savoir quels événements ont été acceptés et lesquels rejetés et pourquoi — pour pouvoir effacer les acceptés localement et décider quoi faire du reste. Un résultat structuré (accepted, rejected: [{eventId, reason}], audit par événement) est ce qui rend l'endpoint utilisable au lieu d'une boîte noire tout-ou-rien.

Si la synchro affecte un état dérivé — un ordre de file, un décompte en cache — le recalculer une fois après tout le lot est moins coûteux et plus sûr que de le pousser par événement. Des mises à jour incrémentales sur un lot d'événements hors-ligne potentiellement conflictuels dérivent ; une reconstruction depuis la vérité source, non.

Le tout marche très bien en test si tu sautes une couche. Ça casse exactement dans le scénario pour lequel la fonctionnalité existe.