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 EXISTSplusCREATE TRIGGERfü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.dbdes 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 jetztshared-state.dbunter 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 diestate.dbdes Gateways auch nur erwähnte, konnte dessen POSIX-Locks aufheben. Diese Lesezugriffe laufen jetzt über das getrackte Offline-File-Access-Registry. hermes doctor --fixprü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:
- 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. - Wenn die WAL-Sonde unbestimmt war, gab der Helper
"wal"als angenommenen Modus zurück, undSessionDBaktivierte 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?oderN/Adargestellt, 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_validabgesichert, wo immermodel_configgelesen 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 ZugriffHERMES_HOMEunter 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_searchist 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 doctorlöst beschädigte Tree-IDs übersqlite_master.rootpageauf und kann Ihnen daher sagen, ob der Index oder die Strukturtabellen beschädigt sind, bevor es eine Recovery empfiehlt;hermes sessions repair --check-onlybeendet 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
.recoverhinterlä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.