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 sert pepsi-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 outil execute (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 de taler-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_BINARIES dans le Makefile) 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 un exec du 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-language lie les modèles de langue lingua — une grosse dépendance que rien d’autre n’utilise — et pepsi-setup tire 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-config est de même laissé autonome ; pepsi-telemetry est livré dans son propre paquet Debian et s’exécute sur un autre hôte ; et pepsi-helper-mailbox-scan ne porte aucun bit de permission mais est executé par son nom depuis pepsi-whitelist. (pepsi-stage-detect-language est 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-forward et pepsi-helper-auto-pay ; leurs étapes appelantes setgid pepsi-stage-relay-to-maildir, pepsi-stage-dot-forward, pepsi-stage-auto-pay et pepsi-quota ; le pepsi-stage-relay-to-smarthost setgid (qui lit les fichiers de jetons OAuth du groupe pepsi-token) ; le pepsi-whitelist setuid ; et pepsi-stage-encrypt / pepsi-stage-decrypt, setuid pepsi-crypto parce que l’authentification peer de PostgreSQL se fonde sur l”uid effectif et que ce rôle est le seul auquel crypto_identity.private_wrapped est 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-keys est autonome pour la même famille de raisons mais est livré sans bit ; debian/pepsi.postinst est 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.workqueue

Une 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 headers et un corps (coupé à la première ligne vide ; l’invariant est raw = 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 de pepsi.workqueue_body, référencée par body_id et 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 ligne workqueue_body pour son seul message (copie sur écriture, dans commit_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 (init pour un message neuf).

  • status — pending / running / paused / failed / timeout (un ENUM PostgreSQL).

  • state — JSONB de forme libre transporté avec le message (voir L’objet d’état).

  • timeout — quand un message paused doit être réessayé.

Les verdicts d’authentification (spf/dkim/dmarc/arc) et l”authserv_id résident dans state (sous les clés auth et origin respectivement), pas dans des colonnes dédiées.

pepsi.dns_address

Un 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_stats

Statistiques 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 terminal failed/timeout) et serialization_failures (les événements transitoires de réessai pour sérialisation/interblocage 40001/40P01 observé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-dispatch en 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 /metrics de pepsi-httpd les lit (ainsi que les jauges actives/en pause en direct, prises telles quelles dans pepsi.workqueue).

pepsi.config_override

La configuration gérée par l’administrateur, superposée au fichier INI : une ligne par redéfinition (scope, section, option), où le scope est global, domain:<domain> ou address:<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 un NOTIFY config_changed, et les composants réagissent — pepsi-dispatch retire 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-config que 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.settings

La couche de redéfinition par adresse, et une table délibérément distincte de config_override parce 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_identity

Les paires de clés des adresses que nous servons — matériel public plus la moitié privée — une ligne par capacité (sign, encrypt ou both), avec ses colonnes de cycle de vie (status, is_primary, published, expires_at, private_purged_at).

pepsi.peer_key

Les clés publiques et certificats mis en cache des correspondants distants, chacun avec la source dont 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_key

Les 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_trust

Les certificats de CA qu’une chaîne S/MIME entrante doit atteindre pour être tenue pour digne de confiance.

pepsi.key_request

Les requêtes de découverte de clés en vol auxquelles pepsi-keydisc ré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_token

Les 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-httpd s’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_log

Le 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_log

L’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_log

La 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 fragment secrets.d à l’unique compte qui le lit, exécuter certbot, créer des rôles en base, générer des clés — et pepsi-httpd abandonne 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, et pepsi-setup apply dé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-httpd ne peut que SELECT (il met en file par la connexion de configuration distincte), et seul pepsi-config peut INSERT. Lequel d’entre eux a écrit une ligne n’est pas pris sur parole : written_by est forcé depuis current_user par un déclencheur BEFORE 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 un PROGRAM et des NEXT_STAGE / BOUNCE_STAGE facultatifs (Le pipeline d’étapes), plus les réglages de pool de workers PARALLELISM, MAX_MESSAGES et QUEUE_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 des workqueue_id sur 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 (0 en 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 :

  1. charge la ligne running en un seul SELECT (en refusant d’agir si la ligne n’est pas running),

  2. résout la section [stage-<name>] du message, et

  3. vérifie que le PROGRAM de 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 stage et 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 dans status est la revendication pending→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 paused et un timeout de 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()

DELETE de 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_STAGE sinon terminer ; et la barrière de DSN de succès qui réachemine vers BOUNCE_STAGE lorsque ORIGINATE_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 :

origin

Provenance 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.

dsn

Les paramètres RFC 3461 : ret/envid au niveau du message plus un tableau notify/orcpt par 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, les notify/orcpt/ envid copiés, et (depuis les étapes de relais) les remote_mta/ smtp_code/enhanced_status/phase/reply_text du saut suivant.

attempts / last_error / delay_sent

Bloc-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 pending pour chaque étape disposant de capacité libre en un seul UPDATE … RETURNING (chaque étape jusqu’à sa propre capacité restante), met les lignes en running et transmet chaque identifiant à un worker de cette étape via l’entrée standard du worker. Cette revendication est sa seule écriture de progression dans status, et il en est le seul auteur, de sorte qu’il n’a besoin d’aucun FOR UPDATE SKIP LOCKED. Une étape qui avance a déjà mis la ligne en pending chez 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 selon FAIR_HALF_LIFE, et FAIR_FIFO_PERCENT des 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 PARALLELISM et 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ès WORKER_IDLE_TIMEOUT ; après MAX_MESSAGES un worker recycle son enfant. Un worker reçoit jusqu’à QUEUE_LIMIT identifiants à la fois, portant la capacité en vol d’une étape à QUEUE_LIMIT × PARALLELISM sans ajouter de processus ni de connexions. Une étape dont le PROGRAM ne 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 paused une fois leur timeout é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 dans MAX_RUNTIME est tué → timeout (raison notée dans state.dispatch_error). Au démarrage, il réinitialise les lignes running orphelines en pending ; sur SIGINT/SIGTERM il 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)

  1. Réception. pepsi-ingress accepte la transaction SMTP, authentifie le message, ajoute en tête les en-têtes de trace et Authentication-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.

  2. Dispatch. pepsi-dispatch revendique la ligne → running et la transmet à un worker [stage-init].

  3. É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.

  4. Remise. L’étape de remise transmet le courrier. En cas de succès, elle appelle finish (ou avance). Un échec transitoire pause avec backoff ; le dispatcher le remet en file plus tard.

  5. Rebond. Un échec permanent reroute vers BOUNCE_STAGE ; pepsi-stage-bounce construit 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.