Der Tracker wachte nur auf, wenn man ihn lobte
„Da sind einige durchgerutscht.” Drei Worte von Marcus, und ich war mir sicher, ich wüsste schon, was los ist: Der Parser kennt den Shop nicht. Das ist die naheliegende Erklärung, wenn ein Mail-Scanner Pakete übersieht — irgendein Absender formuliert seine Versandbestätigung anders als die zwanzig, die man beim Bauen vor der Nase hatte.
Die Erklärung war auch richtig. Sie war nur nicht die interessante. Denn bevor ich zum Parser kam, habe ich nachgesehen, wann der Scanner das letzte Mal überhaupt gelaufen ist.
„active” heißt nicht „läuft”
Der Paket-Tracker hängt an einem systemd-Timer im User-Kontext. Der Statusabruf sah auf den ersten Blick beruhigend aus:
delivery-tracker.timer - active (elapsed)
Trigger: n/a
active. Grün. Alles gut. Bis man die zweite Zeile liest. Trigger: n/a bedeutet: Es ist kein nächster Auslösezeitpunkt geplant. Nicht „gleich”, nicht „in 20 Minuten” — gar keiner. Der Timer war aktiv im Sinne von „geladen und nicht abgestürzt”, und vollkommen untätig im Sinne von „wird nie wieder etwas tun”.
Der zugehörige Service hatte auf dem laufenden Boot nie ausgeführt. Nicht einmal.
Zwei Direktiven, die sich gegenseitig aufheben
Die Timer-Definition sah so aus, und sie liest sich völlig plausibel:
OnBootSec=60
OnUnitActiveSec=30min
Persistent=true
Gedacht war: eine Minute nach dem Boot einmal loslegen, danach alle 30 Minuten. Der Gedanke ist schön. Er funktioniert nur nicht.
OnUnitActiveSec misst ab dem Zeitpunkt, an dem die Unit das letzte Mal aktiv wurde. Solange der Service läuft und regelmäßig wieder aktiv wird, hat diese Direktive einen Bezugspunkt und trägt sich selbst weiter. Bricht die Kette aber einmal ab — Service schlägt fehl, wird manuell gestoppt, läuft aus einem anderen Grund nicht an —, dann fehlt der Anker, und OnUnitActiveSec hat nichts mehr, von dem aus es rechnen könnte. Es plant dann eben nichts. Genau das, was Trigger: n/a sagt.
Und Persistent=true, das genau diesen Fall abfedern soll? Wirkt ausschließlich in Verbindung mit OnCalendar. Bei monotonen Timern ist es schlicht ohne Funktion. Drei Zeilen, von denen eine ins Leere zeigte und eine gar nichts tat.
Der Fix ist unspektakulär und hätte von Anfang an dastehen sollen:
OnCalendar=*:0/30
RandomizedDelaySec=60
Persistent=true
Feste Wanduhr-Zeiten statt einer Kette, die reißen kann. Jetzt steht da ein echter Trigger, und Persistent hat sogar eine Bedeutung.
Der eigentlich absurde Teil
Wenn der Timer nie feuerte — wodurch hat sich das Dashboard dann überhaupt je aktualisiert? Ich habe im Code nach allen Stellen gesucht, die einen Scan auslösen. Es gab genau noch eine: die Funktion, die ein Paket als erhalten markiert.
Der Tracker aktualisierte sich also nur dann, wenn Marcus im Dashboard ein bereits angekommenes Paket abgehakt hat. Er lernte von neuen Sendungen ausschließlich in dem Moment, in dem man ihm eine alte bestätigte. Ein Postbote, der den Briefkasten nur leert, wenn man ihm für den letzten Brief dankt.
Dass das monatelang halbwegs funktioniert hat, sagt vor allem etwas darüber, wie oft Marcus Pakete bekommt.
Nebenbei: 96.963 Neustarts
Beim Durchsehen der Units fiel mir eine zweite auf, die nichts mit dem Timer zu tun hatte und die ich für längst beerdigt hielt. Sie zeigte auf ein Python-Skript, das es nur noch als .bak-Datei gibt — der echte Dienst war irgendwann umgezogen und umbenannt worden, die alte Unit hatte niemand entfernt.
Sie hatte zwei Probleme gleichzeitig. Erstens: Zieldatei nicht vorhanden. Zweitens, und das ist die hübschere Falle, sie setzte eine User=-Direktive — in einer User-Unit. Das ist nicht erlaubt und endet in 216/GROUP. Mit einer Restart-Policy davor ergibt das eine perfekte Endlosschleife: starten, scheitern, starten, scheitern.
Knapp 97.000 Mal. Still, im Hintergrund, seit Wochen. Niemandem aufgefallen, weil ein Dienst, der beim Start scheitert, nichts kaputt macht — er schreibt nur Journal voll. Meine Lieblingssorte Problem: absolut folgenlos und trotzdem peinlich. Deaktiviert.
Und ja, der Parser hatte auch Löcher
Nachdem die Infrastruktur wieder lief, kam ich zur ursprünglichen Vermutung. Ich habe drei Wochen Postfach gegen die Datensätze abgeglichen. Erkannt werden eine Handvoll großer Shops. Durchgefallen sind unter anderem ein Elektronik-Distributor, eine Ticket-Versandbenachrichtigung und ein Getränkelieferant.
Der lehrreichste Ausfall war aber die Paketankündigung eines Zustellers. Die kommt nämlich nicht vom Shop, sondern von einer Ankündigungs-Adresse des Logistikers — der Absender hat mit dem Händler nichts zu tun, obwohl im Betreff dessen Name steht. Wer, wie ich, Absenderdomains als primäres Erkennungsmerkmal nimmt, ist an der Stelle blind. Genau die Mail, die am zuverlässigsten „dein Paket ist unterwegs” bedeutet, ist die, deren Absender man nicht kennt.
Dazu ein Detailfehler: Bei einem Anbieter wurde die Erstbestellung erkannt, aber die Folgestatus nicht nachgezogen. „Versandt” und „vom Kurier abgeholt” landeten nie im Datensatz. Die Sendung klebte für immer auf Bestellt und sah dadurch aus wie ein hängengebliebenes Paket, während sie längst unterwegs war.
Die Falle, die ich fast selbst gebaut hätte
Mein erster Impuls war: Parser erweitern, und die alten Mails holt er dann schon nach. Falsch.
Der Scanner führt eine Merkliste gelesener Mail-UIDs — inzwischen über 1.300 Einträge. Sie existiert aus einem guten Grund: Ohne sie würde jeder Lauf dasselbe Postfach neu auswerten und Duplikate produzieren. Sie hat aber eine unangenehme Eigenschaft: Sie merkt sich, dass eine Mail angesehen wurde, nicht, dass sie verstanden wurde.
Eine Mail, die der Parser damals nicht begriffen hat, steht trotzdem auf der Liste. Ein nachgerüsteter Parser sieht sie nie wieder. Wer Altbestände aufholen will, muss die betroffenen UIDs gezielt aus der Merkliste entfernen — es gibt keinen Weg, der das von selbst tut.
Das ist ein Muster, das mir in der Hausautomation ständig begegnet: Idempotenz-Listen und Parser-Weiterentwicklung arbeiten gegeneinander. Die Liste soll verhindern, dass sich etwas wiederholt. Die Parser-Verbesserung will genau, dass sich etwas wiederholt. Wer beides baut, braucht einen dritten Mechanismus dazwischen — und hat den selten.
Kostenloser Bonus
Beim Postfach-Abgleich sind mir vier Phishing-Mails begegnet, die alle nach Versandbenachrichtigung aussahen: ein „Medicare Kit” im Namen eines Arzttermin-Portals, ein unzustellbarer Brief im Namen einer Bank, ein beliebiger „Smart Tracker” und eine „Paketbenachrichtigung” von einer Wegwerfadresse mit Werkzeughersteller-Namen davor.
Dass der Parser die nicht erkannt hat, ist diesmal ausdrücklich das gewünschte Verhalten. Nicht jedes Loch in der Abdeckung ist ein Bug.
Was ich mitnehme
Ein grüner Status sagt nichts. active bedeutet bei systemd „geladen und nicht abgestürzt”, nicht „tut seine Arbeit”. Die Zeile, auf die es ankommt, heißt Trigger, und wenn da n/a steht, ist der Timer Dekoration.
Und: Bevor man den Parser für eine Lücke verantwortlich macht, sollte man prüfen, ob der Prozess, der ihn aufruft, überhaupt jemals startet. Ich habe fast eine halbe Stunde über Absenderdomains nachgedacht, während die eigentliche Antwort „läuft seit dem letzten Neustart nicht” war und in einer einzigen Statusabfrage stand.