Woher „database is locked“ wirklich kommt: Hermes behebt Lock-Konflikte, doppelte Writer und einen Fehlalarm


Sonntagabend richten Sie einen Cron-Job ein; Montagmorgen öffnen Sie ein Terminal, um die Ergebnisse zu lesen, und der erste Befehl hängt einfach. Zwanzig Sekunden später gibt er mit database is locked auf. Sie haben keine zwei Hermes-Instanzen laufen und haben die Datenbank nie von Hand angefasst – wer hat dann das Lock genommen? Ein Bündel von Fixes, das am 11. September gemergt wurde, beantwortet genau das, und die Antwort ist kontraintuitiv: Das Lock war Hermes selbst, der bei jedem Öffnen des Session-Stores Schreibhoheit für sich beanspruchte, und ein paar Prozesse hätten diese Datei von vornherein überhaupt nie anfassen dürfen.

1. Das Öffnen des Stores nahm ein Write-Lock, selbst wenn nichts geschrieben werden musste

Zur Einordnung: Hermes hält jede Session in einer SQLite-Datei namens state.db – session_search, hermes sessions, Exporte und Insights lesen alle daraus. SQLites Nebenläufigkeitsregel ist unerbittlich: Eine Anweisung, die schreiben könnte, nimmt das Write-Lock – selbst wenn sie null Zeilen trifft.

Genau das war die Falle. Jedes Read-Write-Öffnen von state.db führte früher zusätzlich eine Reihe von Anweisungen aus, die auf einer ruhenden Datenbank nichts änderten:

  • Einfügen des Generation-Stempels (INSERT OR IGNORE);
  • UPDATE messages SET active=1 WHERE active IS NULL;
  • Upsert des FTS-Storage-Version-Markers;
  • DROP TRIGGER IF EXISTS plus CREATE TRIGGER für vier Display-Trigger.

Eine völlig gesunde Datenbank ohne anstehende Arbeit ließ also benachbarte Prozesse – einen Cron-Tick, einen hermes sessions-Aufruf, einen Doctor-Check – die komplette Transaktion abwarten. Wurde das Geduldsfenster überschritten, scheiterte das Öffnen rundheraus mit database is locked.

Der Fix lautet: erst lesen, nur bei echter Veraltung schreiben – den Stempel nur einfügen, wenn er fehlt, active IS NULL nur heilen, wenn eine NULL-Zeile existiert, den Marker überspringen, wenn der gespeicherte Wert schon passt, und Trigger nur neu anlegen, wenn die in sqlite_master.sql hinterlegte Definition von der gewünschten abweicht.

Die Zahlen aus dem Pull Request machen es deutlich:

Szenario Vorher Nachher
Ruhende Datenbank, Nachbarprozess hält das Write-Lock 4 s lang Öffnen dauerte 4,14 s 0,01 s
Halter überlebt das Geduldsfenster database is locked nach 20,5 s öffnet normal

Der Hänger war nie „jemand anderes benutzt die Datenbank“. Es war „wir haben Schreibrechte angefordert, die wir nicht brauchten“.

2. Wer sich still als zweiter Writer betätigte

Dasselbe Bündel fand vier Rollen, die den Haupt-Session-Store wie Schmierpapier behandelten:

  • Der profilübergreifende Group-Chat-Koordinations-Store lag auf der state.db des Root-Gateways, während jedes Profile-Gateway diesen Worker bedingungslos startet – womit jeder Profile-Prozess zu einem langlebigen Writer auf dem Master-Session-Store wurde. Er nutzt jetzt shared-state.db unter dem Install-Root.
  • Das Web-Dashboard gleicht das Schema beim Start jetzt über ein Read-only-Öffnen ab. Ein gesunder Store sieht null schreibbare Öffnungen; nur ein wirklich kaputtes Schema bekommt den einen schreibenden Durchlauf.
  • Der Cron-Lifecycle-Guard durchsuchte Befehlstext nach pfadähnlichen Tokens und las sie mit einem rohen os.open. Ein Befehl, der die state.db des Gateways auch nur erwähnte, konnte dessen POSIX-Locks aufheben. Diese Lesezugriffe laufen jetzt über das getrackte Offline-File-Access-Registry.
  • hermes doctor --fix prüft jetzt vor seinem WAL-Checkpoint, ob ein lebender Writer existiert. Unter einem laufenden Gateway überspringt es den Checkpoint mit einem umsetzbaren Befund, statt sich als zweite Verbindung an das WAL zu hängen.

Zusammen erklärt das, warum database is locked auf echten Maschinen immer wieder auftauchte: nicht ein Bug, sondern eine Datenbank mit mehreren Writern.

3. Ein gesundes WAL wurde für tot erklärt

Die dritte Kategorie ist subtiler. Hermes hat einen Guard: Werden die WAL-Sidecar-Dateien von state.db unlinked, fällt es auf JSONL-Storage zurück – langsamer, aber sicher. Der alte Beweis war das Suffix " (deleted)" in den Einträgen von /proc/<pid>/fd.

Unter OpenZFS wird eine lebende und weiterhin verlinkte -wal/-shm-Datei genau so gemeldet (Dentry unhashed, nlink=1). Der Guard schlug also dauerhaft an, und das Gateway lief für immer auf JSONL – degradiert und völlig still darüber.

Die Prüfung vergleicht jetzt (st_dev, st_ino) des File Descriptors mit dem aktuell überwachten Pfad. Ein echter Orphan, der per Hardlink am Leben gehalten wird, wird weiterhin erkannt – der Guard bleibt also fail-closed.

Zwei Races derselben Familie wurden ebenfalls behoben:

  1. Der Schreibpfad führte seine Sonde „Sidecars verschwunden“ vor dem Nehmen seines eigenen Locks aus, während close() die Sidecars unter diesem Lock unlinkt. Ein Writer, der mit einem sauberen Shutdown kollidierte, markierte sich selbst mit dem klebrigen Flag „WAL generation lost“. Die Sonde läuft jetzt innerhalb des Locks – elf Zeilen.
  2. Wenn die WAL-Sonde unbestimmt war, gab der Helper "wal" als angenommenen Modus zurück, und SessionDB aktivierte auf dieser Annahme seinen lock-freien Read-Pool. Lag die Datei tatsächlich im DELETE-Modus, war der Pool falsch. Der Header muss jetzt bestätigt sein; ist das nicht möglich, reihen sich Lesezugriffe stattdessen am Writer-Lock ein.

4. Eine kaputte Zeile darf nicht die ganze Tabelle zerstören

Abgesehen vom Locking gab es einen Lawinenpunkt: Eine einzelne kaputte Zeile konnte einen ganzen Befehl abräumen. Zu den in freier Wildbahn beobachteten Formen gehören ein TEXT-Müll-Timestamp, fehlerhaftes model_config-JSON und ein Bulk-Delete, der das Limit für gebundene Variablen in SQLite überschritt.

Die Opfer waren hermes sessions list, hermes sessions browse, der Session-Export (JSONL / Markdown / HTML), hermes insights und sessions prune.

Jetzt gibt es auf beiden Seiten Guards:

  • Lesezugriffe laufen durch hermes_cli.timefmt.coerce_epoch(), das eine gespeicherte Zelle in Epochensekunden innerhalb eines vernünftigen Fensters überführt (1970 bis etwa 2103, innerhalb von 32-Bit-time_t) oder nichts zurückgibt – nach einer WARNING, die die Session-ID nennt. Die kaputte Zelle wird als ? oder N/A dargestellt, und der Befehl läuft zu Ende.
  • Schreibzugriffe wenden dasselbe Fenster an: Ein Timestamp außerhalb des Fensters fällt auf jetzt zurück, sodass Hermes keine neue kaputte Zeile persistieren kann.
  • JSON-Marker sind mit json_valid abgesichert, wo immer model_config gelesen wird.
  • Bulk-Arbeit zerlegt ihre IN-Listen in Chunks, um „too many SQL variables“ zu vermeiden, und die N+1-Query des Exporters hydratisiert Messages jetzt in 900-ID-Batches.

5. Sessions greifen nicht mehr auf andere Profile zu

Wenn Sie mehr als ein Profile betreiben, sind zwei dieser Fixes direkt für Sie relevant:

  • Das Desktop-Launch-Backend rief acquire() ohne Pfad auf, sodass sein erster Zugriff HERMES_HOME unter dem gerade aktiven Context-Override auflöste. War das Override-Fenster eines anderen Profiles offen, zeigte das prozesslebenslange Handle für immer auf den Store dieses Profiles. Jetzt ist es auf das Launch-Home festgenagelt.
  • Der Bare-ID-Fallback-Scan in session_search ist weg. Früher öffnete ein Fehlschlag im eigenen Store jede Profile-Datenbank und gab das vollständige Transcript des ersten Besitzers zurück – ohne das Profile zu nennen. Ein Fehlschlag ist jetzt ein Fehlschlag, mit dem Hinweis, profile= oder @session:<profile>/<id> mitzugeben.

Das Löschen eines Profiles schließt außerdem die geteilten Handles dieses Prozesses, bevor das Verzeichnis entfernt wird – das behebt den WinError 32-Fehler (Datei in Benutzung) unter Windows.

6. Ein beschädigter Suchindex ist kein beschädigtes Transcript

Noch einer aus der Praxis: Ein beschädigter FTS5-Index in state.db wurde früher als Korruption der ganzen Datei gemeldet und drängte die Leute auf den schweren Recovery-Pfad.

Fehler werden jetzt nach Provenienz klassifiziert: Ein Fehler aus den Shadow-Tables messages_fts* wird zur Persistenz-Ursache fts_index, und die Oberfläche meldet „Suche degradiert, kanonisches Transcript intakt“. Darüber hinaus:

  • hermes doctor löst beschädigte Tree-IDs über sqlite_master.rootpage auf und kann Ihnen daher sagen, ob der Index oder die Strukturtabellen beschädigt sind, bevor es eine Recovery empfiehlt;
  • hermes sessions repair --check-only beendet sich bei einem ungesunden Store mit einem Exit-Code ungleich Null, was den Befehl skriptbar macht;
  • Die Recovery-Spur deckt die Formen ab, in denen echte Incident-Datenbanken tatsächlich ankommen: ein zerrissener Page-1-Header, Phantom-NOT-NULL-Zeilen, eine beschädigte rowid-Kante, eine CJK-Index-Familie und verwaiste Shadow-Tables, die .recover hinterlässt.

Was Sie tun sollten

Aktualisieren Sie auf den neuesten Build (hermes update). Um Ihre eigene Maschine zu prüfen, decken drei Befehle das Meiste ab:

hermes sessions list --limit 5   # liest sich die Liste noch sauber?
hermes sessions repair --check-only   # Nicht-Null-Exit bei ungesundem Store
hermes doctor --fix              # benennt Index- vs. Strukturschaden, dann der nächste Schritt

Wenn Sie mehrere Profile betreiben, nennen Sie das Profile explizit:

hermes -p worker doctor --fix

Das Fazit

Was diese Fixes verbindet: „Das Lock“ war nie eine einzige Sache. Es war nicht benötigte Schreibhoheit, mehrere Prozesse, die um die Rolle des einzigen Writers konkurrierten, und ein Fehlalarm, der den Storage dauerhaft und still degradierte. Für Nutzer ist die sichtbarste Änderung einfach: Eine ruhende Datenbank sollte sofort öffnen, statt Sie zwanzig Sekunden auf einen Prompt schauen zu lassen.

Für die frühere Runde der Selbstheilung – Erkennung, Quarantäne, Reparatur – siehe state.db heilt sich selbst. Warum lang laufende Sessions die falsche Voreinstellung sind und wie Memory um Session-Grenzen herum entworfen ist, steht in Session-Grenzen. Die Befehle für den Alltag finden sich in der hermes sessions-Referenz, und dieses Bündel liegt vorerst auf main – in den Release Notes taucht es auf, sobald die nächste Version erscheint.