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écanisme | Modèle edge (messaging) | Modèle level (réconciliation) |
|---|---|---|
| Contre les doublons | Pattern inbox (table de déduplication) | Aucun : relire l’état est sans effet cumulatif |
| Publication fiable | Pattern outbox (contre le dual-write) | Aucun : l’état vit dans etcd, pas de message à publier |
| Unité traitée | Transition (delta) | État désiré complet (niveau) |
| Effet d’un rejeu | Cumulatif si non dédupliqué | Convergent, invariant |
| Origine de l’idempotence | Ajoutée par du code et des tables | Structurelle, 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
CREATEaveugle 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 + idempotence | Convergence (level-based) |
|---|---|---|---|
| Réalisable | Non, en présence de pannes | Oui | Oui |
| Ce qui est garanti | Rien de tenable | Effet équivalent à un traitement unique | État réel → état désiré à terme |
| Rôle des doublons | À éliminer par magie | Tolérés, sans effet cumulatif | Non comptés : la boucle est rejouable |
| Mécanisme | Introuvable | Déduplication ou opérations idempotentes | Relecture 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.