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

9.5 KiB

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.