Zurück zum Blog

Nur kurz die Ausgabe ansehen: wie ein Schreibskript zweimal lief

Ein Stapelskript lief sauber durch. Danach wollte ich nur noch die Ausgabe ansehen, startete es dabei ein zweites Mal und hatte neun doppelte Kommentare. Was ein Trockenlauf nicht abdeckt und wie Schreibskripte einen zweiten Lauf aushalten.

5 Min. Lesezeitvon Daniel AlischEngineering, Automatisierung, KI-Agenten

Ein Skript sollte auf einer Liste von Issues je einen Belegkommentar setzen, Labels ändern und einige Karten schließen. Es lief einmal sauber durch. Danach wollte ich mir nur noch die Ausgabe ansehen und hatte damit neun Kommentare doppelt geschrieben.

Die Ausgangslage

Das Skript war eigentlich vorsichtig gebaut. Ohne Schalter macht es einen Trockenlauf und zeigt nur, was passieren würde. Erst mit --apply spricht es mit der API. Der Plan lag als JSON-Datei daneben: je Karte ein Kommentar, Labels dazu oder weg, eventuell schließen. Gegen Fehler im Plan schützt so ein Trockenlauf gut. Gegen das, was dann passierte, schützt er nicht.

Der echte Lauf ging fehlerfrei durch. Dann wollte ich noch die Gegenprobe sehen und schrieb dafür eine Zeile, die ich für einen Anzeigebefehl hielt:

bash
python3 triage_apply.py --apply 2>&1 | head -25 > /dev/null; <gegenprobe>

Gedacht war das als „Ausgabe kürzen und weiter“, wobei > /dev/null sie sogar ganz verschluckt. Tatsächlich ist es ein zweiter vollständiger Schreiblauf mit --apply. Das | head zeigt das alte Ergebnis nicht an. Es startet das Programm neu und schaut ihm beim Arbeiten zu.

Warum es bei neun blieb

Den Abbruch hat die Pipe ausgelöst. head -25 liest 25 Zeilen und beendet sich. Das Skript schreibt weiter in eine Pipe, an deren anderem Ende niemand mehr liest. Weil Python in eine Pipe gepuffert schreibt, merkt es das nicht sofort, sondern erst beim nächsten Leeren des Puffers. Dann kommt ein BrokenPipeError, und das Skript bricht ab. Wo genau das passiert, hängt also am Puffer und nicht an den 25 Zeilen. Bis dahin hatte der zweite Lauf die ersten neun Karten des Plans abgearbeitet.

Je Operation wirkt ein zweiter Lauf unterschiedlich:

  • Label hinzufügen: idempotent. Ist das Label schon da, ändert sich nichts.
  • Label entfernen: beim zweiten Mal kommt ein Fehlercode zurück, sonst passiert nichts.
  • Issue schließen: ein geschlossenes Issue noch einmal zu schließen, schadet nicht.
  • Kommentar schreiben: nicht idempotent. Jeder Aufruf erzeugt einen neuen Kommentar, byte-gleich mit dem vorigen.

Für die Doppelkommentare kann die Pipe nichts, die gingen aufs Konto des zweiten Laufs. Dass es nicht mehr wurden, lag dagegen an der Pipe. Ohne head wäre das Skript bis zum Ende gelaufen und hätte am Schluss auch die neuen Karten ein zweites Mal angelegt. Die Pipe hat also Schlimmeres verhindert, nur aus dem falschen Grund. Darauf sollte man sich nicht verlassen.

Die Reparatur

Doppelte Kommentare lassen sich über die API gezielt löschen. Man holt die Kommentare jeder betroffenen Karte, sucht die Paare gleicher Texte und löscht die Dublette mit DELETE /repos/{owner}/{repo}/issues/comments/{id}, Antwort 204. Sinnvoll ist, jeweils den jüngeren zu löschen, denn auf den älteren hat vielleicht schon jemand reagiert. Danach gehört eine Zählung dazu: je Karte genau ein Belegkommentar.

Das ging schnell. Wer das Repo beobachtet, hat aber in der Zwischenzeit womöglich neun Benachrichtigungen mehr bekommen, und die lassen sich nicht zurückholen.

Was ich daraus ableite

Die erste Regel betrifft die Shell: Einen Schreiblauf starte ich genau einmal und ohne Pipe. Die Ausgabe geht in eine Datei, und angesehen wird dann nur diese Datei.

bash
python3 triage_apply.py --apply > lauf_apply.log 2>&1
echo "rc=$?"
head -25 lauf_apply.log

Damit ist head wieder das, wofür ich es gehalten hatte: ein Lesebefehl. Eine Gewohnheit ist trotzdem kein Schutz. Beim nächsten Mal stehen dieselben Wörter in einer etwas anderen Zeile, und man liest sie wieder als Anzeigebefehl.

Die zweite Regel sollte deshalb ins Skript: Jede nicht idempotente Schreiboperation prüft vorher, ob sie schon passiert ist. Für Kommentare reicht dazu eine unsichtbare Markierung im Text, etwa ein HTML-Kommentar mit einer festen Kennung des Plans. Eine Kennung je Lauf wäre falsch, denn dann fände ein neuer Lauf die Kommentare des alten nicht wieder. Vor dem Schreiben sucht das Skript diese Markierung in den vorhandenen Kommentaren:

python
MARKE = "<!-- triage-plan:abnahme-1 -->"

def kommentar_einmal(nr, text):
    vorhanden = api.list_comments(nr, alle_seiten=True)
    if any(MARKE in k["body"] for k in vorhanden):
        print(f"#{nr}: Kommentar schon da, übersprungen")
        return
    api.comment(nr, text + "\n\n" + MARKE)

Das kostet je Karte einen zusätzlichen Lesezugriff. Dafür schreibt das Skript keinen Kommentar doppelt, wenn es erneut startet, vorausgesetzt, es liest wirklich alle Seiten der Kommentarliste (die GitHub-API liefert ohne Seitenabruf nur die ersten 30). Das gilt, ob der Neustart aus Versehen, nach einem Abbruch in der Mitte oder deshalb kommt, weil ein Agent den Auftrag wiederholt. Gerade der letzte Fall ist bei automatisierten Abläufen nicht theoretisch: Wird ein Lauf neu gestartet, beginnt er sonst von vorn, mit allem, was schon erledigt war.

Die dritte Regel betrifft das Neuanlegen von Objekten: Vor dem Anlegen wird nach einem Objekt mit demselben Titel oder derselben Markierung gesucht. Das ist dieselbe Idee wie beim Kommentar, nur teurer, wenn man sie vergisst.

Der Trockenlauf reicht nicht

Bisher hatte ich den Trockenlauf als die Sicherung eines Stapelskripts betrachtet. Er beantwortet aber nur die Frage, ob der Plan stimmt. Ob es schlimm ist, wenn der Plan zweimal ausgeführt wird, beantwortet er nicht. Diese zweite Frage stellt sich bei jedem Skript, das man mehr als einmal aufrufen kann, also bei allen.

Fazit

Ein Schreibskript, dessen Ausgabe man noch einmal sehen will, startet man nicht noch einmal, man liest sein Protokoll. Und weil man genau das irgendwann trotzdem tut, sollte das Skript den zweiten Lauf aushalten: Es prüft vorher, was schon da ist, und überspringt es. Bei mir hat ein head das zufällig abgefangen, und darauf möchte ich kein Verfahren aufbauen.

Weiterlesen