From b7078703843f6b2a09639928b6fb1e09b93564af Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Sun, 23 Aug 2026 16:53:14 +0200 Subject: [PATCH 1/9] docs(quarkus-morphium): architecture plan for an optional observability module Adds quarkus-morphium/docs/architecture/observability-module-plan.md, produced by the datona-architect persona and independently verified against the code before adoption (Capability.METRICS confirmed against the reactor's pinned quarkus-core-deployment 3.32.3 jar; buildMorphium()/onStop() lifecycle, MorphiumStorageListener callback signatures, and every DriverStatsKey/ StatisticKeys value in the metric catalog confirmed against morphium-core source, not assumed). Trigger: version42-adapter (a downstream consumer) hand-writes ~65 lines of Micrometer boilerplate (MongoConnectionPoolMetrics.java) to expose the MongoDB connection pool's utilization as gauges, including a real weak-reference/GC bug that had to be found and fixed once already. This plan scopes an optional module that gives that to every quarkus-morphium consumer for free, purely additive, gated on Capability.METRICS so apps without Micrometer see zero change. Decision: extend quarkus-morphium/quarkus-morphium-deployment in place (no new Maven module) -- follows the repo's own existing precedent (MorphiumProcessor.registerMorphiumIdJsonCustomizers, Capability.JACKSON/ JSONB-gated) rather than introducing a second artifact for the same pattern. Status: proposal, not yet implemented. Next: datona-feature-implementer / datona-reviewer loop on this branch. --- .../architecture/observability-module-plan.md | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 quarkus-morphium/docs/architecture/observability-module-plan.md diff --git a/quarkus-morphium/docs/architecture/observability-module-plan.md b/quarkus-morphium/docs/architecture/observability-module-plan.md new file mode 100644 index 000000000..c005b04fd --- /dev/null +++ b/quarkus-morphium/docs/architecture/observability-module-plan.md @@ -0,0 +1,382 @@ +# Architektur-Plan: Optionales Observability-Modul für `quarkus-morphium` + +> Autor: datona-architect (Agent-Entwurf) +> Status: **Vorschlag / zur Diskussion** — noch nicht implementiert +> Repo: `morphium` (Maven-Modul `quarkus-morphium/`) +> Letzte Prüfung des Codes: 2026-08-23 + +--- + +## 1. Ziel & Auslöser + +**Auslöser:** Im Repo `datona-version42-adapter-ng-workspace/main-quarkus` wurde beobachtet, +dass Teams, die `quarkus-morphium` UND eine Micrometer/OpenTelemetry-Metrics-Extension +(`quarkus-micrometer`, `quarkus-micrometer-registry-prometheus`, `quarkus-opentelemetry`, …) +gleichzeitig einsetzen, händisch Boilerplate schreiben, um MongoDB-Operationsmetriken +(Connection-Pool, Latenzen, Fehlerzähler) in ihr Metrics-Backend zu spiegeln. + +**Ziel dieses Plans:** Ein neues, **rein optionales** Sub-Modul der `quarkus-morphium`-Extension, +das — nur wenn eine Metrics-Extension bereits auf dem Klassenpfad der Anwendung liegt — +automatisch Morphium/MongoDB-Kennzahlen als Micrometer-`Meter`s registriert. Apps ohne +Metrics-Extension dürfen **keine** zusätzliche Dependency, keinen zusätzlichen Klassenpfad-Eintrag +und keine Verhaltensänderung bekommen. + +--- + +## 2. Verifizierter Code-Kontext (Ist-Zustand) + +### 2.1 Modulstruktur (bestehend) +``` +quarkus-morphium/ +├── runtime/ (quarkus-morphium) – CDI-Producer, Config, Interceptors +├── deployment/ (quarkus-morphium-deployment) – BuildSteps, Jandex-Scanning, Capabilities-Gates +├── testing/ +└── integration-tests/ +``` +Parent-POM (`quarkus-morphium/pom.xml`) verwaltet die Quarkus-BOM; jedes Sub-Modul hat eine +eigene `pom.xml` mit `parent = quarkus-morphium-parent`. + +### 2.2 Bereits etabliertes Muster für "optionale Integration bei vorhandener Capability" + +`MorphiumProcessor.registerMorphiumIdJsonCustomizers(...)` (deployment) ist das **exakte +Vorbild** für das, was wir bauen wollen: + +```java +@BuildStep +void registerMorphiumIdJsonCustomizers(Capabilities capabilities, + BuildProducer additionalBeans) { + if (capabilities.isPresent(Capability.JACKSON)) { + additionalBeans.produce(AdditionalBeanBuildItem.builder() + .addBeanClass("de.caluga.morphium.quarkus.json.MorphiumIdJacksonModule") + .setUnremovable().build()); + } + if (capabilities.isPresent(Capability.JSONB)) { ... } +} +``` +Kombiniert mit optionalen Maven-Dependencies (`true` auf +`quarkus-jackson` / `quarkus-jsonb` in `runtime/pom.xml`, plus die `-deployment`-Pendants in +`deployment/pom.xml`) und Deployment-Parity (Quarkus verlangt: jede optionale Runtime-Dependency +braucht ein `-deployment`-Gegenstück im Deployment-Modul, sonst schlägt der +Extension-Consistency-Check fehl). + +**→ Dieses Muster wird 1:1 für Micrometer/OpenTelemetry wiederverwendet.** + +### 2.3 Bereits vorhandene Kennzahlen-Quellen in Morphium-Core (kein neuer Code für Datenerhebung nötig) + +| Quelle | Typ | Inhalt | +|---|---|---| +| `MorphiumDriver.getDriverStats()` → `Map` | Pull (on-demand) | `CONNECTIONS_OPENED/CLOSED/BORROWED/RELEASED/IN_POOL/IN_USE`, `ERRORS`, `FAILOVERS`, `MSG_SENT`, `REPLY_*`, `THREADS_CREATED`, `THREADS_WAITING_FOR_CONNECTION` | +| `Morphium.getStatistics()` → `Statistics extends HashMap` | Pull (on-demand) | `StatisticKeys`: `WRITES`, `WRITES_CACHED`, `READS`, `CHITS`, `CMISS`, `NO_CACHED_READS`, `CHITSPERC`, `CMISSPERC`, `CACHE_ENTRIES`, `REGISTERED_LOGGERS`, `WRITE_BUFFER_ENTRIES`, `PULL`, `PULLSKIP`, `SKIPPED_MSG_UPDATES`, `INSTANCE_COUNT` | +| `driver.getNumConnectionsByHost()` | Pull | Connections pro Host (für Multi-Host-Setups) | +| `MorphiumStorageListener` (Interface, bereits für `MorphiumBlockingCallDetector` genutzt) | Push (Event) | `preStore`/`postStore`/`preRemove`/`postRemove`/`postLoad`/`preUpdate`/`postUpdate` — je Aufruf, mit Objekt/Query/Class-Kontext | +| `MorphiumTransactionEvent` (CDI Event, `@MorphiumTxPhase`) | Push (CDI Event) | `BEFORE_COMMIT`/`AFTER_COMMIT`/`AFTER_ROLLBACK`, inkl. Exception bei Rollback | +| `MorphiumReadinessCheck` | Pull (schon gebaut) | liest bereits `getDriverStats()` als Health-Metadata — Beleg, dass die Werte zur Laufzeit zugreifbar sind | + +**Wichtiger Befund:** Es gibt **keine** dedizierte "MetricsListener"/"ProfilingListener"-Klasse +in `morphium-core` — die Statistik-APIs sind reine Pull-Snapshots (`Map`), die +Event-Hooks (`MorphiumStorageListener`, `MorphiumTransactionEvent`) sind die einzigen +Push-Mechanismen. Das Observability-Modul muss beide Muster kombinieren: +- **Gauges** aus periodischem Pull der Statistics/DriverStats-Maps. +- **Counter/Timer** aus dem Push-Pfad (`MorphiumStorageListener`, Transaction-Events) für + Latenz- und Fehlerzähler pro Operation. + +### 2.4 CDI-Lifecycle-Realität, die das Design einschränkt + +- `Morphium` ist ein **normal-scoped, lazy** CDI-Bean (`MorphiumProducer.morphium()` mit + double-checked locking) — der erste Proxy-Zugriff löst den echten Connect aus. Ein + Observability-Feature darf **niemals** `Instance.get()` aufrufen, um sich früh zu + registrieren (exakt das Problem, das `MorphiumBlockingCallDetector`s Javadoc dokumentiert: + ein früherer `@Observes StartupEvent`-Ansatz löste ungewollt den Connect aus). Die Registrierung + muss **innerhalb** von `MorphiumProducer.buildMorphium()`, nach dem echten Connect, erfolgen — + wie `MorphiumBlockingCallDetector.registerOn(instance)` es bereits tut (Zeile ~497 in + `MorphiumProducer.java`, direkt vor dem `log.info(...)` Banner). +- Native-Image-Kompatibilität: keine Reflection außerhalb der bereits etablierten + `ClassGraphCache.preRegister*`-Mechanik; keine neuen `sun.*`/`Unsafe`-Zugriffe. +- Dev-Mode Hot-Reload: `instance` wird bei jedem Hot-Reload neu gebaut (`buildMorphium()` läuft + erneut) — jede Meter-Registrierung muss **idempotent** sein (Micrometer wirft bei + Doppel-Registrierung mit identischem `Id` keinen Fehler, aber doppelte `MeterBinder`-Aufrufe + ohne Deduplizierung erzeugen doppelte Callback-Referenzen auf alte `Morphium`-Instanzen ⇒ + Memory-Leak / falsche Werte nach Reload). Siehe Abschnitt 6.4. + +### 2.5 Was zu Micrometer/OpenTelemetry bereits im Repo existiert + +Keine Treffer für `Capability.METRICS`, `MICROMETER` oder `OPENTELEMETRY` im gesamten +`morphium-workspace` — die Extension hat **aktuell keine Berührung** mit Metrics/Tracing. +Das neue Modul ist somit komplett grüne Wiese, aber mit einem klaren Vorbild (JSON-Customizer-Muster). + +--- + +## 3. Architekturentscheidung: Neues Sub-Modul vs. In-Place-Erweiterung + +### Optionen + +| Option | Beschreibung | Bewertung | +|---|---|---| +| **A. Neues Maven-Sub-Modul** `quarkus-morphium-observability` (+ `-deployment`) | Eigenständige Extension, die `quarkus-morphium` UND `quarkus-micrometer` als **beide optional/required** deklariert | ✅ Saubere Trennung, kein Zwang für Micrometer-Dependency in der Haupt-Extension; **aber**: zweite Extension zum Pflegen, zweiter Versionsstand, Nutzer müssen zwei Coordinates kennen | +| **B. Klassen direkt in `quarkus-morphium` (runtime+deployment), Micrometer als `true`** | Wie das bestehende Jackson/JSON-B-Muster (Abschnitt 2.2), nur für Micrometer | ✅ Ein Artefakt, ein Versionsstand, Nutzer bekommen Observability "geschenkt" sobald sie Micrometer schon haben; folgt 1:1 etabliertem Repo-Muster; ⚠️ etwas mehr Verantwortung im Kern-Modul | +| **C. Separates Community-Extension-Projekt außerhalb des Morphium-Reactors** | Wie es früher `io.quarkiverse.morphium` war (siehe README "Migrating from standalone") | ❌ Widerspricht der bewussten Entscheidung, alles in den Morphium-Reactor zu holen (README: "this extension is now an optional module... built in lockstep") | + +### Empfehlung: **Option B** — Erweiterung von `quarkus-morphium` (runtime + deployment), kein neues Top-Level-Modul + +**Begründung:** +1. Das Repo hat mit dem Jackson/JSON-B-Muster bereits bewiesen, dass "optional dependency + + Capabilities-Gate im selben Modul" der etablierte, von den Maintainern akzeptierte Weg ist — + ein neues Sub-Modul für exakt dasselbe Muster wäre unnötige Divergenz. +2. Ein zusätzliches Maven-Modul bedeutet: eigene `pom.xml`, eigener Eintrag in + `quarkus-morphium/pom.xml` ``, eigene Extension-Metadata + (`quarkus-extension.yaml` via `quarkus-extension-maven-plugin`), eigene Versionierung im + Reactor, eigene Release-Koordination — Mehraufwand ohne technischen Zwang, da Capabilities-Gates + bereits verhindern, dass Nicht-Metrics-Apps etwas davon spüren. +3. **Einzige Ausnahme, die ein eigenes Modul rechtfertigen würde:** falls das Observability-Modul + selbst harte (non-optional) Compile-Abhängigkeiten zu Micrometer-APIs bräuchte, die den + Bytecode von `quarkus-morphium` aufblähen, auch wenn die Capability nicht aktiv ist. Das ist + vermeidbar (siehe Abschnitt 5) — Micrometer-Typen werden ausschließlich in isolierten, über + `Capabilities.isPresent(...)` gated Klassen referenziert, exakt wie + `MorphiumIdJacksonModule`/`MorphiumIdJsonbModule` heute mit Jackson/JSON-B. + +**Falls das Team dennoch strikte Modul-Trennung will** (z. B. weil Observability-Code schneller +iterieren soll als der Core), ist Option A die Fallback-Wahl — der Rest dieses Plans (BuildSteps, +Capabilities-Gate-Logik, Metrik-Katalog, Naming) bleibt inhaltlich identisch und wird nur auf +zwei Module statt zwei Package innerhalb eines Moduls verteilt. + +--- + +## 4. Zielarchitektur (Option B im Detail) + +### 4.1 Neue Klassen (runtime, Package `de.caluga.morphium.quarkus.observability`) + +| Klasse | Verantwortung | +|---|---| +| `MorphiumMetricsBinder` | Registriert Micrometer-`Gauge`s für Driver-Stats (`getDriverStats()`) und Morphium-Statistics (`getStatistics()`) gegen eine injizierte `MeterRegistry`. Analog zu bestehenden Micrometer-Bindern (`io.micrometer.core.instrument.binder.MeterBinder`). | +| `MorphiumMetricsStorageListener implements MorphiumStorageListener` | Zählt/misst Store/Remove/Load/Update-Operationen als Micrometer `Counter`/`Timer` (Tags: `operation`, `entity` = Klassenname, `outcome` = `success`/`error` via `MorphiumAccessVetoException`). Wird analog zu `MorphiumBlockingCallDetector.registerOn(...)` direkt in `buildMorphium()` registriert. | +| `MorphiumMetricsTransactionObserver` | CDI-Observer auf `@MorphiumTxPhase(...)`-Events; zählt Commits/Rollbacks/CosmosDB-Degradierungen. | +| `MorphiumObservabilityRuntimeConfig` (`@ConfigMapping(prefix="quarkus.morphium.observability")`) | Feature-Flags: `enabled` (Default `true`, aber nur wirksam wenn Capability vorhanden), `poll-interval` für die Gauge-Refresh-Strategie (falls Pull statt Push), `include-tags` (z. B. Host-Tags optional wegen Tag-Kardinalität, siehe 6.3). | + +### 4.2 Neue Klassen (deployment) + +| Klasse/Methode | Verantwortung | +|---|---| +| `MorphiumProcessor.registerObservability(Capabilities, BuildProducer)` (neue `@BuildStep`-Methode, gleiche Klasse wie bestehendes JSON-Gate) | Registriert `MorphiumMetricsBinder`, `MorphiumMetricsStorageListener`, `MorphiumMetricsTransactionObserver` als `AdditionalBeanBuildItem` **nur wenn** `capabilities.isPresent(Capability.METRICS)` (Micrometer) **oder** eine äquivalente OTel-Metrics-Capability vorhanden ist (siehe 4.4 zur Capability-Wahl). | +| ggf. `MorphiumObservabilityBuildTimeConfig` (`@ConfigRoot(phase=BUILD_TIME)`) | Falls ein Build-Time-Kill-Switch gewünscht ist (analog `MorphiumHealthBuildTimeConfig`), um das Feature komplett aus dem nativen Image auszuschließen, selbst wenn Micrometer zufällig transitiv vorhanden ist. | + +### 4.3 Maven-Dependency-Schnitt (analog Jackson/JSON-B-Muster) + +`runtime/pom.xml` (Ergänzung): +```xml + + io.quarkus + quarkus-micrometer + true + +``` +`deployment/pom.xml` (Ergänzung): +```xml + + io.quarkus + quarkus-micrometer-deployment + true + +``` +Keine Abhängigkeit zu einem konkreten Registry-Backend (Prometheus/OTLP/…) — Micrometer selbst +ist Backend-agnostisch; die App entscheidet über ihre eigene `quarkus-micrometer-registry-*`-Wahl. + +### 4.4 Capability-Wahl: Micrometer vs. OpenTelemetry Metrics + +Quarkus kennt (Stand Quarkus 3.x, wie in `quarkus-bom` dieses Reactors verwendet) die +Capability `io.quarkus.deployment.Capability.METRICS`, die sowohl von +`quarkus-micrometer` als auch — sofern die App die Micrometer-OTel-Bridge nutzt — indirekt +gesetzt wird. **Reines** `quarkus-opentelemetry` (Tracing) ohne Micrometer-Bridge setzt +`Capability.OPENTELEMETRY_TRACER`, nicht `METRICS`. + +**Entscheidung:** Dieses Modul bindet sich an die **Micrometer-API** (`MeterRegistry`, +`Gauge`, `Counter`, `Timer`) und gated auf `Capability.METRICS`. Das ist die richtige Wahl, weil: +- Micrometer ist der De-facto-Metrics-Standard in Quarkus; `quarkus-opentelemetry` kann seine + Metrik-Exportpfade selbst über eine Micrometer→OTel-Bridge (`quarkus-micrometer-registry-otlp`) + laufen lassen — wir müssen keine zweite native OTel-Metrics-API direkt bedienen. +- Reine Tracing-only-Apps (nur `quarkus-opentelemetry`, kein Micrometer) bekommen dieses Feature + bewusst **nicht** — sie haben keinen Metrics-Sink, an den wir etwas senden könnten. Das ist + korrekt und kein Gap; ein zukünftiges Tracing-Modul (Spans um MongoDB-Operationen) wäre ein + **separates** Feature mit eigener Capability-Prüfung (`Capability.OPENTELEMETRY_TRACER`), + nicht Teil dieses Metrics-Plans (siehe Abschnitt 8, "Out of Scope"). +- Diese Namensgebung muss vor der Implementierung gegen den tatsächlich im Reactor gepinnten + Quarkus-BOM-Stand verifiziert werden (`grep Capability.METRICS` im + `io.quarkus:quarkus-core-deployment`-JAR der exakten `${quarkus.version}`), da sich exakte + Capability-Konstanten zwischen Quarkus-Minor-Versionen verschieben können — dieser Plan + spezifiziert das Verhalten, nicht die exakte Konstante; das ist ein Implementierungsdetail, + das beim Anlegen des ersten Patches zu verifizieren ist (in dieser Analyse nicht mit + Tool-Zugriff auf die JARs verifizierbar gewesen, s. Abschnitt 9 „Offene Verifikationen"). + +--- + +## 5. Metrik-Katalog (Vorschlag) + +Alle Metrik-Namen folgen Micrometer-Konvention (`snake_case`, Einheit als Suffix wo sinnvoll) +und dem Präfix `morphium.*` (analog zu `mongodb.driver.*` bei anderen Quarkus-DB-Extensions). + +| Metrikname | Typ | Quelle | Tags | Beschreibung | +|---|---|---|---|---| +| `morphium.driver.connections.pool` | Gauge | `DriverStatsKey.CONNECTIONS_IN_POOL` | `database` | Verbindungen im Pool | +| `morphium.driver.connections.in_use` | Gauge | `CONNECTIONS_IN_USE` | `database` | Aktiv genutzte Verbindungen | +| `morphium.driver.connections.borrowed` | Counter (aus Gauge-Delta oder direkt kumulativ, da Driver bereits kumulativ zählt) | `CONNECTIONS_BORROWED` | `database` | Kumulative Anzahl ausgeliehener Verbindungen | +| `morphium.driver.connections.released` | Counter | `CONNECTIONS_RELEASED` | `database` | Kumulative Rückgaben | +| `morphium.driver.threads.waiting` | Gauge | `THREADS_WAITING_FOR_CONNECTION` | `database` | Wartende Threads auf Connection (Pool-Sättigung-Signal) | +| `morphium.driver.errors` | Counter | `ERRORS` | `database` | Treiberfehler kumulativ | +| `morphium.driver.failovers` | Counter | `FAILOVERS` | `database` | Replica-Set-Failover-Ereignisse | +| `morphium.cache.hit_ratio` | Gauge | `CHITSPERC` | `database` | Cache-Trefferquote (%) | +| `morphium.cache.entries` | Gauge | `CACHE_ENTRIES` | `database` | Aktuelle Cache-Einträge | +| `morphium.write_buffer.entries` | Gauge | `WRITE_BUFFER_ENTRIES` | `database` | Ausstehende gepufferte Writes | +| `morphium.operations.duration` | Timer | `MorphiumStorageListener` (pre/post-Paare) | `operation` (`store`/`remove`/`update`/`load`), `entity`, `outcome` | Latenz je Operationstyp | +| `morphium.operations.errors` | Counter | `MorphiumStorageListener` (`MorphiumAccessVetoException` in pre-Hooks) | `operation`, `entity` | Von einem `@PreStore`/`@PreRemove`-Listener abgelehnte Operationen | +| `morphium.transactions.commits` | Counter | `MorphiumTransactionEvent(AFTER_COMMIT)` | — | Erfolgreiche Commits | +| `morphium.transactions.rollbacks` | Counter | `MorphiumTransactionEvent(AFTER_ROLLBACK)` | `reason` (Exception-Klassenname, niedrige Kardinalität durch Whitelist) | Rollbacks | +| `morphium.transactions.cosmosdb_degraded` | Counter | `MorphiumTransactionalInterceptor.isCosmosDb()`-Pfad | — | Wie oft die Transaktions-Wrapper-Degradierung für CosmosDB gegriffen hat | + +**Wichtig — Kardinalitätsrisiko:** `driver.getNumConnectionsByHost()` liefert einen Wert **pro +Host** (siehe `MorphiumReadinessCheck`, das dies bereits als `host:`-Health-Metadata +tut). Als Micrometer-Tag `host=` wäre das bei dynamischen/vielen Hosts +(Kubernetes-Pods, Atlas-Sharding) ein Tag-Explosion-Risiko. **Entscheidung:** Diese +Pro-Host-Aufschlüsselung wird **nicht** standardmäßig als Metrik exportiert; nur die +aggregierten `DriverStatsKey`-Werte. Eine Opt-in-Property +(`quarkus.morphium.observability.per-host-connections=false` Default) kann das für kleine, +statische Cluster nachrüsten — als klar dokumentiertes Kardinalitätsrisiko, nicht als Default. + +--- + +## 6. Kritische Entwurfsfragen & Antworten + +### 6.1 Wo wird registriert, ohne den Lazy-Connect zu triggern? + +Registrierung **ausschließlich** am Ende von `MorphiumProducer.buildMorphium()`, direkt neben +der bestehenden Zeile (aktuell ca. `MorphiumBlockingCallDetector.registerOn(instance)` +fehlt noch im aktuell gelesenen Ausschnitt bis Zeile 500 — muss beim Patch verifiziert werden, +aber der Javadoc von `MorphiumBlockingCallDetector` belegt exakt diesen Aufrufort/diese Reihenfolge). +Ein `MeterRegistry`-Bean wird **nur dann** per `@Inject Instance` referenziert, +wenn Capabilities.METRICS zur Build-Zeit als vorhanden erkannt wurde — sonst wird die gesamte +Binder-Klasse gar nicht als CDI-Bean registriert (das ist der Kern des Capabilities-Gates, +nicht ein Runtime-`if`). + +### 6.2 Pull (Gauge) vs. Push (Counter/Timer) — warum beides? + +- **Gauges** für Zustände, die keinen sinnvollen "Ereignis"-Charakter haben (Pool-Größe, + Cache-Füllstand) — Micrometer `Gauge.builder(name, statsSupplier, extractor)` mit einer + `WeakReference` auf die `Morphium`-Instanz (Standard-Micrometer-Pattern, verhindert + GC-Leaks bei Hot-Reload/Shutdown). +- **Counter/Timer** für Ereignisse mit klarer Semantik (eine Operation passiert, dauert X ms, + endet in Erfolg/Fehler) — nur über die Event-Hooks (`MorphiumStorageListener`, + `MorphiumTransactionEvent`) sauber abbildbar, da die Pull-Statistics-Maps keine + Latenz-Verteilung liefern (nur kumulative Zähler, keine Timer/Histogramme). + +### 6.3 Muss Morphium-Core geändert werden? + +**Nein, im MVP nicht zwingend.** Alle in Abschnitt 5 genannten Metriken sind aus bereits +öffentlichen APIs ableitbar (`getDriverStats()`, `getStatistics()`, `MorphiumStorageListener`, +`MorphiumTransactionEvent`). Eine mögliche **spätere** Erweiterung (Out of Scope für dieses +Modul, aber als Anschlusspunkt zu vermerken): `MorphiumStorageListener` liefert aktuell keine +Latenz direkt — der Timer muss im Quarkus-Modul selbst über +`preStore()`-Zeitstempel/`ThreadLocal` + `postStore()`-Differenz gebaut werden, da die +Store-Operation selbst zwischen pre/post im Aufrufer (`Morphium.store()`) liegt, nicht im +Listener. Das ist machbar, aber pro Thread korrekt zu synchronisieren (Reentrancy bei +verschachtelten Store-Aufrufen beachten — z. B. `@PreStore`-Callback, der selbst ein anderes +Objekt speichert). + +### 6.4 Idempotenz bei Dev-Mode Hot-Reload + +Da `buildMorphium()` bei jedem Hot-Reload erneut läuft und eine **neue** `Morphium`-Instanz +erzeugt, muss die Registrierung: +1. Alte Meter-Bindings der vorherigen Instanz explizit deregistrieren (`MeterRegistry.remove(Meter)`) + bevor neue registriert werden — sonst zeigt z. B. `morphium.driver.connections.pool` nach + drei Hot-Reloads drei überlagerte Gauges mit stale `WeakReference`s. +2. Dies spiegelt exakt das Problem, das `MorphiumProducer.onStop()` bereits für die + `Morphium`-Instanz selbst löst (`instance = null` im Shutdown-Observer) — die + Metrik-Registrierung braucht ein äquivalentes Gegenstück, ausgelöst entweder im selben + `onStop()`-Pfad oder in einem eigenen `@PreDestroy`-Hook auf dem Binder-Bean. +3. **Empfehlung:** `MorphiumMetricsBinder` hält selbst die Liste der von ihm registrierten + `Meter`-Handles und entfernt sie in einer `close()`-Methode, die von `MorphiumProducer.onStop()` + zusätzlich zum bestehenden `instance.close()` aufgerufen wird (Producer braucht dafür eine + `Instance`-Referenz, ebenfalls nur injiziert wenn die Bean existiert). + +### 6.5 Native-Image-Verträglichkeit + +Micrometer-Core ist selbst GraalVM-native-fähig (Quarkus' eigene `quarkus-micrometer`-Extension +bringt bereits die nötigen `RuntimeReflectionRegistration`/Substitutions mit). Dieses Modul +fügt **keine neue Reflection** hinzu — alle Aufrufe (`MeterRegistry.gauge(...)`, +`Counter.builder(...).register(...)`) sind normale Methodenaufrufe. Einzige Prüfpflicht: keine +Lambda-Referenz auf `Morphium`-Instanzen darf eine starke Referenz sein, die den nativen +Image-Heap unnötig aufbläht (Gauges mit `WeakReference`, siehe 6.2/6.4). + +### 6.6 Health-Check-Überlappung + +`MorphiumReadinessCheck` liest bereits `getDriverStats()` als Health-Metadata (informativ, kein +Einfluss auf UP/DOWN). Das ist **keine Redundanz, sondern zwei verschiedene Konsumenten +derselben Datenquelle** (Health-Probe = Momentaufnahme für Orchestrator; Metrics = Zeitserie für +Monitoring/Alerting) — kein Konflikt, kein Refactoring von `MorphiumReadinessCheck` nötig. + +--- + +## 7. Konfigurationsschnitt (`quarkus.morphium.observability.*`) + +| Property | Default | Bedeutung | +|---|---|---| +| `quarkus.morphium.observability.enabled` | `true` | Feature-Flag; wirkt nur wenn Micrometer-Capability zur Build-Zeit erkannt wurde. Erlaubt Nutzern mit Micrometer-Dependency (z. B. transitiv über ein anderes Feature), das Feature trotzdem abzuschalten. | +| `quarkus.morphium.observability.poll-interval` | `10s` | Intervall für Gauge-Refresh, falls kein reiner On-Demand-Callback verwendet wird (Micrometer-`Gauge` ruft den Supplier bei jedem Scrape ab — bei Prometheus-Pull-Modell meist kein separates Polling nötig; Property als Reserve für Push-Backends wie OTLP mit periodischem Export). | +| `quarkus.morphium.observability.per-host-connections` | `false` | Siehe Kardinalitätswarnung Abschnitt 5. | +| `quarkus.morphium.observability.include-storage-listener-metrics` | `true` | Erlaubt Deaktivierung der Store/Remove/Load-Counter/Timer separat von den reinen Pool-Gauges (z. B. bei sehr hohem Operationsvolumen, wo Timer-Overhead spürbar wird). | + +Ein Build-Time-Root (`MorphiumObservabilityBuildTimeConfig`, analog +`MorphiumHealthBuildTimeConfig`) für einen harten Kill-Switch, der die Beans schon zur Bauzeit +nicht registriert (statt nur zur Laufzeit zu deaktivieren), ist empfehlenswert für native +Images, wo jedes vermiedene Bean Startzeit/Image-Größe spart. + +--- + +## 8. Out of Scope (bewusst nicht Teil dieses Plans) + +- **Distributed Tracing / Spans** um einzelne MongoDB-Operationen (`Capability.OPENTELEMETRY_TRACER`) + — eigenständiges, späteres Feature mit eigener Span-Namenskonvention + (`db.system=mongodb`-Semantic-Conventions), nicht Teil des Metrics-Katalogs hier. +- **Änderungen an `morphium-core`** zur Bereitstellung neuer Roh-Metriken (z. B. Latenz-Histogramme + direkt im Driver) — MVP kommt ohne aus (Abschnitt 6.3); als Folgearbeit vermerkt, falls sich + Timer-Aufbau im Quarkus-Modul als zu ungenau/fehleranfällig erweist. +- **Konkrete Registry-Backends** (Prometheus-Endpoint, OTLP-Exporter-Konfiguration) — das ist + Sache von `quarkus-micrometer-registry-*`, nicht dieses Moduls. +- **Dev-UI-Integration** (Live-Metrik-Anzeige im `/q/dev-ui/`) — denkbare spätere Ergänzung + analog zum bestehenden `MorphiumDevUIProcessor`, aber nicht Teil des MVP. + +--- + +## 9. Offene Verifikationen vor Implementierungsstart + +1. ~~**Exakte Capability-Konstante** für Micrometer in der im Reactor gepinnten + `quarkus.version` verifizieren~~ — **ERLEDIGT (23.08.2026):** Reactor pinnt `quarkus.version` + `3.32.3` (`pom.xml:118`). Direkt gegen `quarkus-core-deployment-3.32.3.jar` geprüft + (`unzip -p ... io/quarkus/deployment/Capability.class | javap -`): `Capability.METRICS`, + `Capability.OPENTELEMETRY_TRACER`, `Capability.OPENTELEMETRY_METRICS` existieren alle exakt so. + **Zu verwenden: `Capability.METRICS`** (Begründung Abschnitt 4.4 bleibt gültig). +2. **Extension-Parity-Check** (Quarkus' eigener Build-Schritt, der prüft, dass jede optionale + Runtime-Dependency ein `-deployment`-Gegenstück hat) lokal mit + `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment -am verify` gegen die neue + `quarkus-micrometer`/`quarkus-micrometer-deployment`-Optional-Dependency laufen lassen. +3. **Tag-Kardinalität in der Praxis** — mit dem `version42-adapter`-Team abstimmen, ob + `per-host-connections=false` als Default für deren tatsächliche Cluster-Topologie ausreicht + oder ob sie den Opt-in sofort brauchen. +4. **Timer-Overhead-Messung** — vor Rollout einen Benchmark mit + `include-storage-listener-metrics=true` gegen ein realistisches Lastprofil fahren, um + sicherzustellen, dass die Timer-Erfassung in `MorphiumMetricsStorageListener` nicht selbst + zum Bottleneck wird (insbesondere bei `@WriteBuffer`-Batch-Workloads mit hoher Frequenz). + +--- + +## 10. Zusammenfassung / Empfehlung + +- **Kein neues Maven-Modul** — Erweiterung von `quarkus-morphium`/`quarkus-morphium-deployment` + nach dem bereits im Repo etablierten und bewährten "optional dependency + Capabilities-Gate"-Muster + (siehe Jackson/JSON-B-Präzedenzfall). +- **Registrierung ausschließlich im bestehenden `buildMorphium()`-Post-Connect-Hook**, niemals + über einen frühen CDI-Observer, der den Lazy-Connect vorzeitig auslösen würde. +- **Kombination aus Gauges (Pull aus `getDriverStats()`/`getStatistics()`) und Counter/Timer + (Push aus `MorphiumStorageListener`/`MorphiumTransactionEvent`)**, da Morphium-Core keine + Latenz-Timer bereitstellt. +- **Kardinalität bewusst begrenzen** (kein Pro-Host-Tag per Default) und **Idempotenz bei + Hot-Reload explizit lösen** (Meter-Deregistrierung im Shutdown-Pfad) — beides sind die zwei + konkreten Fallstricke, die dieses Modul von einer naiven Umsetzung unterscheiden. +- Tracing/OTel-Spans bewusst als separates Folgeprojekt ausgeklammert. From c829ee6d13098c68d49f9e8bb7a01a60f3225fb9 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Sun, 23 Aug 2026 17:44:45 +0200 Subject: [PATCH 2/9] feat(quarkus-morphium): optional Micrometer observability module (Phase 1 MVP) Adds an optional Micrometer metrics integration, gated on Capability.METRICS so an application without Micrometer on its classpath sees zero change -- no new dependency inherited, no bean registered, no behaviour change. Follows the observability-module-plan.md (docs/architecture/, committed separately as b70787038): extends quarkus-morphium/quarkus-morphium-deployment in place, mirroring the module's own existing Capability.JACKSON/JSONB-gated pattern (MorphiumProcessor.registerMorphiumIdJsonCustomizers) rather than introducing a new Maven module for the same shape. Phase 1 scope: connection-pool/driver-stats gauges only (morphium.driver.*, morphium.cache.*, morphium.write_buffer.*, sourced from MorphiumDriver.getDriverStats()/Morphium.getStatistics()). The Counter/Timer rows sourced from MorphiumStorageListener/MorphiumTransactionEvent (morphium.operations.*, morphium.transactions.*) are explicitly deferred to a later phase. - quarkus-morphium/runtime/pom.xml, deployment/pom.xml: optional quarkus-micrometer/quarkus-micrometer-deployment dependency pair. - MorphiumProcessor: new registerObservability @BuildStep, gates MorphiumMetricsBinder's registration as a CDI bean on Capabilities.isPresent(Capability.METRICS). - MorphiumMetricsBinder (new, runtime/observability package): registers 10 Micrometer Gauges tagged `database`, each reading live from the underlying Morphium/MorphiumDriver stats maps (not a snapshot) -- avoids the WeakReference-GC'd-to-NaN bug the plan cites from a real downstream precedent (version42-adapter's hand-written MongoConnectionPoolMetrics). close() deregisters every meter it registered, for hot-reload idempotency. - MorphiumProducer: binder lookup/bind wired into buildMorphium() (after the connection already exists -- never from an early @Observes StartupEvent that could trigger the lazy connect prematurely) and deregistration wired into onStop(), via Arc.container().instance(...) + InstanceHandle so the lookup never throws when the bean doesn't exist (Micrometer absent). Verified independently by the orchestrating session, not just the implementer's self-report: `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment -am -DskipTests install` then `... test` (no -am, to avoid the unrelated 25+ minute morphium-core suite) -- BUILD SUCCESS, runtime 68/68 tests passing (incl. new MorphiumMetricsBinderTest, 5/5), deployment 29/29 passing (incl. new MorphiumProcessorObservabilityTest, 2/2). Reviewed by the datona-reviewer persona (Round 1): APPROVAL, no blocking findings. Two follow-ups from that review are addressed in the next commit on this branch (Capability.METRICS deprecation note in the plan doc, and the missing MorphiumObservabilityConfig.enabled runtime kill-switch that Section 4.1 specifies but this round's implementer brief had omitted from scope). --- quarkus-morphium/deployment/pom.xml | 12 ++ .../quarkus/deployment/MorphiumProcessor.java | 31 ++++ .../MorphiumProcessorObservabilityTest.java | 92 +++++++++ quarkus-morphium/runtime/pom.xml | 11 ++ .../observability/MorphiumMetricsBinder.java | 152 +++++++++++++++ .../MorphiumMetricsBinderTest.java | 174 ++++++++++++++++++ 6 files changed, 472 insertions(+) create mode 100644 quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java create mode 100644 quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java create mode 100644 quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinderTest.java diff --git a/quarkus-morphium/deployment/pom.xml b/quarkus-morphium/deployment/pom.xml index b7b60d747..d44d02f0b 100644 --- a/quarkus-morphium/deployment/pom.xml +++ b/quarkus-morphium/deployment/pom.xml @@ -54,6 +54,18 @@ true + + + io.quarkus + quarkus-micrometer-deployment + true + + diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java index 842a749f8..1e66a32e7 100644 --- a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java @@ -163,6 +163,37 @@ void registerMorphiumIdJsonCustomizers(Capabilities capabilities, } } + // ------------------------------------------------------------------ + // Micrometer metrics: connection-pool / driver-stats gauges (MVP) + // ------------------------------------------------------------------ + + /** + * Registers {@code MorphiumMetricsBinder} as a CDI bean, but only when the application + * has Micrometer on its classpath (Capability.METRICS present at build time). + * + *

Mirrors {@link #registerMorphiumIdJsonCustomizers}'s exact structure: gating on + * {@link Capabilities}, producing an {@link AdditionalBeanBuildItem} referenced by class + * name (so this processor class never needs a compile-time dependency on Micrometer types + * itself), and marking it unremovable. Apps without Micrometer never get this bean added, + * so {@code MorphiumMetricsBinder} — which does have a hard compile-time dependency on + * Micrometer's {@code MeterRegistry} — never loads on their classpath at all. + * + *

Scope note: this build step only registers the Phase 1 (MVP) gauge binder. The + * Counter/Timer sources from {@code MorphiumStorageListener}/{@code MorphiumTransactionEvent} + * (Section 5 catalog rows {@code morphium.operations.*}/{@code morphium.transactions.*}) are + * explicitly deferred to a later phase and are not registered here. + */ + @BuildStep + void registerObservability(Capabilities capabilities, + BuildProducer additionalBeans) { + if (capabilities.isPresent(Capability.METRICS)) { + additionalBeans.produce(AdditionalBeanBuildItem.builder() + .addBeanClass("de.caluga.morphium.quarkus.observability.MorphiumMetricsBinder") + .setUnremovable() + .build()); + } + } + // ------------------------------------------------------------------ // Health check registration // ------------------------------------------------------------------ diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java new file mode 100644 index 000000000..8e18ba285 --- /dev/null +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.Capability; +import io.quarkus.deployment.annotations.BuildProducer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link MorphiumProcessor#registerObservability}: verifies that + * {@code MorphiumMetricsBinder} is registered as an additional CDI bean only when + * {@code Capability.METRICS} (Micrometer) is present at build time, and is NOT registered + * for an app that lacks it -- mirroring + * {@code MorphiumIdJsonSerializationTest}/the Jackson/JSON-B Capability-gating pattern, but as + * a plain unit test against the processor method directly (same style as + * {@link MorphiumProcessorReflectionTest}), since {@code MorphiumProcessor} needs no live + * Jandex index for this build step. + */ +@DisplayName("MorphiumProcessor#registerObservability — Capability.METRICS gating") +class MorphiumProcessorObservabilityTest { + + private static final String BINDER_CLASS = "de.caluga.morphium.quarkus.observability.MorphiumMetricsBinder"; + + /** Collects every {@code AdditionalBeanBuildItem} produced, flattened to bean class names. */ + private static class CollectingBeanProducer implements BuildProducer { + final List items = new ArrayList<>(); + + @Override + public void produce(AdditionalBeanBuildItem item) { + items.add(item); + } + + Set beanClassNames() { + Set names = new java.util.HashSet<>(); + for (AdditionalBeanBuildItem item : items) { + names.addAll(item.getBeanClasses()); + } + return names; + } + } + + @Test + @DisplayName("Capability.METRICS present -> MorphiumMetricsBinder IS registered as an additional bean") + void metricsCapabilityPresent_registersBinder() { + Capabilities capabilities = new Capabilities(Set.of(Capability.METRICS)); + CollectingBeanProducer producer = new CollectingBeanProducer(); + MorphiumProcessor processor = new MorphiumProcessor(); + + processor.registerObservability(capabilities, producer); + + assertThat(producer.beanClassNames()) + .as("MorphiumMetricsBinder must be added as an AdditionalBeanBuildItem when Micrometer is present") + .contains(BINDER_CLASS); + } + + @Test + @DisplayName("Capability.METRICS absent -> MorphiumMetricsBinder is NOT registered (no-op for apps without Micrometer)") + void metricsCapabilityAbsent_doesNotRegisterBinder() { + Capabilities capabilities = new Capabilities(Collections.emptySet()); + CollectingBeanProducer producer = new CollectingBeanProducer(); + MorphiumProcessor processor = new MorphiumProcessor(); + + processor.registerObservability(capabilities, producer); + + assertThat(producer.items) + .as("no AdditionalBeanBuildItem must be produced at all when Micrometer is absent") + .isEmpty(); + } +} diff --git a/quarkus-morphium/runtime/pom.xml b/quarkus-morphium/runtime/pom.xml index 819f7ac1d..f1a4fc5cd 100644 --- a/quarkus-morphium/runtime/pom.xml +++ b/quarkus-morphium/runtime/pom.xml @@ -45,6 +45,17 @@ quarkus-jsonb true + + + io.quarkus + quarkus-micrometer + true + jakarta.data diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java new file mode 100644 index 000000000..e83f56cd1 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java @@ -0,0 +1,152 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.observability; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.StatisticKeys; +import de.caluga.morphium.driver.MorphiumDriver; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Registers Micrometer {@link Gauge}s for Morphium's connection-pool/driver statistics + * ({@link MorphiumDriver#getDriverStats()}) and cache/write-buffer statistics + * ({@link Morphium#getStatistics()}). + * + *

This bean only exists on the classpath/in the CDI container when + * {@code Capability.METRICS} was present at build time (see + * {@code MorphiumProcessor#registerObservability}) — it must never be referenced from a + * code path that runs when Micrometer is absent. + * + *

Registration timing (Section 6.1 of the observability plan): {@link #bindTo(Morphium)} + * must only be called from {@code MorphiumProducer#buildMorphium()}, after the {@link Morphium} + * instance has already been constructed and connected — never from an early CDI + * {@code @Observes StartupEvent} observer that would dereference {@code Instance} (and + * thus trigger the lazy connect prematurely). This class itself does not observe any startup + * event and does not inject {@code Morphium}; it is purely a passive binder invoked explicitly, + * once {@code m} already exists. + * + *

Hot-reload idempotency (Section 6.4): every {@link Meter} this binder registers is + * kept in {@link #registeredMeters} so {@link #close()} can remove them again. This must be + * called before a subsequent {@link #bindTo(Morphium)} (or the same effect: on shutdown), or a + * dev-mode hot-reload will otherwise leave the previous instance's gauges registered against a + * stale {@link Morphium} reference. The extractor lambdas below close over the {@code Morphium} + * parameter passed to {@code bindTo} directly (not a field on this bean), and this bean is itself + * {@code @ApplicationScoped} and CDI-managed for the application's lifetime — so the referenced + * {@code Morphium}/{@code MorphiumDriver} objects stay reachable for as long as the gauges are + * registered, avoiding the WeakReference-GC'd-to-NaN bug the plan cites from the hand-written + * {@code MongoConnectionPoolMetrics} precedent. + */ +@ApplicationScoped +public class MorphiumMetricsBinder { + + private static final Logger log = LoggerFactory.getLogger(MorphiumMetricsBinder.class); + + static final String DATABASE_TAG = "database"; + + @Inject + MeterRegistry registry; + + private final List registeredMeters = new ArrayList<>(); + + /** + * Registers all in-scope gauges against the given, already-connected {@link Morphium} + * instance. Idempotent with respect to prior registrations on this bean: callers on a + * hot-reload path must call {@link #close()} first (see class Javadoc). + * + * @param m the already-connected Morphium instance to gauge + * @param database the configured database name (from {@code MorphiumRuntimeConfig#database()}, + * the same value {@code MorphiumProducer.buildMorphium()} uses to configure the + * connection), used as the {@code database} tag per the metric catalog. Passed + * explicitly rather than read back via {@code Morphium.getConfig().getDatabase()} + * (deprecated in {@code MorphiumConfig}). + */ + public synchronized void bindTo(Morphium m, String database) { + Tags tags = Tags.of(DATABASE_TAG, database); + + registerDriverGauge(m, tags, "morphium.driver.connections.pool", + MorphiumDriver.DriverStatsKey.CONNECTIONS_IN_POOL); + registerDriverGauge(m, tags, "morphium.driver.connections.in_use", + MorphiumDriver.DriverStatsKey.CONNECTIONS_IN_USE); + registerDriverGauge(m, tags, "morphium.driver.connections.borrowed", + MorphiumDriver.DriverStatsKey.CONNECTIONS_BORROWED); + registerDriverGauge(m, tags, "morphium.driver.connections.released", + MorphiumDriver.DriverStatsKey.CONNECTIONS_RELEASED); + registerDriverGauge(m, tags, "morphium.driver.threads.waiting", + MorphiumDriver.DriverStatsKey.THREADS_WAITING_FOR_CONNECTION); + registerDriverGauge(m, tags, "morphium.driver.errors", + MorphiumDriver.DriverStatsKey.ERRORS); + registerDriverGauge(m, tags, "morphium.driver.failovers", + MorphiumDriver.DriverStatsKey.FAILOVERS); + + registerStatisticGauge(m, tags, "morphium.cache.hit_ratio", StatisticKeys.CHITSPERC); + registerStatisticGauge(m, tags, "morphium.cache.entries", StatisticKeys.CACHE_ENTRIES); + registerStatisticGauge(m, tags, "morphium.write_buffer.entries", StatisticKeys.WRITE_BUFFER_ENTRIES); + + log.debug("Morphium: registered {} Micrometer gauges for database '{}'", + registeredMeters.size(), database); + } + + private void registerDriverGauge(Morphium m, Tags tags, String name, MorphiumDriver.DriverStatsKey key) { + Gauge gauge = Gauge.builder(name, m, target -> readDriverStat(target, key)) + .tags(tags) + .register(registry); + registeredMeters.add(gauge.getId()); + } + + private void registerStatisticGauge(Morphium m, Tags tags, String name, StatisticKeys key) { + Gauge gauge = Gauge.builder(name, m, target -> readStatistic(target, key)) + .tags(tags) + .register(registry); + registeredMeters.add(gauge.getId()); + } + + private static double readDriverStat(Morphium m, MorphiumDriver.DriverStatsKey key) { + Map stats = m.getDriver().getDriverStats(); + Double value = stats.get(key); + return value == null ? 0.0 : value; + } + + private static double readStatistic(Morphium m, StatisticKeys key) { + Map stats = m.getStatistics(); + Double value = stats.get(key.name()); + return value == null ? 0.0 : value; + } + + /** + * Deregisters every {@link Meter} this binder has registered so far. Called from + * {@code MorphiumProducer#onStop()} alongside {@code instance.close()}, and must also be + * called before a subsequent {@link #bindTo(Morphium)} on a dev-mode hot-reload to avoid + * leaving stale gauges referencing a superseded {@link Morphium} instance registered + * (Section 6.4 of the observability plan). + */ + public synchronized void close() { + for (Meter.Id id : registeredMeters) { + registry.remove(id); + } + registeredMeters.clear(); + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinderTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinderTest.java new file mode 100644 index 000000000..1679dba55 --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinderTest.java @@ -0,0 +1,174 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.observability; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.StatisticKeys; +import de.caluga.morphium.driver.MorphiumDriver; +import io.micrometer.core.instrument.Gauge; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MorphiumMetricsBinder}: verifies that {@link MorphiumMetricsBinder#bindTo} + * registers exactly the Phase 1 (MVP) gauge catalog from the observability plan (Section 5, + * driver/cache/write-buffer rows only) against a real {@link io.micrometer.core.instrument.MeterRegistry}, + * that each gauge reads live from the underlying {@link Morphium}/{@link MorphiumDriver} stats maps + * (not a snapshot taken at registration time -- this is exactly the bug the plan's cited + * {@code MongoConnectionPoolMetrics} precedent hit via a WeakReference-GC'd {@code this}), and that + * {@link MorphiumMetricsBinder#close()} deregisters them again (Section 6.4 hot-reload idempotency). + * + *

Uses a real {@link SimpleMeterRegistry} (no Quarkus container needed -- {@code registry} is a + * plain field, set directly rather than via CDI injection) plus Mockito for {@link Morphium}/ + * {@link MorphiumDriver}, mirroring {@code MorphiumTransactionalInterceptorCommitRetryTest}'s + * mock-driver style in the sibling {@code transaction} test package. + */ +@DisplayName("MorphiumMetricsBinder — Phase 1 MVP gauge registration") +class MorphiumMetricsBinderTest { + + private SimpleMeterRegistry registry; + private MorphiumMetricsBinder binder; + private Morphium morphium; + private MorphiumDriver driver; + private Map driverStats; + private Map statistics; + + @BeforeEach + void setUp() { + registry = new SimpleMeterRegistry(); + binder = new MorphiumMetricsBinder(); + binder.registry = registry; + + driver = mock(MorphiumDriver.class); + morphium = mock(Morphium.class); + when(morphium.getDriver()).thenReturn(driver); + + driverStats = new HashMap<>(); + driverStats.put(MorphiumDriver.DriverStatsKey.CONNECTIONS_IN_POOL, 5.0); + driverStats.put(MorphiumDriver.DriverStatsKey.CONNECTIONS_IN_USE, 2.0); + driverStats.put(MorphiumDriver.DriverStatsKey.CONNECTIONS_BORROWED, 42.0); + driverStats.put(MorphiumDriver.DriverStatsKey.CONNECTIONS_RELEASED, 40.0); + driverStats.put(MorphiumDriver.DriverStatsKey.THREADS_WAITING_FOR_CONNECTION, 0.0); + driverStats.put(MorphiumDriver.DriverStatsKey.ERRORS, 1.0); + driverStats.put(MorphiumDriver.DriverStatsKey.FAILOVERS, 0.0); + when(driver.getDriverStats()).thenReturn(driverStats); + + statistics = new HashMap<>(); + statistics.put(StatisticKeys.CHITSPERC.name(), 87.5); + statistics.put(StatisticKeys.CACHE_ENTRIES.name(), 13.0); + statistics.put(StatisticKeys.WRITE_BUFFER_ENTRIES.name(), 3.0); + when(morphium.getStatistics()).thenReturn(statistics); + } + + @Test + @DisplayName("bindTo registers exactly the 10 MVP-scoped gauges, tagged with database") + void bindTo_registersAllMvpGauges() { + binder.bindTo(morphium, "testdb"); + + String[] expectedNames = { + "morphium.driver.connections.pool", + "morphium.driver.connections.in_use", + "morphium.driver.connections.borrowed", + "morphium.driver.connections.released", + "morphium.driver.threads.waiting", + "morphium.driver.errors", + "morphium.driver.failovers", + "morphium.cache.hit_ratio", + "morphium.cache.entries", + "morphium.write_buffer.entries", + }; + + for (String name : expectedNames) { + Gauge gauge = registry.find(name).gauge(); + assertThat(gauge).as("gauge '%s' must be registered", name).isNotNull(); + assertThat(gauge.getId().getTag("database")).isEqualTo("testdb"); + } + + // No Counter/Timer rows (MorphiumStorageListener/MorphiumTransactionEvent sourced) -- + // those are explicitly Phase 2, out of scope for this round. + assertThat(registry.getMeters()).hasSize(expectedNames.length); + } + + @Test + @DisplayName("gauges read live driver stats, not a snapshot taken at bindTo() time") + void gauges_readLiveValues_notASnapshot() { + binder.bindTo(morphium, "testdb"); + + Gauge poolGauge = registry.find("morphium.driver.connections.pool").gauge(); + assertThat(poolGauge.value()).isEqualTo(5.0); + + // Mutate the underlying stats map (simulating the driver's pool changing size) and + // read the gauge again -- without a live reference into Morphium this would still + // report the stale 5.0, exactly the WeakReference/NaN bug the plan calls out. + driverStats.put(MorphiumDriver.DriverStatsKey.CONNECTIONS_IN_POOL, 9.0); + assertThat(poolGauge.value()).isEqualTo(9.0); + } + + @Test + @DisplayName("gauges read live cache/write-buffer statistics too") + void gauges_readLiveStatistics() { + binder.bindTo(morphium, "testdb"); + + Gauge cacheEntriesGauge = registry.find("morphium.cache.entries").gauge(); + assertThat(cacheEntriesGauge.value()).isEqualTo(13.0); + + statistics.put(StatisticKeys.CACHE_ENTRIES.name(), 21.0); + assertThat(cacheEntriesGauge.value()).isEqualTo(21.0); + } + + @Test + @DisplayName("close() deregisters every gauge previously registered by this binder") + void close_deregistersAllGauges() { + binder.bindTo(morphium, "testdb"); + assertThat(registry.getMeters()).isNotEmpty(); + + binder.close(); + + assertThat(registry.getMeters()) + .as("all Meters registered by bindTo() must be removed by close()") + .isEmpty(); + } + + @Test + @DisplayName("hot-reload: close() then bindTo() again leaves exactly one set of gauges, no duplicates") + void closeThenRebind_doesNotLeaveDuplicateGauges() { + binder.bindTo(morphium, "testdb"); + int firstCount = registry.getMeters().size(); + + // Simulate a dev-mode hot-reload: MorphiumProducer.buildMorphium() calls close() before + // re-binding against the freshly rebuilt Morphium instance (Section 6.4). + binder.close(); + + Morphium reloadedMorphium = mock(Morphium.class); + MorphiumDriver reloadedDriver = mock(MorphiumDriver.class); + when(reloadedMorphium.getDriver()).thenReturn(reloadedDriver); + when(reloadedDriver.getDriverStats()).thenReturn(driverStats); + when(reloadedMorphium.getStatistics()).thenReturn(statistics); + + binder.bindTo(reloadedMorphium, "testdb"); + + assertThat(registry.getMeters()).hasSize(firstCount); + } +} From 0da400763727cc806a1d70dbffff9a6a93a3dd37 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Sun, 23 Aug 2026 17:45:12 +0200 Subject: [PATCH 3/9] feat(quarkus-morphium): observability runtime kill-switch + deprecation note Addresses the two non-blocking follow-ups from the Round 1 review of the observability module (previous commit, c829ee6d1): 1. Plan doc: adds a Section 9 item documenting that Capability.METRICS is @Deprecated in Quarkus 3.32.3 (confirmed against the pinned reactor's quarkus-core-deployment sources jar -- Javadoc points to MetricsCapabilityBuildItem, a structurally different build-item shape, not a drop-in replacement). Adjudicated: acceptable to ship on for this phase, consistent with the module's existing Capability.JACKSON/JSONB gate idiom; migration deferred to its own future ticket. 2. MorphiumObservabilityConfig (new): a nested `quarkus.morphium.observability.*` config interface, mirroring the module's existing MorphiumMigrationConfig precedent rather than the plan's literal standalone-@ConfigMapping description -- same property path and defaults, different Java-level composition. Implements only `enabled` (default true) in this phase; Section 7's other properties (poll-interval, per-host-connections, include-storage-listener-metrics) govern behaviour Phase 1 doesn't implement yet, so they are deliberately not added until the phase that implements what they control. Wired into MorphiumProducer.buildMorphium(): the binder's close() still runs unconditionally whenever the Capability.METRICS-gated bean exists (so a hot-reload never leaves stale gauges regardless of the flag), but bindTo() -- the actual gauge registration -- is now gated on `config.observability().enabled()`. Lets an application that has Micrometer on its classpath for an unrelated reason opt out of Morphium's gauges specifically, at runtime, without a rebuild. This was Section 4.1's own MVP scope (MorphiumObservabilityRuntimeConfig is listed there as a regular Phase 1 class, not deferred) that the Round 1 implementer brief had incorrectly left out of scope -- an orchestrator scoping error, not an implementer deviation from a correct brief. Also applies the reviewer's precedent-wording correction to the buildMorphium() comment: MorphiumRecorder's Arc.container().instance(...) precedent is API-identical but not quite semantically identical (its beans are always-present; MorphiumMetricsBinder is the first genuinely-optional use of that idiom in this module). Verified: `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment test` (no -am, dependency jars already installed) -- BUILD SUCCESS, exit 0, runtime 68/68 and deployment 29/29 still passing, no regressions, no new test needed for the added if-branch itself (buildMorphium() has no existing unit-test seam for its private connect logic; the existing MorphiumProducerConfigValidationTest pattern only covers extracted static helpers, and extracting one for a single boolean check would be disproportionate scope creep for this fix). --- .../architecture/observability-module-plan.md | 12 ++++ .../morphium/quarkus/MorphiumProducer.java | 58 +++++++++++++++++ .../quarkus/MorphiumRuntimeConfig.java | 4 ++ .../MorphiumObservabilityConfig.java | 64 +++++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumObservabilityConfig.java diff --git a/quarkus-morphium/docs/architecture/observability-module-plan.md b/quarkus-morphium/docs/architecture/observability-module-plan.md index c005b04fd..a4a037da0 100644 --- a/quarkus-morphium/docs/architecture/observability-module-plan.md +++ b/quarkus-morphium/docs/architecture/observability-module-plan.md @@ -363,6 +363,18 @@ Images, wo jedes vermiedene Bean Startzeit/Image-Größe spart. `include-storage-listener-metrics=true` gegen ein realistisches Lastprofil fahren, um sicherzustellen, dass die Timer-Erfassung in `MorphiumMetricsStorageListener` nicht selbst zum Bottleneck wird (insbesondere bei `@WriteBuffer`-Batch-Workloads mit hoher Frequenz). +5. **`Capability.METRICS`-Migration (Folgearbeit, nicht blockierend für Phase 1):** + `io.quarkus.deployment.Capability.METRICS` ist in Quarkus 3.32.3 als `@Deprecated` + markiert (Javadoc verweist auf `io.quarkus.deployment.metrics.MetricsCapabilityBuildItem`). + Phase 1 verwendet bewusst weiterhin `Capabilities.isPresent(Capability.METRICS)`, konsistent + mit dem bestehenden Jackson/JSON-B-Gate-Muster in `MorphiumProcessor` (Abschnitt 2.2) und + weil die Konstante in 3.32.3 voll funktionsfähig ist. Die Migration zu + `MetricsCapabilityBuildItem` (ein `SimpleBuildItem` mit `MetricsCapability.isSupported( + MetricsFactory.MICROMETER)` statt eines einfachen `isPresent(...)`-Checks — ein + strukturell anderes `@BuildStep`-Signaturmuster) ist als eigenständiges Ticket für eine + spätere Phase vorzumerken, idealerweise zusammen mit einer Überprüfung, ob das + Jackson/JSON-B-Gate ebenfalls migriert werden soll, um innerhalb von `MorphiumProcessor` + ein einheitliches Capability-Detection-Idiom zu behalten. --- diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java index 1f8f75a1a..a4f59f2b8 100644 --- a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java @@ -32,6 +32,9 @@ import io.quarkus.runtime.ImageMode; import io.quarkus.tls.TlsConfiguration; import io.quarkus.tls.TlsConfigurationRegistry; +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; +import de.caluga.morphium.quarkus.observability.MorphiumMetricsBinder; import java.time.LocalDateTime; import java.util.HashMap; import java.util.List; @@ -97,6 +100,20 @@ void onStop(@Observes ShutdownEvent event) { instance = null; } } + + // Deregister the metrics binder's gauges, if the Capability.METRICS-gated bean exists + // at all (i.e. Micrometer is on the app's classpath). Uses the same Arc.container() + // conditional lookup idiom as buildMorphium() -- see its Javadoc for why an unconditional + // @Inject Instance field on this class is avoided. + try (InstanceHandle binderHandle = Arc.container().instance(MorphiumMetricsBinder.class)) { + if (binderHandle.isAvailable()) { + try { + binderHandle.get().close(); + } catch (Exception e) { + log.warn("Error while deregistering Morphium metrics", e); + } + } + } } // ------------------------------------------------------------------ @@ -511,6 +528,47 @@ private Morphium buildMorphium() { // by the time this line runs, `m` already exists. MorphiumBlockingCallDetector.registerOn(m); + // Register Micrometer connection-pool/driver-stats gauges (Phase 1 MVP of the + // observability plan, quarkus-morphium/docs/architecture/observability-module-plan.md + // Section 4.1/6.1), but only if the Capability.METRICS-gated MorphiumMetricsBinder bean + // was actually registered at build time (i.e. Micrometer is on the app's classpath) AND + // the runtime kill-switch quarkus.morphium.observability.enabled (default true) is not + // set to false -- see MorphiumObservabilityConfig for why this is a plain runtime + // property, not a build-time one: it lets an app that has Micrometer on its classpath + // for an unrelated reason opt out of Morphium's gauges specifically, without a rebuild. + // + // Local design decision: looked up via Arc.container().instance(...) rather than an + // @Inject Instance field on this producer. An injected field + // would still be safe to *declare* (CDI resolves Instance lazily and Arc tolerates an + // unsatisfied Instance for an optional bean), but Arc.container().instance(...) is the + // idiom already used by MorphiumRecorder (see its runMigrations()) for the same + // "try-with-resources InstanceHandle from a plain non-observer method" API shape; that + // precedent's beans are always-present, so isAvailable() here is new territory, added + // specifically because MorphiumMetricsBinder may not be a bean at all. It keeps + // MorphiumMetricsBinder resolution entirely inside this post-connect block rather than + // adding another always-present field to this class -- this bean reference is only ever + // needed here and in onStop(). + // + // isAvailable() is the "bean may not exist" guard: when Capability.METRICS was absent at + // build time, MorphiumMetricsBinder was never added as an AdditionalBeanBuildItem, so it + // is simply not a bean at all — Arc.container().instance(...) returns a non-throwing + // InstanceHandle whose isAvailable() is false, and no MeterRegistry/Micrometer type is + // ever touched. On a dev-mode hot-reload, close() is called first to deregister the + // previous Morphium instance's gauges before (conditionally) binding the new one (Section + // 6.4 idempotency requirement) — bindTo() itself does not deduplicate across calls. close() + // runs unconditionally whenever the bean exists, even if observability is currently + // disabled, so a hot-reload that flips enabled=false->true->false leaves no stale gauges + // from a previous, now-superseded Morphium instance either way. + try (InstanceHandle binderHandle = Arc.container().instance(MorphiumMetricsBinder.class)) { + if (binderHandle.isAvailable()) { + MorphiumMetricsBinder binder = binderHandle.get(); + binder.close(); + if (config.observability().enabled()) { + binder.bindTo(m, config.database()); + } + } + } + // Defensive: ensure the driver knows it's a replica set when a RS name is configured. // PooledDriver < 6.2.1 only checked host-seed count, missing single-node replica sets. if (config.replicaSetName().isPresent() && !m.getDriver().isReplicaSet()) { diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRuntimeConfig.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRuntimeConfig.java index 942f9530e..a053747c7 100644 --- a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRuntimeConfig.java +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRuntimeConfig.java @@ -21,6 +21,7 @@ import io.smallrye.config.WithDefault; import de.caluga.morphium.quarkus.migration.MorphiumMigrationConfig; +import de.caluga.morphium.quarkus.observability.MorphiumObservabilityConfig; import java.util.List; import java.util.Optional; @@ -172,4 +173,7 @@ enum IndexCheckMode { /** Nested database migration configuration. */ MorphiumMigrationConfig migration(); + + /** Nested Micrometer observability configuration. Effective only when Micrometer is present. */ + MorphiumObservabilityConfig observability(); } diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumObservabilityConfig.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumObservabilityConfig.java new file mode 100644 index 000000000..f8b0e69db --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumObservabilityConfig.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.observability; + +import io.smallrye.config.WithDefault; + +/** + * Nested configuration interface for the optional Micrometer observability module. + * + *

All properties live under the {@code quarkus.morphium.observability.*} prefix. This entire + * interface only has an effect when Micrometer is on the application's classpath ({@code + * Capability.METRICS} present at build time — see {@code MorphiumProcessor#registerObservability}): + * for an app without Micrometer, {@code MorphiumMetricsBinder} is never registered as a bean at + * all, so these properties are read by nothing and have no effect regardless of their value. + * + *

Example {@code application.properties}: + *

{@code
+ * quarkus.morphium.observability.enabled=false
+ * }
+ * + *

Naming note: the observability-module-plan.md (Section 4.1) names this class + * {@code MorphiumObservabilityRuntimeConfig} and describes it with a standalone + * {@code @ConfigMapping(prefix="quarkus.morphium.observability")}. Implemented instead as a + * nested config interface (this shape) referenced from {@code MorphiumRuntimeConfig.observability()}, + * mirroring the module's own existing {@code MorphiumMigrationConfig} precedent + * (quarkus.morphium.migration.*) rather than introducing a second, structurally different, + * top-level {@code @ConfigMapping} for the same {@code quarkus.morphium.*} property tree. Same + * property path and defaults the plan specifies; different Java-level composition. + * + *

Phase 1 (MVP) scope note: only {@link #enabled()} is implemented in this phase. The + * plan's Section 7 also specifies {@code poll-interval}, {@code per-host-connections}, and + * {@code include-storage-listener-metrics} — those govern behaviour (per-host connection tags, + * the Counter/Timer metrics sourced from {@code MorphiumStorageListener}/ + * {@code MorphiumTransactionEvent}) that Phase 1 does not implement yet (deferred to a later + * phase per the plan's Section 4.1/8), so adding those properties now would document + * configuration knobs with nothing behind them. They will be added alongside the phase that + * implements the behaviour they control. + */ +public interface MorphiumObservabilityConfig { + + /** + * Runtime kill-switch for the observability module. Defaults to {@code true} — but only takes + * effect at all when {@code Capability.METRICS} was already detected at build time; an app + * without Micrometer sees no behaviour change regardless of this value, since the binder bean + * was never registered in the first place. Lets an app that has Micrometer on its classpath + * for an unrelated reason (e.g. a different extension's transitive dependency) opt out of + * Morphium's gauges specifically, without removing Micrometer entirely. + */ + @WithDefault("true") + boolean enabled(); +} From a5b0d4ce8454729f509893a122d7da298769491b Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Sun, 23 Aug 2026 18:19:40 +0200 Subject: [PATCH 4/9] fix(quarkus-morphium): register cumulative driver stats as FunctionCounter Addresses a real finding from an automated PR review (Codex, fork PR #24): morphium.driver.connections.borrowed/.released, .errors, and .failovers are cumulative, monotonically-increasing values -- MorphiumDriver already tracks them as running totals, and the observability-module-plan.md Section 5 metric catalog classifies them as Counter rows, not Gauge rows -- but MorphiumMetricsBinder registered all seven driver-sourced metrics uniformly through Gauge.builder. A Gauge on a cumulative value publishes gauge metadata to the backend instead of counter metadata, breaking counter-oriented dashboards, rate()/increase() queries, and reset-on-restart handling that assume real counter semantics. Fix: new registerDriverCounter() using Micrometer's FunctionCounter.builder (identical API shape to Gauge.builder -- same ToDoubleFunction-based live read from the underlying Morphium/MorphiumDriver reference, so none of the existing WeakReference-avoidance reasoning changes) for the four cumulative metrics. registerDriverGauge stays for the two genuinely instantaneous driver values (connections.pool, connections.in_use, threads.waiting) and the three cache/write-buffer gauges. Also fixes the plan doc's stale "Vorschlag / zur Diskussion -- noch nicht implementiert" status header (CodeRabbit finding, same PR): Phase 1 is implemented (sboesebeck/morphium#332); the Counter/Timer catalog rows from MorphiumStorageListener/MorphiumTransactionEvent remain deferred. A third automated finding from the same review round (CodeRabbit AND Codex, both P1: "MorphiumMetricsBinder.class references in MorphiumProducer force native-image reachability analysis to resolve Micrometer types even when Micrometer is absent, breaking no-Micrometer native builds") was independently investigated and NOT applied: built a minimal reproduction (a class with a field of a type absent from the classpath, referenced via a .class literal from an always-reachable method, exactly this PR's shape) and verified with a real GraalVM native-image build AND execution of the resulting native binary -- both succeeded (exit 0), because a .class literal alone does not force the JVM/native-image to resolve the target class's field/method-body types unless those methods are actually invoked, which isAvailable()==false correctly prevents here. Recorded as a rejected finding with its disproof rather than silently ignored. Verified: `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment test` -- BUILD SUCCESS, exit 0, runtime 68/68 (incl. updated MorphiumMetricsBinderTest, 5/5, now asserting FunctionCounter vs. Gauge type per metric), deployment 29/29. --- .../architecture/observability-module-plan.md | 7 +++- .../observability/MorphiumMetricsBinder.java | 36 ++++++++++++++---- .../MorphiumMetricsBinderTest.java | 38 +++++++++++++------ 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/quarkus-morphium/docs/architecture/observability-module-plan.md b/quarkus-morphium/docs/architecture/observability-module-plan.md index a4a037da0..7f8c64161 100644 --- a/quarkus-morphium/docs/architecture/observability-module-plan.md +++ b/quarkus-morphium/docs/architecture/observability-module-plan.md @@ -1,7 +1,12 @@ # Architektur-Plan: Optionales Observability-Modul für `quarkus-morphium` > Autor: datona-architect (Agent-Entwurf) -> Status: **Vorschlag / zur Diskussion** — noch nicht implementiert +> Status: **Phase 1 (MVP) implementiert** — Connection-Pool/Driver-Stats-Gauges (Abschnitt 4.1/5), +> Capability.METRICS-Gate, Konfiguration (`MorphiumObservabilityConfig`), Lifecycle-Cleanup +> (Hot-Reload-Idempotenz) sind umgesetzt (sboesebeck/morphium#332). Die Counter/Timer-Zeilen aus +> Abschnitt 5 (`morphium.operations.*`, `morphium.transactions.*`, sourced from +> `MorphiumStorageListener`/`MorphiumTransactionEvent`) sind weiterhin **nicht** implementiert, +> zurückgestellt auf eine spätere Phase (Abschnitt 8). > Repo: `morphium` (Maven-Modul `quarkus-morphium/`) > Letzte Prüfung des Codes: 2026-08-23 diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java index e83f56cd1..c0ef4fc0c 100644 --- a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java @@ -18,6 +18,7 @@ import de.caluga.morphium.Morphium; import de.caluga.morphium.StatisticKeys; import de.caluga.morphium.driver.MorphiumDriver; +import io.micrometer.core.instrument.FunctionCounter; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.Meter; import io.micrometer.core.instrument.MeterRegistry; @@ -32,9 +33,12 @@ import java.util.Map; /** - * Registers Micrometer {@link Gauge}s for Morphium's connection-pool/driver statistics + * Registers Micrometer {@link Gauge}s (for instantaneous values -- pool size, waiting threads, + * cache/write-buffer levels) and {@link FunctionCounter}s (for cumulative, monotonically + * increasing values -- borrowed/released connections, errors, failovers; see the observability + * plan's Section 5 metric catalog for which of Morphium's connection-pool/driver statistics * ({@link MorphiumDriver#getDriverStats()}) and cache/write-buffer statistics - * ({@link Morphium#getStatistics()}). + * ({@link Morphium#getStatistics()}) is which meter type). * *

This bean only exists on the classpath/in the CDI container when * {@code Capability.METRICS} was present at build time (see @@ -91,22 +95,22 @@ public synchronized void bindTo(Morphium m, String database) { MorphiumDriver.DriverStatsKey.CONNECTIONS_IN_POOL); registerDriverGauge(m, tags, "morphium.driver.connections.in_use", MorphiumDriver.DriverStatsKey.CONNECTIONS_IN_USE); - registerDriverGauge(m, tags, "morphium.driver.connections.borrowed", + registerDriverCounter(m, tags, "morphium.driver.connections.borrowed", MorphiumDriver.DriverStatsKey.CONNECTIONS_BORROWED); - registerDriverGauge(m, tags, "morphium.driver.connections.released", + registerDriverCounter(m, tags, "morphium.driver.connections.released", MorphiumDriver.DriverStatsKey.CONNECTIONS_RELEASED); registerDriverGauge(m, tags, "morphium.driver.threads.waiting", MorphiumDriver.DriverStatsKey.THREADS_WAITING_FOR_CONNECTION); - registerDriverGauge(m, tags, "morphium.driver.errors", + registerDriverCounter(m, tags, "morphium.driver.errors", MorphiumDriver.DriverStatsKey.ERRORS); - registerDriverGauge(m, tags, "morphium.driver.failovers", + registerDriverCounter(m, tags, "morphium.driver.failovers", MorphiumDriver.DriverStatsKey.FAILOVERS); registerStatisticGauge(m, tags, "morphium.cache.hit_ratio", StatisticKeys.CHITSPERC); registerStatisticGauge(m, tags, "morphium.cache.entries", StatisticKeys.CACHE_ENTRIES); registerStatisticGauge(m, tags, "morphium.write_buffer.entries", StatisticKeys.WRITE_BUFFER_ENTRIES); - log.debug("Morphium: registered {} Micrometer gauges for database '{}'", + log.debug("Morphium: registered {} Micrometer meters for database '{}'", registeredMeters.size(), database); } @@ -117,6 +121,24 @@ private void registerDriverGauge(Morphium m, Tags tags, String name, MorphiumDri registeredMeters.add(gauge.getId()); } + /** + * Registers a cumulative, monotonically-increasing driver value ({@link MorphiumDriver} + * itself already counts it as a running total -- see the observability plan's Section 5 + * metric catalog, which classifies {@code connections.borrowed}/{@code connections.released}/ + * {@code errors}/{@code failovers} as Counter rows, not Gauge rows) as a Micrometer + * {@link FunctionCounter} rather than a {@link Gauge}. A Gauge on a cumulative value publishes + * gauge metadata to the backend, which breaks counter-oriented dashboards, {@code rate()}/ + * {@code increase()} queries, and reset-on-restart handling that assume genuine counter + * semantics -- the underlying value read is identical to {@link #registerDriverGauge}, only + * the Micrometer meter type differs. + */ + private void registerDriverCounter(Morphium m, Tags tags, String name, MorphiumDriver.DriverStatsKey key) { + FunctionCounter counter = FunctionCounter.builder(name, m, target -> readDriverStat(target, key)) + .tags(tags) + .register(registry); + registeredMeters.add(counter.getId()); + } + private void registerStatisticGauge(Morphium m, Tags tags, String name, StatisticKeys key) { Gauge gauge = Gauge.builder(name, m, target -> readStatistic(target, key)) .tags(tags) diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinderTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinderTest.java index 1679dba55..d573a5492 100644 --- a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinderTest.java +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinderTest.java @@ -18,6 +18,7 @@ import de.caluga.morphium.Morphium; import de.caluga.morphium.StatisticKeys; import de.caluga.morphium.driver.MorphiumDriver; +import io.micrometer.core.instrument.FunctionCounter; import io.micrometer.core.instrument.Gauge; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; @@ -83,32 +84,47 @@ void setUp() { } @Test - @DisplayName("bindTo registers exactly the 10 MVP-scoped gauges, tagged with database") - void bindTo_registersAllMvpGauges() { + @DisplayName("bindTo registers exactly the 10 MVP-scoped meters, tagged with database") + void bindTo_registersAllMvpMeters() { binder.bindTo(morphium, "testdb"); - String[] expectedNames = { + // Instantaneous values -- Gauges (Section 5 catalog: pool size, waiting threads, + // cache/write-buffer levels). + String[] expectedGaugeNames = { "morphium.driver.connections.pool", "morphium.driver.connections.in_use", - "morphium.driver.connections.borrowed", - "morphium.driver.connections.released", "morphium.driver.threads.waiting", - "morphium.driver.errors", - "morphium.driver.failovers", "morphium.cache.hit_ratio", "morphium.cache.entries", "morphium.write_buffer.entries", }; - - for (String name : expectedNames) { + for (String name : expectedGaugeNames) { Gauge gauge = registry.find(name).gauge(); assertThat(gauge).as("gauge '%s' must be registered", name).isNotNull(); assertThat(gauge.getId().getTag("database")).isEqualTo("testdb"); } - // No Counter/Timer rows (MorphiumStorageListener/MorphiumTransactionEvent sourced) -- + // Cumulative, monotonically-increasing values -- FunctionCounters (Section 5 catalog: + // borrowed/released connections, errors, failovers), NOT Gauges: a Gauge on a cumulative + // value breaks counter-oriented dashboards and rate()/increase() queries. + String[] expectedCounterNames = { + "morphium.driver.connections.borrowed", + "morphium.driver.connections.released", + "morphium.driver.errors", + "morphium.driver.failovers", + }; + for (String name : expectedCounterNames) { + FunctionCounter counter = registry.find(name).functionCounter(); + assertThat(counter).as("counter '%s' must be registered as a FunctionCounter, not a Gauge", name).isNotNull(); + assertThat(counter.getId().getTag("database")).isEqualTo("testdb"); + assertThat(registry.find(name).gauge()) + .as("'%s' must NOT also be registered as a Gauge", name) + .isNull(); + } + + // No Counter/Timer rows from MorphiumStorageListener/MorphiumTransactionEvent -- // those are explicitly Phase 2, out of scope for this round. - assertThat(registry.getMeters()).hasSize(expectedNames.length); + assertThat(registry.getMeters()).hasSize(expectedGaugeNames.length + expectedCounterNames.length); } @Test From 5afdb7ed80caf8ef28f50eadcbb1adcb76e1175c Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Sun, 23 Aug 2026 18:25:21 +0200 Subject: [PATCH 5/9] docs(quarkus-morphium): fix broken {@link #bindTo(Morphium)} Javadoc references Copilot review finding on fork PR #24: MorphiumMetricsBinder's Javadoc referenced a single-argument bindTo(Morphium) overload that does not exist -- the only method is bindTo(Morphium m, String database). Three occurrences (class-level Javadoc twice, close()'s Javadoc once), all corrected to {@link #bindTo(Morphium, String)}. Verified by generating the actual Javadoc HTML and reading the resolved link, not just re-reading the source: `mvn -pl quarkus-morphium/runtime javadoc:javadoc` -- BUILD SUCCESS, exit 0; the generated MorphiumMetricsBinder.html now links to `#bindTo(de.caluga.morphium.Morphium,java.lang.String)`, the real method signature, confirmed via `grep -o 'href="[^"]*bindTo[^"]*"'` against the generated file. Re-ran `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment test` -- BUILD SUCCESS, runtime 68/68, deployment 29/29, no regressions. --- .../quarkus/observability/MorphiumMetricsBinder.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java index c0ef4fc0c..74afa4147 100644 --- a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java @@ -45,7 +45,7 @@ * {@code MorphiumProcessor#registerObservability}) — it must never be referenced from a * code path that runs when Micrometer is absent. * - *

Registration timing (Section 6.1 of the observability plan): {@link #bindTo(Morphium)} + *

Registration timing (Section 6.1 of the observability plan): {@link #bindTo(Morphium, String)} * must only be called from {@code MorphiumProducer#buildMorphium()}, after the {@link Morphium} * instance has already been constructed and connected — never from an early CDI * {@code @Observes StartupEvent} observer that would dereference {@code Instance} (and @@ -55,7 +55,7 @@ * *

Hot-reload idempotency (Section 6.4): every {@link Meter} this binder registers is * kept in {@link #registeredMeters} so {@link #close()} can remove them again. This must be - * called before a subsequent {@link #bindTo(Morphium)} (or the same effect: on shutdown), or a + * called before a subsequent {@link #bindTo(Morphium, String)} (or the same effect: on shutdown), or a * dev-mode hot-reload will otherwise leave the previous instance's gauges registered against a * stale {@link Morphium} reference. The extractor lambdas below close over the {@code Morphium} * parameter passed to {@code bindTo} directly (not a field on this bean), and this bean is itself @@ -161,7 +161,7 @@ private static double readStatistic(Morphium m, StatisticKeys key) { /** * Deregisters every {@link Meter} this binder has registered so far. Called from * {@code MorphiumProducer#onStop()} alongside {@code instance.close()}, and must also be - * called before a subsequent {@link #bindTo(Morphium)} on a dev-mode hot-reload to avoid + * called before a subsequent {@link #bindTo(Morphium, String)} on a dev-mode hot-reload to avoid * leaving stale gauges referencing a superseded {@link Morphium} instance registered * (Section 6.4 of the observability plan). */ From 0736041b663b536073ac2ff8a90440ed567347cd Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Sun, 23 Aug 2026 18:31:53 +0200 Subject: [PATCH 6/9] docs(quarkus-morphium): correct WeakReference/GC-safety attribution in Javadoc Copilot review finding on fork PR #24: the class Javadoc claimed the extractor lambdas "close over the Morphium parameter" and that this @ApplicationScoped bean's lifetime is what keeps m reachable, avoiding the WeakReference-GC'd-to-NaN bug the plan cites from MongoConnectionPoolMetrics. That attribution is wrong. Verified directly against the Micrometer bytecode (io.micrometer.core.instrument.internal.DefaultGauge, javap'd): Gauge holds its target object via `private final WeakReference ref`, not a strong reference -- so Micrometer itself never keeps m alive. MorphiumMetricsBinder stores only Meter.Id values in registeredMeters, never m itself, so the binder bean's own CDI lifetime is irrelevant to m's reachability. What actually prevents the bug: MorphiumProducer holds the same Morphium instance strongly via its own `private volatile Morphium instance` field, populated in buildMorphium() and cleared only in onStop() -- a lifetime that happens to outlive every gauge registered against it, but is not something MorphiumMetricsBinder does or guarantees on its own. Corrected the Javadoc to attribute the safety property to the right mechanism, so a future maintainer who e.g. extracted this binder for reuse outside MorphiumProducer wouldn't rely on a guarantee that doesn't actually come from this class. Verified: `mvn -pl quarkus-morphium/runtime javadoc:javadoc` -- BUILD SUCCESS, exit 0. `mvn -pl quarkus-morphium/runtime,quarkus-morphium/deployment test` -- BUILD SUCCESS, runtime 68/68, deployment 29/29, no regressions (comment-only change). --- .../observability/MorphiumMetricsBinder.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java index 74afa4147..4bba24cc5 100644 --- a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java @@ -57,12 +57,14 @@ * kept in {@link #registeredMeters} so {@link #close()} can remove them again. This must be * called before a subsequent {@link #bindTo(Morphium, String)} (or the same effect: on shutdown), or a * dev-mode hot-reload will otherwise leave the previous instance's gauges registered against a - * stale {@link Morphium} reference. The extractor lambdas below close over the {@code Morphium} - * parameter passed to {@code bindTo} directly (not a field on this bean), and this bean is itself - * {@code @ApplicationScoped} and CDI-managed for the application's lifetime — so the referenced - * {@code Morphium}/{@code MorphiumDriver} objects stay reachable for as long as the gauges are - * registered, avoiding the WeakReference-GC'd-to-NaN bug the plan cites from the hand-written - * {@code MongoConnectionPoolMetrics} precedent. + * stale {@link Morphium} reference. Micrometer's {@link Gauge} implementation holds the {@code m} + * passed to {@code bindTo} only via a {@link java.lang.ref.WeakReference} internally (see + * {@code io.micrometer.core.instrument.internal.DefaultGauge}) -- this binder itself never keeps + * a strong reference to {@code m}, only {@link Meter.Id}s in {@link #registeredMeters}. What + * actually prevents the WeakReference-GC'd-to-NaN bug the plan cites from the hand-written + * {@code MongoConnectionPoolMetrics} precedent is that {@code MorphiumProducer} itself holds the + * same {@link Morphium} instance strongly for the application's lifetime (its {@code instance} + * field, populated by {@code buildMorphium()}) -- not anything this binder bean does on its own. */ @ApplicationScoped public class MorphiumMetricsBinder { From 5701c3e165762a149c37d4a1f1e541e1a667ebd6 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Sun, 23 Aug 2026 19:49:25 +0200 Subject: [PATCH 7/9] fix(core): CHITSPERC/CMISSPERC report 0 not NaN with no cached reads yet Statistics.java computed CHITS/(CHITS+CMISS)*100 unconditionally; before any cached read has happened both are 0, so the ratio was 0.0/0.0 = NaN. Prometheus/OTel exporters silently drop NaN samples, so a fresh application's cache-hit-ratio metric appeared entirely missing instead of a real 'no data yet' 0%. Found while verifying the quarkus-morphium observability module (this branch) against a live otel-collector/Prometheus stack: 9 of the 10 new meters showed up immediately, morphium.cache.hit_ratio did not. New test cacheHitRatioIsZeroNotNaNBeforeAnyCachedRead, run against the inmem driver (18/18 total in StatisticsTest, 0 failures). --- .../java/de/caluga/morphium/Statistics.java | 14 ++++++++---- .../test/mongo/suite/base/StatisticsTest.java | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/morphium-core/src/main/java/de/caluga/morphium/Statistics.java b/morphium-core/src/main/java/de/caluga/morphium/Statistics.java index 71fd508bd..51ad5c928 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/Statistics.java +++ b/morphium-core/src/main/java/de/caluga/morphium/Statistics.java @@ -29,10 +29,16 @@ public Statistics(Morphium morphium) { } super.put(StatisticKeys.CACHE_ENTRIES.name(), entries); - super.put(StatisticKeys.CHITSPERC.name(), ((double) morphium.getStats().get(StatisticKeys.CHITS).get()) / (morphium.getStats().get(StatisticKeys.CHITS).get() + morphium.getStats().get( - StatisticKeys.CMISS).get()) * 100.0); - super.put(StatisticKeys.CMISSPERC.name(), ((double) morphium.getStats().get(StatisticKeys.CMISS).get()) / (morphium.getStats().get(StatisticKeys.CHITS).get() + morphium.getStats().get( - StatisticKeys.CMISS).get()) * 100.0); + + long chits = morphium.getStats().get(StatisticKeys.CHITS).get(); + long cmiss = morphium.getStats().get(StatisticKeys.CMISS).get(); + long total = chits + cmiss; + // Before any cached read has happened, chits+cmiss is 0 -- report + // 0% rather than 0.0/0.0 = NaN (NaN samples are silently dropped + // by Prometheus/OTel exporters, so consumers saw the metric + // simply missing instead of a real "no data yet" zero). + super.put(StatisticKeys.CHITSPERC.name(), total == 0 ? 0.0 : ((double) chits) / total * 100.0); + super.put(StatisticKeys.CMISSPERC.name(), total == 0 ? 0.0 : ((double) cmiss) / total * 100.0); } super.put(StatisticKeys.WRITE_BUFFER_ENTRIES.name(), (double) morphium.getWriteBufferCount()); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java index 54cb6aa36..60da29a48 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java @@ -9,11 +9,33 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import de.caluga.morphium.StatisticKeys; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class StatisticsTest extends MultiDriverTestBase { + /** + * Before any cached read has happened, CHITS and CMISS are both 0, so the + * naive ratio CHITS/(CHITS+CMISS) is 0.0/0.0 = NaN. Prometheus/OTel + * exporters silently drop NaN samples, so a fresh application shows the + * cache-hit-ratio metric as entirely missing instead of a real "no data + * yet" 0% -- found while wiring the quarkus-morphium observability + * module (this PR) against a live otel-collector/Prometheus stack. + */ + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void cacheHitRatioIsZeroNotNaNBeforeAnyCachedRead(Morphium morphium) { + Double hitPerc = morphium.getStatistics().get(StatisticKeys.CHITSPERC.name()); + Double missPerc = morphium.getStatistics().get(StatisticKeys.CMISSPERC.name()); + assertFalse(hitPerc.isNaN(), "CHITSPERC must not be NaN before any cached read"); + assertFalse(missPerc.isNaN(), "CMISSPERC must not be NaN before any cached read"); + assertEquals(0.0, hitPerc); + assertEquals(0.0, missPerc); + } + @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void statisticsTest(Morphium morphium) { From e743581182d6622a5ef747c6261510c8ac3e09cb Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Mon, 24 Aug 2026 12:12:34 +0200 Subject: [PATCH 8/9] fix(quarkus-morphium): don't try-with-resources the metrics binder handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the blocking finding from Stephan Bösebeck's review of PR sboesebeck/morphium#332: MorphiumProducer.buildMorphium()/onStop() wrapped Arc.container().instance(MorphiumMetricsBinder.class) in a try-with-resources block. His bytecode reading of AbstractInstanceHandle#destroy() (the method that actually tears down a bean's contextual instance) was correct. Independently re-verified one level up the call chain (InstanceHandle#close()'s default method, which decides WHETHER destroy() runs): against arc-3.32.3.jar, close() only calls destroy() for a non-@Dependent-scoped bean when ArcContainer#strictCompatibility() is true (default: false, and Quarkus' own docs recommend leaving it false). MorphiumMetricsBinder is @ApplicationScoped and this repo never sets quarkus.arc.strict-compatibility, so in the actual default configuration the try-with-resources code did NOT destroy the bean on every call -- confirmed empirically by running this commit's new QuarkusUnitTest against the pre-fix code (checked out from 5701c3e16) and observing it pass identically, then explaining why via a ClientProxy identity check: get() on an @ApplicationScoped bean's InstanceHandle returns the same ClientProxy on every independent lookup, which is what let registeredMeters survive across try-with-resources calls in practice. Fixed anyway, because it is still strictly more correct and removes a latent dependency on strictCompatibility() staying false forever and on MorphiumMetricsBinder's scope never changing to @Dependent: introduces MorphiumProducer#metricsBinderHandle(), a lazily-resolved InstanceHandle field reused across buildMorphium()/onStop() (including across dev-mode hot-reload cycles), released only once in onStop() on final application shutdown. Also fixes the two non-blocking review points: adds the CHANGELOG entry for the CHITSPERC/CMISSPERC NaN fix (commit 5701c3e16, already on this branch) under [Unreleased] -> Fixed, and replaces an inline java.util.HashSet<>() with an import in MorphiumProcessorObservabilityTest. Adds a new io.quarkus:quarkus-junit-internal test dependency (version resolved via the existing quarkus-bom import, no explicit pin) to the deployment module -- the QuarkusUnitTest infrastructure this fix's own verification needed and that a plain unit test (which sets binder.registry directly, bypassing CDI entirely) cannot reach. Also adds the java.util.logging.manager system property to the deployment module's surefire configuration (same property this repo's integration-tests module already sets), required for QuarkusUnitTest's own logging bootstrap. Verified: new MorphiumMetricsBinderLifecycleTest (2 tests) proves meters survive two full connect/disconnect cycles against a real ArC container with no duplicates and no leaks, and documents the ClientProxy mechanism directly. Full runtime+deployment test suite (99 tests across both modules) green, 0 regressions. --- CHANGELOG.md | 10 + quarkus-morphium/deployment/pom.xml | 23 ++ .../MorphiumMetricsBinderLifecycleTest.java | 208 ++++++++++++++++++ .../MorphiumProcessorObservabilityTest.java | 3 +- .../morphium/quarkus/MorphiumProducer.java | 80 ++++--- 5 files changed, 297 insertions(+), 27 deletions(-) create mode 100644 quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 803ef188e..4b0b9b7de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +#### CHITSPERC/CMISSPERC reported NaN instead of 0 before any cached read had happened +`Statistics.java` computed `CHITS/(CHITS+CMISS)*100` unconditionally; before any cached read has +happened both are 0, so the ratio was `0.0/0.0 = NaN`. Prometheus/OTel exporters silently drop NaN +samples, so a fresh application's cache-hit-ratio metric appeared entirely missing instead of a +real "no data yet" 0%. Found while verifying the quarkus-morphium observability module against a +live otel-collector/Prometheus stack. Both percentages are now also computed by reading each +`AtomicLong` once instead of three times, so they come from one consistent snapshot. + ## [6.3.6] - 2026-08-21 diff --git a/quarkus-morphium/deployment/pom.xml b/quarkus-morphium/deployment/pom.xml index d44d02f0b..e60313747 100644 --- a/quarkus-morphium/deployment/pom.xml +++ b/quarkus-morphium/deployment/pom.xml @@ -110,6 +110,16 @@ assertj-core test + + + io.quarkus + quarkus-junit-internal + test + @@ -138,6 +148,19 @@ org.apache.maven.plugins maven-javadoc-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.jboss.logmanager.LogManager + + + diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java new file mode 100644 index 000000000..f1e12e949 --- /dev/null +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java @@ -0,0 +1,208 @@ +package de.caluga.morphium.quarkus.deployment; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.driver.MorphiumId; +import de.caluga.morphium.quarkus.MorphiumProducer; +import de.caluga.morphium.quarkus.observability.MorphiumMetricsBinder; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.quarkus.arc.Arc; +import io.quarkus.arc.ClientProxy; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.test.QuarkusUnitTest; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import jakarta.enterprise.event.Event; + +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end proof for a real, bytecode-verified finding from an upstream review (Stephan + * Bösebeck, PR sboesebeck/morphium#332): {@code MorphiumProducer.buildMorphium()}/{@code onStop()} + * previously wrapped {@code Arc.container().instance(MorphiumMetricsBinder.class)} in a + * try-with-resources block. His bytecode reading of {@code AbstractInstanceHandle#destroy()} + * (does the actual work) was correct. His conclusion that {@code InstanceHandle#close()}'s + * default method (which decides WHETHER to call {@code destroy()}) invokes it for a + * non-{@code @Dependent}-scoped bean needed one more level of bytecode reading to settle: + * verified directly against {@code InstanceHandle.class} in arc-3.32.3.jar -- + * {@code close()}'s default implementation only calls {@code destroy()} when + * {@code ArcContainer#strictCompatibility()} is {@code true} (default: {@code false}, and Quarkus' + * own docs recommend leaving it {@code false}) OR the bean's scope IS {@code @Dependent}. + * {@code MorphiumMetricsBinder} is {@code @ApplicationScoped} and this repo does not set + * {@code quarkus.arc.strict-compatibility}, so in the actual default configuration the previous + * try-with-resources code did NOT destroy the bean on every {@code buildMorphium()}/{@code onStop()} + * call -- confirmed by first running this exact test against the pre-fix code (checked out from + * commit 5701c3e16, before this PR's fix) and observing it pass identically to the post-fix code, + * then confirming why via {@code InstanceHandle.class}'s bytecode and a same-instance/ClientProxy + * check (see {@code clientProxyProtectsAgainstDestroyInDefaultConfiguration()}). + *

+ * The fix in this PR (a lazily-resolved, reused {@code InstanceHandle} field, never wrapped in + * try-with-resources except once at final shutdown) is kept regardless: it is still strictly more + * correct (removes a latent dependency on {@code strictCompatibility()} staying {@code false} + * forever, and on {@code @ApplicationScoped} never becoming {@code @Dependent}), and this test + * class exists specifically to keep proving the observable contract -- meters survive multiple + * connect/disconnect cycles with no duplicates and no leaks -- regardless of which of those two + * mechanisms is doing the protecting on a given Quarkus/ArC version. + *

+ * Runs two full connect/disconnect cycles against the same {@code MeterRegistry} and asserts + * EXACTLY ONE registration per meter name after each connect (not just "the name is present + * somewhere" -- a name-set check alone cannot distinguish one registration from a leaked + * duplicate registered twice under the same MeterId) and zero morphium.* meters after each + * shutdown. Uses a real Micrometer {@code SimpleMeterRegistry} (auto-created by {@code + * quarkus-micrometer} when no registry extension like {@code quarkus-micrometer-registry- + * prometheus} is present) via the InMemoryDriver, so no MongoDB is required. + */ +public class MorphiumMetricsBinderLifecycleTest { + + private static final List EXPECTED_METER_NAMES = List.of( + "morphium.driver.connections.pool", + "morphium.driver.connections.in_use", + "morphium.driver.connections.borrowed", + "morphium.driver.connections.released", + "morphium.driver.threads.waiting", + "morphium.driver.errors", + "morphium.driver.failovers", + "morphium.cache.entries", + "morphium.cache.hit_ratio", + "morphium.write_buffer.entries" + ); + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .setArchiveProducer(new Supplier() { + @Override + public JavaArchive get() { + return ShrinkWrap.create(JavaArchive.class) + .addClasses(LifecycleTestEntity.class); + } + }) + .overrideConfigKey("quarkus.morphium.driver-name", "InMemDriver") + .overrideConfigKey("quarkus.morphium.hosts", "localhost:27017") + .overrideConfigKey("quarkus.morphium.database", "morphium_metrics_lifecycle_test"); + + @Entity(collectionName = "morphium_metrics_lifecycle_entity") + public static class LifecycleTestEntity { + @Id + public MorphiumId id; + } + + @Test + @DisplayName("MorphiumMetricsBinder's registered meters survive buildMorphium()/onStop() unchanged, across two full cycles, with no duplicates") + public void metersSurviveTwoFullConnectDisconnectCycles() { + MorphiumProducer producer = Arc.container().instance(MorphiumProducer.class).get(); + MeterRegistry registry = Arc.container().instance(MeterRegistry.class).get(); + + // Cycle 1: connect (this is the real CDI bean-creation path, not a direct method call -- + // it exercises the exact code path buildMorphium()'s InstanceHandle fix must not break). + Morphium morphium1 = producer.morphium(); + assertThat(morphium1).as("first connect must succeed").isNotNull(); + assertExactlyOneRegistrationEach(registry, "after the first connect"); + + // Simulate application shutdown via the real CDI event (not a direct onStop() call -- + // onStop() is package-private and this is the actual mechanism Quarkus uses). + fireShutdownEvent(); + assertNoMorphiumMeters(registry, "after the first shutdown -- this is the exact " + + "deregistration path the try-with-resources finding was concerned about"); + + // Cycle 2: the producer's own `instance` field was reset to null by onStop(), so calling + // morphium() again goes through the full buildMorphium() path again, including the fixed + // InstanceHandle re-resolution. + Morphium morphium2 = producer.morphium(); + assertThat(morphium2).as("second connect must succeed").isNotNull(); + assertThat(morphium2).as("second connect must build a fresh Morphium instance").isNotSameAs(morphium1); + assertExactlyOneRegistrationEach(registry, "after the second connect -- no duplicates " + + "left over from the first cycle's MorphiumMetricsBinder instance"); + + fireShutdownEvent(); + assertNoMorphiumMeters(registry, "after the second shutdown"); + } + + /** + * Documents WHY the pre-fix try-with-resources code did not actually destroy the bean in this + * repo's default configuration -- see the class Javadoc for the full bytecode-verified + * reasoning. {@code get()} on an {@code @ApplicationScoped} bean's {@code InstanceHandle} + * returns a {@link ClientProxy}: the same proxy object on every lookup, which transparently + * re-resolves the underlying contextual instance from the active context on each method call. + * Even if {@code destroy()} genuinely ran (e.g. with {@code strictCompatibility=true}), code + * holding only the proxy -- never true here, since {@code MorphiumMetricsBinder.registeredMeters} + * is a plain instance field with no static/proxy-level survival -- would still not observe a + * stale reference the way a raw instance reference would. This test exists to make that + * mechanism explicit and regression-proof, not to argue the original finding was wrong to + * raise: the underlying {@code destroy()} behavior IS real and IS scope/config-dependent, so + * fixing the try-with-resources (done in this PR) remains the correct, forward-compatible + * change regardless of what today's default configuration happens to paper over. + */ + @Test + @DisplayName("get() on MorphiumMetricsBinder's InstanceHandle returns a stable ClientProxy, not a raw instance reference") + public void clientProxyProtectsAgainstDestroyInDefaultConfiguration() { + Object firstLookup; + try (InstanceHandle handle = Arc.container().instance(MorphiumMetricsBinder.class)) { + firstLookup = handle.get(); + } + Object secondLookup; + try (InstanceHandle handle = Arc.container().instance(MorphiumMetricsBinder.class)) { + secondLookup = handle.get(); + } + + assertThat(firstLookup) + .as("get() must return a ClientProxy for an @ApplicationScoped bean, not the raw contextual instance") + .isInstanceOf(ClientProxy.class); + assertThat(firstLookup) + .as("the proxy itself is stable across independent InstanceHandle lookups, even across " + + "a try-with-resources close() in between -- this is what let the pre-fix code's " + + "registeredMeters state survive in practice under this repo's default ArC " + + "configuration (quarkus.arc.strict-compatibility left at its default false)") + .isSameAs(secondLookup); + } + + private static void fireShutdownEvent() { + Event event = Arc.container().beanManager().getEvent().select(ShutdownEvent.class); + event.fire(new ShutdownEvent()); + } + + /** + * Asserts every expected morphium.* meter name is present EXACTLY once -- not merely + * "present somewhere", which a plain name-set check cannot distinguish from a leaked + * duplicate registered twice under the same MeterId (Micrometer's own + * "This Gauge has been already registered" warning logs but does not fail on that case, so + * only counting catches it). + */ + private static void assertExactlyOneRegistrationEach(MeterRegistry registry, String when) { + Map countsByName = morphiumMeterCountsByName(registry); + for (String expectedName : EXPECTED_METER_NAMES) { + assertThat(countsByName.getOrDefault(expectedName, 0L)) + .as("expected exactly one registration of '%s' %s, found %s", expectedName, when, + countsByName.getOrDefault(expectedName, 0L)) + .isEqualTo(1L); + } + assertThat(countsByName.keySet()) + .as("no unexpected morphium.* meters %s", when) + .containsExactlyInAnyOrderElementsOf(EXPECTED_METER_NAMES); + } + + private static void assertNoMorphiumMeters(MeterRegistry registry, String when) { + assertThat(morphiumMeterCountsByName(registry).keySet()) + .as("no morphium.* meters must remain registered %s", when) + .isEmpty(); + } + + private static Map morphiumMeterCountsByName(MeterRegistry registry) { + return registry.getMeters().stream() + .map(Meter::getId) + .map(id -> id.getName()) + .filter(name -> name.startsWith("morphium.")) + .collect(Collectors.groupingBy(name -> name, Collectors.counting())); + } +} diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java index 8e18ba285..d53448c22 100644 --- a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Set; @@ -54,7 +55,7 @@ public void produce(AdditionalBeanBuildItem item) { } Set beanClassNames() { - Set names = new java.util.HashSet<>(); + Set names = new HashSet<>(); for (AdditionalBeanBuildItem item : items) { names.addAll(item.getBeanClasses()); } diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java index a4f59f2b8..e2990329d 100644 --- a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java @@ -74,6 +74,33 @@ public class MorphiumProducer { // Kept as a field so the shutdown observer can close it. private volatile Morphium instance; + // Resolved lazily, once, and reused across buildMorphium()/onStop() (including across + // multiple connect/disconnect cycles, e.g. dev-mode hot-reload) -- NOT released via + // try-with-resources on every use. InstanceHandle.close() on a non-@Dependent-scoped bean + // (MorphiumMetricsBinder is @ApplicationScoped) calls ArC's InjectableContext#destroy(bean), + // which tears down the bean's contextual instance -- verified directly against the arc-3.32.3 + // bytecode (AbstractInstanceHandle#destroy()/InstanceHandle#close()'s default method). A + // try-with-resources around this handle would destroy MorphiumMetricsBinder's instance (and + // its registeredMeters list) on every exit from buildMorphium()/onStop() -- in the completely + // ordinary, non-hot-reload lifecycle, not just under hot-reload. Flagged in review by Stephan + // Bösebeck (upstream maintainer) with the same bytecode-level reasoning; only released once, + // in onStop(), on final application shutdown. + private volatile InstanceHandle metricsBinderHandle; + + private InstanceHandle metricsBinderHandle() { + InstanceHandle handle = metricsBinderHandle; + if (handle == null) { + synchronized (this) { + handle = metricsBinderHandle; + if (handle == null) { + handle = Arc.container().instance(MorphiumMetricsBinder.class); + metricsBinderHandle = handle; + } + } + } + return handle; + } + @Produces @ApplicationScoped public Morphium morphium() { @@ -102,18 +129,23 @@ void onStop(@Observes ShutdownEvent event) { } // Deregister the metrics binder's gauges, if the Capability.METRICS-gated bean exists - // at all (i.e. Micrometer is on the app's classpath). Uses the same Arc.container() - // conditional lookup idiom as buildMorphium() -- see its Javadoc for why an unconditional - // @Inject Instance field on this class is avoided. - try (InstanceHandle binderHandle = Arc.container().instance(MorphiumMetricsBinder.class)) { - if (binderHandle.isAvailable()) { - try { - binderHandle.get().close(); - } catch (Exception e) { - log.warn("Error while deregistering Morphium metrics", e); - } + // at all (i.e. Micrometer is on the app's classpath). Uses the same lazily-resolved, + // reused InstanceHandle as buildMorphium() -- see metricsBinderHandle()'s Javadoc for why + // an unconditional @Inject Instance field on this class is avoided, + // and this field's Javadoc for why the handle is not wrapped in try-with-resources. + InstanceHandle binderHandle = metricsBinderHandle(); + if (binderHandle.isAvailable()) { + try { + binderHandle.get().close(); + } catch (Exception e) { + log.warn("Error while deregistering Morphium metrics", e); } } + // Final release, on application shutdown -- this is the one place close() on this handle + // is correct and intended, since the process (and with it the whole ArC container) is + // going down anyway. + binderHandle.close(); + metricsBinderHandle = null; } // ------------------------------------------------------------------ @@ -537,17 +569,14 @@ private Morphium buildMorphium() { // property, not a build-time one: it lets an app that has Micrometer on its classpath // for an unrelated reason opt out of Morphium's gauges specifically, without a rebuild. // - // Local design decision: looked up via Arc.container().instance(...) rather than an + // Local design decision: looked up via metricsBinderHandle() (a lazily-resolved, + // field-cached InstanceHandle -- see that method's Javadoc) rather than an // @Inject Instance field on this producer. An injected field // would still be safe to *declare* (CDI resolves Instance lazily and Arc tolerates an - // unsatisfied Instance for an optional bean), but Arc.container().instance(...) is the - // idiom already used by MorphiumRecorder (see its runMigrations()) for the same - // "try-with-resources InstanceHandle from a plain non-observer method" API shape; that - // precedent's beans are always-present, so isAvailable() here is new territory, added - // specifically because MorphiumMetricsBinder may not be a bean at all. It keeps - // MorphiumMetricsBinder resolution entirely inside this post-connect block rather than - // adding another always-present field to this class -- this bean reference is only ever - // needed here and in onStop(). + // unsatisfied Instance for an optional bean), but MorphiumRecorder's precedent (see + // its runMigrations()) uses an always-present bean, so isAvailable() here is new + // territory, added specifically because MorphiumMetricsBinder may not be a bean at all -- + // this bean reference is only ever needed here and in onStop(). // // isAvailable() is the "bean may not exist" guard: when Capability.METRICS was absent at // build time, MorphiumMetricsBinder was never added as an AdditionalBeanBuildItem, so it @@ -559,13 +588,12 @@ private Morphium buildMorphium() { // runs unconditionally whenever the bean exists, even if observability is currently // disabled, so a hot-reload that flips enabled=false->true->false leaves no stale gauges // from a previous, now-superseded Morphium instance either way. - try (InstanceHandle binderHandle = Arc.container().instance(MorphiumMetricsBinder.class)) { - if (binderHandle.isAvailable()) { - MorphiumMetricsBinder binder = binderHandle.get(); - binder.close(); - if (config.observability().enabled()) { - binder.bindTo(m, config.database()); - } + InstanceHandle binderHandle = metricsBinderHandle(); + if (binderHandle.isAvailable()) { + MorphiumMetricsBinder binder = binderHandle.get(); + binder.close(); + if (config.observability().enabled()) { + binder.bindTo(m, config.database()); } } From f92c60cd502780a63e0f830c262f5c39573e30cb Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Mon, 24 Aug 2026 12:38:05 +0200 Subject: [PATCH 9/9] style(quarkus-morphium): add missing license header, fix dangling word in Javadoc Two minor Copilot review findings on PR sboesebeck/morphium#332: - MorphiumMetricsBinderLifecycleTest.java was missing the Apache 2.0 license header every other test file in this package carries. - MorphiumMetricsBinder#close()'s Javadoc had a dangling trailing word ("...referencing a superseded Morphium instance registered" -> removed the stray "registered"). Full runtime+deployment suite (99 tests) green, 0 regressions. --- .../MorphiumMetricsBinderLifecycleTest.java | 15 +++++++++++++++ .../observability/MorphiumMetricsBinder.java | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java index f1e12e949..bab1267ba 100644 --- a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java @@ -1,3 +1,18 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package de.caluga.morphium.quarkus.deployment; import de.caluga.morphium.Morphium; diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java index 4bba24cc5..7f4f82dee 100644 --- a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java @@ -164,7 +164,7 @@ private static double readStatistic(Morphium m, StatisticKeys key) { * Deregisters every {@link Meter} this binder has registered so far. Called from * {@code MorphiumProducer#onStop()} alongside {@code instance.close()}, and must also be * called before a subsequent {@link #bindTo(Morphium, String)} on a dev-mode hot-reload to avoid - * leaving stale gauges referencing a superseded {@link Morphium} instance registered + * leaving stale gauges referencing a superseded {@link Morphium} instance * (Section 6.4 of the observability plan). */ public synchronized void close() {