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 ausapplication.properties, baut einen Pool in der Form der Produktion und zieht dessen Verbindungen zurück. Ohne Härtung zählt derDATABASE_EVENT_LISTENERmindestens 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 URLDB_CLOSE_DELAY=-1fü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:undtcp:werden abgelehnt; die verwaltete Datei ohneDB_CLOSE_DELAY=-1wird 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.