This is an automated email from the ASF dual-hosted git repository.
anmolnar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zookeeper.git
The following commit(s) were added to refs/heads/master by this push:
new 6925dea5f ZOOKEEPER-4767: New implementation of prometheus quantile
metrics based on DataSketches
6925dea5f is described below
commit 6925dea5fd50c6b3fe1e0e6064216939c041c13e
Author: Yike Xiao <[email protected]>
AuthorDate: Wed Jul 8 22:00:54 2026 +0800
ZOOKEEPER-4767: New implementation of prometheus quantile metrics based on
DataSketches
Reviewers: tisonkun, anmolnar
Author: Shawyeok
Closes #2086 from Shawyeok/sketches-summary
---
.../zookeeper-prometheus-metrics/pom.xml | 6 +
.../prometheus/PrometheusMetricsProvider.java | 141 ++++++++--
.../metrics/prometheus/SketchesSummary.java | 295 +++++++++++++++++++++
.../prometheus/PrometheusMetricsProviderTest.java | 31 +--
.../PrometheusMetricsSummaryRotationTest.java | 91 +++++++
.../metrics/prometheus/SketchesSummaryTest.java | 90 +++++++
.../administrators-guide/administration.mdx | 19 +-
7 files changed, 616 insertions(+), 57 deletions(-)
diff --git a/zookeeper-metrics-providers/zookeeper-prometheus-metrics/pom.xml
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/pom.xml
index 9cdb235c8..8350115e6 100644
--- a/zookeeper-metrics-providers/zookeeper-prometheus-metrics/pom.xml
+++ b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/pom.xml
@@ -34,6 +34,7 @@
<properties>
<prometheus.version>1.3.10</prometheus.version>
<jetty.version>9.4.58.v20250814</jetty.version>
+ <datasketches.version>7.0.1</datasketches.version>
</properties>
<dependencies>
<dependency>
@@ -75,6 +76,11 @@
<artifactId>prometheus-metrics-config</artifactId>
<version>${prometheus.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.datasketches</groupId>
+ <artifactId>datasketches-java</artifactId>
+ <version>${datasketches.version}</version>
+ </dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-server</artifactId>
diff --git
a/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/main/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsProvider.java
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/main/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsProvider.java
index 001b7f25d..2ce0273d7 100644
---
a/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/main/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsProvider.java
+++
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/main/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsProvider.java
@@ -31,6 +31,11 @@
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
@@ -81,6 +86,9 @@ public class PrometheusMetricsProvider implements
MetricsProvider {
private Server server;
private int numWorkerThreads;
+ private long workerShutdownTimeoutMs = 1000;
+ private int summaryRotateSeconds = 60;
+ private ScheduledExecutorService summaryRotateExecutor;
private String host;
// SSL Configuration fields
@@ -101,7 +109,27 @@ public class PrometheusMetricsProvider implements
MetricsProvider {
public static final String HTTP_PORT = "httpPort";
public static final String EXPORT_JVM_INFO = "exportJvmInfo";
public static final String HTTPS_PORT = "httpsPort";
+ /**
+ * @deprecated DataSketches-based summaries are lock-free per-thread and
no longer require
+ * worker threads. This property is ignored. See ZOOKEEPER-4741.
+ */
+ @Deprecated
public static final String NUM_WORKER_THREADS = "numWorkerThreads";
+ /**
+ * @deprecated DataSketches-based summaries no longer use a bounded worker
queue. This property
+ * is ignored. See ZOOKEEPER-4741.
+ */
+ @Deprecated
+ public static final String MAX_QUEUE_SIZE = "maxQueueSize";
+ /** Timeout in ms for shutting down the summary rotation executor. */
+ public static final String WORKER_SHUTDOWN_TIMEOUT_MS =
"workerShutdownTimeoutMs";
+ /**
+ * Interval in seconds for rotating per-thread DataSketches into the
aggregated result that is
+ * exposed via {@code /metrics}. Quantiles from observations in the
current interval become
+ * visible after the next rotation. Default is 60 seconds.
+ */
+ public static final String PROMETHEUS_SUMMARY_ROTATE_INTERVAL_SECONDS =
+ "prometheusMetricsSummaryRotateIntervalSeconds";
public static final String SSL_KEYSTORE_LOCATION = "ssl.keyStore.location";
public static final String SSL_KEYSTORE_PASSWORD = "ssl.keyStore.password";
public static final String SSL_KEYSTORE_TYPE = "ssl.keyStore.type";
@@ -144,6 +172,14 @@ public void configure(Properties configuration) throws
MetricsProviderLifeCycleE
this.httpsPort =
Integer.parseInt(configuration.getProperty(HTTPS_PORT, "-1"));
this.exportJvmInfo =
Boolean.parseBoolean(configuration.getProperty(EXPORT_JVM_INFO, "true"));
this.numWorkerThreads =
Integer.parseInt(configuration.getProperty(NUM_WORKER_THREADS, "10"));
+ if (configuration.containsKey(NUM_WORKER_THREADS) ||
configuration.containsKey(MAX_QUEUE_SIZE)) {
+ LOG.warn("The configuration {} and {} are deprecated and ignored.
See ZOOKEEPER-4741.",
+ NUM_WORKER_THREADS, MAX_QUEUE_SIZE);
+ }
+ this.workerShutdownTimeoutMs =
+
Long.parseLong(configuration.getProperty(WORKER_SHUTDOWN_TIMEOUT_MS, "1000"));
+ this.summaryRotateSeconds =
+
Integer.parseInt(configuration.getProperty(PROMETHEUS_SUMMARY_ROTATE_INTERVAL_SECONDS,
"60"));
// If httpsPort is specified, parse all SSL properties
if (this.httpsPort != -1) {
@@ -244,6 +280,7 @@ public void start() throws
MetricsProviderLifeCycleException {
LOG.info("Prometheus metrics provider with Jetty started. HTTP
port: {}, HTTPS port: {}",
httpPort != -1 ? httpPort : "disabled", httpsPort != -1 ?
httpsPort : "disabled");
+ startSummaryRotateTask();
} catch (Exception e) {
LOG.error("Failed to start Prometheus Jetty server", e);
// Ensure server is stopped on startup failure
@@ -252,6 +289,48 @@ public void start() throws
MetricsProviderLifeCycleException {
}
}
+ private void startSummaryRotateTask() {
+ summaryRotateExecutor = new ScheduledThreadPoolExecutor(1, new
SummaryRotateThreadFactory());
+ summaryRotateExecutor.scheduleAtFixedRate(() -> {
+ try {
+ rootContext.rotateAllSummaries();
+ } catch (Exception err) {
+ LOG.error("Cannot rotate Prometheus summaries", err);
+ }
+ }, summaryRotateSeconds, summaryRotateSeconds, TimeUnit.SECONDS);
+ }
+
+ private void shutdownSummaryRotateExecutor() {
+ if (summaryRotateExecutor == null) {
+ return;
+ }
+ LOG.info("Shutting down Prometheus summary rotate executor with
timeout {}ms", workerShutdownTimeoutMs);
+ summaryRotateExecutor.shutdown();
+ try {
+ if
(!summaryRotateExecutor.awaitTermination(workerShutdownTimeoutMs,
TimeUnit.MILLISECONDS)) {
+ LOG.warn("Summary rotate executor did not terminate in {}ms;
forcing shutdown",
+ workerShutdownTimeoutMs);
+ summaryRotateExecutor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ summaryRotateExecutor.shutdownNow();
+ } finally {
+ summaryRotateExecutor = null;
+ }
+ }
+
+ private static class SummaryRotateThreadFactory implements ThreadFactory {
+ private static final AtomicInteger counter = new AtomicInteger(1);
+
+ @Override
+ public Thread newThread(Runnable runnable) {
+ Thread thread = new Thread(runnable, "PrometheusSummaryRotate-" +
counter.getAndIncrement());
+ thread.setDaemon(true);
+ return thread;
+ }
+ }
+
private void setKeyStoreScanner(SslContextFactory.Server
sslContextFactory) {
KeyStoreScanner keystoreScanner = new
KeyStoreScanner(sslContextFactory);
keystoreScanner.setScanInterval(SCAN_INTERVAL);
@@ -335,6 +414,7 @@ private ServerConnector createSslConnector(Server server,
int acceptors, int sel
@Override
public void stop() {
+ shutdownSummaryRotateExecutor();
if (server != null) {
try {
LOG.info("Stopping Prometheus Jetty server.");
@@ -482,16 +562,12 @@ public void unregisterGaugeSet(final String name) {
unregisterGauge(name);
}
- private io.prometheus.metrics.core.metrics.Summary
createPrometheusSummary(String name, DetailLevel detailLevel,
- String... labelNames) {
- io.prometheus.metrics.core.metrics.Summary.Builder builder =
io.prometheus.metrics.core.metrics.Summary
- .builder().name(name).help(name + "
summary").quantile(0.5, 0.05); // Median
-
+ private SketchesSummary createSketchesSummary(String name, DetailLevel
detailLevel, String... labelNames) {
+ SketchesSummary.Builder builder = SketchesSummary.build(name, name
+ " summary")
+ .quantile(0.5); // Median
if (detailLevel == DetailLevel.ADVANCED) {
- builder.quantile(0.95, 0.05) // 95th percentile
- .quantile(0.99, 0.05); // 99th percentile
+ builder.quantile(0.95).quantile(0.99); // 95th and 99th
percentile
}
-
if (labelNames.length > 0) {
builder.labelNames(labelNames);
}
@@ -508,9 +584,7 @@ public Summary getSummary(String name, DetailLevel
detailLevel) {
throw new IllegalArgumentException(
"Already registered a summary as " + key + " with
a different detail level");
}
- io.prometheus.metrics.core.metrics.Summary prometheusSummary =
createPrometheusSummary(key,
- detailLevel);
- return new PrometheusSummaryWrapper(prometheusSummary);
+ return new PrometheusSummaryWrapper(createSketchesSummary(key,
detailLevel), key);
});
}
@@ -524,11 +598,16 @@ public SummarySet getSummarySet(String name, DetailLevel
detailLevel) {
throw new IllegalArgumentException(
"Already registered a summary set as " + key + "
with a different detail level");
}
- io.prometheus.metrics.core.metrics.Summary prometheusSummary =
createPrometheusSummary(key, detailLevel,
- LABEL);
- return new PrometheusLabelledSummaryWrapper(prometheusSummary);
+ return new
PrometheusLabelledSummaryWrapper(createSketchesSummary(key, detailLevel,
LABEL), key);
});
}
+
+ void rotateAllSummaries() {
+ basicSummaries.values().forEach(s -> s.inner.rotate());
+ advancedSummaries.values().forEach(s -> s.inner.rotate());
+ basicSummarySets.values().forEach(s -> s.inner.rotate());
+ advancedSummarySets.values().forEach(s -> s.inner.rotate());
+ }
}
// --- Wrapper classes to adapt Prometheus metrics to ZooKeeper's metric
interfaces ---
@@ -578,29 +657,43 @@ public void inc(String key) {
}
}
- private static class PrometheusSummaryWrapper implements Summary {
- private final io.prometheus.metrics.core.metrics.Summary
prometheusSummary;
+ static class PrometheusSummaryWrapper implements Summary {
+ // VisibleForTesting
+ final SketchesSummary inner;
+ private final String name;
- public
PrometheusSummaryWrapper(io.prometheus.metrics.core.metrics.Summary
prometheusSummary) {
- this.prometheusSummary = prometheusSummary;
+ PrometheusSummaryWrapper(SketchesSummary inner, String name) {
+ this.inner = inner;
+ this.name = name;
}
@Override
public void add(long value) {
- this.prometheusSummary.observe(value);
+ try {
+ inner.observe(value);
+ } catch (IllegalArgumentException err) {
+ LOG.error("invalid delta {} for metric {}", value, name, err);
+ }
}
}
- private static class PrometheusLabelledSummaryWrapper implements
SummarySet {
- private final io.prometheus.metrics.core.metrics.Summary
prometheusSummary;
+ static class PrometheusLabelledSummaryWrapper implements SummarySet {
+ // VisibleForTesting
+ final SketchesSummary inner;
+ private final String name;
- public
PrometheusLabelledSummaryWrapper(io.prometheus.metrics.core.metrics.Summary
prometheusSummary) {
- this.prometheusSummary = prometheusSummary;
+ PrometheusLabelledSummaryWrapper(SketchesSummary inner, String name) {
+ this.inner = inner;
+ this.name = name;
}
@Override
public void add(String key, long value) {
- this.prometheusSummary.labelValues(key).observe(value);
+ try {
+ inner.labels(key).observe(value);
+ } catch (IllegalArgumentException err) {
+ LOG.error("invalid value {} for metric {} with key {}", value,
name, key, err);
+ }
}
}
}
diff --git
a/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/main/java/org/apache/zookeeper/metrics/prometheus/SketchesSummary.java
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/main/java/org/apache/zookeeper/metrics/prometheus/SketchesSummary.java
new file mode 100644
index 000000000..c874f95fa
--- /dev/null
+++
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/main/java/org/apache/zookeeper/metrics/prometheus/SketchesSummary.java
@@ -0,0 +1,295 @@
+/*
+ * 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.zookeeper.metrics.prometheus;
+
+import io.prometheus.metrics.model.registry.Collector;
+import io.prometheus.metrics.model.registry.PrometheusRegistry;
+import io.prometheus.metrics.model.snapshots.Labels;
+import io.prometheus.metrics.model.snapshots.MetricMetadata;
+import io.prometheus.metrics.model.snapshots.MetricSnapshot;
+import io.prometheus.metrics.model.snapshots.Quantile;
+import io.prometheus.metrics.model.snapshots.Quantiles;
+import io.prometheus.metrics.model.snapshots.SummarySnapshot;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.DoubleAdder;
+import java.util.concurrent.locks.StampedLock;
+import org.apache.datasketches.quantiles.DoublesSketch;
+import org.apache.datasketches.quantiles.DoublesUnion;
+import org.apache.datasketches.quantiles.DoublesUnionBuilder;
+
+/**
+ * A DataSketches based Summary metric, aim for lock-free and low gc overhead.
+ *
+ * <p>Per-thread {@link DoublesSketch} instances accumulate observations
without contention. A
+ * periodic {@link #rotate()} aggregates them into a single result sketch that
is exposed via
+ * {@link #collect()}. Quantile values returned to Prometheus are based on the
most recently
+ * rotated result; before the first rotation they are {@link Double#NaN}.
+ */
+public class SketchesSummary implements Collector {
+
+ private final String name;
+ private final String help;
+ private final List<String> labelNames;
+ private final List<Double> quantiles;
+ private final ConcurrentMap<List<String>, Child> children = new
ConcurrentHashMap<>();
+ private final Child noLabelsChild;
+
+ private SketchesSummary(Builder b) {
+ this.name = b.name;
+ this.help = b.help == null ? b.name : b.help;
+ this.labelNames = Collections.unmodifiableList(new
ArrayList<>(b.labelNames));
+ this.quantiles = Collections.unmodifiableList(new
ArrayList<>(b.quantiles));
+ if (this.labelNames.isEmpty()) {
+ this.noLabelsChild = new Child(this.quantiles);
+ this.children.put(Collections.<String>emptyList(),
this.noLabelsChild);
+ } else {
+ this.noLabelsChild = null;
+ }
+ }
+
+ /** Observe a value on the no-labels child. */
+ public void observe(double amt) {
+ if (noLabelsChild == null) {
+ throw new IllegalStateException("Summary has labels; use
labels(...).observe()");
+ }
+ noLabelsChild.observe(amt);
+ }
+
+ /** Get or create the child for the given label values. */
+ public Child labels(String... values) {
+ if (values.length != labelNames.size()) {
+ throw new IllegalArgumentException(
+ "Incorrect number of label values: expected " +
labelNames.size() + " got " + values.length);
+ }
+ List<String> key =
Collections.unmodifiableList(Arrays.asList(values.clone()));
+ Child existing = children.get(key);
+ if (existing != null) {
+ return existing;
+ }
+ return children.computeIfAbsent(key, k -> new Child(quantiles));
+ }
+
+ /**
+ * Aggregate per-thread sketches across all children into their result
sketches. Must be called
+ * periodically (the {@link PrometheusMetricsProvider} schedules this) so
that quantile values
+ * become visible to scrapers.
+ */
+ public void rotate() {
+ for (Child child : children.values()) {
+ child.rotate();
+ }
+ }
+
+ /** Register this collector and return it (fluent style). */
+ public SketchesSummary register(PrometheusRegistry registry) {
+ registry.register(this);
+ return this;
+ }
+
+ @Override
+ public MetricSnapshot collect() {
+ List<SummarySnapshot.SummaryDataPointSnapshot> dataPoints = new
ArrayList<>(children.size());
+ String[] labelNamesArray = labelNames.toArray(new String[0]);
+ for (Map.Entry<List<String>, Child> entry : children.entrySet()) {
+ Child child = entry.getValue();
+ Child.Value value = child.get();
+ List<Quantile> quantileList = new
ArrayList<>(value.quantiles.size());
+ for (Map.Entry<Double, Double> q : value.quantiles.entrySet()) {
+ quantileList.add(new Quantile(q.getKey(), q.getValue()));
+ }
+ Labels labels = labelNames.isEmpty()
+ ? Labels.EMPTY
+ : Labels.of(labelNamesArray, entry.getKey().toArray(new
String[0]));
+ dataPoints.add(SummarySnapshot.SummaryDataPointSnapshot.builder()
+ .count((long) value.count)
+ .sum(value.sum)
+ .quantiles(Quantiles.of(quantileList))
+ .labels(labels)
+ .build());
+ }
+ return new SummarySnapshot(new MetricMetadata(name, help), dataPoints);
+ }
+
+ @Override
+ public String getPrometheusName() {
+ return name;
+ }
+
+ public static Builder build(String name, String help) {
+ return new Builder().name(name).help(help);
+ }
+
+ public static Builder build() {
+ return new Builder();
+ }
+
+ public static class Builder {
+ private String name;
+ private String help;
+ private final List<String> labelNames = new ArrayList<>();
+ private final List<Double> quantiles = new ArrayList<>();
+
+ public Builder name(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public Builder help(String help) {
+ this.help = help;
+ return this;
+ }
+
+ public Builder labelNames(String... labelNames) {
+ for (String label : labelNames) {
+ if ("quantile".equals(label)) {
+ throw new IllegalArgumentException("Summary cannot have a
label named 'quantile'.");
+ }
+ this.labelNames.add(label);
+ }
+ return this;
+ }
+
+ public Builder quantile(double quantile) {
+ if (quantile < 0.0 || quantile > 1.0) {
+ throw new IllegalArgumentException(
+ "Quantile " + quantile + " invalid: Expected number
between 0.0 and 1.0.");
+ }
+ quantiles.add(quantile);
+ return this;
+ }
+
+ public SketchesSummary create() {
+ if (name == null) {
+ throw new IllegalStateException("SketchesSummary name must be
set");
+ }
+ return new SketchesSummary(this);
+ }
+
+ public SketchesSummary register(PrometheusRegistry registry) {
+ return create().register(registry);
+ }
+ }
+
+ /** A single child (per label-values combination). */
+ public static class Child {
+
+ public static class Value {
+ public final double count;
+ public final double sum;
+ public final SortedMap<Double, Double> quantiles;
+
+ private Value(double count, double sum, List<Double> quantiles,
DoublesSketch doublesSketch) {
+ this.count = count;
+ this.sum = sum;
+ this.quantiles =
Collections.unmodifiableSortedMap(snapshot(quantiles, doublesSketch));
+ }
+
+ private SortedMap<Double, Double> snapshot(List<Double> quantiles,
DoublesSketch doublesSketch) {
+ SortedMap<Double, Double> result = new TreeMap<>();
+ for (Double q : quantiles) {
+ result.put(q, doublesSketch != null &&
!doublesSketch.isEmpty()
+ ? doublesSketch.getQuantile(q) : Double.NaN);
+ }
+ return result;
+ }
+ }
+
+ // Having these separate leaves us open to races, however Prometheus
as a whole has other
+ // races that mean adding atomicity here wouldn't be useful. This
should be reevaluated in
+ // the future.
+ private final DoubleAdder count = new DoubleAdder();
+ private final DoubleAdder sum = new DoubleAdder();
+ private final List<Double> quantiles;
+ /*
+ * Use 2 rotating thread local accessors so that we can safely swap
them.
+ */
+ private volatile ThreadLocalAccessor current;
+ private volatile ThreadLocalAccessor replacement;
+ private volatile DoublesSketch result;
+
+ private Child(List<Double> quantiles) {
+ this.quantiles = quantiles;
+ this.current = new ThreadLocalAccessor();
+ this.replacement = new ThreadLocalAccessor();
+ }
+
+ public void observe(double amt) {
+ count.add(1);
+ sum.add(amt);
+ LocalSketch localSketch = current.localData.get();
+ long stamp = localSketch.lock.readLock();
+ try {
+ localSketch.sketch.update(amt);
+ } finally {
+ localSketch.lock.unlockRead(stamp);
+ }
+ }
+
+ public void rotate() {
+ ThreadLocalAccessor swap = current;
+ current = replacement;
+ replacement = swap;
+ List<Thread> deadThreads = new ArrayList<>();
+ final DoublesUnion aggregated = new DoublesUnionBuilder().build();
+ swap.map.forEach((thread, localSketch) -> {
+ long stamp = localSketch.lock.writeLock();
+ try {
+ aggregated.union(localSketch.sketch);
+ localSketch.sketch.reset();
+ if (!thread.isAlive()) {
+ deadThreads.add(thread);
+ }
+ } finally {
+ localSketch.lock.unlockWrite(stamp);
+ }
+ });
+ for (Thread deadThread : deadThreads) {
+ swap.map.remove(deadThread);
+ current.map.remove(deadThread);
+ }
+ result = aggregated.getResultAndReset();
+ }
+
+ public Value get() {
+ return new Value(count.sum(), sum.sum(), quantiles, result);
+ }
+ }
+
+ private static class ThreadLocalAccessor {
+ private final Map<Thread, LocalSketch> map = new ConcurrentHashMap<>();
+ private final ThreadLocal<LocalSketch> localData =
ThreadLocal.withInitial(() -> {
+ LocalSketch sketch = new LocalSketch();
+ map.put(Thread.currentThread(), sketch);
+ return sketch;
+ });
+ }
+
+ private static class LocalSketch {
+ private final DoublesSketch sketch = DoublesSketch.builder().build();
+ private final StampedLock lock = new StampedLock();
+ }
+}
diff --git
a/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsProviderTest.java
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsProviderTest.java
index 2d36b0017..533468450 100644
---
a/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsProviderTest.java
+++
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsProviderTest.java
@@ -287,6 +287,8 @@ public void testBasicSummary() throws Exception {
Summary summary = provider.getRootContext().getSummary("cc",
MetricsContext.DetailLevel.BASIC);
summary.add(10);
summary.add(10);
+ // Force per-thread sketches to be aggregated into the result sketch.
+ ((PrometheusMetricsProvider.PrometheusSummaryWrapper)
summary).inner.rotate();
int[] count = { 0 };
provider.dump((k, v) -> {
count[0]++;
@@ -332,6 +334,7 @@ public void testAdvancedSummary() throws Exception {
Summary summary = provider.getRootContext().getSummary("cc",
MetricsContext.DetailLevel.ADVANCED);
summary.add(10);
summary.add(10);
+ ((PrometheusMetricsProvider.PrometheusSummaryWrapper)
summary).inner.rotate();
int[] count = { 0 };
provider.dump((k, v) -> {
count[0]++;
@@ -411,33 +414,6 @@ public void testTraceCall() throws ServletException,
IOException {
verify(response).sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
}
- @Test
- public void testSummary_asyncAndExceedMaxQueueSize() throws Exception {
- final Properties config = new Properties();
- config.setProperty("numWorkerThreads", "1");
- config.setProperty("maxQueueSize", "1");
- config.setProperty("httpPort", "0"); // ephemeral port
- config.setProperty("exportJvmInfo", "false");
-
- PrometheusMetricsProvider metricsProvider = null;
- try {
- metricsProvider = new PrometheusMetricsProvider();
- metricsProvider.configure(config);
- metricsProvider.start();
- final Summary summary =
metricsProvider.getRootContext().getSummary("cc",
- MetricsContext.DetailLevel.ADVANCED);
-
- // make sure no error is thrown
- for (int i = 0; i < 10; i++) {
- summary.add(10);
- }
- } finally {
- if (metricsProvider != null) {
- metricsProvider.stop();
- }
- }
- }
-
@Test
public void testSummarySet() throws Exception {
final String name = "ss";
@@ -451,6 +427,7 @@ public void testSummarySet() throws Exception {
for (int i = 0; i < count; i++) {
Arrays.asList(keys).forEach(key -> summarySet.add(key, 1));
}
+ ((PrometheusMetricsProvider.PrometheusLabelledSummaryWrapper)
summarySet).inner.rotate();
// validate with dump call
final Map<String, Number> expectedMetricsMap = new HashMap<>();
diff --git
a/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsSummaryRotationTest.java
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsSummaryRotationTest.java
new file mode 100644
index 000000000..6e6728e6d
--- /dev/null
+++
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/PrometheusMetricsSummaryRotationTest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.zookeeper.metrics.prometheus;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+import io.prometheus.metrics.model.registry.PrometheusRegistry;
+import java.util.Properties;
+import java.util.SortedMap;
+import java.util.concurrent.TimeUnit;
+import org.apache.zookeeper.metrics.MetricsContext;
+import org.apache.zookeeper.metrics.Summary;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.opentest4j.AssertionFailedError;
+
+/**
+ * Tests about PrometheusSummary, make sure sketches rotation task works as
expected.
+ */
+public class PrometheusMetricsSummaryRotationTest {
+
+ private PrometheusMetricsProvider provider;
+
+ private final int summaryRotateSeconds = 2;
+
+ @BeforeEach
+ public void setup() throws Exception {
+ PrometheusRegistry.defaultRegistry.clear();
+ provider = new PrometheusMetricsProvider();
+ Properties configuration = new Properties();
+ configuration.setProperty("httpHost", "127.0.0.1"); // local host for
test
+ configuration.setProperty("httpPort", "0"); // ephemeral port
+ configuration.setProperty("exportJvmInfo", "false");
+
configuration.setProperty(PrometheusMetricsProvider.PROMETHEUS_SUMMARY_ROTATE_INTERVAL_SECONDS,
+ String.valueOf(summaryRotateSeconds));
+ provider.configure(configuration);
+ provider.start();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ if (provider != null) {
+ provider.stop();
+ }
+ PrometheusRegistry.defaultRegistry.clear();
+ }
+
+ @Test
+ void testSummaryRotation() throws InterruptedException {
+ Summary summary = provider.getRootContext().getSummary("rotation_" +
System.currentTimeMillis(),
+ MetricsContext.DetailLevel.ADVANCED);
+ for (int i = 0; i < 10; i++) {
+ summary.add(10);
+ }
+ SketchesSummary innerSummary =
+ ((PrometheusMetricsProvider.PrometheusSummaryWrapper)
summary).inner;
+ boolean assertionFailed = true;
+ int timeout = summaryRotateSeconds + 1;
+ for (int i = 0; i < timeout && assertionFailed; i++) {
+ SortedMap<Double, Double> quantiles =
innerSummary.labels().get().quantiles;
+ try {
+ assertEquals(10, quantiles.get(0.5));
+ assertEquals(10, quantiles.get(0.95));
+ assertEquals(10, quantiles.get(0.99));
+ assertionFailed = false;
+ } catch (AssertionFailedError e) {
+ TimeUnit.SECONDS.sleep(1);
+ }
+ }
+ if (assertionFailed) {
+ fail("Quantiles not updated after " + timeout + "seconds, it is
likely that the summary is not rotated.");
+ }
+ }
+}
diff --git
a/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/SketchesSummaryTest.java
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/SketchesSummaryTest.java
new file mode 100644
index 000000000..fbbd8a1c4
--- /dev/null
+++
b/zookeeper-metrics-providers/zookeeper-prometheus-metrics/src/test/java/org/apache/zookeeper/metrics/prometheus/SketchesSummaryTest.java
@@ -0,0 +1,90 @@
+/*
+ * 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.zookeeper.metrics.prometheus;
+
+import static java.util.Arrays.binarySearch;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.Random;
+import java.util.SortedMap;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests about SketchesSummary, make sure the quantile error is within the
expected range.
+ */
+public class SketchesSummaryTest {
+
+ @Test
+ void testQuantileError() {
+ SketchesSummary summary = SketchesSummary.build("test", "test help")
+ .quantile(0.5)
+ .quantile(0.9)
+ .quantile(0.99)
+ .create();
+ Random random = new Random();
+ int[] samples = new int[1_000];
+ double sum = 0;
+ for (int i = 0; i < samples.length; i++) {
+ int sample = random.nextInt(1_000_000) + 1;
+ summary.observe(sample);
+ sum += sample;
+ samples[i] = sample;
+ }
+ Arrays.sort(samples);
+ summary.rotate();
+ SketchesSummary.Child.Value value = summary.labels().get();
+ assertEquals(samples.length, value.count);
+ assertEquals(sum, value.sum);
+ // The default k of DoublesSketches is 128, rank error of about 1.7%.
+ // See more: org.apache.datasketches.quantiles.DoublesSketch
+ assertQuantileError(samples, value.quantiles, 0.017);
+ }
+
+ @Test
+ public void testEmptySketch() {
+ SketchesSummary summary = SketchesSummary.build("testEmptySketch",
"test help")
+ .quantile(0.5)
+ .quantile(0.9)
+ .quantile(0.99)
+ .create();
+ summary.observe(10);
+ summary.rotate();
+ summary.observe(10);
+ summary.rotate();
+ summary.rotate();
+ for (Double quantile : summary.labels().get().quantiles.values()) {
+ assertTrue(Double.isNaN(quantile));
+ }
+ }
+
+ private static void assertQuantileError(int[] samples, SortedMap<Double,
Double> quantiles, double delta) {
+ for (Map.Entry<Double, Double> entry : quantiles.entrySet()) {
+ double quantile = entry.getKey();
+ int expected = (int) (samples.length * quantile - 1);
+ int actual = binarySearch(samples, entry.getValue().intValue());
+ if (Math.abs(expected - actual) > samples.length * delta) {
+ Assertions.fail(String.format("expected error delta: %s,
actual: %s abs(%s-%s)",
+ delta, Math.abs(expected - actual), expected, actual));
+ }
+ }
+ }
+}
diff --git
a/zookeeper-website/app/pages/_docs/docs/_mdx/admin-ops/administrators-guide/administration.mdx
b/zookeeper-website/app/pages/_docs/docs/_mdx/admin-ops/administrators-guide/administration.mdx
index 612ec6079..bdcc3bd6c 100644
---
a/zookeeper-website/app/pages/_docs/docs/_mdx/admin-ops/administrators-guide/administration.mdx
+++
b/zookeeper-website/app/pages/_docs/docs/_mdx/admin-ops/administrators-guide/administration.mdx
@@ -163,17 +163,18 @@ Since 3.6.0 ZooKeeper binary package bundles an
integration with [Prometheus.io]
If this property is set to **true** Prometheus.io will export useful metrics
about the JVM.
The default is true.
- _metricsProvider.numWorkerThreads_ :
- **New in 3.7.1:**
+ **New in 3.7.1**, **deprecated in 3.10.0:**
Number of worker threads for reporting Prometheus summary metrics.
- Default value is 1.
- If the number is less than 1, the main thread will be used.
+ This property is ignored since ZOOKEEPER-4741; the DataSketches-based summary
+ implementation is lock-free per-thread and no longer requires a worker pool.
- _metricsProvider.maxQueueSize_ :
- **New in 3.7.1:**
+ **New in 3.7.1**, **deprecated in 3.10.0:**
The max queue size for Prometheus summary metrics reporting task.
- Default value is 10000.
+ This property is ignored since ZOOKEEPER-4741; the DataSketches-based summary
+ implementation does not use a bounded worker queue.
- _metricsProvider.workerShutdownTimeoutMs_ :
**New in 3.7.1:**
- The timeout in ms for Prometheus worker threads shutdown.
+ The timeout in ms for shutting down the Prometheus summary rotation executor.
Default value is 1000ms.
- _metricsProvider.ssl.keyStore.location_ and
_metricsProvider.ssl.keyStore.password_ :
**New in 3.10.0:**
@@ -207,3 +208,9 @@ Since 3.6.0 ZooKeeper binary package bundles an integration
with [Prometheus.io]
**New in 3.10.0:**
The enabled protocols to be used in TLS negotiation for
PrometheusMetricsProvider.
Default value is the Jetty default.
+- _metricsProvider.prometheusMetricsSummaryRotateIntervalSeconds_ :
+ **New in 3.10.0:**
+ Interval in seconds for rotating per-thread DataSketches into the aggregated
+ result exposed via `/metrics`. Quantiles from observations in the current
+ interval become visible after the next rotation.
+ Default value is 60 seconds.