22. Architecture¶
Chaque étape de traitement après l’ingress est un programme d’étape indépendant piloté par une unique table de base de données. Ce chapitre couvre la disposition de l’espace de travail, le modèle de données, le contrat d’étape, le dispatcher et le cycle de vie du message.
22.1. Disposition de l’espace de travail¶
Pepsi est un espace de travail Cargo (édition 2024). La racine de l’espace de travail est elle-même le paquet ``pepsi`` et possède chaque [[bin]] de src/bin/*.rs ; les crates membres pepsi-* sont des bibliothèques dont les binaires fins appellent le run(). Ainsi le binaire pepsi-X réside dans src/bin/pepsi-X.rs et délègue à la bibliothèque de la crate pepsi-x.
pepsi-common— infrastructure partagée : validation de domaine (domain), clés et signature DKIM/ARC (keys/sign/auth), le client SMTP et LMTP sortant (smtp), le moteur SRS (srs), les types DSN (dsn), la machinerie de rétrogradation MIME (mime), la séparation en-tête/corps (message), la couche de sockets d’écoute (net), le résolveur de destinataires locaux (local), la couche de redéfinition par adresse (settings), les règles de réponse automatique RFC 3834 (autoreply) et l”échafaudage d’étape (stage).pepsi-ingress,pepsi-dispatch,pepsi-httpd,pepsi-setup,pepsi-keydisc,pepsi-sendmail,pepsi-telemetry-client,pepsi-telemetry— les services et clients qui ne sont pas des étapes.pepsi-queue,pepsi-status,pepsi-config,pepsi-settings,pepsi-whitelist,pepsi-quota,pepsi-keys,pepsi-tlsrpt,pepsi-failure-bouncer,pepsi-list,pepsi-archive,pepsi-secure-link— les outils en ligne de commande de l’opérateur (aucun n’est une étape ; les trois derniers contiennent aussi la logique des listes de diffusion, des archives et des liens sécurisés que sertpepsi-httpd).pepsi-crypto,pepsi-keymat,pepsi-setup-model,pepsi-stage-validate— d’autres bibliothèques partagées (le code des protocoles OpenPGP/S/MIME, le chargement des clés stockées, le modèle de l’entretien de configuration, et les validateurs de configuration par étape).pepsi-helper-*— les petits helpers privilégiés ou à usage unique qu’une étape ou un outilexecute (maildir-writer,dot-forward,auto-pay,mailbox-scan,token-refresh).pepsi-stage-*— les programmes d’étape.vendor/taler-rust— un sous-module git intégré (vendored) (un espace de travail séparé, exclu ici) fournissant la machinerie config/CLI/base de données detaler-common(taler_main,ConfigSource,Config/Section,taler_common::db).
Chaque programme définit un constants::CONFIG_SOURCE qui l’identifie (projet/composant/exec), et les programmes d’étape définissent en plus un constants::PROGRAM — le nom de binaire utilisé comme PROGRAM dans une section d’étape.
22.2. Le binaire unifié¶
Un build de développement (la fonctionnalité Cargo multibin par défaut) compile chaque programme en son propre exécutable. Un build de release active à la place la fonctionnalité unibin, qui lie la plupart des programmes en un unique exécutable multi-appel, pepsi : un petit main inspecte argv[0] et aiguille vers le point d’entrée du programme correspondant. make install place ce binaire unique dans $PREFIX/libexec/pepsi/pepsi et crée un lien symbolique $PREFIX/bin par nom de programme pointant vers lui (pepsi-ingress -> ../libexec/pepsi/pepsi, et ainsi de suite). Les opérateurs, le dispatcher et les unités systemd continuent d’utiliser sans changement les noms de chaque programme ; seule la forme sur disque diffère. Les main des programmes pliés résident dans la bibliothèque propre du paquet racine (pepsi::programs::<name>), de sorte que le même code sous-tend à la fois les binaires autonomes et le binaire unifié.
La motivation reflète la raison pour laquelle un projet C préfère une bibliothèque partagée plutôt que de lier statiquement le même code dans chaque exécutable. Les programmes de Pepsi partagent énormément de code — le runtime asynchrone (Tokio), les piles SQL et TLS (sqlx, rustls), la machinerie SMTP/MIME/DKIM de pepsi-common, la couche CLI/config, et plus encore. Lorsque chaque programme est son propre binaire lié statiquement, chacun porte une copie privée de tout ce code commun. Les plier en un binaire unique conserve une seule copie, avec trois bénéfices visés :
- Plus petit sur disque
La quarantaine de programmes pliés (
FOLDED_BINARIESdans leMakefile) deviennent un seul binaire plus un lien symbolique (quelques octets) chacun, au lieu de quarante exécutables de plusieurs mégaoctets qui ré-incorporent chacun le code partagé.- Démarrage de processus plus rapide
Pepsi est gourmand en processus : le dispatcher exécute chaque étape comme un pool de processus workers et les relance (après
MAX_MESSAGES, un timeout ou un plantage ; voir Le dispatcher). Comme chaque worker — de chaque étape — fait unexecdu même fichier, le texte de ce fichier est lu sur disque au plus une fois puis servi depuis le cache de pages du noyau pour chaque lancement ultérieur. Avec des binaires séparés, le premier lancement de chaque étape distincte paie sa propre lecture à cache froid.- Moins de mémoire physique
Les segments en lecture seule (code et données en lecture seule) d’un exécutable
mmap-é sont adossés à des pages physiques partagées : chaque processus exécutant le fichier mappe la même copie physique (ceci est indépendant de l’ASLR, qui ne randomise que l’adresse virtuelle, pas l’adossement au cache de pages). Avec un binaire unique, le dispatcher, l’ingress, le serveur HTTP et tous les workers d’étape — potentiellement des dizaines de processus — partagent une unique copie résidente du code commun. Avec des binaires séparés, chaque programme distinct en cours d’exécution garde sa propre copie résidente de ce code dupliqué, exactement la redondance qu’une bibliothèque partagée supprime.
Deux classes de programmes sont délibérément gardées séparées, installées comme leurs propres exécutables $PREFIX/bin (leurs cibles [[bin]] se construisent dans les deux modes de fonctionnalité et sont exclues du binaire unifié) :
Programmes déployés ou invoqués séparément.
pepsi-stage-detect-languagelie les modèles de languelingua— une grosse dépendance que rien d’autre n’utilise — etpepsi-setuptire un client HTTP et la logique de provisionnement ponctuelle. En plier l’un ou l’autre grossirait le binaire partagé, et l’ensemble résident de chaque processus, avec du code qu’un seul programme utilise ; ils restent donc à l’écart.pepsi-configest de même laissé autonome ;pepsi-telemetryest livré dans son propre paquet Debian et s’exécute sur un autre hôte ; etpepsi-helper-mailbox-scanne porte aucun bit de permission mais estexecuté par son nom depuispepsi-whitelist. (pepsi-stage-detect-languageest lui-même un second binaire multi-appel à deux programmes : le même fichier est aussi pepsi-detect-language(1), installé comme lien symbolique vers lui dans$PREFIX/bin.)Binaires privilégiés. Onze programmes portent leur propre bit de permission : les helpers setuid-root
pepsi-helper-maildir-writer,pepsi-helper-dot-forwardetpepsi-helper-auto-pay; leurs étapes appelantes setgidpepsi-stage-relay-to-maildir,pepsi-stage-dot-forward,pepsi-stage-auto-payetpepsi-quota; lepepsi-stage-relay-to-smarthostsetgid (qui lit les fichiers de jetons OAuth du groupepepsi-token) ; lepepsi-whitelistsetuid ; etpepsi-stage-encrypt/pepsi-stage-decrypt, setuidpepsi-cryptoparce que l’authentification peer de PostgreSQL se fonde sur l”uid effectif et que ce rôle est le seul auquelcrypto_identity.private_wrappedest accordé. Un lien symbolique ne peut porter un bit setuid/setgid — le bit doit résider sur le fichier cible — donc les plier forcerait ce bit sur le binaire partagé, où il s’appliquerait à chaque programme lié symboliquement et ferait s’effondrer la séparation de privilèges décrite dans Installation. (pepsi-keysest autonome pour la même famille de raisons mais est livré sans bit ;debian/pepsi.postinstest la liste faisant autorité de ce qui est réellement positionné.)
Note
Les gains de temps de démarrage et de mémoire sont réalisés à l’exécution par les services de longue durée gourmands en processus (le dispatcher et ses pools de workers, où de nombreux processus exécutent le seul fichier en concurrence) ; une commande d’opérateur ponctuelle invoquée une seule fois — disons pepsi-status via SSH — bénéficie du gain de taille sur disque mais d’aucun bénéfice de démarrage lié au partage. Les binaires autonomes (STANDALONE_BINARIES dans le Makefile est la liste faisant autorité) incorporent eux aussi chacun leur propre copie du code commun, de sorte que la déduplication est large mais non totale — un compromis délibéré pour les deux raisons ci-dessus.
22.3. Le modèle de données¶
Un seul schéma PostgreSQL, pepsi, contient 61 tables. Deux d’entre elles sont ce sur quoi tout le système repose : la file de messages, et la surcouche de configuration, qui vit là parce que la base de données est le seul état partagé, inscriptible et transactionnel dont dispose un déploiement. La plupart des autres accompagnent l’une de ces deux — caches, compteurs et surcharges par adresse que consulte le pipeline. Le reste appartient à des sous-systèmes autonomes qui gardent leur propre état dans le même schéma : le magasin de clés de bout en bout (ci-dessous), le portail de liens sécurisés, le secrétaire de confirmation d’envoi, les quotas de boîtes aux lettres, la surface d’administration, les listes de diffusion (23 tables list_* et mailing_list) et leur archive web (neuf tables archive_*). pepsi-setup/db/pepsi-0001.sql fait foi pour la liste.
pepsi.workqueueUne ligne par message en vol. Outre l’enveloppe et les métadonnées analysées, le message lui-même est stocké scindé en une colonne
headerset un corps (coupé à la première ligne vide ; l’invariant estraw = headers || CRLF || body). Les étapes qui ne lisent que les en-têtes ne chargent ni ne réécrivent jamais le corps. Le corps est une ligne depepsi.workqueue_body, référencée parbody_idet partagée par toutes les lignes détachées du même message (paramètres par adresse, la ligne par destinataire d’une étape de relais, une diffusion de liste, les clones de bounce et de DSN), de sorte qu’un message destiné à N destinataires est stocké une fois, et non N fois. Une étape qui réécrit un corps obtient une nouvelle ligneworkqueue_bodypour son seul message (copie sur écriture, danscommit_row), de sorte qu’une ligne sœur ne voit jamais la réécriture d’une autre. Les corps sont libérés par un trigger lorsque la dernière ligne qui les référence disparaît, et pepsi-dispatch balaie ce qu’une situation de concurrence laisse derrière elle (workqueue_body_gc). Les colonnes de contrôle du pipeline sont :stage— la section[stage-<name>]du programme actuellement responsable du message (initpour un message neuf).status—pending/running/paused/failed/timeout(unENUMPostgreSQL).state—JSONBde forme libre transporté avec le message (voir L’objet d’état).timeout— quand un messagepauseddoit être réessayé.
Les verdicts d’authentification (
spf/dkim/dmarc/arc) et l”authserv_idrésident dansstate(sous les clésauthetoriginrespectivement), pas dans des colonnes dédiées.pepsi.dns_addressUn cache des adresses A/AAAA d’hôtes MX résolues, avec la santé de connexion par adresse, maintenu par
pepsi-stage-relay-to-internet. Une adresse fonctionnelle est préférée jusqu’à expiration de son TTL DNS ; un hôte est re-résolu une fois que toutes ses adresses ont échoué ou expiré.pepsi.stage_stats/pepsi.dispatch_statsStatistiques cumulatives du pipeline : nombres de messages par étape, temps de traitement total, kills/timeouts et plantages, plus les totaux globaux d’étapes/messages. La ligne globale porte aussi
messages_failed(les messages qui ont fini dans un état terminalfailed/timeout) etserialization_failures(les événements transitoires de réessai pour sérialisation/interblocage40001/40P01observés par le dispatcher) — tous deux censés rester à zéro en fonctionnement normal, de sorte qu’un compteur qui monte signale un défaut plutôt qu’une limite de charge.pepsi-dispatchen est le seul écrivain : il accumule les deltas en mémoire et les vide en une transaction environ une fois par minute, chaque fois que le pipeline devient inactif, et à l’arrêt. Les étapes fusionnées dans la passe de worker d’une autre étape lui sont rapportées sur la ligne de statut de worker de cette passe plutôt qu’écrites par le worker, de sorte qu’une table qui a une ligne par étape n’a jamais tous les workers d’une étape en contention sur elle. Le/metricsdepepsi-httpdles lit (ainsi que les jauges actives/en pause en direct, prises telles quelles danspepsi.workqueue).pepsi.config_overrideLa configuration gérée par l’administrateur, superposée au fichier INI : une ligne par redéfinition
(scope, section, option), où le scope estglobal,domain:<domain>ouaddress:<address>. C’est ce qui permet de reconfigurer un déploiement en fonctionnement sans éditer de fichiers. Cela fonctionne comme la file : un déclencheur émet unNOTIFYconfig_changed, et les composants réagissent —pepsi-dispatchretire ses workers d’étape afin que leurs remplaçants lisent les nouvelles valeurs.Deux propriétés sont structurelles plutôt que conventionnelles. Les sections qui doivent exister avant même qu’une connexion à la base existe — et les sections de listener, qui sont une frontière de sécurité — ne sont jamais lues d’ici, de sorte qu’une base compromise ne peut ni déplacer une socket d’écoute ni rediriger la connexion à la base. Et le droit d’écriture appartient à un rôle PostgreSQL dédié
pepsi-configque ne détient aucun composant traitant du courrier : un worker d’étape peut lire la configuration, il ne peut pas la modifier. Voir Configuration.pepsi.settingsLa couche de redéfinition par adresse, et une table délibérément distincte de
config_overrideparce que les titulaires de comptes l’écrivent eux-mêmes par e-mail. La distinction est une frontière de privilèges, non une duplication.
Les lignes sont insérées par la fonction stockée workqueue_add, qui délibérément ne notifie pas : PostgreSQL retient un verrou à l’échelle de la base depuis le moment où une transaction met une notification en file jusqu’à son commit, de sorte que garder pg_notify hors de la transaction d’admission est ce qui permet aux sessions SMTP concurrentes de committer en groupe. pepsi-ingress coalesce à la place et réveille le dispatcher une seule fois par rafale, hors bande et après le commit des lignes (DISPATCH_WAKE_INTERVAL) ; les écrivains hors de la boucle du dispatcher — workqueue_inject, workqueue_resume, keydisc_release, pepsi-failure-bouncer et les commandes de réparation de pepsi-queue — notifient explicitement (via pepsi_common::db::notify_workqueue), et ceux qui sont à l’intérieur restent silencieux parce que la ligne de statut du worker ramène d’elle-même le coordinateur à la même revendication. Le contrat est écrit en toutes lettres en tête de pepsi-setup/db/procedures.sql.
Une étape injecte un message latéral entièrement nouveau avec workqueue_inject (via StageContext::enqueue_new), et engendre des clones de la ligne courante — un DSN de délai, un rebond par destinataire, un DSN de succès — avec workqueue_pause ou workqueue_finish_with_clones, qui la clonent côté serveur de sorte que le corps ne fait jamais d’aller-retour par l’application ; le token unique du clone le rend au plus une fois.
Cinq autres tables forment le magasin de clés de la cryptographie de bout en bout, qui n’a aucune part dans la machine à états des messages et se gère avec son propre outil (pepsi-keys ; la conception est dans Gestion des clés) :
pepsi.crypto_identityLes paires de clés des adresses que nous servons — matériel public plus la moitié privée — une ligne par capacité (
sign,encryptouboth), avec ses colonnes de cycle de vie (status,is_primary,published,expires_at,private_purged_at).pepsi.peer_keyLes clés publiques et certificats mis en cache des correspondants distants, chacun avec la
sourcedont il provient, l’indication de savoir si cette résolution a été validée par DNSSEC, le dernier verdict de validité et une échéance de cache.pepsi.peer_has_own_keyLes correspondants dont il est prouvé qu’ils détiennent l’une de nos propres clés (ils ont chiffré pour elle et signé avec leur propre adresse), de sorte que notre fichier de clé n’est pas joint au courrier qui leur est destiné. Écrite par
pepsi-stage-autocrypt-learn.pepsi.ca_trustLes certificats de CA qu’une chaîne S/MIME entrante doit atteindre pour être tenue pour digne de confiance.
pepsi.key_requestLes requêtes de découverte de clés en vol auxquelles
pepsi-keydiscrépond, une ligne par adresse, portant les méthodes demandées et celles qui ont répondu, et — une fois réglées sans clé — faisant également office de cache négatif (negative_until) qui empêche un second message pour la même adresse de se garer à nouveau.
Sept autres tables appartiennent à la surface d’administration (L’API d’administration) et à la configuration initiale en ligne, et se situent elles aussi hors de la machine à états des messages :
pepsi.admin_account/pepsi.admin_session/pepsi.api_tokenLes comptes d’administrateurs distants (condensats de mot de passe Argon2id), leurs sessions de navigateur en cours, et les jetons porteurs avec lesquels l’automatisation s’authentifie. Les sessions sont des lignes plutôt que de la mémoire de processus, de sorte qu’un redémarrage ne déconnecte personne et que deux processus
pepsi-httpds’accordent sur qui est connecté. Seuls les condensats d’un cookie de session et de la moitié secrète d’un jeton sont stockés : une sauvegarde de base de données ne contient aucun identifiant utilisable.pepsi.event_logLe journal d’audit — une ligne par changement de configuration, opération sur les clés, connexion, échec de connexion, modification de compte ou de jeton et action administrative sur la file, avec le principal qui l’a effectuée. Il est écrit par l’API et par les outils en ligne de commande de l’opérateur, à travers un unique helper de
pepsi-common, de sorte qu’il est complet quelle que soit la surface qui a agi ; un journal qui n’enregistrerait que ce qui s’est passé par HTTP inviterait à tirer la mauvaise conclusion d’une absence.pepsi.mail_logL’enregistrement par message, sur consentement explicite (
[pepsi] MAIL_LOG, désactivé par défaut). Puisque la ligne d’un message remis est supprimée, un déploiement ordinaire ne conserve aucun journal par message. L’activer fait conserver au déploiement la trace de qui correspond avec qui : c’est donc un choix explicite, à rétention bornée. Voir Le journal de courrier (désactivé par défaut).
Deux autres appartiennent à la configuration initiale en ligne (Installation), et portent la frontière de privilèges la plus tranchée du schéma :
pepsi.setup_task/pepsi.setup_task_logLa file d’intentions que la configuration initiale pilotée par navigateur écrit et qu’un programme root vide. Configurer un serveur de messagerie est privilégié — écrire
/etc/pepsi/pepsi.conf, remettre chaque fragmentsecrets.dà l’unique compte qui le lit, exécuter certbot, créer des rôles en base, générer des clés — etpepsi-httpdabandonne ses privilèges avant d’accepter une connexion. Il n’agit donc pas : il écrit une ligne décrivant ce qui devrait être vrai, issue d’un ensemble fermé de onze natures aux paramètres strictement validés, etpepsi-setup applydécide du comment. On atteint root par une table, jamais par une socket parlant un protocole, et chaque demande et chaque issue est une ligne durable. La seconde table porte les lignes de progression qu’émet une tâche en cours, de sorte qu’une exécution de certbot puisse être observée sans que l’applicateur retienne une connexion HTTP.Une ligne dans cette table est une demande adressée à un processus tournant en root : les droits y sont donc les plus serrés du schéma. Aucun compte qui traite du courrier ne peut y toucher,
pepsi-httpdne peut queSELECT(il met en file par la connexion de configuration distincte), et seulpepsi-configpeutINSERT. Lequel d’entre eux a écrit une ligne n’est pas pris sur parole :written_byest forcé depuiscurrent_userpar un déclencheurBEFORE INSERT, c’est donc un fait sur la connexion plutôt qu’une prétention dans la charge utile, et l’applicateur refuse tout le reste. Le modèle de confiance complet est dans pepsi-setup(1).
Celles-ci portent l’autre frontière d’accès du schéma. Les trois tables d’identifiants sont accordées au rôle pepsi-httpd seul — un composant capable de lire admin_session pourrait se forger une session d’administration — et les deux journaux sont en ajout seul pour tout ce qui traite du courrier : chaque rôle peut INSERT (c’est ce qui rend le journal d’audit complet) et aucun ne peut UPDATE ni DELETE. pepsi-setup réapplique les deux restrictions à chaque exécution, car le GRANT … ON ALL TABLES global qu’il donne aux rôles du pipeline les annulerait sinon, puis il les vérifie contre le serveur en fonctionnement.
crypto_identity.private_wrapped détient chaque clé privée scellée en AES-256-GCM sous une clé de chiffrement de clés (KEK) qui ne réside que dans un fragment secrets.d, et pepsi-setup accorde cette unique colonne au seul rôle pepsi-crypto : chaque rôle de service ordinaire (pepsi, pepsi-ingress, pepsi-httpd, pepsi-telemetry) voit son droit au niveau table remplacé par un droit de niveau colonne qui l’omet. Ainsi la compromission d’une étape sans rapport ne livre que la moitié publique de chaque identité et rien de plus, et une base volée ne livre que du texte chiffré, même pour cette colonne.
Le schéma est une série numérotée de patchs accompagnée d’un fichier de procédures recréable. Un patch publié n’est jamais modifié : la première version, 0.0.0, a livré pepsi-0001.sql, et chaque version ultérieure qui modifie le schéma ajoute le patch suivant, qui fait avancer sur place une base de données existante. Voir Mise à niveau et Étendre le pipeline.
22.4. Le pipeline d’étapes¶
Tout ce qui suit l’ingress est une machine à états sur une table unique. Les pièces mobiles sont :
Sections d’étape. Chaque
[stage-<name>]nomme unPROGRAMet desNEXT_STAGE/BOUNCE_STAGEfacultatifs (Le pipeline d’étapes), plus les réglages de pool de workersPARALLELISM,MAX_MESSAGESetQUEUE_LIMIT(combien de messages le dispatcher pipeline à la fois vers un worker). Les noms d’étape sont des étiquettes choisies par l’opérateur, indépendantes des noms de crates, de sorte qu’un même binaire peut servir plusieurs étapes.Workers d’étape. Chaque étape s’exécute comme un pool de processus workers persistants (
PROGRAM worker). Un worker lit desworkqueue_idsur l’entrée standard (pipelinés jusqu’àQUEUE_LIMITà la fois, traités strictement dans l’ordre), fait son travail par message, appelle exactement un helper terminal et écrit une ligne de statut (0en cas de succès) par message sur la sortie standard. Une ligne de statut peut porter un second champ, un rapport JSON des étapes fusionnées dans cette passe, que le dispatcher intègre à ses statistiques. Pour une opération manuelle ou des tests, un seul identifiant peut être envoyé par tube à un worker (echo <workqueue_id> | PROGRAM worker) ; il n’existe pas de forme positionnelle à coup unique.Le dispatcher. L’unique coordinateur de longue durée qui revendique les lignes et les transmet aux workers.
22.5. Le contrat d’étape¶
Chaque worker ouvre le pool de base de données une fois au démarrage, puis appelle pour chaque identifiant de message pepsi_common::stage::prepare_on(pool, PROGRAM, id, cfg, load). Cela :
charge la ligne
runningen un seulSELECT(en refusant d’agir si la ligne n’est pasrunning),résout la section
[stage-<name>]du message, etvérifie que le
PROGRAMde cette section correspond à ce binaire.
L’argument load (stage::Load) est fixé par étape et sélectionne l’une des trois requêtes préparées, de sorte que la ligne est récupérée en un aller-retour ne tirant que les colonnes dont l’étape a besoin parmi les deux potentiellement volumineuses (headers, et le corps, joint depuis workqueue_body) ; les colonnes bon marché d’enveloppe/statut sont toujours chargées. Load::Metadata ne charge aucune des grandes colonnes (enveloppe/state uniquement — SRS, discard, if, aliases, route) ; Load::Headers ajoute le bloc d’en-têtes mais pas le corps (bounce, check-whitelist, vacation, block-language) ; Load::Full ajoute les deux, réassemblés avec ctx.raw_message() pour les étapes qui transmettent ou hachent le message (relais, ARC, DKIM-sign, detect-language, les étapes cryptographiques). Le bloc d’en-têtes, lorsqu’il est chargé, est lu via ctx.headers() ; lui comme raw_message() renvoient une erreur si l’étape a sous-déclaré son Load. La sortie standard est réservée au protocole de statut du worker, de sorte qu’un corps d’étape ne doit jamais y écrire. Le pool partagé fonctionne en READ COMMITTED — au plus un écrivain touche jamais une ligne de file donnée (l’unique dispatcher revendique en série, un worker ne modifie que sa propre ligne revendiquée), de sorte que l’isolation sérialisable n’apporte rien — pourtant les helpers terminaux et le dispatcher acheminent quand même leurs écritures de file via pepsi_common::db::with_retry comme assurance bon marché contre un échec transitoire de sérialisation ou d’interblocage.
Une étape qui réécrit le message note ses changements via les setters de contenu (set_headers, set_mail_from, merge_state, …) plutôt qu’en émettant du SQL ; le worker plie ces changements et la transition d’étape en un seul UPDATE, construit à partir exactement des colonnes que l’étape a touchées (les étapes de signature, par exemple, ne changent que la colonne headers, laissant le corps intact). Comme les changements de contenu voyagent sur la ligne en mémoire, un successeur fusionné les voit et ils sont persistés avec le commit unique de la chaîne — la fusion ne perd jamais une mise à jour. Le programme appelle ensuite exactement un helper terminal, dont la sémantique de base de données est le contrat :
advance()/advance_to()Déplacer
stageet y mettre la ligne en ``pending``, afin que le pool de workers de l’étape suivante la revendique. L’étape possède cette transition de bout en bout : la seule écriture de progression du dispatcher dansstatusest la revendicationpending→running, jamais l’inverse.reroute(stage, state)Comme advance, mais fusionne
state; c’est ainsi qu’un message atteint une étape de rebond.pause(state, secs)Mettre
pausedet untimeoutde réessai (le dispatcher le remet en file).pause_with(merge, secs, keep_indices, clones)La pause enrichie : elle réduit aussi la ligne à un sous-ensemble de destinataires et/ou engendre des clones latéraux (un DSN de retard, des DSN de succès pour les destinataires déjà servis), la pause et les clones étant validés ensemble en un seul appel à
workqueue_pause.fail(state)Terminal
failed(laissé à un opérateur).finish()DELETEde la ligne (remise ou abandonnée).finish_with(clones)supprime et engendre des clones latéraux (rebonds par destinataire, DSN de succès) en un seul appel àworkqueue_finish_with_clones.advance_or_finish()/complete_success(...)Les chemins terminaux de l’étape de remise : avancer vers
NEXT_STAGEsinon terminer ; et la barrière de DSN de succès qui réachemine versBOUNCE_STAGElorsqueORIGINATE_SUCCESS_DSN+NOTIFY=SUCCESS.
Trois autres helpers déploient la ligne en éventail au lieu de la conclure. Ils réduisent l’ensemble de destinataires de cette ligne et créent des lignes sœurs pending en un seul appel à workqueue_split, en clonant expéditeur/en-têtes/corps côté serveur, et l’appelant doit encore terminer cette ligne avec l’un des terminaux ci-dessus : split_off_recipients détache un sous-ensemble d’indices sur une unique ligne sœur (la remise locale gardant les destinataires locaux et faisant suivre le reste) ; split_to_one_per_row détache les destinataires 1.. chacun sur sa propre ligne à l’étape courante (les relais SMTP, qui remettent à un destinataire d’enveloppe par tentative) ; et fan_out est la forme générale, dont les FanGroup peuvent porter des adresses entièrement nouvelles, ce qui exprime donc les réécritures de destinataires (pepsi-stage-dot-forward, pepsi-stage-relay-to-lmtp). Un groupe est créé pending sauf si son status indique Retry (paused à son étape avec une heure de nouvelle tentative — c’est ainsi que pepsi-stage-milter met de côté un destinataire que le filtre a différé) ou Failed ; ces deux-là conservent le received_at du message, de sorte qu’un MAX_LIFETIME mesuré sur eux compte à partir de l’arrivée. Comme ils ne persistent pas les écritures de contenu accumulées en mémoire, ils rejettent une étape qui aurait combiné une réécriture de contenu avec l’un d’eux plutôt que de la laisser tomber silencieusement — le même garde-fou que porte pause.
enqueue_new se tient entièrement en dehors de la transition : il injecte un message latéral entièrement nouveau (une réponse automatique, une demande de paiement) à une étape nommée via workqueue_inject. rewrite_recipients est un terminal de commodité qui écrit un rcpt_to et un state entièrement nouveaux puis fait avancer, le tout dans l’unique commit du worker ; pepsi-stage-aliases l’utilise, en reconstruisant en même temps le state.dsn.rcpt parallèle.
Deux optimisations se logent sous ce contrat sans le modifier.
Fusion. Lorsque [pepsi] ALLOW_FUSION est activée (la valeur par défaut) et qu’une étape avance vers un successeur plié dans le même binaire, qui n’a pas besoin de plus de colonnes que celles déjà chargées (son Load n’est pas supérieur) et qui ne refuse pas via son propre garde-fou can_fuse, le worker exécute le successeur dans ce même processus — évitant à la fois l”UPDATE d’avancement et le SELECT de chargement du successeur. Les écritures de contenu accumulées par la chaîne et la transition finale sont ensuite validées en un seul UPDATE à la fin. Le registre qui rend cela possible est global au processus et n’est installé que par le binaire unifié, de sorte que la fusion est inerte dans une compilation de développement à un binaire par programme. Chaque saut fusionné est rapporté au dispatcher sur la ligne de statut du worker, de sorte que pepsi.stage_stats le compte quand même.
Traitement par lots. Le worker d’une étape sans corps (Load::Metadata) traite en outre les messages par lots : chaque passe vide avidement les identifiants déjà pipelinés sur son entrée standard (jusqu’au QUEUE_LIMIT de l’étape, en s’arrêtant dès qu’une lecture bloquerait), charge le lot entier en un SELECT … = ANY(…), exécute chaque corps, puis valide les avancées, échecs et remises en file différés en un UPDATE par tableaux pour chaque forme d’issue — la cible de l’avancement voyage ligne par ligne, de sorte que des branches if divergentes partagent quand même une seule instruction. Les terminaux en éventail continuent de valider message par message. Cela fait passer une étape sans corps saturée de deux allers-retours par message à environ deux par QUEUE_LIMIT messages. Les étapes qui chargent les en-têtes ou le corps conservent la boucle à un identifiant à la fois, car leurs grandes colonnes ne se mettent pas en tableau à bon compte. Le protocole du worker est inchangé dans les deux cas : une ligne de statut par identifiant d’entrée, dans l’ordre.
22.6. L’objet d’état¶
Le JSONB state est le seul canal entre les étapes. Les helpers terminaux fusionnent les nouvelles clés (state || $new en SQL) de sorte que les données antérieures survivent ; la seule exception est pepsi-stage-bounce, qui efface state car un rebond est un nouveau message à expéditeur nul. Les clés standard sont :
originProvenance d’origine SMTP semée par l’ingress : IP du client, HELO/EHLO, indicateurs ESMTP/SMTPUTF8, la déclaration
BODY=, paramètres TLS, listener, DNS inverse/iprev, et l”authserv_id(dont l’étape ARC a besoin pour reproduire l’identité de l’AAR). Lue par l’étape ARC et par la décision de rétrogradation des étapes de relais.dsnLes paramètres RFC 3461 :
ret/envidau niveau du message plus un tableaunotify/orcptpar destinataire. Chaque étape doit le préserver.bounceÉcrite par une étape de remise ou de discard lors de l’acheminement vers
BOUNCE_STAGE, consommée par l’étape de rebond :kind(permanent/success/delay),diagnostic,failed_recipient, lesnotify/orcpt/envidcopiés, et (depuis les étapes de relais) lesremote_mta/smtp_code/enhanced_status/phase/reply_textdu saut suivant.attempts/last_error/delay_sentBloc-notes de réessai de l’étape de remise.
dispatch_errorÉcrite par le dispatcher lorsqu’il force une ligne à
failed/timeout.
L’objet d’état a son propre chapitre — L’état du message — et la référence exhaustive des clés est pepsi.state(7).
22.7. Le dispatcher¶
pepsi-dispatch est l”unique coordinateur (en exécuter exactement un par système). Il fait un LISTEN sur les canaux workqueue et config_changed (en se reconnectant avec un backoff décorrélé) avec un battement de cœur de filet de sécurité POLL_INTERVAL, et :
Revendique par lots. À chaque réveil, il revendique le travail
pendingpour chaque étape disposant de capacité libre en un seulUPDATE … RETURNING(chaque étape jusqu’à sa propre capacité restante), met les lignes enrunninget transmet chaque identifiant à un worker de cette étape via l’entrée standard du worker. Cette revendication est sa seule écriture de progression dansstatus, et il en est le seul auteur, de sorte qu’il n’a besoin d’aucunFOR UPDATE SKIP LOCKED. Une étape qui avance a déjà mis la ligne enpendingchez son successeur ; le dispatcher la revendique donc simplement de nouveau au tour d’ordonnancement suivant pour le pool de cette étape (les workers sont par étape, de sorte qu’un message n’est pas chaîné à l’intérieur d’un seul processus, sauf là où le successeur est fusionné dans la même passe).Partage chaque étape équitablement entre les expéditeurs. Les lignes en attente qui remplissent la capacité libre d’une étape sont choisies comme on répartit le temps CPU entre processus (CFS/EEVDF) : chaque expéditeur — le compte authentifié, sinon l’adresse qui se connecte (IPv6 par /64), conservé dans la colonne générée
workqueue.sender_key— accumule le temps de worker que ses messages consomment à l’étape, et la revendication privilégie les lignes des expéditeurs qui en ont le moins, de sorte qu’une rafale d’un expéditeur s’intercale avec le courrier de tous les autres au lieu de passer en premier. Un nouveau venu démarre à égalité avec l’expéditeur le moins servi (le temps calme n’accumule aucun crédit), les avances décroissent selonFAIR_HALF_LIFE, etFAIR_FIFO_PERCENTdes créneaux restent servis par ancienneté afin qu’aucune ligne ne soit jamais affamée. Cette comptabilité ne réside que dans la mémoire du dispatcher et est simplement oubliée au redémarrage ; la sélection reste l’unique instruction de revendication ci-dessus, alimentée par la comptabilité sous forme de tableaux.Passe à l’échelle de façon élastique, pipeline le travail. Le pool de workers de chaque étape croît à la demande jusqu’à son
PARALLELISMet rétrécit à l’inactivité : aucun worker ne s’exécute tant qu’une étape ne voit pas de travail ; le travail est tassé sur le moins de workers possible, de sorte qu’un surplus se refroidit et est récupéré aprèsWORKER_IDLE_TIMEOUT; aprèsMAX_MESSAGESun worker recycle son enfant. Un worker reçoit jusqu’àQUEUE_LIMITidentifiants à la fois, portant la capacité en vol d’une étape àQUEUE_LIMIT × PARALLELISMsans ajouter de processus ni de connexions. Une étape dont lePROGRAMne peut être démarré est tenue à l’écart avec un court délai de refroidissement de lancement plutôt que réessayée en boucle serrée.Remet en file les lignes
pausedune fois leurtimeoutécoulé (il dort jusqu’à la première échéance, ou le battement de cœur).Récupère. Un worker qui rapporte un statut non nul ou dont l’enfant plante →
failed(et est remplacé) ; celui qui ne répond pas dansMAX_RUNTIMEest tué →timeout(raison notée dansstate.dispatch_error). Au démarrage, il réinitialise les lignesrunningorphelines enpending; surSIGINT/SIGTERMil arrête les workers et réinitialise leurs lignes (et toute ligne revendiquée mais non assignée).
Le dispatcher ne traite jamais lui-même le contenu d’un message — il ne fait qu’exécuter les workers d’étape, qui notent leur propre issue sur la ligne.
22.8. Cycle de vie du message (de bout en bout)¶
Réception.
pepsi-ingressaccepte la transaction SMTP, authentifie le message, ajoute en tête les en-têtes de trace etAuthentication-Results, puis le scinde et le stocke àstage = init,status = pending. Le dispatcher est réveillé hors bande par une tâche de coalescence, pas depuis la transaction d’admission.Dispatch.
pepsi-dispatchrevendique la ligne →runninget la transmet à un worker[stage-init].Étapes. Chaque étape fait avancer le message en le mettant elle-même en
pendingà l’étape suivante ; le pool de cette étape le revendique ensuite : p. ex. ARC → SRS → DKIM-sign → remise.Remise. L’étape de remise transmet le courrier. En cas de succès, elle appelle
finish(ou avance). Un échec transitoirepauseavec backoff ; le dispatcher le remet en file plus tard.Rebond. Un échec permanent
rerouteversBOUNCE_STAGE;pepsi-stage-bounceconstruit un DSN (non signé), qui est ensuite signé et remis comme tout autre message. Un rebond ne rebondit jamais lui-même.
Pour ajouter une nouvelle étape n’importe où dans ce flux — disons un filtre anti-spam ou un hook d’archivage — vous écrivez un programme d’étape et insérez sa section dans le graphe ; voir Étendre le pipeline.