Aller au contenu
gaetancottrez.dev

Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard

Publié: at 08:00 | (10 min de lecture)
Les délais sont une obligation, le regroupement une optimisation : comment j'ai construit un batcher incapable de mettre en retard

Table des matières

Ouvrir table des matières

Le paradoxe : on vous demande de grouper, on vous interdit d’être en retard

Le batching, vous connaissez. Regrouper plusieurs opérations en une seule — moins d’appels réseau, moins de connexions, moins de charge pour tout le monde. C’est une des optimisations les plus naturelles du métier, et en général personne ne vous en voudra si vous groupez un peu trop ou un peu mal.

Sauf que parfois, les envois que vous voulez grouper portent chacun une échéance. Dans mon cas, un système tiers auquel ma plateforme transmet des flux recommande de grouper les envois (pour ménager un quota horaire, dont je vous ai déjà raconté l’arbitrage)… tout en imposant des délais de transmission par objet, avec de vraies conséquences en cas de retard.

Relisez ces deux phrases : le regroupement est une recommandation, le délai est une obligation. Elles n’ont pas le même poids. Et un tampon naïf — « j’accumule dans un buffer, j’envoie quand il est plein » — fait exactement le mauvais échange : il économise un dépassement de quota toléré au prix d’un délai fiscal manqué et sanctionné. Autant dire qu’il optimise la mauvaise variable.

Alors avant d’écrire la moindre ligne de code, j’ai posé l’exigence sur papier, et c’est elle qui structure tout cet article : la couche de regroupement doit être incapable, par construction, de mettre un objet en retard. Pas « configurée pour ne pas le faire ». Incapable. La nuance est toute la différence entre un système qu’on espère sûr et un système qui l’est.

La garde d’admission : tout se joue à l’entrée

Où placer la sûreté ? Ma réponse : à l’entrée du lot, pas à la sortie. C’est le morceau que je trouve le plus élégant du design, parce qu’il tient en une règle : un objet n’entre dans un lot que s’il lui reste plus qu’une marge de sécurité avant son échéance.

Concrètement, chaque candidat au regroupement passe par une garde d’admission qui peut refuser pour quatre raisons :

Et que se passe-t-il en cas de refus ? Rien de grave, et c’est le deuxième point capital : l’objet refusé garde son chemin d’envoi unitaire, strictement inchangé. Le regroupement est une optimisation de transport, pas un changement de contrat : un refus d’admission dégrade vers le comportement historique, octet pour octet. Le batcher ne peut donc pas casser — au pire, il n’optimise pas.

L’argument de sûreté complet tient alors en une phrase, et je vous invite à la savourer : un objet n’est bufferisé que s’il a plus d’une marge d’avance, et le lot est scellé pour envoi tant qu’il reste de la marge — donc le tampon ne peut pas rendre en retard un objet qui était dans les temps. Ce n’est pas une promesse de la configuration. C’est une propriété de la structure.

La garde d'admission : tout est dans la marge

On veut du code ! Un seul lot ouvert, et une machine à états qui survit aux crashs

Passons au squelette. Deux tables : le lot, et ses membres.

CREATE TABLE batch (
  id           UUID PRIMARY KEY,
  partition    TEXT NOT NULL,        -- un type de flux = une partition
  state        TEXT NOT NULL,        -- OPEN | SEALED | SENT | SETTLED
  sealed_at    TIMESTAMPTZ,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Le verrou structurel : AU PLUS UN lot ouvert par partition.
CREATE UNIQUE INDEX one_open_batch_per_partition
  ON batch (partition) WHERE state = 'OPEN';

Ce petit index mérite qu’on s’arrête. C’est un index unique partiel : l’unicité ne porte que sur les lignes OPEN. Résultat : il ne peut jamais exister deux lots ouverts pour la même partition, quelle que soit la concurrence. Deux processus qui tentent un find-or-create simultané ? L’un gagne, l’autre se prend une violation d’unicité et retente — il trouve alors le lot du gagnant. La course est résolue par la base, pas par un verrou applicatif. (Petit aparté : mon ORM ne sait pas exprimer un index partiel, alors la migration est écrite à la main en SQL. Ne laissez jamais un outil vous priver du bon design — la base sait faire, servez-vous.)

Ensuite, le cycle de vie : OPEN → SEALED → SENT → SETTLED. Et chaque transition est un compare-and-set :

// La transition ne s'applique que si l'état est encore celui attendu.
const res = await sql`
  UPDATE batch SET state = 'SENT', sent_at = now()
  WHERE id = ${batchId} AND state = 'SEALED'
`;
if (res.count === 0) {
  // Quelqu'un est passé avant nous (ou l'a déjà fait) : on s'arrête, sans erreur.
  return { outcome: "lost-cas" };
}

Pourquoi ce formalisme ? Parce que le moteur d’envoi peut planter à n’importe quel point — entre le scellement et l’envoi, pendant l’envoi, après. Avec des transitions CAS, la reprise est triviale : le prochain passage retrouve le lot dans son état réel et reprend là où on en était. Le gagnant du CAS est unique, donc les effets de bord attachés à une transition (comme émettre l’événement de métrique) s’exécutent exactement une fois. Rien de bien sorcier, mais ça transforme « pourvu que ça ne plante pas au mauvais moment » en « plantez quand vous voulez ».

Geler les octets à l’entrée, pour des retries ennuyeux

Encore deux décisions de structure, discrètes mais qui portent beaucoup.

Un. La charge utile de chaque membre est gelée dans le stockage dès l’admission, pas construite au moment de l’envoi. Double bénéfice : une erreur de conversion frappe un objet à la fois, à l’entrée — au lieu d’empoisonner tout un lot à l’heure de l’envoi ; et les octets d’un lot scellé sont reproductibles indéfiniment.

Deux. La reconstruction de l’archive est déterministe : membres dans un ordre stable, horodatage interne épinglé à l’instant du scellement, nom d’envoi réservé avant d’ouvrir la connexion. Conséquence : un retry re-téléverse exactement les mêmes octets sous exactement le même nom. Côté tiers, un retry est indistinguable de l’original — jamais un double envoi. La contrepartie assumée : un crash entre la réservation du nom et l’envoi brûle un numéro pour rien. Les identifiants doivent être uniques, pas denses — encore une fois, on choisit son sens d’erreur, et on sur-consomme plutôt que de risquer le doublon.

Quant au déclenchement du scellement, quatre causes, et l’ordre n’est pas décoratif : le délai d’abord (un membre approche de la marge → on scelle), puis l’âge du lot, le nombre d’objets, et la taille — cette dernière en basculant sans le nouveau membre : le lot courant se scelle, un lot neuf s’ouvre avec l’arrivant. Le délai passe avant les critères d’optimisation, dans le code comme dans la doctrine.

Livré éteint (oui, encore)

Si vous avez lu l’article sur le quota, vous connaissez la chanson : tout ce mécanisme a été livré éteint. Le plafond d’objets par lot vaut 1 par défaut — et rappelez-vous la garde d’admission : plafond ≤ 1, refus, chemin unitaire. Autrement dit, le déploiement du batcher a été byte-identique au comportement existant. Aucun émetteur touché, aucune fixture de test modifiée.

Et il y a un piège de coordination que je veux vous signaler, parce qu’il est vicieux : lever le plafond sans démarrer le moteur d’envoi, c’est enrôler des objets que rien ne viendra jamais flusher — un retard garanti, fabriqué par la configuration. La parade est organisationnelle autant que technique : lever le plafond est une action d’opérateur couplée au démarrage du moteur, documentée comme telle. Une optimisation qu’on active en deux temps doit nommer explicitement l’ordre des deux temps.

Les frontières : ce qu’on ne groupe jamais, là où on ne groupe jamais

Un batcher générique qui marche, c’est bien. Savoir où il ne doit pas s’appliquer, c’est mieux — et ces deux frontières-là m’ont appris autant que le cœur du système.

Ce qu’on ne groupe jamais. Un de mes types de messages est un accusé qui, chez le tiers, se corrèle à son message d’origine… par le nom de l’envoi, dérivé du nom d’UN unique message d’origine. Vous voyez le problème : il n’existe aucun nom d’envoi qui puisse référencer N origines. Grouper ces accusés n’est pas déconseillé, c’est structurellement impossible — l’argument relève de l’espace de nommage, pas du volume, et aucun réglage ne peut le contourner. Du coup, ce flux-là reste unitaire pour toujours, et on maîtrise sa pression sur le quota autrement : en abaissant sa cadence de drainage (configurable par l’opérateur). Quand le groupement est impossible, le débit reste un levier.

Là où on ne groupe jamais. Ma plateforme a un environnement bac à sable pour les intégrateurs, dont le contrat produit promet des résultats immédiats et déterministes. Bufferiser une émission jusqu’à une heure y détruirait la promesse. La parade : la garde d’admission refuse tout enrôlement quand le mode sandbox est actif — et ce refus est placé en tête de la garde, avant même la lecture des plafonds, précisément pour qu’aucune configuration ne puisse le réactiver par accident. La sûreté par construction, jusque dans l’ordre des if.

Et ma preuve préférée de toute cette histoire : pour valider cette garde, le test nominal ne suffisait pas. Un test qui montre « la sandbox n’a pas bufferisé » ne dit pas pourquoi — c’est peut-être la garde, c’est peut-être juste la config ambiante. Alors un run de contre-factuel a été exécuté : l’ancien binaire, sans la garde, même configuration — et lui, il bufferisait. Voilà ce qui démontre que c’est bien la garde qui protège le contrat, et non les réglages du moment. Prouvez le mécanisme, pas la météo.

Les deux frontières du batcher

Épilogue

Ce que je retiens de ce chantier, et que je vous encourage à voler :

Cet article est le deuxième d’une petite famille sur les systèmes contraints par un tiers : le premier, Un compteur ne fait pas un quota, racontait l’arbitrage du plafond horaire que ce batcher ménage. Vous pourriez aussi aimer l’architecture hexagonale, qui donne à ce genre de moteur une frontière propre entre domaine et infrastructure, et les tests unitaires — parce qu’une machine à états CAS sans tests de reprise après crash, c’est de la décoration.

Et vous, votre batcher : il espère être à l’heure, ou il est incapable d’être en retard ? Racontez-moi vos tampons en commentaire — je me ferai une joie de vous répondre.

Vous pourriez aussi aimer

Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données

Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données

Et si l'état de votre objet ne vous appartenait pas ? Modéliser une machine à états réglementaire

Et si l'état de votre objet ne vous appartenait pas ? Modéliser une machine à états réglementaire

Article précédent
Ce que ni les tests unitaires ni les E2E ne voient : l'interblocage qui dormait sous les 16 Kio
Article suivant
Un compteur ne fait pas un quota : pourquoi et comment j'ai fait respecter une limite partagée en base de données