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, diepepsi-httpdausliefert).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 mitexecstartet (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 vontaler-commonbereitstellt (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_BINARIESimMakefile) 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 Dateiexect, 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-languageverlinkt dielingua-Sprachmodelle — eine große Abhängigkeit, die nichts anderes nutzt — undpepsi-setupzieht 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-configbleibt ebenfalls eigenständig;pepsi-telemetrywird in einem eigenen Debian-Paket ausgeliefert und läuft auf einem anderen Host; undpepsi-helper-mailbox-scanträgt kein Berechtigungsbit, wird aber vonpepsi-whitelistnamentlich mitexecgestartet. (pepsi-stage-detect-languageist 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-forwardundpepsi-helper-auto-pay; ihre setgid-Stage-Aufruferpepsi-stage-relay-to-maildir,pepsi-stage-dot-forward,pepsi-stage-auto-payundpepsi-quota; das setgidpepsi-stage-relay-to-smarthost(das die OAuth-Token-Dateien der Gruppepepsi-tokenliest); das setuidpepsi-whitelist; undpepsi-stage-encrypt/pepsi-stage-decrypt, setuidpepsi-crypto, weil die Peer-Authentifizierung von PostgreSQL an der effektiven uid ansetzt und allein dieser Rollecrypto_identity.private_wrappedgewä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-keysist aus derselben Gruppe von Gründen eigenständig, wird aber ohne Bit ausgeliefert;debian/pepsi.postinstist 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.workqueueEin 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 istraw = headers || CRLF || body). Stages, die nur Header benötigen, laden oder überschreiben den Nachrichtentext nie. Der Nachrichtentext ist ein Datensatz vonpepsi.workqueue_body, referenziert überbody_idund 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 neuenworkqueue_body-Datensatz (Copy-on-Write, incommit_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 (initfür eine frische Nachricht).status—pending/running/paused/failed/timeout(ein PostgreSQL-ENUM).state— frei strukturiertesJSONB, das mit der Nachricht mitgeführt wird (siehe Das State-Objekt).timeout— wann einepaused-Nachricht zur Wiederholung fällig wird.
Die Authentifizierungsurteile (
spf/dkim/dmarc/arc) und dieauthserv_idliegen instate(unter den Schlüsselnauthbzw.origin), nicht in eigenen Spalten.pepsi.dns_addressEin 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_statsKumulative 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 terminalenfailed/timeout-Zustand endeten) undserialization_failures(vorübergehende40001/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-dispatchist 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/metricsvonpepsi-httpdliest sie (und die Live-Anzeigen für aktiv/pausiert direkt auspepsi.workqueue).pepsi.config_overrideDie vom Administrator verwaltete Konfiguration, die über die INI-Datei gelegt wird: ein Datensatz je
(scope, section, option)-Überschreibung, wobei scopeglobal,domain:<domain>oderaddress:<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 einconfig_changed-NOTIFYaus, und die Komponenten reagieren —pepsi-dispatchzieht 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.settingsDie Überschreibungsschicht je Adresse, und bewusst eine von
config_overridegetrennte 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_identityDie Schlüsselpaare der von uns bedienten Adressen — öffentliches Material plus die private Hälfte — ein Datensatz je Fähigkeit (
sign,encryptoderboth), mit seinen Lebenszyklusspalten (status,is_primary,published,expires_at,private_purged_at).pepsi.peer_keyDie 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_keyKorrespondenten, 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_trustDie CA-Zertifikate, die eine eingehende S/MIME-Kette erreichen muss, um als vertrauenswürdig zu gelten.
pepsi.key_requestDie laufenden Schlüsselsuchanfragen, die
pepsi-keydiscbeantwortet: 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_tokenKonten 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_logDas 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_logDie 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_logDie Absichtswarteschlange, die das browsergesteuerte Setup schreibt und ein root-Programm leert. Einen Mailserver einzurichten ist privilegiert —
/etc/pepsi/pepsi.confschreiben, jedessecrets.d-Fragment dem einen Konto übergeben, das es liest, certbot ausführen, Datenbankrollen anlegen, Schlüssel erzeugen —, undpepsi-httpdgibt 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, undpepsi-setup applyentscheidet, 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-httpddarf nurSELECT(es reiht über die getrennte Konfigurationsverbindung ein), und nurpepsi-configdarfINSERT. Wer davon einen Datensatz geschrieben hat, wird nicht auf Treu und Glauben genommen:written_bywird von einemBEFORE INSERT-Auslöser auscurrent_usererzwungen, 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 einPROGRAMund ein optionalesNEXT_STAGE/BOUNCE_STAGE(Die Stage-Pipeline) sowie die Worker-Pool-StellschraubenPARALLELISM,MAX_MESSAGESundQUEUE_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 liestworkqueue_ids von der Standardeingabe (bis zuQUEUE_LIMITgleichzeitig pipelinet, streng der Reihe nach verarbeitet), erledigt seine Arbeit pro Nachricht, ruft genau einen terminalen Helper auf und schreibt pro Nachricht eine Statuszeile (0bei 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:
lädt den
running-Datensatz in einem einzigenSELECT(und weigert sich zu handeln, sofern der Datensatz nichtrunningist),löst den
[stage-<name>]-Abschnitt der Nachricht auf, undverifiziert, dass das
PROGRAMdieses 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
stageund 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 aufstatus, der Fortschritt bewirkt, ist der Anspruchpending→running, nie umgekehrt.reroute(stage, state)Wie advance, führt aber
statezusammen; so erreicht eine Nachricht eine Bounce-Stage.pause(state, secs)Setzt
pausedund 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()DELETEdes Datensatzes (zugestellt oder verworfen).finish_with(clones)löscht und erzeugt seitliche Klone (Bounces je Empfänger, Erfolgs-DSNs) in einem einzigenworkqueue_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 zuBOUNCE_STAGEumleitet, wennORIGINATE_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:
originHerkunftsdaten des SMTP-Ursprungs, von Ingress gesetzt: Client-IP, HELO/EHLO, ESMTP/SMTPUTF8-Flags, die
BODY=-Deklaration, TLS-Parameter, Listener, Reverse-DNS/iprev und dieauthserv_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.dsnDie RFC-3461-Parameter:
ret/envidauf Nachrichtenebene plus ein Array vonnotify/orcptpro Empfänger. Jede Stage muss es bewahren.bounceVon einer Zustell-/Discard-Stage beim Routen zu
BOUNCE_STAGEgeschrieben, von der Bounce-Stage verbraucht:kind(permanent/success/delay),diagnostic,failed_recipient, die kopiertennotify/orcpt/envidsowie (von den Relay-Stages)remote_mta/smtp_code/enhanced_status/phase/reply_textdes nächsten Hops.attempts/last_error/delay_sentZwischenspeicher für Wiederholungen der Zustell-Stage.
dispatch_errorVom Dispatcher geschrieben, wenn er einen Datensatz zu
failed/timeoutzwingt.
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 einemUPDATE … RETURNING(jede Stage bis zu ihrer eigenen verbleibenden Kapazität), setzt die Datensätze aufrunningund übergibt jede ID an einen Worker dieser Stage über dessen Standardeingabe. Dieser Anspruch ist sein einziger Schreibvorgang aufstatus, der Fortschritt bewirkt, und er ist der einzige Schreiber, der ihn ausführt, sodass er keinFOR UPDATE SKIP LOCKEDbenötigt. Eine Stage, die weiterschaltet, hat den Datensatz bereits bei ihrem Nachfolger aufpendinggesetzt, 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 mitFAIR_HALF_LIFE, undFAIR_FIFO_PERCENTder 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
PARALLELISMund 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 nachWORKER_IDLE_TIMEOUTabgeräumt wird; nachMAX_MESSAGESerneuert ein Worker seinen Kindprozess. Ein Worker bekommt bis zuQUEUE_LIMITIDs auf einmal zugeführt, was die in Bearbeitung befindliche Kapazität einer Stage aufQUEUE_LIMIT × PARALLELISMhebt, ohne Prozesse oder Verbindungen hinzuzufügen. Eine Stage, derenPROGRAMnicht 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 ihrtimeoutablä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 vonMAX_RUNTIMEantwortet, wird beendet →timeout(Grund instate.dispatch_errorfestgehalten). Beim Start setzt er verwaisterunning-Datensätze aufpendingzurück; beiSIGINT/SIGTERMstoppt 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)¶
Empfangen.
pepsi-ingressnimmt die SMTP-Transaktion an, authentifiziert die Nachricht, stellt die Trace- undAuthentication-Results-Header voran, teilt sie auf und speichert sie beistage = init,status = pending. Der Dispatcher wird außerhalb des Bandes von einer zusammenfassenden Aufgabe geweckt, nicht aus der Zulassungstransaktion heraus.Dispatch.
pepsi-dispatchbeansprucht den Datensatz →runningund übergibt ihn an einen[stage-init]-Worker.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.Zustellen. Die Zustell-Stage überträgt die Nachricht. Bei Erfolg
finisht sie (oder schaltet weiter). Ein vorübergehender Fehlerpauset mit Backoff; der Dispatcher reiht sie später erneut ein.Bounce. Ein dauerhafter Fehler
reroutet zuBOUNCE_STAGE;pepsi-stage-bouncebaut 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.