140 lines
9.5 KiB
Markdown
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.
|