}. Every call to
+ * {@link #getAsDouble()} either returns the cached value (if {@code clock.get()
+ * - lastRead < ttl}) or invokes the delegate and refreshes the cache.
+ *
+ * Used to amortise Postgres queries behind Prometheus gauges over a
+ * 30-second TTL (see {@link AlertingMetrics#DEFAULT_GAUGE_TTL}).
+ */
+ static final class TtlCache {
+ private final Supplier delegate;
+ private final Duration ttl;
+ private final Supplier clock;
+ private volatile Instant lastRead = Instant.MIN;
+ private volatile long cached = 0L;
+
+ TtlCache(Supplier delegate, Duration ttl, Supplier clock) {
+ this.delegate = delegate;
+ this.ttl = ttl;
+ this.clock = clock;
+ }
+
+ synchronized double getAsDouble() {
+ Instant now = clock.get();
+ if (lastRead == Instant.MIN || Duration.between(lastRead, now).compareTo(ttl) >= 0) {
+ cached = delegate.get();
+ lastRead = now;
+ }
+ return cached;
}
}
}
diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java
new file mode 100644
index 00000000..194bc982
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/metrics/AlertingMetricsCachingTest.java
@@ -0,0 +1,111 @@
+package com.cameleer.server.app.alerting.metrics;
+
+import com.cameleer.server.core.alerting.AlertState;
+import io.micrometer.core.instrument.MeterRegistry;
+import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that {@link AlertingMetrics} caches gauge values for a configurable TTL,
+ * so that Prometheus scrapes do not cause one Postgres query per scrape.
+ */
+class AlertingMetricsCachingTest {
+
+ @Test
+ void gaugeSupplierIsCalledAtMostOncePerTtl() {
+ // The instances supplier is shared across every AlertState gauge, so each
+ // full gauge snapshot invokes it once per AlertState (one cache per state).
+ final int stateCount = AlertState.values().length;
+
+ AtomicInteger enabledRulesCalls = new AtomicInteger();
+ AtomicInteger disabledRulesCalls = new AtomicInteger();
+ AtomicInteger instancesCalls = new AtomicInteger();
+ AtomicReference now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
+ Supplier clock = now::get;
+
+ MeterRegistry registry = new SimpleMeterRegistry();
+
+ Supplier enabledRulesSupplier = () -> { enabledRulesCalls.incrementAndGet(); return 7L; };
+ Supplier disabledRulesSupplier = () -> { disabledRulesCalls.incrementAndGet(); return 3L; };
+ Supplier instancesSupplier = () -> { instancesCalls.incrementAndGet(); return 5L; };
+
+ AlertingMetrics metrics = new AlertingMetrics(
+ registry,
+ enabledRulesSupplier,
+ disabledRulesSupplier,
+ instancesSupplier,
+ Duration.ofSeconds(30),
+ clock
+ );
+
+ // First scrape — each supplier invoked exactly once per gauge.
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(1);
+ assertThat(disabledRulesCalls.get()).isEqualTo(1);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount);
+
+ // Second scrape within TTL — served from cache.
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(1);
+ assertThat(disabledRulesCalls.get()).isEqualTo(1);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount);
+
+ // Third scrape still within TTL (29 s later) — still cached.
+ now.set(now.get().plusSeconds(29));
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(1);
+ assertThat(disabledRulesCalls.get()).isEqualTo(1);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount);
+
+ // Advance past TTL — next scrape re-queries the delegate.
+ now.set(Instant.parse("2026-04-20T00:00:31Z"));
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(2);
+ assertThat(disabledRulesCalls.get()).isEqualTo(2);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount * 2);
+
+ // Immediate follow-up — back in cache.
+ metrics.snapshotAllGauges();
+ assertThat(enabledRulesCalls.get()).isEqualTo(2);
+ assertThat(disabledRulesCalls.get()).isEqualTo(2);
+ assertThat(instancesCalls.get()).isEqualTo(stateCount * 2);
+ }
+
+ @Test
+ void gaugeValueReflectsCachedResult() {
+ AtomicReference enabledValue = new AtomicReference<>(10L);
+ AtomicReference now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
+
+ MeterRegistry registry = new SimpleMeterRegistry();
+ AlertingMetrics metrics = new AlertingMetrics(
+ registry,
+ enabledValue::get,
+ () -> 0L,
+ () -> 0L,
+ Duration.ofSeconds(30),
+ now::get
+ );
+
+ // Read once — value cached at 10.
+ metrics.snapshotAllGauges();
+
+ // Mutate the underlying supplier output; cache should shield it.
+ enabledValue.set(99L);
+ double cached = registry.find("alerting_rules_total").tag("state", "enabled").gauge().value();
+ assertThat(cached).isEqualTo(10.0);
+
+ // After TTL, new value surfaces.
+ now.set(now.get().plusSeconds(31));
+ metrics.snapshotAllGauges();
+ double refreshed = registry.find("alerting_rules_total").tag("state", "enabled").gauge().value();
+ assertThat(refreshed).isEqualTo(99.0);
+ }
+}