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/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) { diff --git a/quarkus-morphium/deployment/pom.xml b/quarkus-morphium/deployment/pom.xml index b7b60d747..e60313747 100644 --- a/quarkus-morphium/deployment/pom.xml +++ b/quarkus-morphium/deployment/pom.xml @@ -54,6 +54,18 @@ true + + + io.quarkus + quarkus-micrometer-deployment + true + + @@ -98,6 +110,16 @@ assertj-core test + + + io.quarkus + quarkus-junit-internal + test + @@ -126,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/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/MorphiumMetricsBinderLifecycleTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java new file mode 100644 index 000000000..bab1267ba --- /dev/null +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumMetricsBinderLifecycleTest.java @@ -0,0 +1,223 @@ +/* + * 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; +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 new file mode 100644 index 000000000..d53448c22 --- /dev/null +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorObservabilityTest.java @@ -0,0 +1,93 @@ +/* + * 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.HashSet; +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 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/docs/architecture/observability-module-plan.md b/quarkus-morphium/docs/architecture/observability-module-plan.md new file mode 100644 index 000000000..7f8c64161 --- /dev/null +++ b/quarkus-morphium/docs/architecture/observability-module-plan.md @@ -0,0 +1,399 @@ +# Architektur-Plan: Optionales Observability-Modul für `quarkus-morphium` + +> Autor: datona-architect (Agent-Entwurf) +> 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 + +--- + +## 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). +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. + +--- + +## 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. 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/MorphiumProducer.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java index 1f8f75a1a..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 @@ -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; @@ -71,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() { @@ -97,6 +127,25 @@ 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 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; } // ------------------------------------------------------------------ @@ -511,6 +560,43 @@ 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 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 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 + // 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. + InstanceHandle binderHandle = metricsBinderHandle(); + 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/MorphiumMetricsBinder.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java new file mode 100644 index 000000000..7f4f82dee --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinder.java @@ -0,0 +1,176 @@ +/* + * 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.FunctionCounter; +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 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()}) 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 + * {@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, 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 + * 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, 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. 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 { + + 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); + registerDriverCounter(m, tags, "morphium.driver.connections.borrowed", + MorphiumDriver.DriverStatsKey.CONNECTIONS_BORROWED); + registerDriverCounter(m, tags, "morphium.driver.connections.released", + MorphiumDriver.DriverStatsKey.CONNECTIONS_RELEASED); + registerDriverGauge(m, tags, "morphium.driver.threads.waiting", + MorphiumDriver.DriverStatsKey.THREADS_WAITING_FOR_CONNECTION); + registerDriverCounter(m, tags, "morphium.driver.errors", + MorphiumDriver.DriverStatsKey.ERRORS); + 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 meters 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()); + } + + /** + * 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) + .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, String)} on a dev-mode hot-reload to avoid + * leaving stale gauges referencing a superseded {@link Morphium} instance + * (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/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(); +} 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..d573a5492 --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/observability/MorphiumMetricsBinderTest.java @@ -0,0 +1,190 @@ +/* + * 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.FunctionCounter; +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 meters, tagged with database") + void bindTo_registersAllMvpMeters() { + binder.bindTo(morphium, "testdb"); + + // 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.threads.waiting", + "morphium.cache.hit_ratio", + "morphium.cache.entries", + "morphium.write_buffer.entries", + }; + 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"); + } + + // 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(expectedGaugeNames.length + expectedCounterNames.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); + } +}