This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new cf33ce8f6b [#12944] fix(core): Use ExponentiallyDecayingReservoir so
infrequent operations don't report zero duration (#12946)
cf33ce8f6b is described below
commit cf33ce8f6bf445bd1d8a7406d9f34f2dd853feca
Author: Jerry Shao <[email protected]>
AuthorDate: Tue Sep 8 16:38:01 2026 +0800
[#12944] fix(core): Use ExponentiallyDecayingReservoir so infrequent
operations don't report zero duration (#12946)
### What changes were proposed in this pull request?
Timers and histograms in `MetricsSource` (used by `gravitino-server`,
`gravitino-relational-store`, `iceberg-rest-server`, and catalog
metrics) and `HttpServerMetricsSource`'s Jersey `@Timed` listener were
backed by `com.codahale.metrics.SlidingTimeWindowArrayReservoir`, with a
60-second window by default (`gravitino.metrics.timeSlidingWindowSecs`).
This PR switches both to `ExponentiallyDecayingReservoir` (dropwizard's
own default reservoir), which decays sample weight over time instead of
hard-expiring it. It also deprecates
`gravitino.metrics.timeSlidingWindowSecs`, which is no longer consulted,
and updates the config docs accordingly.
### Why are the changes needed?
`SlidingTimeWindowArrayReservoir.getSnapshot()` calls `trim()` first,
discarding any sample older than the window. If nothing landed inside
the window, the snapshot is built from an empty array, and dropwizard's
`UniformSnapshot` hard-codes `0.0`/`0` for
`getMean()`/`getMax()`/`getValue(quantile)` when there are no values.
`count`, however, comes from `Histogram`'s own separate, never-expiring
`LongAdder`, so it stays accurate.
In practice this means nearly every real API endpoint — anything not
called at least once every 60 seconds — reports a nonzero request count
but `mean`/`p95`/`p99`/`max` of exactly `0.0`, making `/metrics`
unusable for latency alerting or slow-operation investigation. Only
endpoints invoked more frequently than the window (e.g. health-check
probes) showed correct data.
Fix: #12944
**Known limitation:** `ExponentiallyDecayingReservoir` mitigates this
but does not fully eliminate it. Its `rescale()` periodically rescales
each sample's decayed weight by `exp(-alpha * elapsedSeconds)` (default
`alpha=0.015`), and once that factor underflows to `0.0` in
double-precision arithmetic — after roughly 13.8 hours of inactivity —
it clears every sample outright, reproducing the same "count survives,
duration reads zero" symptom the old reservoir showed at 60 seconds.
This still represents a large improvement (60s → ~14h) and is expected
to be a non-issue for endpoints seeing regular production traffic; see
the discussion on this PR for context.
### Does this PR introduce _any_ user-facing change?
Yes, an intentional one: the statistical semantics of exposed
`mean`/`p95`/`p99`/`max` change from a strict "last N seconds" window to
an exponentially-decayed distribution biased toward roughly the last 5
minutes, and infrequently-called operations keep reporting a real
duration for far longer (on the order of half a day, per the known
limitation above) instead of reading zero after 60 seconds of
inactivity. `gravitino.metrics.timeSlidingWindowSecs` is deprecated and
no longer affects reservoir behavior, but is kept (not removed) for
backward compatibility.
### How was this patch tested?
- `TestReservoirIdleBehavior`: a deterministic regression suite (using
an injectable `Clock`, no real sleeping) covering:
- The original bug reproduced against the old reservoir (count survives,
`max` goes to `0` after a simulated 61s idle period).
- The new reservoir surviving that same 61s idle period.
- The actual `MetricsSource.getTimer()`/`getHistogram()` production
methods (via a `MetricsSource` subclass overriding the new
`newReservoir()` seam), not just the raw reservoir classes, defaulting
to `ExponentiallyDecayingReservoir`.
- The known ~14h limitation reproduced through that same production
path, advancing the clock in 30s increments with periodic
`getSnapshot()` calls in between to mirror real Prometheus scraping.
- `TestHttpServerMetricsSource`: added a wiring test asserting the
Jersey listener's reservoir supplier produces an
`ExponentiallyDecayingReservoir` instance.
- Ran the existing `core` metrics and `server-common` web test suites —
all pass.
---------
Co-authored-by: Claude Sonnet 5 <[email protected]>
---
.../main/java/org/apache/gravitino/Configs.java | 10 +-
.../gravitino/metrics/source/MetricsSource.java | 41 ++---
.../metrics/source/TestReservoirIdleBehavior.java | 171 +++++++++++++++++++++
docs/gravitino-server-config.md | 6 +-
.../server/web/HttpServerMetricsSource.java | 10 +-
.../server/web/TestHttpServerMetricsSource.java | 31 ++++
6 files changed, 229 insertions(+), 40 deletions(-)
diff --git a/core/src/main/java/org/apache/gravitino/Configs.java
b/core/src/main/java/org/apache/gravitino/Configs.java
index ed47ef8b5a..c638288c39 100644
--- a/core/src/main/java/org/apache/gravitino/Configs.java
+++ b/core/src/main/java/org/apache/gravitino/Configs.java
@@ -420,8 +420,16 @@ public class Configs {
public static final int DEFAULT_METRICS_TIME_SLIDING_WINDOW_SECONDS = 60;
public static final ConfigEntry<Integer> METRICS_TIME_SLIDING_WINDOW_SECONDS
=
new ConfigBuilder("gravitino.metrics.timeSlidingWindowSecs")
- .doc("The seconds of Gravitino metrics time sliding window")
+ .doc(
+ "The seconds of Gravitino metrics time sliding window. No longer
used: timers and "
+ + "histograms use an ExponentiallyDecayingReservoir, which
decays samples "
+ + "instead of expiring them on a fixed window, so
infrequently-invoked "
+ + "operations keep reporting a real duration for far longer
(on the order of "
+ + "half a day with the default decay rate) instead of
reading zero after 60 "
+ + "seconds of inactivity. An operation idle for longer than
that will still "
+ + "eventually report a duration of zero.")
.version(ConfigConstants.VERSION_0_5_1)
+ .deprecated()
.intConf()
.createWithDefault(DEFAULT_METRICS_TIME_SLIDING_WINDOW_SECONDS);
diff --git
a/core/src/main/java/org/apache/gravitino/metrics/source/MetricsSource.java
b/core/src/main/java/org/apache/gravitino/metrics/source/MetricsSource.java
index 1d079f49ae..e920d8efaa 100644
--- a/core/src/main/java/org/apache/gravitino/metrics/source/MetricsSource.java
+++ b/core/src/main/java/org/apache/gravitino/metrics/source/MetricsSource.java
@@ -20,16 +20,13 @@
package org.apache.gravitino.metrics.source;
import com.codahale.metrics.Counter;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
import com.codahale.metrics.Gauge;
import com.codahale.metrics.Histogram;
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
-import com.codahale.metrics.SlidingTimeWindowArrayReservoir;
+import com.codahale.metrics.Reservoir;
import com.codahale.metrics.Timer;
-import java.util.concurrent.TimeUnit;
-import org.apache.gravitino.Config;
-import org.apache.gravitino.Configs;
-import org.apache.gravitino.GravitinoEnv;
/**
* MetricsSource provides utilities to collect specified kind metrics, all
metrics must create with
@@ -47,19 +44,10 @@ public abstract class MetricsSource {
public static final String JVM_METRIC_NAME = "jvm";
private final MetricRegistry metricRegistry;
private final String metricsSourceName;
- private final int timeSlidingWindowSeconds;
protected MetricsSource(String name) {
this.metricsSourceName = name;
metricRegistry = new MetricRegistry();
- Config config = GravitinoEnv.getInstance().config();
- if (config != null) {
- this.timeSlidingWindowSeconds =
- config.get(Configs.METRICS_TIME_SLIDING_WINDOW_SECONDS).intValue();
- } else {
- // Couldn't get config when testing
- this.timeSlidingWindowSeconds =
Configs.DEFAULT_METRICS_TIME_SLIDING_WINDOW_SECONDS;
- }
}
/**
@@ -107,12 +95,7 @@ public abstract class MetricsSource {
* @return a new or pre-existing Histogram
*/
public Histogram getHistogram(String name) {
- return this.metricRegistry.histogram(
- name,
- () ->
- new Histogram(
- new SlidingTimeWindowArrayReservoir(
- getTimeSlidingWindowSeconds(), TimeUnit.SECONDS)));
+ return this.metricRegistry.histogram(name, () -> new
Histogram(newReservoir()));
}
/**
@@ -122,12 +105,7 @@ public abstract class MetricsSource {
* @return a new or pre-existing Timer
*/
public Timer getTimer(String name) {
- return this.metricRegistry.timer(
- name,
- () ->
- new Timer(
- new SlidingTimeWindowArrayReservoir(
- getTimeSlidingWindowSeconds(), TimeUnit.SECONDS)));
+ return this.metricRegistry.timer(name, () -> new Timer(newReservoir()));
}
/**
@@ -140,7 +118,14 @@ public abstract class MetricsSource {
return this.metricRegistry.meter(name);
}
- protected int getTimeSlidingWindowSeconds() {
- return timeSlidingWindowSeconds;
+ /**
+ * Creates the {@link Reservoir} backing new timers and histograms.
Package-visible so tests can
+ * override it to inject a reservoir with a controllable {@link
com.codahale.metrics.Clock}
+ * instead of exercising real wall-clock time.
+ *
+ * @return a new {@link ExponentiallyDecayingReservoir}
+ */
+ protected Reservoir newReservoir() {
+ return new ExponentiallyDecayingReservoir();
}
}
diff --git
a/core/src/test/java/org/apache/gravitino/metrics/source/TestReservoirIdleBehavior.java
b/core/src/test/java/org/apache/gravitino/metrics/source/TestReservoirIdleBehavior.java
new file mode 100644
index 0000000000..a9098cbe2f
--- /dev/null
+++
b/core/src/test/java/org/apache/gravitino/metrics/source/TestReservoirIdleBehavior.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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 org.apache.gravitino.metrics.source;
+
+import com.codahale.metrics.Clock;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
+import com.codahale.metrics.Histogram;
+import com.codahale.metrics.Reservoir;
+import com.codahale.metrics.SlidingTimeWindowArrayReservoir;
+import com.codahale.metrics.Timer;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Regression test for {@code MetricsSource} recording a nonzero count but a
zero duration once an
+ * endpoint or method hasn't been invoked within the reservoir's window: {@link
+ * SlidingTimeWindowArrayReservoir} discards every sample once it falls
outside its fixed window, so
+ * {@link Timer} and {@link Histogram} then report count-only. {@link
+ * MetricsSource#getTimer(String)} and {@link
MetricsSource#getHistogram(String)} now use {@link
+ * ExponentiallyDecayingReservoir} instead, which decays sample weight over
time rather than
+ * expiring it on a fixed window, so infrequently-invoked operations keep
reporting a real duration
+ * for far longer (on the order of half a day with the default decay rate)
than the old 60-second
+ * window.
+ *
+ * <p>This is a mitigation, not a complete fix: {@link
ExponentiallyDecayingReservoir} rescales its
+ * samples' decayed weights periodically, and once that scaling factor
underflows to zero in
+ * double-precision arithmetic (in practice, after roughly 13.8 hours of
inactivity with the default
+ * decay rate), it clears every sample outright, reproducing the same "count
survives, duration
+ * reads zero" symptom the old reservoir showed at 60 seconds. {@link
+ * #metricsSourceTimerEventuallyZeroesOutAfterVeryLongIdlePeriod()} documents
that known, unresolved
+ * boundary through the actual {@link MetricsSource} timer-creation path.
+ */
+public class TestReservoirIdleBehavior {
+
+ /** A {@link Clock} whose tick/time only move when {@link #advance} is
called. */
+ private static class ManualClock extends Clock {
+ private final AtomicLong nanos = new AtomicLong(0);
+
+ @Override
+ public long getTick() {
+ return nanos.get();
+ }
+
+ @Override
+ public long getTime() {
+ return TimeUnit.NANOSECONDS.toMillis(nanos.get());
+ }
+
+ void advance(long duration, TimeUnit unit) {
+ nanos.addAndGet(unit.toNanos(duration));
+ }
+ }
+
+ /**
+ * A {@code MetricsSource} whose reservoirs are driven by a {@link
ManualClock} instead of real
+ * wall-clock time, so idle periods can be simulated deterministically while
still going through
+ * the production {@link MetricsSource#getTimer(String)}/{@link
+ * MetricsSource#getHistogram(String)} methods rather than constructing
reservoirs directly.
+ */
+ private static class ManualClockMetricsSource extends MetricsSource {
+ private final ManualClock clock;
+
+ ManualClockMetricsSource(ManualClock clock) {
+ super("test-manual-clock");
+ this.clock = clock;
+ }
+
+ @Override
+ protected Reservoir newReservoir() {
+ return new ExponentiallyDecayingReservoir(1028, 0.015, clock);
+ }
+ }
+
+ @Test
+ void slidingTimeWindowReservoirZeroesOutAfterIdlePeriod() {
+ ManualClock clock = new ManualClock();
+ Histogram histogram =
+ new Histogram(new SlidingTimeWindowArrayReservoir(60,
TimeUnit.SECONDS, clock));
+
+ for (int i = 1; i <= 10; i++) {
+ histogram.update(i * 100L);
+ }
+ Assertions.assertTrue(histogram.getSnapshot().getMax() > 0);
+
+ // Simulate the endpoint going quiet for longer than the 60-second window.
+ clock.advance(61, TimeUnit.SECONDS);
+
+ Assertions.assertEquals(10, histogram.getCount(), "count must survive the
idle period");
+ Assertions.assertEquals(
+ 0,
+ histogram.getSnapshot().getMax(),
+ "this is the bug: the old reservoir silently zeroes out duration stats
once idle");
+ }
+
+ @Test
+ void exponentiallyDecayingReservoirSurvivesShortIdlePeriod() {
+ ManualClock clock = new ManualClock();
+ Histogram histogram = new Histogram(new
ExponentiallyDecayingReservoir(1028, 0.015, clock));
+
+ for (int i = 1; i <= 10; i++) {
+ histogram.update(i * 100L);
+ }
+ Assertions.assertTrue(histogram.getSnapshot().getMax() > 0);
+
+ // Same idle period the old reservoir already failed at; the new one must
not go to zero here.
+ clock.advance(61, TimeUnit.SECONDS);
+
+ Assertions.assertEquals(10, histogram.getCount());
+ Assertions.assertTrue(
+ histogram.getSnapshot().getMax() > 0,
+ "ExponentiallyDecayingReservoir must keep reporting real duration data
after a short "
+ + "idle period instead of collapsing to zero");
+ }
+
+ @Test
+ void metricsSourceTimerUsesExponentiallyDecayingReservoirByDefault() {
+ MetricsSource metricsSource = new ManualClockMetricsSource(new
ManualClock());
+ Timer timer = metricsSource.getTimer("op.total");
+ timer.update(100, TimeUnit.MILLISECONDS);
+
+ // Exercised through the real production method, not a
directly-constructed reservoir, so this
+ // would fail if MetricsSource ever reverted to
SlidingTimeWindowArrayReservoir.
+ Assertions.assertTrue(timer.getSnapshot().getMax() > 0);
+ }
+
+ @Test
+ void metricsSourceTimerEventuallyZeroesOutAfterVeryLongIdlePeriod() {
+ ManualClock clock = new ManualClock();
+ MetricsSource metricsSource = new ManualClockMetricsSource(clock);
+ Timer timer = metricsSource.getTimer("op.total");
+
+ for (int i = 1; i <= 10; i++) {
+ timer.update(i * 100L, TimeUnit.MILLISECONDS);
+ }
+ Assertions.assertTrue(timer.getSnapshot().getMax() > 0);
+
+ // Advance in increments, calling getSnapshot() along the way, to mirror
periodic Prometheus
+ // scraping every 30s during the idle period rather than a single large
jump.
+ for (int i = 0; i < 1680; i++) { // 1680 * 30s = 14h
+ clock.advance(30, TimeUnit.SECONDS);
+ timer.getSnapshot();
+ }
+
+ Assertions.assertEquals(10, timer.getCount(), "count must still survive
the idle period");
+ Assertions.assertEquals(
+ 0,
+ timer.getSnapshot().getMax(),
+ "known limitation: ExponentiallyDecayingReservoir's rescale() clears
every sample once "
+ + "its decayed weight underflows to zero, so a sufficiently long
idle period (roughly "
+ + "half a day with the default decay rate) still reproduces the
original bug");
+ }
+}
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index bc05401fc2..8b0725382f 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -248,9 +248,9 @@ line with catalog count, plugin count, and query
concurrency: `-Xms4g -Xmx4g
#### Metrics
-| Configuration Item | Description
| Default Value |
-|-------------------------------------------|------------------------------------------------------|---------------|
-| `gravitino.metrics.timeSlidingWindowSecs` | Width in seconds of the metrics
time sliding window. | `60` |
+| Configuration Item | Description
| Default Value |
+|-------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------|
+| `gravitino.metrics.timeSlidingWindowSecs` | Deprecated, no longer used.
Duration timers and histograms now use an exponentially-decaying reservoir
instead of a fixed time window, so infrequently-invoked operations keep
reporting a real duration for far longer (on the order of half a day) instead
of reading zero after 60 seconds of inactivity. Operations idle longer than
that will still eventually report a duration of zero. | `60` |
### Storing Metadata
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/web/HttpServerMetricsSource.java
b/server-common/src/main/java/org/apache/gravitino/server/web/HttpServerMetricsSource.java
index 437cb30090..386f5b9bdc 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/web/HttpServerMetricsSource.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/web/HttpServerMetricsSource.java
@@ -20,9 +20,8 @@
package org.apache.gravitino.server.web;
import com.codahale.metrics.Clock;
-import com.codahale.metrics.SlidingTimeWindowArrayReservoir;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
import
com.codahale.metrics.jersey2.InstrumentedResourceMethodApplicationListener;
-import java.util.concurrent.TimeUnit;
import org.apache.gravitino.metrics.MetricNames;
import org.apache.gravitino.metrics.source.MetricsSource;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
@@ -34,12 +33,7 @@ public class HttpServerMetricsSource extends MetricsSource {
super(name);
resourceConfig.register(
new InstrumentedResourceMethodApplicationListener(
- getMetricRegistry(),
- Clock.defaultClock(),
- false,
- () ->
- new SlidingTimeWindowArrayReservoir(
- getTimeSlidingWindowSeconds(), TimeUnit.SECONDS)));
+ getMetricRegistry(), Clock.defaultClock(), false,
ExponentiallyDecayingReservoir::new));
// Register QueuedThreadPool specific metrics with instance checks
ThreadPool threadPool = server.getThreadPool();
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpServerMetricsSource.java
b/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpServerMetricsSource.java
index 7ed99b8219..8b36a7336a 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpServerMetricsSource.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/web/TestHttpServerMetricsSource.java
@@ -20,6 +20,7 @@
package org.apache.gravitino.server.web;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -27,9 +28,13 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import com.codahale.metrics.ExponentiallyDecayingReservoir;
import com.codahale.metrics.Gauge;
import com.codahale.metrics.MetricRegistry;
+import com.codahale.metrics.Reservoir;
import
com.codahale.metrics.jersey2.InstrumentedResourceMethodApplicationListener;
+import java.lang.reflect.Field;
+import java.util.function.Supplier;
import org.apache.gravitino.metrics.MetricNames;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
import org.eclipse.jetty.util.thread.ThreadPool;
@@ -203,6 +208,32 @@ public class TestHttpServerMetricsSource {
assertNotNull(listener);
}
+ @Test
+ void testReservoirSupplierProducesExponentiallyDecayingReservoir() throws
Exception {
+ // Regression guard: HttpServerMetricsSource wires an
ExponentiallyDecayingReservoir supplier
+ // into the Jersey listener so infrequently-called endpoints don't report
a zero duration.
+ // This reaches into the listener's private field because
dropwizard-metrics-jersey2 exposes
+ // no getter for it; it would fail if the supplier ever reverted to a
+ // SlidingTimeWindowArrayReservoir (or any other reservoir type).
+ QueuedThreadPool realQueuedThreadPool = new QueuedThreadPool(10, 5, 60000);
+ when(mockJettyServer.getThreadPool()).thenReturn(realQueuedThreadPool);
+
+ metricsSource = new HttpServerMetricsSource("test-server",
mockResourceConfig, mockJettyServer);
+
+ ArgumentCaptor<InstrumentedResourceMethodApplicationListener> captor =
+
ArgumentCaptor.forClass(InstrumentedResourceMethodApplicationListener.class);
+ verify(mockResourceConfig).register(captor.capture());
+
+ Field reservoirSupplierField =
+
InstrumentedResourceMethodApplicationListener.class.getDeclaredField("reservoirSupplier");
+ reservoirSupplierField.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ Supplier<Reservoir> reservoirSupplier =
+ (Supplier<Reservoir>) reservoirSupplierField.get(captor.getValue());
+
+ assertInstanceOf(ExponentiallyDecayingReservoir.class,
reservoirSupplier.get());
+ }
+
@Test
void testMetricsSourceName() {
// Arrange