Geräte-Leases in Postgres: Schluss mit parallelen Agenten, die um Emulatoren streiten
Wie eine einzige Postgres-Tabelle und ein kurzes Bash-Skript parallele Claude-Code-Sessions davon abhalten, auf Emulatoren und Simulatoren zu kollidieren – plus die drei Bugs, die die Tests fanden.
Ein einziges pkill -f maestro hat einmal auch einen Android-Emulator mitgerissen, weil das virtuelle Gerät zufällig maestro_e2e hieß. Das war eine von drei stillen Kollisionen in einer einzigen Session. Die Lösung: eine Postgres-Tabelle, eine Bash-Datei mit 243 Zeilen und drei zusätzliche Zeilen pro Test-Runner.
Der Commit, der die Registry einführte, erklärt den Hintergrund. Es gab neun Worktrees, Sessions liefen parallel, und trotzdem verhielt sich jeder Runner, als wäre er allein. Die drei Kollisionen:
pkill -f "maestro"hat auch den Android-Emulator beendet, weil der AVD-Namemaestro_e2epasste.- Simulatoren wurden gebootet, gelöscht und entfernt, ohne zu prüfen, ob eine andere Session gerade Tests darauf laufen ließ.
- Wenn sich zwei Sessions ein Gerät teilen, sieht der Fehler wie ein Produktbug aus. Das ist die teure Kollision, denn nichts am Symptom weist auf die wahre Ursache hin.
Heute hat das Repo elf Worktrees. Jede Claude-Code-Session bekommt ihren eigenen, viele sind gleichzeitig aktiv, und alle greifen auf dieselben zwei Android-Emulatoren, ein echtes Galaxy Z Fold über Samsungs Remote Test Lab und zwei iOS-Simulatoren zu. Nichts hielt sie auseinander.
Eine Tabelle, und der Primärschlüssel ist das Lock
Die Registry ist eine einzige Postgres-Tabelle in einer eigenen Datenbank namens yarina_devtools. Bewusst kein Schema in der App-Datenbank: Sie soll nicht in App-Migrationen, Dumps und Backups landen, und sie zu löschen soll nie beängstigend sein.
CREATE TABLE IF NOT EXISTS device_lease (
device_id text PRIMARY KEY,
platform text NOT NULL CHECK (platform IN ('ios', 'android')),
worktree text NOT NULL,
session_id text NOT NULL,
holder_pid integer NOT NULL,
purpose text NOT NULL DEFAULT '',
acquired_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL
);
Für die Atomarität sorgt allein der Primärschlüssel. Ein Claim ist ein einziges Statement: INSERT … ON CONFLICT (device_id) DO UPDATE … WHERE die aktuelle Zeile derselben Session gehört oder abgelaufen ist, dann RETURNING session_id. Postgres serialisiert konkurrierende Claims über diesen Schlüssel, sodass von N Wettläufern genau einer gewinnt. Hätte das Design eine Verlaufstabelle mit einem active-Flag verwendet, wäre „Ist das Gerät frei?“ eine eigene Abfrage – und genau dort würde sich ein Race zwischen den Anwärtern einschleichen.
Bevor ein Runner ein Gerät anfasst, erledigt er immer dieselben drei Dinge:
source "$REPO_ROOT/e2e/scripts/device-lease.sh"
trap 'lease_release_all' EXIT INT TERM
lease_claim "$DEVICE" android "android $SUITE" || exit 1
Ein abgelehnter Claim gibt aus, wer das Gerät hält (Worktree, Session, Zweck, Dauer), zusammen mit dem Befehl zum Freigeben. Für die Lebendigkeitsprüfung führt das Skript kill -0 gegen die PID des Halters aus. Postgres kann nicht wissen, ob ein Prozess läuft, aber hier liegt alles auf einer Maschine, also kann der Aufrufer nachfragen. Eine TTL von 4 Stunden deckt den Fall einer wiederverwendeten PID ab. Die Tabelle wird beim ersten Claim angelegt; sie beim Session-Start anzulegen wurde ausprobiert und verworfen, weil die meisten Sessions nie ein Gerät brauchen.
Drei Bugs, die die Tests fanden
Im echten Einsatz wären sie nie aufgefallen.
psql gibt ein Command-Tag aus. Ein abgelehnter Claim liefert null Zeilen zurück – das ist die Ablehnung. Ohne -q schrieb psql aber INSERT 0 0 auf stdout, die Prüfung auf leere Ausgabe sah Text, und jeder Konkurrenztest galt als Erfolg. Der Konkurrenztest hat es aufgedeckt; im Alltag wäre es verborgen geblieben.
Lebendigkeit vor dem Claim zu prüfen erzeugt ein Race. Die erste Version suchte die PID des Amtsinhabers in einer Abfrage und gab das Ergebnis an den Insert weiter. Acht gleichzeitige Anwärter sahen jeweils keinen Inhaber, schlossen daraus, das Gerät sei frei, und alle acht „gewannen“. Jetzt ist die Reihenfolge umgekehrt: erst den Claim versuchen, nie einem lebenden Halter etwas wegnehmen, und erst nach einer Ablehnung den Inhaber prüfen und ihn per Compare-and-Swap auf (holder_pid, acquired_at) löschen, dann einmal erneut versuchen. Von acht gewinnt einer.
Ein CLI-Claim, der seine eigene PID speichert, stirbt im Moment des Beendens. Manuelle Claims speichern deshalb holder_pid 0, also keinen besitzenden Prozess, und die TTL entscheidet, wann sie verfallen.
Ein Runner brachte ein viertes Problem mit. Mit exec maestro … wird der Shell-Prozess ersetzt, der EXIT-Trap kann nicht laufen, und der Lease bleibt nach dem Lauf bestehen. Rufen Sie maestro als normalen Befehl auf. Der Trap ist unverzichtbar: Wird der Lease nur freigegeben, wenn alles gut geht, bleibt das Gerät nach fehlgeschlagenen Läufen blockiert – und genau die werden am ehesten sofort neu gestartet.
Fail closed und Lücken offen benennen
Ist Postgres nicht erreichbar, verweigert der Runner die Weiterarbeit und sagt warum. Stillschweigend zu einem No-op zu degradieren ist schlimmer als gar keine Registry, weil der Bediener sich geschützt glaubt, obwohl er es nicht ist. YARINA_DEVICES_OPTIONAL=1 ist der dokumentierte Weg, ungeschützt weiterzumachen.
Ein direkter Aufruf von maestro test --device X umgeht die Leases komplett. Ein Wrapper-Shim über maestro, simctl und adb wurde erwogen und verworfen – zu aufdringlich und leicht zu umgehen, sobald etwas das echte Binary aufruft. Also bilden die Runner die Grenze, und die Regel steht in CLAUDE.md, der einen Datei, die jede Session liest.
Ein Lease macht einen grünen Lauf nicht wahr
Geteilte Geräte erzwangen zwei weitere Prüfungen.
Den Build per Hash verifizieren. Drei grüne Läufe hatten in Wahrheit ein APK getestet, das eine andere Session installiert hatte. Der Ersatzlauf verglich den Hash des APK auf dem Gerät (pm path, dann shasum) mit dem lokalen Build, einmal davor und einmal danach. Das ist jetzt feste Regel für jedes geteilte Gerät, denn ein bestandener Flow sagt nichts über Code, der gar nicht auf dem Gerät ist.
Die Last der Maschine im Blick behalten. Code, der bei einer Last um 8 bestand, scheiterte bei einer Last um 20 an Retry-Timeouts – ohne jede Änderung. Zwei Simulatoren und ein Android-Emulator zusammen trieben den Load Average auf 94 und ließen SpringBoard 16-mal abstürzen, daher startet der iOS-Runner oberhalb einer Lastschwelle jetzt nicht mehr. Mehr RAM für den Emulator trieb den Swap auf 93 %, und das Betriebssystem beendete den Emulator mitten im Lauf – noch etwas, das nach Flakiness aussieht.
So setzen Sie es im eigenen Repo um
- Eine Tabelle mit der Geräte-ID als Primärschlüssel verwenden und mit einem einzigen
INSERT … ON CONFLICT … WHERE … RETURNINGclaimen. - Aus einem Trap auf
EXIT INT TERMfreigeben undexecaus dem Runner heraushalten. - Den Lauf verweigern, wenn der Store nicht erreichbar ist, mit genau einem expliziten Notausgang.
- Vor dem Vertrauen auf das Lock einen Konkurrenztest mit N Wettläufern fahren, auf leere Ausgabe prüfen und psql
-qmitgeben. - Bevor Sie einem grünen Lauf auf einer geteilten Maschine glauben, den Hash des APK auf dem Gerät bilden und
uptimeansehen. - Die Regel dort festhalten, wo jede Session sie liest, denn eine Registry, von der niemand weiß, wird standardmäßig umgangen.
Was hängen bleiben sollte
Die Kernpunkte: Parallele Agenten brauchen ein Lock um physische Geräte, weil zwei Sessions auf einem Emulator Fehler erzeugen, die als Produktbugs durchgehen. Das Lock gehört in den Primärschlüssel, als ein atomares Statement ohne Lesen-dann-Schreiben. Ein grüner Konkurrenztest kann lügen – INSERT 0 0 galt als Erfolg, bis jemand nachsah. Fail closed und den Halter benennen, denn ein stilles No-op ist schlimmer als gar keine Registry. Und schließlich ist ein Lease nicht die letzte Verteidigungslinie: Build hashen, Last beobachten, dann dem Lauf vertrauen.