Bonus Chapitre 45-46 / 24

L'idempotence native : level-based plutôt qu'inbox/outbox & Exactly-once est un mythe, la convergence est la réponse

Comprendre pourquoi la réconciliation level-based rend l'idempotence native — sans inbox ni outbox — et pourquoi la livraison exactly-once est un mythe que la convergence remplace, avec du YAML, kubectl et du C#/KubeOps.

L’idempotence native : level-based plutôt qu’inbox/outbox

L’idée en une phrase

Un système edge-based (piloté par des messages, chapitre day 13-14) n’atteint l’idempotence qu’en la construisant : le pattern inbox — une table des identifiants de messages déjà traités — filtre les doublons, le pattern outbox publie les messages sortants dans la même transaction que le changement d’état. Un système level-based obtient l’idempotence gratuitement : le reconciler relit l’état désiré complet à chaque cycle et comble l’écart avec l’état réel, si bien que rejouer la boucle produit le même résultat sans effet cumulatif — aucune table de déduplication n’est requise.

Analogie : Considérons une liste de courses opposée à une pile de billets « ajoute un article ». La liste décrit l’état voulu du panier : la relire dix fois ne change rien, on achète exactement ce qui y figure. Les billets incrémentaux, eux, décrivent des transitions : traiter deux fois le billet « ajoute du lait » met deux briques dans le panier. Pour éviter cela, il faut tenir un registre des billets déjà honorés — c’est précisément le rôle de l’inbox. La liste, elle, se passe de registre : l’état complet suffit.

Points clés

  • Le pattern inbox protège un consommateur de messages contre la livraison at-least-once : le broker pouvant remettre deux fois le même message, le consommateur enregistre les identifiants déjà traités et ignore les répétitions. C’est une déduplication ajoutée par-dessus un modèle qui ne l’offre pas nativement.
  • Le pattern outbox résout le problème du dual-write : écrire dans la base et publier un message ne peut pas être atomique entre deux systèmes distincts. On écrit donc le message sortant dans une table locale, dans la même transaction que le changement d’état, puis un relais l’expédie. Deux mécanismes, une seule finalité : simuler une livraison fiable sur un support qui ne l’est pas.
  • Un reconciler level-based n’a besoin ni de l’un ni de l’autre. Il ne traite pas des transitions mais relit l’état désiré entier (« il doit exister 3 replicas »), calcule l’écart avec l’état réel observé, et agit. Relire deux fois le même état désiré conduit au même état final : l’idempotence est structurelle, pas outillée.
  • Le champ observedGeneration (vu au chapitre day 43-44) n’est pas une table d’inbox. Il n’empêche pas un traitement incorrect en cas de doublon ; il évite seulement un travail inutile lorsque la spec n’a pas changé. La correction ne dépend pas de lui : même sans ce champ, le rejeu resterait sûr.
  • L’idempotence native a une condition : chaque action doit être elle-même idempotente. On n’écrit pas « créer un pod » mais « garantir que 3 pods existent » (create-or-update, chapitre day 27-28). La boucle est sûre au rejeu parce que ses opérations le sont.

Exemple concret

Un service doit passer de 2 à 3 replicas. Modèle edge : un producteur émet un message ScaleUp(+1). Le broker livrant at-least-once, le message arrive deux fois ; sans inbox, le consommateur exécute deux incréments et le service monte à 4 replicas — un replica de trop. Corriger impose une table des identifiants traités. Modèle level : le reconciler lit spec.replicas = 3 (désiré) et 2 pods en cours (réel), calcule l’écart 3 − 2 = 1, crée un pod. Relancé aussitôt, il lit 3 désiré et 3 réel, écart nul, n’agit pas. Le même état d’entrée produit la convergence, jamais une accumulation : ni registre, ni identifiant de message, ni transaction distribuée.

Tableau — obtenir l’idempotence : edge vs level

MécanismeModèle edge (messaging)Modèle level (réconciliation)
Contre les doublonsPattern inbox (table de déduplication)Aucun : relire l’état est sans effet cumulatif
Publication fiablePattern outbox (contre le dual-write)Aucun : l’état vit dans etcd, pas de message à publier
Unité traitéeTransition (delta)État désiré complet (niveau)
Effet d’un rejeuCumulatif si non dédupliquéConvergent, invariant
Origine de l’idempotenceAjoutée par du code et des tablesStructurelle, native au modèle

Code YAML — l’état désiré est un niveau, pas un incrément

# On déclare un NIVEAU ("il doit exister 3 replicas"), jamais une transition
# ("ajoute 1 replica"). C'est ce qui rend la relecture idempotente par nature.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app
spec:
  replicas: 3            # état DÉSIRÉ absolu : relu tel quel à chaque cycle
  selector:
    matchLabels:
      app: app
  template:
    metadata:
      labels:
        app: app
    spec:
      containers:
        - name: web
          image: nginx:1.27

Code kubectl — rejouer la même intention ne cumule rien

# Déclarer l'état DÉSIRÉ complet (le "niveau"), pas un incrément.
kubectl apply -f app.yaml
# deployment.apps/app configured

# Rejouer exactement la même commande : aucun effet cumulatif.
kubectl apply -f app.yaml
# deployment.apps/app unchanged     # etat deja atteint -> operation idempotente

Code C# / KubeOps — inbox ajouté vs idempotence native

// MODÈLE EDGE (messaging) : le handler traite une transition.
// En livraison at-least-once, le même message peut arriver deux fois ;
// sans garde, l'effet s'accumule. Le pattern inbox rétablit l'idempotence.
public class ScaleUpHandler : IHandleMessages<ScaleUp>
{
    private readonly IInboxStore _inbox;   // table de deduplication : le "inbox pattern"

    public async Task Handle(ScaleUp msg, IMessageContext ctx)
    {
        if (await _inbox.AlreadyProcessed(msg.MessageId))
            return;                        // doublon deja vu -> on ignore

        await ScaleBy(delta: +1);          // transition : cumulative si rejouee
        await _inbox.MarkProcessed(msg.MessageId);
    }
}

// MODÈLE LEVEL (réconciliation) : ni inbox, ni MessageId, ni transaction.
// La boucle relit l'état DÉSIRÉ complet et comble l'écart ; la rejouer
// dix fois donne le même résultat qu'une fois — idempotence native.
public class ReplicaController : IEntityController<V1App>
{
    public async Task ReconcileAsync(V1App app, CancellationToken token)
    {
        int desired = app.Spec.Replicas;            // niveau desire (pas un delta)
        int actual  = await CountRunningPods(app);  // etat reel observe
        int diff    = desired - actual;             // ecart recalcule a chaque cycle

        if (diff > 0) await CreatePods(app, diff);
        else if (diff < 0) await DeletePods(app, -diff);
        // Aucun accuse, aucun offset, aucune deduplication : rien a acquitter.
    }
}

Piège courant : « Le modèle level-based rend tout code idempotent automatiquement » est inexact. Ce qui est natif, c’est l’absence de besoin d’une table de déduplication : relire l’état désiré ne cumule rien. Mais le rejeu n’est sûr que si chaque action est elle-même idempotente. Un reconciler qui exécute un CREATE aveugle au lieu d’un create-or-update (chapitre day 27-28) échoue au second passage ou duplique une ressource. L’idempotence native dispense de l’inbox, pas de la rigueur sur les effets de bord.


Exactly-once est un mythe, la convergence est la réponse

L’idée en une phrase

La livraison exactly-once — la garantie qu’un message soit remis et traité une fois et une seule — est irréalisable dans un système distribué sujet aux pannes : un récepteur peut tomber entre l’action et l’accusé de réception, et l’émetteur ne peut alors distinguer « traité » de « perdu ». Le modèle level-based ne cherche pas à résoudre ce problème, il le dissout : il n’y a pas de message à livrer une fois, seulement un état désiré vers lequel converger, chaque passage de la boucle étant idempotent — le nombre de livraisons cesse d’être une question.

Analogie : Considérons un GPS qui recalcule l’itinéraire. La destination est l’état désiré ; la position courante, l’état réel. Peu importe combien de fois l’itinéraire est recalculé, ni qu’un recalcul soit manqué faute de signal : tant que la destination est fixée, la trajectoire converge vers elle. Demander « combien de fois l’itinéraire a-t-il été livré exactement une fois ? » n’a aucun sens ; seule compte l’arrivée. La convergence remplace le comptage des livraisons.

Points clés

  • La livraison exactly-once est impossible pour une raison précise : un récepteur qui agit puis tombe avant d’émettre son accusé laisse l’émetteur dans l’incertitude. Retransmettre risque un doublon (at-least-once) ; ne pas retransmettre risque une perte (at-most-once). Aucune troisième voie ne garantit exactement une fois sur le fil.
  • Ce que l’on veut réellement n’est pas l’exactly-once de livraison mais l’exactly-once d’effet : que le résultat soit celui d’un traitement unique. Cet effet s’obtient par l’idempotence, c’est-à-dire at-least-once plus des opérations sans cumul. La distinction livraison / effet est le cœur du malentendu.
  • Les « exactly-once semantics » annoncées par certains brokers (Kafka) désignent un traitement transactionnel borné à leur propre périmètre (producteur idempotent, transactions lecture-traitement-écriture). Dès qu’un effet de bord sort de ce périmètre — appeler une API externe, écrire dans une autre base — l’at-least-once et l’idempotence réapparaissent.
  • La réponse level-based change la question. Un reconciler ne livre pas un message une fois ; il relit l’état désiré et converge. Rejouer la boucle N fois équivaut à la jouer une fois, car chaque passage est idempotent. « Combien de fois a-t-elle été déclenchée ? » devient sans objet.
  • La garantie offerte n’est donc pas « chaque événement traité une fois » mais « l’état réel finit par égaler l’état désiré » : une convergence éventuelle. C’est pourquoi un contrôleur ne gère ni offset, ni accusé (rappel du chapitre day 43-44) : il n’y a rien à acquitter, seulement un écart à combler.

Exemple concret

Un contrôleur doit garantir l’existence d’un load balancer cloud d’adresse 203.0.113.10. À t₀, il appelle l’API cloud EnsureLoadBalancer. Le load balancer est créé, mais l’accusé de l’API se perd sur le réseau : le contrôleur ignore si l’opération a « vraiment » abouti. Modèle edge sans idempotence : croyant avoir échoué, il réémet CreateLoadBalancer et se retrouve avec deux load balancers. Modèle level : au cycle suivant, il rappelle simplement EnsureLoadBalancer, opération idempotente indexée par nom ; le load balancer existant est reconnu, aucun doublon n’est créé. La question « le message a-t-il été livré exactement une fois ? » n’est jamais posée : le contrôleur vérifie l’état réel et converge vers l’état désiré, que l’appel précédent ait abouti ou non.

Tableau — trois réponses à une livraison non fiable

PropriétéExactly-once (livraison)At-least-once + idempotenceConvergence (level-based)
RéalisableNon, en présence de pannesOuiOui
Ce qui est garantiRien de tenableEffet équivalent à un traitement uniqueÉtat réel → état désiré à terme
Rôle des doublonsÀ éliminer par magieTolérés, sans effet cumulatifNon comptés : la boucle est rejouable
MécanismeIntrouvableDéduplication ou opérations idempotentesRelecture d’état et convergence
Question centrale« Livré une seule fois ? »« Effet unique ? »« État atteint ? »

Code YAML — un état désiré, pas un événement à livrer

# On ne "livre" pas cet objet une fois : on le publie comme ETAT DESIRE.
# Le contrôleur de service convergera vers lui, quel que soit le nombre de cycles.
apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: LoadBalancer      # etat vise : un LB routant vers les pods "app: web"
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 8080

Code kubectl — la convergence est invariante par rejeu

# "exactly-once" n'a pas de sens ici : on applique un ETAT, pas un evenement.
kubectl apply -f service.yaml
# service/web configured

# Perte reseau, panne du controleur, rejeu : le resultat converge, invariant.
kubectl apply -f service.yaml
# service/web unchanged
kubectl get svc web
# NAME   TYPE           CLUSTER-IP   EXTERNAL-IP     PORT(S)        AGE
# web    LoadBalancer   10.0.12.7    203.0.113.10    80:31200/TCP   2m

Code C# / KubeOps — converger sans compter les livraisons

// La question "le message a-t-il ete livre exactement une fois ?" n'a pas de sens ici.
// On ne livre rien : on CONVERGE vers l'etat desire. Rejouer la boucle n'ajoute
// aucun effet de bord, car "ensure" est idempotent (create-or-update, day 27-28).
public class LoadBalancerController : IEntityController<V1Endpoint>
{
    private readonly ICloudLb _cloud;

    public LoadBalancerController(ICloudLb cloud) => _cloud = cloud;

    public async Task ReconcileAsync(V1Endpoint ep, CancellationToken token)
    {
        // AGIR de facon idempotente : creer si absent, mettre a jour sinon.
        // Si l'accuse du cloud est perdu et que la boucle rejoue, aucun doublon :
        // "ensure" indexe par nom converge vers un unique load balancer.
        await _cloud.EnsureLoadBalancer(name: ep.Name(), ip: ep.Spec.Ip, token);
        // Aucun ack a renvoyer : la preuve du succes, c'est l'etat reel au cycle suivant.
    }
}

Piège courant : « Kafka fait de l’exactly-once, donc la livraison exactly-once existe » confond deux notions. Les exactly-once semantics de Kafka garantissent un traitement unique à l’intérieur d’un périmètre transactionnel Kafka-vers-Kafka (producteur idempotent et transactions), non une livraison exactly-once vers des effets de bord arbitraires. Dès qu’une action franchit cette frontière — un paiement, un appel d’API externe — la seule garantie tenable redevient at-least-once plus idempotence. Le modèle level-based, lui, ne prétend pas livrer une fois : il converge, ce qui rend la question du comptage sans objet.


Quiz — validation des acquis
Bonus 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Relire la fiche memo ci-dessus en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.