22. Architektur

Jeder Verarbeitungsschritt nach dem Ingress ist ein eigenständiges Stage-Programm, das von einer einzigen Datenbanktabelle angetrieben wird. Dieses Kapitel behandelt das Workspace-Layout, das Datenmodell, den Stage-Vertrag, den Dispatcher und den Nachrichten-Lebenszyklus.

22.1. Workspace-Layout

Pepsi ist ein Cargo-Workspace (Edition 2024). Die Workspace-Wurzel ist selbst das ``pepsi``-Paket und besitzt jedes [[bin]] in src/bin/*.rs; die pepsi-*-Member-Crates sind Bibliotheken, deren run() die dünnen Binärprogramme aufrufen. So liegt das Binärprogramm pepsi-X in src/bin/pepsi-X.rs und delegiert an die Bibliothek der Crate pepsi-x.

  • pepsi-common — gemeinsame Infrastruktur: Domain-Validierung (domain), DKIM/ARC-Schlüssel und Signieren (keys/sign/auth), der ausgehende SMTP- und LMTP-Client (smtp), die SRS-Engine (srs), DSN-Typen (dsn), die MIME-Herabstufungsmaschinerie (mime), die Trennung von Header und Nachrichtentext (message), die Schicht der lauschenden Sockets (net), der Auflöser lokaler Empfänger (local), die Überschreibungsschicht pro Adresse (settings), die RFC-3834-Regeln für automatische Antworten (autoreply) und das Stage-Gerüst (stage).

  • pepsi-ingress, pepsi-dispatch, pepsi-httpd, pepsi-setup, pepsi-keydisc, pepsi-sendmail, pepsi-telemetry-client, pepsi-telemetry — die Dienste und Clients, die keine Stages sind.

  • 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 — die Kommandozeilenwerkzeuge für den Betrieb (keines davon ist eine Stage; die letzten drei enthalten außerdem die Mailinglisten-, Archiv- und Secure-Link-Logik, die pepsi-httpd ausliefert).

  • pepsi-crypto, pepsi-keymat, pepsi-setup-model, pepsi-stage-validate — weitere gemeinsame Bibliotheken (der OpenPGP/S/MIME-Protokollcode, das Laden gespeicherter Schlüssel, das Modell des Setup-Interviews und die Konfigurationsvalidatoren pro Stage).

  • pepsi-helper-* — die kleinen privilegierten oder einzweckigen Helfer, die eine Stage oder ein Werkzeug mit exec startet (maildir-writer, dot-forward, auto-pay, mailbox-scan, token-refresh).

  • pepsi-stage-* — die Stage-Programme.

  • vendor/taler-rust — ein mitgeliefertes Git-Submodul (ein separater Workspace, hier ausgeschlossen), das die Konfigurations-/CLI-/Datenbankmechanik von taler-common bereitstellt (taler_main, ConfigSource, Config/Section, taler_common::db).

Jedes Programm definiert ein constants::CONFIG_SOURCE, das es identifiziert (Projekt/Komponente/Exec), und Stage-Programme definieren zusätzlich ein constants::PROGRAM — den Binärnamen, der als PROGRAM in einem Stage-Abschnitt verwendet wird.

22.2. Das vereinheitlichte Binärprogramm

Ein Entwicklungs-Build (das Standard-Cargo-Feature multibin) kompiliert jedes Programm zu seiner eigenen ausführbaren Datei. Ein Release-Build aktiviert stattdessen das Feature unibin, das die meisten Programme zu einer ausführbaren Multi-Call-Datei pepsi verlinkt: Ein kleines main untersucht argv[0] und springt zum Einstiegspunkt des passenden Programms. make install legt dieses eine Binärprogramm in $PREFIX/libexec/pepsi/pepsi ab und erstellt einen $PREFIX/bin-Symlink pro Programmnamen, der darauf zeigt (pepsi-ingress -> ../libexec/pepsi/pepsi und so weiter). Operatoren, der Dispatcher und die systemd-Units verwenden weiterhin unverändert die programmweisen Namen; nur die Form auf der Festplatte unterscheidet sich. Die main-Funktionen der eingefalteten Programme liegen in der eigenen Bibliothek des Root-Pakets (pepsi::programs::<name>), sodass derselbe Code sowohl die eigenständigen als auch das vereinheitlichte Binärprogramm trägt.

Die Motivation spiegelt wider, warum ein C-Projekt eine gemeinsame Bibliothek dem statischen Einlinken desselben Codes in jede ausführbare Datei vorzieht. Pepsis Programme teilen sich sehr viel Code — die asynchrone Laufzeit (Tokio), die SQL- und TLS-Stacks (sqlx, rustls), die SMTP-/MIME-/DKIM-Maschinerie in pepsi-common, die CLI-/Konfigurationsschicht und mehr. Wenn jedes Programm sein eigenes statisch gelinktes Binärprogramm ist, trägt jedes eine private Kopie dieses gesamten gemeinsamen Codes. Sie in ein einziges Binärprogramm einzufalten behält eine Kopie, mit drei beabsichtigten Vorteilen:

Kleiner auf der Festplatte

Die gut vierzig eingefalteten Programme (FOLDED_BINARIES im Makefile) werden zu einem Binärprogramm plus jeweils einem Symlink (ein paar Byte), statt zu vierzig mehrere Megabyte großen ausführbaren Dateien, die jeweils den gemeinsamen Code erneut einbetten.

Schnellerer Prozessstart

Pepsi ist prozesslastig: Der Dispatcher betreibt jede Stage als einen Pool von Worker-Prozessen und startet sie neu (nach MAX_MESSAGES, einem Timeout oder einem Absturz; siehe Der Dispatcher). Weil jeder Worker — jeder Stage — die gleiche Datei exect, wird der Text dieser Datei höchstens einmal von der Festplatte gelesen und danach für jeden weiteren Start aus dem Kernel-Page-Cache bedient. Mit separaten Binärprogrammen zahlt der erste Start jeder einzelnen Stage seinen eigenen Kaltcache-Lesevorgang.

Weniger physischer Speicher

Die nur lesbaren Segmente (Code und nur lesbare Daten) einer mmapten ausführbaren Datei werden durch gemeinsame physische Seiten gestützt: Jeder Prozess, der die Datei ausführt, bildet dieselbe physische Kopie ab (dies ist unabhängig von ASLR, das nur die virtuelle Adresse randomisiert, nicht die Page-Cache-Stützung). Mit einem Binärprogramm teilen sich der Dispatcher, Ingress, der HTTP-Server und alle Stage-Worker — potenziell Dutzende Prozesse — eine einzige residente Kopie des gemeinsamen Codes. Mit separaten Binärprogrammen behält jedes laufende einzelne Programm seine eigene residente Kopie dieses duplizierten Codes — genau die Redundanz, die eine gemeinsame Bibliothek beseitigt.

Zwei Klassen von Programmen werden bewusst getrennt gehalten und als eigene ausführbare Dateien in $PREFIX/bin installiert (ihre [[bin]]-Targets bauen in beiden Feature-Modi und sind vom vereinheitlichten Binärprogramm ausgeschlossen):

  • Programme, die getrennt ausgeliefert oder aufgerufen werden. pepsi-stage-detect-language verlinkt die lingua-Sprachmodelle — eine große Abhängigkeit, die nichts anderes nutzt — und pepsi-setup zieht einen HTTP-Client und die einmalige Einrichtungslogik herein. Eines davon einzufalten würde das gemeinsame Binärprogramm und das residente Speicherabbild jedes Prozesses mit Code wachsen lassen, den nur ein Programm nutzt, daher bleiben sie draußen. pepsi-config bleibt ebenfalls eigenständig; pepsi-telemetry wird in einem eigenen Debian-Paket ausgeliefert und läuft auf einem anderen Host; und pepsi-helper-mailbox-scan trägt kein Berechtigungsbit, wird aber von pepsi-whitelist namentlich mit exec gestartet. (pepsi-stage-detect-language ist selbst ein zweites Multi-Call-Binärprogramm aus zwei Programmen: dieselbe Datei ist auch pepsi-detect-language(1), installiert als $PREFIX/bin-Symlink darauf.)

  • Privilegierte Binärprogramme. Elf Programme tragen ihr eigenes Rechte-Bit: die setuid-root-Helper pepsi-helper-maildir-writer, pepsi-helper-dot-forward und pepsi-helper-auto-pay; ihre setgid-Stage-Aufrufer pepsi-stage-relay-to-maildir, pepsi-stage-dot-forward, pepsi-stage-auto-pay und pepsi-quota; das setgid pepsi-stage-relay-to-smarthost (das die OAuth-Token-Dateien der Gruppe pepsi-token liest); das setuid pepsi-whitelist; und pepsi-stage-encrypt / pepsi-stage-decrypt, setuid pepsi-crypto, weil die Peer-Authentifizierung von PostgreSQL an der effektiven uid ansetzt und allein dieser Rolle crypto_identity.private_wrapped gewährt ist. Ein Symlink kann kein setuid-/setgid-Bit halten — das Bit muss auf der Zieldatei liegen —, sodass ihr Einfalten dieses Bit auf das gemeinsame Binärprogramm zwingen würde, wo es für jedes per Symlink verknüpfte Programm gälte und die in Installation beschriebene Privilegientrennung zusammenbrechen ließe. (pepsi-keys ist aus derselben Gruppe von Gründen eigenständig, wird aber ohne Bit ausgeliefert; debian/pepsi.postinst ist die maßgebliche Liste dessen, was tatsächlich gesetzt ist.)

Bemerkung

Die Gewinne bei Startzeit und Speicher werden zur Laufzeit von den langlebigen, prozesslastigen Diensten realisiert (dem Dispatcher und seinen Worker-Pools, wo viele Prozesse die eine Datei gleichzeitig ausführen); ein einmal aufgerufener Operator-Befehl — etwa pepsi-status über SSH — sieht den Gewinn bei der Festplattengröße, aber keinen Startzeitvorteil durch das Teilen. Auch die eigenständigen Binärprogramme (STANDALONE_BINARIES im Makefile ist die maßgebliche Liste) betten jeweils noch ihre eigene Kopie des gemeinsamen Codes ein, sodass die Deduplizierung groß, aber nicht vollständig ist — ein bewusster Kompromiss aus den beiden obigen Gründen.

22.3. Das Datenmodell

Ein PostgreSQL-Schema, pepsi, enthält 61 Tabellen. Zwei davon sind das, worum sich das ganze System dreht: die Nachrichtenwarteschlange und das Konfigurations-Overlay, das dort liegt, weil die Datenbank der einzige gemeinsame, beschreibbare, transaktionale Zustand ist, den eine Installation hat. Die meisten übrigen sind Begleiter einer dieser beiden — Caches, Zähler und adressbezogene Überschreibungen, die die Pipeline zu Rate zieht. Der Rest gehört zu in sich geschlossenen Teilsystemen, die ihren eigenen Zustand im selben Schema halten: der Ende-zu-Ende-Schlüsselspeicher (unten), das Secure-Link-Portal, der Confirm-to-Send-Sekretär, Postfach-Quotas, die administrative Oberfläche, die Mailinglisten (23 list_*-Tabellen und mailing_list) und ihr Web-Archiv (neun archive_*-Tabellen). pepsi-setup/db/pepsi-0001.sql ist die maßgebliche Liste.

pepsi.workqueue

Ein Datensatz je Nachricht in Bearbeitung. Neben dem Umschlag und den geparsten Metadaten wird die Nachricht selbst aufgeteilt gespeichert, in eine headers-Spalte und einen Nachrichtentext (getrennt an der ersten Leerzeile; die Invariante ist raw = headers || CRLF || body). Stages, die nur Header benötigen, laden oder überschreiben den Nachrichtentext nie. Der Nachrichtentext ist ein Datensatz von pepsi.workqueue_body, referenziert über body_id und geteilt von jedem Datensatz, der von derselben Nachricht abgespalten wurde (Einstellungen pro Adresse, der eine Datensatz je Empfänger einer Relay-Stage, eine Listen-Auffächerung, Bounce- und DSN-Klone), sodass eine Nachricht an N Empfänger einmal gespeichert wird, nicht N Mal. Eine Stage, die einen Nachrichtentext umschreibt, erhält nur für ihre eigene Nachricht einen neuen workqueue_body-Datensatz (Copy-on-Write, in commit_row), sodass ein Geschwister nie die Umschreibung eines anderen sieht. Nachrichtentexte werden von einem Trigger freigegeben, wenn der letzte auf sie verweisende Datensatz verschwindet, und pepsi-dispatch räumt auf, was ein Wettlauf hinterlässt (workqueue_body_gc). Die Spalten zur Pipeline-Steuerung sind:

  • stage — der [stage-<name>]-Abschnitt des aktuell für die Nachricht zuständigen Programms (init für eine frische Nachricht).

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

  • state — frei strukturiertes JSONB, das mit der Nachricht mitgeführt wird (siehe Das State-Objekt).

  • timeout — wann eine paused-Nachricht zur Wiederholung fällig wird.

Die Authentifizierungsurteile (spf/dkim/dmarc/arc) und die authserv_id liegen in state (unter den Schlüsseln auth bzw. origin), nicht in eigenen Spalten.

pepsi.dns_address

Ein Cache aufgelöster A/AAAA-Adressen von MX-Hosts mit Verbindungs-Gesundheit pro Adresse, gepflegt von pepsi-stage-relay-to-internet. Eine funktionierende Adresse wird bevorzugt, bis ihre DNS-TTL abläuft; ein Host wird neu aufgelöst, sobald alle seine Adressen fehlgeschlagen oder abgelaufen sind.

pepsi.stage_stats / pepsi.dispatch_stats

Kumulative Pipeline-Statistiken: Nachrichtenzahlen pro Stage, Gesamtverarbeitungszeit, Kills/Timeouts und Abstürze sowie die globalen Stage-/Nachrichten-Gesamtwerte. Der globale Datensatz trägt außerdem messages_failed (Nachrichten, die in einem terminalen failed/timeout-Zustand endeten) und serialization_failures (vorübergehende 40001/40P01-Serialisierungs-/Deadlock-Wiederholungsereignisse, die der Dispatcher beobachtet hat) — beide sollen im Normalbetrieb bei null bleiben, sodass ein steigender Zähler eher einen Defekt als eine Lastgrenze signalisiert. pepsi-dispatch ist der einzige Schreiber: Er sammelt die Deltas im Speicher und schreibt sie in einer Transaktion etwa einmal pro Minute weg, immer wenn die Pipeline in den Leerlauf geht, und beim Herunterfahren. Stages, die in den Worker-Durchlauf einer anderen Stage fusioniert sind, werden ihm auf der Worker-Statuszeile dieses Durchlaufs zurückgemeldet, statt vom Worker geschrieben zu werden, sodass bei einer Tabelle mit einer Zeile je Stage nie jeder Worker einer Stage um sie konkurriert. Das /metrics von pepsi-httpd liest sie (und die Live-Anzeigen für aktiv/pausiert direkt aus pepsi.workqueue).

pepsi.config_override

Die vom Administrator verwaltete Konfiguration, die über die INI-Datei gelegt wird: ein Datensatz je (scope, section, option)-Überschreibung, wobei scope global, domain:<domain> oder address:<address> ist. Das ist es, was eine laufende Installation umkonfigurierbar macht, ohne Dateien zu bearbeiten. Es funktioniert genauso wie die Warteschlange: Ein Auslöser gibt ein config_changed-NOTIFY aus, und die Komponenten reagieren — pepsi-dispatch zieht seine Stage-Worker zurück, damit ihre Nachfolger die neuen Werte lesen.

Zwei Eigenschaften sind strukturell und nicht bloß Konvention. Abschnitte, die existieren müssen, bevor es eine Datenbankverbindung gibt — und die Listener-Abschnitte, die eine Sicherheitsgrenze sind —, werden nie von hier gelesen, sodass eine kompromittierte Datenbank weder einen lauschenden Socket verschieben noch die Datenbankverbindung umlenken kann. Und das Schreibrecht gehört einer eigenen PostgreSQL-Rolle pepsi-config, die keine mailverarbeitende Komponente hält: Ein Stage-Worker darf die Konfiguration lesen und darf sie nicht ändern. Siehe Konfiguration.

pepsi.settings

Die Überschreibungsschicht je Adresse, und bewusst eine von config_override getrennte Tabelle, weil Kontoinhaber sie selbst per E-Mail schreiben. Die Unterscheidung ist eine Privilegiengrenze, keine Verdopplung.

Datensätze werden von der gespeicherten Funktion workqueue_add eingefügt, die bewusst nicht benachrichtigt: PostgreSQL hält von dem Moment an, in dem eine Transaktion eine Benachrichtigung einreiht, bis zu deren Commit eine datenbankweite Sperre; pg_notify aus der Zulassungstransaktion herauszuhalten ist daher das, was gleichzeitigen SMTP-Sitzungen einen Gruppen-Commit erlaubt. pepsi-ingress fasst stattdessen zusammen und weckt den Dispatcher einmal je Schub, außerhalb des Bandes und nachdem die Datensätze festgeschrieben sind (DISPATCH_WAKE_INTERVAL); Schreiber außerhalb der Schleife des Dispatchers — workqueue_inject, workqueue_resume, keydisc_release, pepsi-failure-bouncer und die Reparaturbefehle von pepsi-queue — benachrichtigen ausdrücklich (über pepsi_common::db::notify_workqueue), und Schreiber innerhalb der Schleife bleiben stumm, weil die eigene Statuszeile des Workers den Koordinator zu demselben Anspruch führt. Der Vertrag ist am Anfang von pepsi-setup/db/procedures.sql ausgeschrieben.

Eine Stage injiziert eine brandneue Seiten-Nachricht mit workqueue_inject (über StageContext::enqueue_new) und erzeugt Klone des aktuellen Datensatzes — eine Delay-DSN, einen Bounce je Empfänger, eine Erfolgs-DSN — mit workqueue_pause oder workqueue_finish_with_clones, die ihn serverseitig klonen, sodass der Nachrichtentext nie durch die Anwendung hin- und herläuft; das eindeutige token des Klons macht ihn höchstens einmalig.

Fünf weitere Tabellen bilden den Schlüsselspeicher der Ende-zu-Ende-Kryptographie, der am Nachrichten-Zustandsautomaten keinen Anteil hat und mit einem eigenen Werkzeug verwaltet wird (pepsi-keys; der Entwurf steht in Schlüsselverwaltung):

pepsi.crypto_identity

Die Schlüsselpaare der von uns bedienten Adressen — öffentliches Material plus die private Hälfte — ein Datensatz je Fähigkeit (sign, encrypt oder both), mit seinen Lebenszyklusspalten (status, is_primary, published, expires_at, private_purged_at).

pepsi.peer_key

Die zwischengespeicherten öffentlichen Schlüssel und Zertifikate entfernter Korrespondenten, jeweils mit der source, aus der sie stammen, ob diese Abfrage DNSSEC-validiert war, dem letzten Gültigkeitsurteil und einer Cache-Frist.

pepsi.peer_has_own_key

Korrespondenten, die nachweislich einen unserer eigenen Schlüssel besitzen (sie haben an ihn verschlüsselt und mit ihrer eigenen Adresse signiert), sodass Mail an sie unsere Schlüsseldatei nicht angehängt wird. Geschrieben von pepsi-stage-autocrypt-learn.

pepsi.ca_trust

Die CA-Zertifikate, die eine eingehende S/MIME-Kette erreichen muss, um als vertrauenswürdig zu gelten.

pepsi.key_request

Die laufenden Schlüsselsuchanfragen, die pepsi-keydisc beantwortet: ein Datensatz je Adresse, der die angefragten Methoden und die Methoden führt, die geantwortet haben — und der, sobald er ohne Schlüssel abgeschlossen ist, zugleich als Negativ-Cache (negative_until) dient, der eine zweite Nachricht an dieselbe Adresse davon abhält, erneut geparkt zu werden.

Sieben weitere Tabellen gehören zur administrativen Oberfläche (Die administrative API) und zum Online-Setup und stehen ebenfalls außerhalb des Nachrichten-Zustandsautomaten:

pepsi.admin_account / pepsi.admin_session / pepsi.api_token

Konten entfernter Administratoren (Argon2id-Passwort-Hashes), ihre laufenden Browser-Sitzungen und die Bearer-Tokens, mit denen sich Automatisierung authentifiziert. Sitzungen sind Datensätze statt Prozessspeicher, sodass ein Neustart niemanden abmeldet und zwei pepsi-httpd-Prozesse sich einig sind, wer angemeldet ist. Gespeichert werden nur die Digests eines Sitzungs-Cookies und der geheimen Hälfte eines Tokens: Eine Datenbanksicherung enthält kein verwendbares Credential.

pepsi.event_log

Das Prüfprotokoll — ein Datensatz je Konfigurationsänderung, Schlüsseloperation, Anmeldung, fehlgeschlagener Anmeldung, Konto- oder Token-Änderung und administrativer Warteschlangenaktion, samt dem Prinzipal, der sie durchgeführt hat. Es wird von der API und von den Kommandozeilenwerkzeugen des Operators geschrieben, über einen Helper in pepsi-common, sodass es vollständig ist, gleich welche Oberfläche gehandelt hat; ein Journal, das nur festhielte, was über HTTP geschah, lüde dazu ein, aus einer Abwesenheit den falschen Schluss zu ziehen.

pepsi.mail_log

Die Opt-in-Aufzeichnung je Nachricht ([pepsi] MAIL_LOG, standardmäßig aus). Da der Datensatz einer zugestellten Nachricht gelöscht wird, führt eine gewöhnliche Installation überhaupt kein Journal je Nachricht. Dies einzuschalten lässt die Installation festhalten, wer mit wem korrespondiert, weshalb es eine ausdrückliche Entscheidung mit begrenzter Aufbewahrung ist. Siehe Das Maillog (standardmäßig aus).

Zwei weitere gehören zum Online-Setup (Installation) und tragen die schärfste Privilegiengrenze des Schemas:

pepsi.setup_task / pepsi.setup_task_log

Die Absichtswarteschlange, die das browsergesteuerte Setup schreibt und ein root-Programm leert. Einen Mailserver einzurichten ist privilegiert — /etc/pepsi/pepsi.conf schreiben, jedes secrets.d-Fragment dem einen Konto übergeben, das es liest, certbot ausführen, Datenbankrollen anlegen, Schlüssel erzeugen —, und pepsi-httpd gibt Privilegien ab, bevor es eine Verbindung annimmt. Es handelt also nicht: Es schreibt einen Datensatz, der beschreibt, was wahr sein soll, aus einer geschlossenen Menge von elf Arten mit streng geprüften Parametern, und pepsi-setup apply entscheidet, wie. Root wird über eine Tabelle erreicht, nie über einen Socket, der ein Protokoll spricht, und jede Anfrage und jedes Ergebnis ist ein dauerhafter Datensatz. Die zweite Tabelle trägt die Fortschrittszeilen, die eine laufende Aufgabe ausgibt, sodass ein certbot-Lauf beobachtet werden kann, ohne dass der Applier eine HTTP-Verbindung hält.

Ein Datensatz in dieser Tabelle ist eine Anfrage an einen als root laufenden Prozess, daher sind die Berechtigungen die engsten des Schemas: kein Konto, das Mail verarbeitet, darf sie überhaupt berühren, pepsi-httpd darf nur SELECT (es reiht über die getrennte Konfigurationsverbindung ein), und nur pepsi-config darf INSERT. Wer davon einen Datensatz geschrieben hat, wird nicht auf Treu und Glauben genommen: written_by wird von einem BEFORE INSERT-Auslöser aus current_user erzwungen, ist also eine Tatsache über die Verbindung statt einer Behauptung in der Nutzlast, und der Applier lehnt alles andere ab. Das vollständige Vertrauensmodell steht in pepsi-setup(1).

Diese tragen die andere Zugriffsgrenze des Schemas. Die drei Zugangsdaten-Tabellen sind allein der Rolle pepsi-httpd gewährt — eine Komponente, die admin_session lesen könnte, könnte sich selbst eine administrative Sitzung prägen — und die beiden Journale sind für alles, was Mail verarbeitet, nur anfügbar: Jede Rolle darf INSERT (das ist es, was das Prüfprotokoll vollständig macht) und keine darf UPDATE oder DELETE. pepsi-setup wendet beide Einschränkungen bei jedem Lauf erneut an, weil das pauschale GRANT … ON ALL TABLES, das es den Pipeline-Rollen erteilt, sie sonst rückgängig machen würde, und prüft sie dann gegen den laufenden Server.

crypto_identity.private_wrapped hält jeden privaten Schlüssel mit AES-256-GCM unter einem Schlüsselverschlüsselungsschlüssel versiegelt, der nur in einem secrets.d-Fragment liegt, und pepsi-setup gewährt diese eine Spalte allein der Rolle pepsi-crypto: Bei jeder gewöhnlichen Dienstrolle (pepsi, pepsi-ingress, pepsi-httpd, pepsi-telemetry) wird die Berechtigung auf Tabellenebene durch eine auf Spaltenebene ersetzt, die sie auslässt. So liefert die Kompromittierung einer unbeteiligten Stage die öffentliche Hälfte jeder Identität und nicht mehr, und eine gestohlene Datenbank liefert selbst für diese Spalte nur Chiffrat.

Das Schema ist eine nummerierte Patch-Serie mit einer neu erzeugbaren Prozedurdatei. Ein veröffentlichter Patch wird nie geändert: Das erste Release, 0.0.0, lieferte pepsi-0001.sql aus, und jedes spätere Release, das das Schema ändert, fügt den nächsten Patch hinzu, der eine bestehende Datenbank an Ort und Stelle vorwärts migriert. Siehe Upgrade und Die Pipeline erweitern.

22.4. Die Stage-Pipeline

Alles nach dem Ingress ist ein Zustandsautomat über einer einzigen Tabelle. Die beweglichen Teile sind:

  • Stage-Abschnitte. Jedes [stage-<name>] benennt ein PROGRAM und ein optionales NEXT_STAGE / BOUNCE_STAGE (Die Stage-Pipeline) sowie die Worker-Pool-Stellschrauben PARALLELISM, MAX_MESSAGES und QUEUE_LIMIT (wie viele Nachrichten der Dispatcher gleichzeitig an einen Worker pipelinet). Stage-Namen sind vom Operator gewählte Etiketten, unabhängig von Crate-Namen, sodass dasselbe Binärprogramm mehrere Stages bedienen kann.

  • Stage-Worker. Jede Stage läuft als Pool persistenter Worker-Prozesse (PROGRAM worker). Ein Worker liest workqueue_ids von der Standardeingabe (bis zu QUEUE_LIMIT gleichzeitig pipelinet, streng der Reihe nach verarbeitet), erledigt seine Arbeit pro Nachricht, ruft genau einen terminalen Helper auf und schreibt pro Nachricht eine Statuszeile (0 bei Erfolg) auf die Standardausgabe. Eine Statuszeile kann ein zweites Feld tragen, einen JSON-Bericht über die in diesen Durchlauf fusionierten Stages, den der Dispatcher in seine Statistik einrechnet. Für manuellen Betrieb oder Tests kann eine einzelne ID an einen Worker weitergeleitet werden (echo <workqueue_id> | PROGRAM worker); eine positionale Einmal-Form gibt es nicht.

  • Der Dispatcher. Der einzige langlebige Koordinator, der Datensätze beansprucht und sie an Worker weitergibt.

22.5. Der Stage-Vertrag

Jeder Worker öffnet den Datenbank-Pool einmal beim Start und ruft dann für jede Nachrichten-ID pepsi_common::stage::prepare_on(pool, PROGRAM, id, cfg, load) auf. Dies:

  1. lädt den running-Datensatz in einem einzigen SELECT (und weigert sich zu handeln, sofern der Datensatz nicht running ist),

  2. löst den [stage-<name>]-Abschnitt der Nachricht auf, und

  3. verifiziert, dass das PROGRAM dieses Abschnitts mit diesem Binärprogramm übereinstimmt.

Das Argument load (stage::Load) ist pro Stage fest und wählt eines von drei vorbereiteten Statements aus, sodass der Datensatz in einem Round-Trip geladen wird und von den beiden potenziell großen Spalten (headers und dem aus workqueue_body hinzugejointen Nachrichtentext) nur die Spalten zieht, die die Stage benötigt; die günstigen Umschlag-/Status-Spalten werden immer geladen. Load::Metadata lädt keine der großen Spalten (nur Umschlag/state — SRS, discard, if, aliases, route); Load::Headers fügt den Header-Block hinzu, aber nicht den Nachrichtentext (bounce, check-whitelist, vacation, block-language); Load::Full fügt beide hinzu, mit ctx.raw_message() wieder zusammengesetzt, für Stages, die die Nachricht übertragen oder hashen (relay, ARC, DKIM-sign, detect-language, die Krypto-Stages). Der Header-Block wird, sofern geladen, über ctx.headers() gelesen; sowohl dieser als auch raw_message() melden einen Fehler, wenn die Stage ihren Load zu niedrig deklariert hat. Die Standardausgabe ist dem Worker-Statusprotokoll vorbehalten, daher darf ein Stage-Rumpf niemals auf sie schreiben. Der gemeinsame Pool läuft mit READ COMMITTED — höchstens ein Schreiber berührt jemals einen gegebenen Warteschlangen-Datensatz (der einzige Dispatcher beansprucht seriell, ein Worker verändert nur seinen eigenen beanspruchten Datensatz), sodass serialisierbare Isolation nichts bringt — dennoch leiten die terminalen Helper und der Dispatcher ihre Warteschlangen-Schreibvorgänge weiterhin durch pepsi_common::db::with_retry als günstige Versicherung gegen einen vorübergehenden Serialisierungs-/Deadlock-Fehler.

Eine Stage, die die Nachricht umschreibt, zeichnet ihre Änderungen über die Inhalts-Setter (set_headers, set_mail_from, merge_state, …) auf, statt SQL abzusetzen; der Worker faltet diese Änderungen und die Stage-Transition in ein UPDATE zusammen, aufgebaut aus genau den Spalten, die die Stage berührt hat (Signier-Stages ändern beispielsweise nur die headers-Spalte und lassen den Nachrichtentext unberührt). Weil Inhaltsänderungen auf dem In-Memory-Datensatz mitreisen, sieht ein fusionierter Nachfolger sie, und sie werden mit dem einen Commit der Kette persistiert — Fusion lässt nie ein Update fallen. Das Programm ruft dann genau einen terminalen Helper auf, dessen Datenbanksemantik der Vertrag ist:

advance() / advance_to()

Verschiebt stage und setzt den Datensatz dort auf ``pending``, sodass der Worker-Pool der nächsten Stage ihn beansprucht. Die Stage besitzt diesen Übergang von Anfang bis Ende: Der einzige Schreibvorgang des Dispatchers auf status, der Fortschritt bewirkt, ist der Anspruch pending→running, nie umgekehrt.

reroute(stage, state)

Wie advance, führt aber state zusammen; so erreicht eine Nachricht eine Bounce-Stage.

pause(state, secs)

Setzt paused und ein Wiederholungs-timeout (der Dispatcher reiht sie erneut ein).

pause_with(merge, secs, keep_indices, clones)

Die reichere Pause: Sie reduziert den Datensatz zusätzlich auf eine Teilmenge der Empfänger und/oder erzeugt seitliche Klone (eine Delay-DSN, Erfolgs-DSNs für die bereits zugestellten Empfänger); Pause und Klone werden gemeinsam in einem einzigen workqueue_pause-Aufruf committet.

fail(state)

Terminal failed (für einen Operator belassen).

finish()

DELETE des Datensatzes (zugestellt oder verworfen). finish_with(clones) löscht und erzeugt seitliche Klone (Bounces je Empfänger, Erfolgs-DSNs) in einem einzigen workqueue_finish_with_clones-Aufruf.

advance_or_finish() / complete_success(...)

Die terminalen Pfade der Zustell-Stage: weiterschalten zu NEXT_STAGE, sonst abschließen; und das Erfolgs-DSN-Tor, das zu BOUNCE_STAGE umleitet, wenn ORIGINATE_SUCCESS_DSN + NOTIFY=SUCCESS.

Drei weitere Helfer fächern den Datensatz auf, statt ihn abzuschließen. Sie reduzieren die Empfängermenge dieses Datensatzes und legen in einem einzigen workqueue_split-Aufruf Geschwister-Datensätze im Zustand pending an, wobei Absender/Header/Nachrichtentext serverseitig geklont werden; der Aufrufer muss diesen Datensatz weiterhin mit einem der obigen Terminale abschließen: split_off_recipients löst eine Index-Teilmenge auf ein einzelnes Geschwister heraus (lokale Zustellung, die die lokalen Empfänger behält und den Rest weiterschickt); split_to_one_per_row löst die Empfänger 1.. auf je einen eigenen Datensatz in der aktuellen Stage heraus (die SMTP-Relays, die je Versuch einen Umschlagempfänger zustellen); und fan_out ist die allgemeine Form, deren FanGroups brandneue Adressen tragen dürfen, sodass sie Empfänger-Umschreibungen ausdrückt (pepsi-stage-dot-forward, pepsi-stage-relay-to-lmtp). Eine Gruppe wird als pending angelegt, es sei denn, ihr status lautet Retry (paused in ihrer Stage mit einem Wiederholungszeitpunkt — pepsi-stage-milter parkt auf diese Weise einen Empfänger, den der Filter zurückgestellt hat) oder Failed; diese beiden behalten das received_at der Nachricht, sodass ein an ihnen gemessenes MAX_LIFETIME ab der Ankunft zählt. Da sie die angesammelten Inhaltsschreibvorgänge im Speicher nicht persistieren, weisen sie eine Stage zurück, die eine Inhaltsumschreibung mit einem von ihnen kombiniert hat, statt sie stillschweigend fallen zu lassen — dieselbe Sicherung, die auch pause trägt.

enqueue_new steht ganz außerhalb des Übergangs: Es injiziert über workqueue_inject eine brandneue seitliche Nachricht (eine automatische Antwort, eine Zahlungsaufforderung) in einer benannten Stage. rewrite_recipients ist ein bequemes Terminal, das ein völlig neues rcpt_to und state schreibt und weiterschaltet, alles im einzigen Commit des Workers; pepsi-stage-aliases verwendet es und baut dabei das parallele state.dsn.rcpt im Gleichschritt neu auf.

Zwei Optimierungen liegen unter diesem Vertrag, ohne ihn zu verändern.

Fusion. Ist [pepsi] ALLOW_FUSION eingeschaltet (der Standard) und rückt eine Stage zu einem Nachfolger vor, der in dasselbe Binärprogramm eingefaltet ist, nicht mehr Spalten benötigt, als bereits geladen sind (sein Load ist nicht größer), und der sich nicht über seine eigene can_fuse-Schranke verweigert, so führt der Worker den Nachfolger im selben Prozess aus — und spart sich sowohl das UPDATE des Vorrückens als auch das Lade-SELECT des Nachfolgers. Die angesammelten Inhaltsschreibvorgänge der Kette und der abschließende Übergang werden dann am Ende in einem einzigen UPDATE committet. Die Registratur, die das möglich macht, ist prozessglobal und wird nur vom vereinheitlichten Binärprogramm installiert, sodass Fusion in einem Entwicklungsbau mit einzelnen Programmen wirkungslos ist. Jeder fusionierte Sprung wird dem Dispatcher auf der Statuszeile des Workers gemeldet, damit pepsi.stage_stats ihn weiterhin zählt.

Stapelverarbeitung. Ein Worker ohne Nachrichtentext (Load::Metadata) stapelt zusätzlich über Nachrichten hinweg: Jeder Durchlauf leert gierig die bereits auf seiner Standardeingabe pipelineten IDs (bis zum QUEUE_LIMIT der Stage, und hält in dem Augenblick an, in dem ein Lesen blockieren würde), lädt den ganzen Stapel in einem SELECT … = ANY(…), führt jeden Rumpf aus und committet dann die aufgeschobenen Vorrück-/Fehl-/Wiedereinreihungsvorgänge in einem arrayisierten UPDATE je Ergebnis*form* — das Ziel des Vorrückens reist je Datensatz mit, sodass auch abweichende if-Zweige ein einziges Statement teilen. Die auffächernden Terminale committen weiterhin je Nachricht. Das bringt eine ausgelastete Stage ohne Nachrichtentext von zwei Round-Trips je Nachricht auf ungefähr zwei je QUEUE_LIMIT Nachrichten. Stages, die die Header oder den Nachrichtentext laden, behalten die Schleife mit einer ID nach der anderen, denn ihre großen Spalten lassen sich nicht billig in Arrays fassen. Das Worker-Protokoll bleibt in beiden Fällen unverändert: eine Statuszeile je Eingabe-ID, der Reihe nach.

22.6. Das State-Objekt

Das state-JSONB ist der einzige Kanal zwischen Stages. Die terminalen Helper führen neue Schlüssel zusammen (state || $new in SQL), sodass frühere Daten erhalten bleiben; die eine Ausnahme ist pepsi-stage-bounce, das state löscht, weil ein Bounce eine neue Null-Absender-Nachricht ist. Die Standardschlüssel sind:

origin

Herkunftsdaten des SMTP-Ursprungs, von Ingress gesetzt: Client-IP, HELO/EHLO, ESMTP/SMTPUTF8-Flags, die BODY=-Deklaration, TLS-Parameter, Listener, Reverse-DNS/iprev und die authserv_id (die die ARC-Stage benötigt, um die AAR-Identität zu reproduzieren). Wird von der ARC-Stage und der Herabstufungsentscheidung der Relay-Stages gelesen.

dsn

Die RFC-3461-Parameter: ret/envid auf Nachrichtenebene plus ein Array von notify/orcpt pro Empfänger. Jede Stage muss es bewahren.

bounce

Von einer Zustell-/Discard-Stage beim Routen zu BOUNCE_STAGE geschrieben, von der Bounce-Stage verbraucht: kind (permanent/success/delay), diagnostic, failed_recipient, die kopierten notify/orcpt/ envid sowie (von den Relay-Stages) remote_mta/ smtp_code/enhanced_status/phase/reply_text des nächsten Hops.

attempts / last_error / delay_sent

Zwischenspeicher für Wiederholungen der Zustell-Stage.

dispatch_error

Vom Dispatcher geschrieben, wenn er einen Datensatz zu failed/timeout zwingt.

Das State-Objekt hat ein eigenes Kapitel — Der Nachrichten-State — und die vollständige Schlüsselreferenz ist pepsi.state(7).

22.7. Der Dispatcher

pepsi-dispatch ist der einzige Koordinator (betreiben Sie genau einen pro System). Er LISTENt auf den Kanälen workqueue und config_changed (mit dekorreliertem Backoff beim Wiederverbinden) und einem Sicherheitsnetz-Heartbeat gemäß POLL_INTERVAL, und:

  • Beansprucht in Stapeln. Bei jedem Aufwachen beansprucht er pending-Arbeit für jede Stage mit freier Kapazität in einem UPDATE … RETURNING (jede Stage bis zu ihrer eigenen verbleibenden Kapazität), setzt die Datensätze auf running und übergibt jede ID an einen Worker dieser Stage über dessen Standardeingabe. Dieser Anspruch ist sein einziger Schreibvorgang auf status, der Fortschritt bewirkt, und er ist der einzige Schreiber, der ihn ausführt, sodass er kein FOR UPDATE SKIP LOCKED benötigt. Eine Stage, die weiterschaltet, hat den Datensatz bereits bei ihrem Nachfolger auf pending gesetzt, sodass der Dispatcher ihn in der nächsten Einplanungsrunde für den Pool dieser Stage einfach erneut beansprucht (Worker sind stagespezifisch, sodass eine Nachricht nicht innerhalb eines Prozesses verkettet wird, außer wo der Nachfolger in denselben Durchlauf fusioniert ist).

  • Verteilt jede Stage gerecht auf die Absender. Welche ausstehenden Datensätze die freie Kapazität einer Stage füllen, wird wie CPU-Zeit unter Prozessen entschieden (CFS/EEVDF): Jeder Absender — das authentifizierte Konto, sonst die verbindende Adresse (IPv6 je /64), festgehalten in der generierten Spalte workqueue.sender_key — sammelt die Worker-Zeit an, die seine Nachrichten in der Stage verbrauchen, und der Anspruch bevorzugt die Datensätze der Absender mit dem geringsten Wert, sodass sich ein Schub eines Absenders mit der Mail aller anderen verzahnt, statt zuerst an die Reihe zu kommen. Ein Neuankömmling beginnt gleichauf mit dem am wenigsten bedienten Absender (ruhige Zeit bringt kein Guthaben), Vorsprünge verfallen mit FAIR_HALF_LIFE, und FAIR_FIFO_PERCENT der Plätze bleiben nach Alter geordnet (älteste zuerst), sodass kein Datensatz verhungern kann. Die Buchführung liegt nur im Speicher des Dispatchers und wird bei einem Neustart einfach vergessen; die Auswahl ist weiterhin die eine Anspruchsanweisung von oben, der die Buchführung als Arrays übergeben wird.

  • Skaliert elastisch, pipelinet Arbeit. Der Worker-Pool jeder Stage wächst bei Bedarf bis zu seinem PARALLELISM und schrumpft im Leerlauf: Kein Worker läuft, bis eine Stage Arbeit sieht; Arbeit wird auf möglichst wenige Worker gepackt, sodass ein Überschuss kalt wird und nach WORKER_IDLE_TIMEOUT abgeräumt wird; nach MAX_MESSAGES erneuert ein Worker seinen Kindprozess. Ein Worker bekommt bis zu QUEUE_LIMIT IDs auf einmal zugeführt, was die in Bearbeitung befindliche Kapazität einer Stage auf QUEUE_LIMIT × PARALLELISM hebt, ohne Prozesse oder Verbindungen hinzuzufügen. Eine Stage, deren PROGRAM nicht gestartet werden kann, wird mit einer kurzen Start-Abkühlphase zurückgehalten, statt in einer engen Schleife wiederholt zu werden.

  • Reiht paused-Datensätze erneut ein, sobald ihr timeout abläuft (er schläft bis zur frühesten Fälligkeitszeit oder dem Heartbeat).

  • Stellt wieder her. Ein Worker, der einen Status ungleich null meldet oder dessen Kindprozess abstürzt → failed (und wird ersetzt); einer, der nicht innerhalb von MAX_RUNTIME antwortet, wird beendet → timeout (Grund in state.dispatch_error festgehalten). Beim Start setzt er verwaiste running-Datensätze auf pending zurück; bei SIGINT/SIGTERM stoppt er die Worker und setzt deren (sowie alle beanspruchten, aber nicht zugewiesenen) Datensätze zurück.

Der Dispatcher verarbeitet Nachrichteninhalte niemals selbst — er betreibt nur die Stage-Worker, die ihr eigenes Ergebnis auf dem Datensatz festhalten.

22.8. Nachrichten-Lebenszyklus (Ende zu Ende)

  1. Empfangen. pepsi-ingress nimmt die SMTP-Transaktion an, authentifiziert die Nachricht, stellt die Trace- und Authentication-Results-Header voran, teilt sie auf und speichert sie bei stage = init, status = pending. Der Dispatcher wird außerhalb des Bandes von einer zusammenfassenden Aufgabe geweckt, nicht aus der Zulassungstransaktion heraus.

  2. Dispatch. pepsi-dispatch beansprucht den Datensatz → running und übergibt ihn an einen [stage-init]-Worker.

  3. Stages. Jede Stage schaltet die Nachricht weiter und setzt sie selbst bei der nächsten Stage auf pending; der Pool jener Stage beansprucht sie dann: z. B. ARC → SRS → DKIM-sign → zustellen.

  4. Zustellen. Die Zustell-Stage überträgt die Nachricht. Bei Erfolg finisht sie (oder schaltet weiter). Ein vorübergehender Fehler pauset mit Backoff; der Dispatcher reiht sie später erneut ein.

  5. Bounce. Ein dauerhafter Fehler reroutet zu BOUNCE_STAGE; pepsi-stage-bounce baut eine (unsignierte) DSN, die dann wie jede andere Nachricht signiert und zugestellt wird. Ein Bounce wird niemals selbst gebounct.

Um irgendwo in diesem Ablauf einen neuen Schritt hinzuzufügen — etwa einen Spam-Filter oder einen Archivierungs-Hook — schreiben Sie ein Stage-Programm und fügen seinen Abschnitt in den Graphen ein; siehe Die Pipeline erweitern.