Files
werkjournal/docs/implementation/h2-connection-lifecycle.md
2026-10-23 18:57:19 +02:00

140 lines
9.5 KiB
Markdown

# H2-Verbindungslebenszyklus
Am 2026-10-21, rund 72 Minuten nach dem Start von 0.1.123-SNAPSHOT, schlug in Produktion jeder
Schreibvorgang fehl, während Lesen durchgehend funktionierte. Im Journal stand nur
`UI failure <id> (org.h2.jdbc.JdbcSQLNonTransientConnectionException)`, weil
`UiErrorConfiguration` bewusst keine Exception-Meldungen protokolliert. Ein Neustart des Dienstes
hat den Zustand sofort und ohne Datenverlust beendet. Plattenplatz, Hostsharing-Quota, Dateirechte,
Datenbeschädigung und die Migration des Release waren bereits ausgeschlossen.
Dieses Arbeitspaket sollte die führende Erklärung prüfen — die Datasource-URL enthielt kein
`DB_CLOSE_DELAY`, H2 schließt die Datenbank dann, sobald die letzte Verbindung geht — und sie
beheben. Geprüft ist sie; belegt ist sie **nicht**. Der folgende Abschnitt hält fest, was die
Messungen hergeben, bevor die Härtung begründet wird.
## Was gemessen wurde
Alle Messungen mit H2 2.4.240 und HikariCP 6.3.3, den Fassungen aus `gradle/libs.versions.toml`.
**Der Fehlerfall ist so nicht reproduzierbar.** Ein Hikari-Pool mit `maximumPoolSize=4`, der seine
Verbindungen vollständig zurückzieht, bringt H2 dazu, die Datei-Datenbank zu schließen — der
anschließende Schreibvorgang öffnet sie aber einfach wieder und gelingt. Das gilt sowohl beim Leeren
über `idleTimeout`/`maxLifetime` als auch bei 8000 Öffnen-und-Schließen-Durchläufen auf vier
Threads, die das Rennen zwischen schließender und öffnender Sitzung absichtlich suchen. In keinem
Durchlauf trat eine Exception auf. Die Erklärung „der Pool zieht die letzte Verbindung zurück, H2
schließt die Datenbank weg, der nächste Schreibvorgang scheitert“ trägt also in dieser Form nicht.
**Die Annahme über die SQLState-Klasse trägt ebenfalls nicht.** H2 2.4.240 vergibt für die
90xxx-Fehler den Fehlercode selbst als SQLState, nicht eine 08er-Klasse; nur
`ERROR_OPENING_DATABASE_1` (8000) hat `08000`. Die Auswahl der Exception-Klasse läuft über den
Fehlercode, nicht über den SQLState. Von den im Auftrag genannten Kandidaten erzeugen
`OBJECT_CLOSED` (90007) und `IO_EXCEPTION` (90028/90031) **keine**
`JdbcSQLNonTransientConnectionException`, sondern `JdbcSQLNonTransientException` — sie scheiden
damit aus.
**Die Ursachenkette grenzt weiter ein.** `UiErrorConfiguration` protokolliert
`causes.lastOrNull()`, also die unterste Ursache (bis zu 16 Gliedern). Protokolliert wurde die
H2-Klasse selbst; die Exception hatte demnach keine weitere Ursache. Das schließt die Kandidaten
aus, die eine Ursache mitführen:
| Auslöser | Exception-Klasse | unterste Ursache |
| --- | --- | --- |
| `DATABASE_IS_CLOSED` (90098) | `JdbcSQLNonTransientConnectionException` | keine |
| `OutOfMemoryError` → `OUT_OF_MEMORY` (90108) | `JdbcSQLNonTransientConnectionException` | `java.lang.OutOfMemoryError` |
| `IOException` → `IO_EXCEPTION_2` (90031) | `JdbcSQLNonTransientException` | `java.io.IOException` |
| `MVStoreException` (`ERROR_CLOSED`, `ERROR_WRITING_FAILED`) | `JdbcSQLNonTransientException` | `org.h2.mvstore.MVStoreException` |
Übrig bleibt als Form, die genau zur protokollierten Zeile passt, ein ursachenloses
`DATABASE_IS_CLOSED`. Der Speichermangel — naheliegend bei `-Xmx384m` — hätte als unterste Ursache
`java.lang.OutOfMemoryError` hinterlassen, ein Schreibfehler der Ablage einen `MVStoreException`
oder `IOException`. Das Symptombild zeigt also auf eine geschlossene Datenbank; der Weg dorthin
bleibt offen, denn das Leeren des Pools allein genügt nachweislich nicht. Der Widerspruch, dass
Lesen weiter funktionierte, ist mit einer geschlossenen Datenbank ebenfalls nicht erklärt und bleibt
ungeklärt. **Die Ursache ist damit nicht bewiesen.**
Der fertige Commit auf `failure-diagnostics` ergänzt die Logzeile um die Kette der
Exception-Klassen samt `sqlState` und `errorCode`. Er wird getrennt ausgeliefert und ist das
Mittel, die Ursache beim nächsten Auftreten zu belegen statt zu raten.
## Was trotzdem belegt und behoben ist
Belegt ist die Vorbedingung: **ohne `DB_CLOSE_DELAY` schließt und öffnet der laufende Prozess seine
Datenbank wiederholt unter sich selbst.** Über einen `DATABASE_EVENT_LISTENER` sind die
`closingDatabase`-Ereignisse direkt sichtbar; ein Lauf von 16 Sekunden mit leerlaufendem Pool, einem
Anlegen und einem abschließenden Schreibvorgang zählte zwei Schließungen und drei Öffnungen, ohne
dass eine Anfrage fehlschlug. Jede dieser Öffnungen liest die Ablage neu ein und findet in einem
Zustand statt, den die Anwendung nicht erwartet. Das ist für sich genommen unnötiges Risiko, auch
wenn es den Vorfall nicht erklärt. Mit `DB_CLOSE_DELAY=-1` bleibt die Datenbank über die gesamte
Prozesslaufzeit genau einmal geöffnet.
Die Härtung ist damit als Beseitigung dieser Unruhe begründet, nicht als bewiesener Fix.
## Die Wahl zwischen den beiden Wegen
Zwei Wege standen zur Wahl.
**`;DB_CLOSE_DELAY=-1` an der Standard-URL in `application.properties`** — gewählt. Der Parameter
steht sichtbar in der Konfiguration, gilt für jede Verbindung des Pools und verändert nichts an den
gespeicherten Daten. Sein Nachteil ist, dass eine Umgebungsüberschreibung von
`WERKJOURNAL_DATABASE_URL` oder `SPRING_DATASOURCE_URL` ihn ersetzen könnte. Der Lesezugriff auf
`~/.config/werkjournal/environment` in Produktion zeigt, dass dort **keine** Überschreibung der
Datasource-URL gesetzt ist: die Datei führt ausschließlich `WERKJOURNAL_GUEST_MODE`,
`WERKJOURNAL_AUTH_BOOTSTRAP_CODE` und die drei Push-Schlüssel. Die Standard-URL ist in Produktion
also tatsächlich maßgeblich. Die Deployment-Preflight schließt die verbleibende Lücke, indem sie
eine künftige Überschreibung nur noch mit demselben Parameter annimmt.
**`spring.datasource.hikari.connection-init-sql=SET DB_CLOSE_DELAY -1`** — verworfen. Der Weg wirkt
zwar unabhängig von der URL, aber `SET DB_CLOSE_DELAY` ist in H2 eine **dauerhafte Einstellung der
Datenbankdatei**: nach einem Lauf mit dieser Anweisung meldet auch eine Verbindung ohne sie
`DB_CLOSE_DELAY = -1`. Die Einstellung wandert damit unsichtbar in die Daten und gilt anschließend
auch für Werkzeuge, die die Datei kurz öffnen — etwa den Offline-Befehl `AdminRecovery`. Eine
Konfigurationszeile, die sich zurücknehmen lässt, ist einer in die Datei geschriebenen Einstellung
vorzuziehen, die das nicht tut. Hinzu kommt, dass die Anweisung bei jeder neuen Verbindung läuft,
statt einmalig Teil der Verbindungsdaten zu sein.
## Preflight und die Sicherungsgarantie
`scripts/deploy-update-remote.py` hat eine Überschreibung bisher als Zeichenkette mit
`jdbc:h2:file:./data/werkjournal` verglichen. Diese Prüfung sichert zu, dass die Datenbank im
verwalteten Sicherungspfad liegt, denn das Deployment stoppt den Dienst und kopiert das geschlossene
Datenverzeichnis in ein Rollback-Bündel. Ein Zeichenkettenvergleich hätte die neue URL abgelehnt.
`check_database_url` vergleicht jetzt den **Dateipfad** statt der Zeichenkette: der Pfad aus der URL
wird — relativ wie absolut — gegen `<Arbeitsverzeichnis>/data/werkjournal` normalisiert und muss
genau darauf fallen. Alles, was aus dem Sicherungspfad hinausführt, wird weiterhin abgelehnt, auch
ein Umweg über `..`, ein anderer Dateiname im selben Verzeichnis und jede URL, die keine
Datei-Datenbank ist (`mem:`, `tcp:`). Zusätzlich muss die Überschreibung `DB_CLOSE_DELAY=-1`
mitführen, damit die Härtung nicht stillschweigend wieder verlorengeht, und jeder weitere Parameter
wird abgelehnt.
Dass jeder weitere Parameter abgelehnt wird, schützt vor allem `DB_CLOSE_ON_EXIT`. Die Vorgabe
`TRUE` bleibt bewusst stehen: der Shutdown-Hook schließt die Datenbank beim Prozessende sauber, und
genau darauf beruht die konsistente Sicherung des gestoppten Dienstes. Auch mit
`DB_CLOSE_DELAY=-1` ist das nachgewiesen — die Datenbank wird beim Prozessende weiterhin
geschlossen; `-1` verhindert nur das Schließen im Betrieb, nicht das beim Beenden.
`DB_CLOSE_ON_EXIT=FALSE` würde das Datenverzeichnis ohne diese Zusicherung zurücklassen und wird
deshalb von der Preflight zurückgewiesen.
Nicht angefasst ist die fest eingetragene URL in `AdminRecovery`: dieser Offline-Befehl läuft in
einem eigenen, kurzlebigen Prozess ohne Pool, in dem die Datenbank ohnehin nur einmal geöffnet und
beim Beenden geschlossen wird.
## Nachweise
- `H2ConnectionLifecycleTest`: liest die Parameter der konfigurierten Standard-URL aus
`application.properties`, baut einen Pool in der Form der Produktion und zieht dessen Verbindungen
zurück. Ohne Härtung zählt der `DATABASE_EVENT_LISTENER` mindestens eine Schließung der Datenbank,
mit der konfigurierten URL keine. „Mindestens“, weil Hikari beim Aufbau des Pools eine
Probeverbindung öffnet und wieder schließt, die ihrerseits schon eine Schließung auslösen kann; die
Aussage ist, dass überhaupt geschlossen wird, nicht wie oft es in einem Lauf geschieht. Der zweite
Test verlangt zusätzlich ausdrücklich, dass die konfigurierte URL `DB_CLOSE_DELAY=-1` führt.
- `tests/operations/test_update.py`, `ManagedDatabaseUrlTest`: die verwaltete Datei wird relativ,
absolut und über einen Umweg akzeptiert, solange sie offen bleibt; Pfade außerhalb des
Sicherungsverzeichnisses, ein anderer Dateiname, `mem:` und `tcp:` werden abgelehnt; die
verwaltete Datei ohne `DB_CLOSE_DELAY=-1` wird abgelehnt; jeder weitere Parameter —
`DB_CLOSE_ON_EXIT=FALSE`, `CIPHER`, ein Parameter ohne Wert — wird abgelehnt.
- `tests/operations/test_update.py`,
`test_runtime_preflight_rejects_external_database_and_inline_overrides`: derselbe Weg durch die
vollständige Preflight über die geschützte Umgebungsdatei, einschließlich der jetzt
angenommenen gehärteten Überschreibung.