[fix] bound what one anonymous push request can consume (#4273)

This commit is contained in:
Duansg
2026-08-14 10:41:02 +08:00
committed by GitHub
parent 001e229236
commit cbb62a3358
11 changed files with 767 additions and 46 deletions
@@ -60,16 +60,37 @@ public class OnlineParser {
} }
public static Map<String, MetricFamily> parseMetrics(InputStream inputStream) throws IOException { public static Map<String, MetricFamily> parseMetrics(InputStream inputStream) throws IOException {
Map<String, MetricFamily> metricFamilyMap = new ConcurrentHashMap<>(10); return parseMetrics(inputStream, Integer.MAX_VALUE);
}
/**
* Parses at most {@code maxSamples} samples from the supplied stream.
*
* @param inputStream The Prometheus text stream
* @param maxSamples The maximum number of samples to materialize
* @return The parsed metric families, or {@code null} when the text format is invalid
* @throws IOException When the stream cannot be read
* @throws SampleLimitExceededException When another sample follows the configured limit
*/
public static Map<String, MetricFamily> parseMetrics(InputStream inputStream, int maxSamples) throws IOException {
if (maxSamples < 0) {
throw new IllegalArgumentException("maxSamples must not be negative");
}
final Map<String, MetricFamily> metricFamilyMap = new ConcurrentHashMap<>(10);
int sampleCount = 0;
try { try {
int i = getChar(inputStream); int i = getChar(inputStream);
while (i != -1) { while (i != -1) {
if (i == '#' || i == '\n') { if (i == '#' || i == '\n') {
skipToLineEnd(inputStream).maybeEol().maybeEof().noElse(); skipToLineEnd(inputStream).maybeEol().maybeEof().noElse();
} else { } else {
StringBuilder stringBuilder = new StringBuilder(); if (sampleCount >= maxSamples) {
throw new SampleLimitExceededException(maxSamples);
}
final StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append((char) i); stringBuilder.append((char) i);
parseMetric(inputStream, metricFamilyMap, stringBuilder); parseMetric(inputStream, metricFamilyMap, stringBuilder);
sampleCount++;
} }
i = getChar(inputStream); i = getChar(inputStream);
// To address the `\n\r` scenario, it is necessary to skip // To address the `\n\r` scenario, it is necessary to skip
@@ -84,6 +105,16 @@ public class OnlineParser {
return metricFamilyMap; return metricFamilyMap;
} }
/**
* Signals that parsing stopped before materializing a sample beyond the configured limit.
*/
public static final class SampleLimitExceededException extends IOException {
public SampleLimitExceededException(int limit) {
super("prometheus payload exceeds the " + limit + " sample limit");
}
}
/** /**
* Parses Prometheus metrics from the given {@link InputStream}, but only for the specified metric name. * Parses Prometheus metrics from the given {@link InputStream}, but only for the specified metric name.
* <p> * <p>
@@ -30,6 +30,8 @@ import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.fail;
class OnlineParserTest { class OnlineParserTest {
@@ -459,4 +461,27 @@ class OnlineParserTest {
assertEquals("run_as", metricFamily.getMetricList().get(0).getLabels().get(3).getName()); assertEquals("run_as", metricFamily.getMetricList().get(0).getLabels().get(3).getName());
assertEquals("NT AUTHORITY\nLocalService", metricFamily.getMetricList().get(0).getLabels().get(3).getValue()); assertEquals("NT AUTHORITY\nLocalService", metricFamily.getMetricList().get(0).getLabels().get(3).getValue());
} }
@Test
void testParseMetricsStopsBeforeSampleBeyondLimit() {
final String metrics = "metric_a 1\nmetric_b 2\nmetric_c 3\nmetric_d 4\n";
final ByteArrayInputStream inputStream =
new ByteArrayInputStream(metrics.getBytes(StandardCharsets.UTF_8));
assertThrows(OnlineParser.SampleLimitExceededException.class,
() -> OnlineParser.parseMetrics(inputStream, 2));
assertTrue(inputStream.available() > 0, "samples after the limit should remain unread");
}
@Test
void testParseMetricsAllowsExactlyTheSampleLimit() throws Exception {
final String metrics = "metric_a 1\nmetric_b 2\n";
final InputStream inputStream = new ByteArrayInputStream(metrics.getBytes(StandardCharsets.UTF_8));
final Map<String, MetricFamily> metricFamilyMap = OnlineParser.parseMetrics(inputStream, 2);
assertNotNull(metricFamilyMap);
assertEquals(2, metricFamilyMap.size());
}
} }
@@ -19,13 +19,19 @@
package org.apache.hertzbeat.push.service.impl; package org.apache.hertzbeat.push.service.impl;
import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.time.Instant; import java.time.Instant;
import java.util.HashMap;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors; import java.util.concurrent.atomic.AtomicInteger;
import jakarta.annotation.Nullable;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.collector.collect.prometheus.parser.MetricFamily; import org.apache.hertzbeat.collector.collect.prometheus.parser.MetricFamily;
@@ -37,6 +43,7 @@ import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator; import org.apache.hertzbeat.common.util.SnowFlakeIdGenerator;
import org.apache.hertzbeat.push.dao.PushMonitorDao; import org.apache.hertzbeat.push.dao.PushMonitorDao;
import org.apache.hertzbeat.push.service.PushGatewayService; import org.apache.hertzbeat.push.service.PushGatewayService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/** /**
@@ -46,26 +53,81 @@ import org.springframework.stereotype.Service;
@Slf4j @Slf4j
@Service @Service
public class PushGatewayServiceImpl implements PushGatewayService { public class PushGatewayServiceImpl implements PushGatewayService {
private static final byte PUSH_MONITOR_TYPE = (byte) 1;
private final CommonDataQueue commonDataQueue; private final CommonDataQueue commonDataQueue;
private final PushMonitorDao pushMonitorDao; private final PushMonitorDao pushMonitorDao;
private final Map<String, Long> jobInstanceMap; private final Map<JobInstance, Long> jobInstanceMap;
public PushGatewayServiceImpl(CommonDataQueue commonDataQueue, PushMonitorDao pushMonitorDao) { /**
* Cap on push monitors created automatically from unknown job/instance pairs.
*
* <p>The route is unauthenticated by design, and every new pair used to persist a
* monitor row and add a `jobInstanceMap` entry that is never removed, so a caller
* iterating over made up names could grow the database and the heap without bound.
* Above the cap an unknown pair is refused while the pairs already known keep working,
* which is why eviction is not used here: evicting a live entry would make the next
* push for that pair create a second monitor for the same job and instance.
*/
private final int maxAutoCreatedMonitors;
/**
* Cap on how many bytes a single push body may carry.
*
* <p>The parser materializes its result in memory, and the servlet container does not bound
* a non form request body, so an independent byte limit is still needed alongside the sample
* limit to keep long names and label values from exhausting the heap.
*/
private final long maxBodyBytes;
/**
* Cap on how many samples a single push body may carry. The parser stops before allocating
* a sample beyond this limit, so a compact body cannot create an unbounded object graph.
*/
private final int maxSamples;
/**
* One entry per pair whose monitor is being created, so that concurrent pushes naming the
* same unknown pair wait for one creation instead of each starting their own. Persistence
* stays outside any shared lock: a slow database would otherwise hold every request for an
* unknown pair, and each of those requests is already holding its parsed samples.
*/
private final Map<JobInstance, CompletableFuture<Long>> monitorCreationMap;
/**
* Successful and in flight creations together, so that the cap is claimed before the
* database write rather than counted after it.
*/
private final AtomicInteger trackedMonitorCount;
public PushGatewayServiceImpl(CommonDataQueue commonDataQueue, PushMonitorDao pushMonitorDao,
@Value("${hertzbeat.push.max-auto-created-monitors:10000}") int maxAutoCreatedMonitors,
@Value("${hertzbeat.push.max-body-bytes:5242880}") long maxBodyBytes,
@Value("${hertzbeat.push.max-samples:10000}") int maxSamples) {
if (maxAutoCreatedMonitors < 0 || maxBodyBytes < 0 || maxSamples < 0) {
throw new IllegalArgumentException("push gateway limits must not be negative");
}
this.commonDataQueue = commonDataQueue; this.commonDataQueue = commonDataQueue;
this.pushMonitorDao = pushMonitorDao; this.pushMonitorDao = pushMonitorDao;
this.maxAutoCreatedMonitors = maxAutoCreatedMonitors;
this.maxBodyBytes = maxBodyBytes;
this.maxSamples = maxSamples;
jobInstanceMap = new ConcurrentHashMap<>(); jobInstanceMap = new ConcurrentHashMap<>();
pushMonitorDao.findMonitorsByType((byte) 1).forEach(monitor -> pushMonitorDao.findMonitorsByType(PUSH_MONITOR_TYPE).forEach(monitor ->
jobInstanceMap.put(monitor.getApp() + "_" + monitor.getName(), monitor.getId())); jobInstanceMap.put(new JobInstance(monitor.getApp(), monitor.getName()), monitor.getId()));
monitorCreationMap = new ConcurrentHashMap<>();
trackedMonitorCount = new AtomicInteger(jobInstanceMap.size());
} }
@Override @Override
public boolean pushPrometheusMetrics(InputStream inputStream, String job, String instance) { public boolean pushPrometheusMetrics(InputStream inputStream, String job, String instance) {
try { try {
long curTime = Instant.now().toEpochMilli(); final long curTime = Instant.now().toEpochMilli();
Map<String, MetricFamily> metricFamilyMap = OnlineParser.parseMetrics(inputStream); final Map<String, MetricFamily> metricFamilyMap = OnlineParser.parseMetrics(
new BoundedInputStream(inputStream, maxBodyBytes), maxSamples);
if (metricFamilyMap == null) { if (metricFamilyMap == null) {
log.error("parse prometheus metrics is null, job: {}, instance: {}", job, instance); log.error("parse prometheus metrics is null, job: {}, instance: {}", job, instance);
return false; return false;
@@ -74,20 +136,11 @@ public class PushGatewayServiceImpl implements PushGatewayService {
if (job != null && instance != null) { if (job != null && instance != null) {
// auto create monitor when job and instance not null // auto create monitor when job and instance not null
// job is app, instance is the name // job is app, instance is the name
id = jobInstanceMap.computeIfAbsent(job + "_" + instance, key -> { final Long monitorId = resolveMonitorId(new JobInstance(job, instance));
log.info("auto create monitor by prometheus push, job: {}, instance: {}", job, instance); if (monitorId == null) {
long monitorId = SnowFlakeIdGenerator.generateId(); return false;
Monitor monitor = Monitor.builder() }
.id(monitorId) id = monitorId;
.app(job)
.name(instance)
.instance(instance)
.type((byte) 1)
.status(CommonConstants.MONITOR_UP_CODE)
.build();
this.pushMonitorDao.save(monitor);
return monitorId;
});
} }
for (Map.Entry<String, MetricFamily> entry : metricFamilyMap.entrySet()) { for (Map.Entry<String, MetricFamily> entry : metricFamilyMap.entrySet()) {
CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder(); CollectRep.MetricsData.Builder builder = CollectRep.MetricsData.newBuilder();
@@ -110,9 +163,18 @@ public class PushGatewayServiceImpl implements PushGatewayService {
builder.addField(CollectRep.Field.newBuilder().setName("value") builder.addField(CollectRep.Field.newBuilder().setName("value")
.setType(CommonConstants.TYPE_NUMBER).setLabel(false).build()); .setType(CommonConstants.TYPE_NUMBER).setLabel(false).build());
} }
Map<String, String> labelMap = metric.getLabels() // A repeated label name is refused rather than resolved: the exposition
.stream() // format requires the names of a label set to be unique, and keeping one
.collect(Collectors.toMap(MetricFamily.Label::getName, MetricFamily.Label::getValue)); // of the values would emit a schema carrying that name twice. Built by
// hand so the refusal is a rejection this method can answer with a
// warning, not the error trace a collector's exception would produce.
Map<String, String> labelMap = new HashMap<>(metric.getLabels().size());
for (MetricFamily.Label label : metric.getLabels()) {
if (labelMap.containsKey(label.getName())) {
throw new DuplicateLabelException(label.getName());
}
labelMap.put(label.getName(), label.getValue());
}
CollectRep.ValueRow.Builder valueRowBuilder = CollectRep.ValueRow.newBuilder(); CollectRep.ValueRow.Builder valueRowBuilder = CollectRep.ValueRow.newBuilder();
for (String field : metricsFields) { for (String field : metricsFields) {
String fieldValue = labelMap.get(field); String fieldValue = labelMap.get(field);
@@ -125,9 +187,210 @@ public class PushGatewayServiceImpl implements PushGatewayService {
} }
} }
return true; return true;
} catch (BodyTooLargeException e) {
// A rejection, not a failure: caught apart from the generic handler below so that a
// caller repeating oversized bodies costs one warning line each, not a stack trace
log.warn("reject prometheus push over the {} byte body limit, job: {}, instance: {}",
maxBodyBytes, job, instance);
return false;
} catch (OnlineParser.SampleLimitExceededException e) {
log.warn("reject prometheus push over the {} sample limit, job: {}, instance: {}",
maxSamples, job, instance);
return false;
} catch (DuplicateLabelException e) {
log.warn("reject prometheus push repeating a label name, job: {}, instance: {}: {}",
job, instance, e.getMessage());
return false;
} catch (Exception e) { } catch (Exception e) {
log.error("push prometheus metrics error", e); log.error("push prometheus metrics error", e);
return false; return false;
} }
} }
/**
* Returns the monitor id a job/instance pair resolves to, creating the monitor on first
* sight, or null once {@link #maxAutoCreatedMonitors} is reached.
*
* <p>Concurrent pushes naming the same unknown pair share one creation through a future, and
* the cap is claimed by an atomic count before the database write. Nothing here holds a lock
* across that write: unrelated pairs persist in parallel, and a slow database delays only the
* requests naming the pair being created, which matters because every waiting request is
* holding the samples it already parsed.
*
* @param pair Job and instance the push named
* @return The monitor id, or null when the cap leaves no room for a new one
*/
@Nullable
private Long resolveMonitorId(JobInstance pair) {
final Long known = jobInstanceMap.get(pair);
if (known != null) {
return known;
}
final CompletableFuture<Long> proposedCreation = new CompletableFuture<>();
final CompletableFuture<Long> ongoingCreation = monitorCreationMap.putIfAbsent(pair, proposedCreation);
if (ongoingCreation != null) {
try {
return ongoingCreation.join();
} catch (CompletionException e) {
// The request owning the creation reports the failure once; joining its exception
// here would multiply a single database error by every request that waited
return null;
}
}
try {
// Looked up again now that the creation is claimed: another request may have finished
// this pair between the lookup above and this claim, and going on to create it would
// leave two monitors for one pair and spend a second slot of the cap
final Long createdMeanwhile = jobInstanceMap.get(pair);
if (createdMeanwhile != null) {
proposedCreation.complete(createdMeanwhile);
return createdMeanwhile;
}
if (!reserveMonitorSlot()) {
proposedCreation.complete(null);
log.warn("reject prometheus push for unknown job: {}, instance: {}, "
+ "already tracking {} push monitors, limit is {}",
pair.job(), pair.instance(), trackedMonitorCount.get(), maxAutoCreatedMonitors);
return null;
}
boolean created = false;
try {
final long monitorId = createMonitor(pair);
jobInstanceMap.put(pair, monitorId);
created = true;
proposedCreation.complete(monitorId);
return monitorId;
} finally {
if (!created) {
trackedMonitorCount.decrementAndGet();
}
}
} catch (RuntimeException | Error e) {
proposedCreation.completeExceptionally(e);
throw e;
} finally {
monitorCreationMap.remove(pair, proposedCreation);
}
}
/**
* Claims one slot of the cap, or reports that none is left. Claiming before the database
* write is what keeps concurrent creations from exceeding the cap together.
*/
private boolean reserveMonitorSlot() {
int tracked = trackedMonitorCount.get();
while (tracked < maxAutoCreatedMonitors) {
if (trackedMonitorCount.compareAndSet(tracked, tracked + 1)) {
return true;
}
tracked = trackedMonitorCount.get();
}
return false;
}
/**
* Persists a push monitor after its slot of the cap has been claimed.
*/
private long createMonitor(JobInstance pair) {
final String job = pair.job();
final String instance = pair.instance();
log.info("auto create monitor by prometheus push, job: {}, instance: {}", job, instance);
final long monitorId = SnowFlakeIdGenerator.generateId();
final Monitor monitor = Monitor.builder()
.id(monitorId)
.app(job)
.name(instance)
.instance(instance)
.type(PUSH_MONITOR_TYPE)
.status(CommonConstants.MONITOR_UP_CODE)
.build();
this.pushMonitorDao.save(monitor);
return monitorId;
}
/**
* Identifies the monitor a push belongs to.
*
* <p>The two names are kept apart instead of being joined into one string: a separator
* carries no meaning in either name, so `job + "_" + instance` maps ("a", "b_c") and
* ("a_b", "c") onto the same key. Colliding pairs would push their samples into whichever
* monitor was created first, and at startup they would collapse into a single map entry,
* making the cap count fewer monitors than the database actually holds.
*/
private record JobInstance(String job, String instance) {
}
/**
* Raised when a sample repeats a label name, which the exposition format does not allow.
* Kept apart from the generic handler so a malformed body costs one warning line rather
* than an error trace on a route that takes its input from anyone.
*/
static final class DuplicateLabelException extends IOException {
DuplicateLabelException(String name) {
super("sample repeats the label name " + name);
}
}
/**
* Raised when a body goes past {@link #maxBodyBytes}. It is kept apart from the other read
* failures so the caller can answer a body that is merely too large without an error trace.
*/
static final class BodyTooLargeException extends IOException {
BodyTooLargeException(long limit) {
super("push body exceeds the " + limit + " byte limit");
}
}
/**
* Fails the read once the body has delivered more than {@code limit} bytes, instead of
* letting the parser accumulate an unbounded body in memory. Reading stops at the
* failure, so the bytes beyond the limit are never buffered.
*/
static final class BoundedInputStream extends InputStream {
private final InputStream delegate;
private final long limit;
private long bytesRead;
BoundedInputStream(InputStream delegate, long limit) {
this.delegate = delegate;
this.limit = limit;
}
@Override
public int read() throws IOException {
final int value = delegate.read();
if (value != -1) {
recordBytesRead(1);
}
return value;
}
@Override
public int read(byte[] buffer, int offset, int length) throws IOException {
final int bytesReadNow = delegate.read(buffer, offset, length);
if (bytesReadNow > 0) {
recordBytesRead(bytesReadNow);
}
return bytesReadNow;
}
private void recordBytesRead(int increment) throws IOException {
bytesRead += increment;
if (bytesRead > limit) {
throw new BodyTooLargeException(limit);
}
}
@Override
public void close() throws IOException {
delegate.close();
}
}
} }
@@ -0,0 +1,367 @@
/*
* 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.hertzbeat.push.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.hertzbeat.common.entity.manager.Monitor;
import org.apache.hertzbeat.common.entity.message.CollectRep;
import org.apache.hertzbeat.common.queue.CommonDataQueue;
import org.apache.hertzbeat.push.dao.PushMonitorDao;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
/**
* Test case for {@link PushGatewayServiceImpl}.
*
* <p>`/api/push/prometheus/**` is unauthenticated by design, so the resource a single
* anonymous request may consume has to be bounded: the body it may carry, the samples it
* may enqueue, and the number of push monitors it may bring into existence.
*/
@ExtendWith(MockitoExtension.class)
class PushGatewayServiceImplTest {
private static final String BODY = "sample_metric{label=\"a\"} 1\n";
@Mock
private CommonDataQueue commonDataQueue;
@Mock
private PushMonitorDao pushMonitorDao;
@BeforeEach
void setUp() {
// The stream test below builds no service, so this default must not be strict
lenient().when(pushMonitorDao.findMonitorsByType((byte) 1)).thenReturn(List.of());
}
private PushGatewayServiceImpl createService(int maxMonitors, long maxBodyBytes, int maxSamples) {
return new PushGatewayServiceImpl(commonDataQueue, pushMonitorDao, maxMonitors, maxBodyBytes, maxSamples);
}
private static ByteArrayInputStream createBody(String content) {
return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
}
/**
* The exposition format requires the names of a label set to be unique, so a sample that
* repeats one is refused rather than resolved. It stays a rejection though: answering it
* with an error trace would let a malformed body fill the log on an anonymous route.
*/
@Test
void testSampleRepeatingTheLabelNameIsRejected() {
final PushGatewayServiceImpl service = createService(10, 1024, 100);
assertFalse(service.pushPrometheusMetrics(
createBody("sample_metric{label=\"a\",label=\"b\"} 1\n"), "job1", "instance1"));
verify(commonDataQueue, never()).sendMetricsData(any());
}
@Test
void testPushIsAcceptedWithinTheLimits() {
final PushGatewayServiceImpl service = createService(10, 1024, 100);
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job1", "instance1"));
verify(pushMonitorDao).save(any(Monitor.class));
}
@Test
void testBodyBeyondTheByteLimitIsRejected() {
final PushGatewayServiceImpl service = createService(10, 16, 100);
assertFalse(service.pushPrometheusMetrics(createBody(BODY.repeat(100)), "job1", "instance1"));
verify(pushMonitorDao, never()).save(any(Monitor.class));
}
@Test
void testBodyBeyondTheSampleLimitIsRejected() {
final PushGatewayServiceImpl service = createService(10, 1024 * 1024, 2);
final StringBuilder many = new StringBuilder();
for (int index = 0; index < 10; index++) {
many.append("sample_metric{label=\"value").append(index).append("\"} 1\n");
}
final ByteArrayInputStream inputStream = createBody(many.toString());
assertFalse(service.pushPrometheusMetrics(inputStream, "job1", "instance1"));
assertTrue(inputStream.available() > 0, "the parser should stop before consuming the remaining samples");
verify(pushMonitorDao, never()).save(any(Monitor.class));
}
@Test
void testBodyAtTheSampleLimitIsAccepted() {
final PushGatewayServiceImpl service = createService(10, 1024, 2);
final String twoSamples = "sample_metric{label=\"a\"} 1\n"
+ "sample_metric{label=\"b\"} 2\n";
assertTrue(service.pushPrometheusMetrics(createBody(twoSamples), "job1", "instance1"));
}
/**
* An unknown job/instance pair persists a monitor row and adds a map entry that is
* never removed, so without a cap an anonymous caller iterating over made up names
* grows the database and the heap without bound.
*/
@Test
void testAutoCreationStopsAtTheMonitorLimit() {
final PushGatewayServiceImpl service = createService(2, 1024, 100);
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job1", "instance1"));
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job2", "instance2"));
assertFalse(service.pushPrometheusMetrics(createBody(BODY), "job3", "instance3"));
verify(pushMonitorDao, times(2)).save(any(Monitor.class));
}
/**
* The cap must not turn into eviction: a pair already known has to keep resolving to
* the monitor it created, otherwise a later push would create a second monitor for the
* same job and instance.
*/
@Test
void testKnownPairsKeepWorkingAtTheLimit() {
final PushGatewayServiceImpl service = createService(1, 1024, 100);
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job1", "instance1"));
assertFalse(service.pushPrometheusMetrics(createBody(BODY), "other", "instance"));
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job1", "instance1"));
verify(pushMonitorDao, times(1)).save(any(Monitor.class));
}
/**
* The route is anonymous, so nothing stops a caller from sending its unknown pairs all at
* once. Testing the cap and claiming the entry in two steps lets every request that already
* passed the test create a monitor of its own, which is the cap being exceeded by as many
* requests as the container serves in parallel.
*/
@Test
void testConcurrentPushesForUnknownPairsStopAtTheMonitorLimit() throws Exception {
final int callers = 16;
final PushGatewayServiceImpl service = createService(1, 1024, 100);
final CyclicBarrier startTogether = new CyclicBarrier(callers);
final ExecutorService pool = Executors.newFixedThreadPool(callers);
final AtomicInteger accepted = new AtomicInteger();
try {
final List<Future<?>> pushes = new ArrayList<>();
for (int index = 0; index < callers; index++) {
final String instance = "instance" + index;
pushes.add(pool.submit(() -> {
startTogether.await();
if (service.pushPrometheusMetrics(createBody(BODY), "job", instance)) {
accepted.incrementAndGet();
}
return null;
}));
}
for (final Future<?> push : pushes) {
push.get(30, TimeUnit.SECONDS);
}
} finally {
pool.shutdownNow();
}
assertEquals(1, accepted.get());
verify(pushMonitorDao, times(1)).save(any(Monitor.class));
}
@Test
void testFailedSaveDoesNotConsumeTheMonitorLimit() {
when(pushMonitorDao.save(any(Monitor.class)))
.thenThrow(new IllegalStateException("database unavailable"))
.thenAnswer(invocation -> invocation.getArgument(0));
final PushGatewayServiceImpl service = createService(1, 1024, 100);
assertFalse(service.pushPrometheusMetrics(createBody(BODY), "job1", "instance1"));
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job2", "instance2"));
verify(pushMonitorDao, times(2)).save(any(Monitor.class));
}
@Test
void testNegativeLimitsAreRejectedAtConstruction() {
assertThrows(IllegalArgumentException.class, () -> createService(-1, 1024, 100));
assertThrows(IllegalArgumentException.class, () -> createService(1, -1, 100));
assertThrows(IllegalArgumentException.class, () -> createService(1, 1024, -1));
}
@Test
void testConcurrentPushesForTheSamePairCreateOneMonitor() throws Exception {
final int callers = 8;
final CyclicBarrier startTogether = new CyclicBarrier(callers);
final PushGatewayServiceImpl service = createService(1, 1024, 100);
final ExecutorService pool = Executors.newFixedThreadPool(callers);
try {
final List<Future<Boolean>> pushes = new ArrayList<>();
for (int index = 0; index < callers; index++) {
pushes.add(pool.submit(() -> {
startTogether.await();
return service.pushPrometheusMetrics(createBody(BODY), "job", "instance");
}));
}
for (final Future<Boolean> push : pushes) {
assertTrue(push.get(30, TimeUnit.SECONDS));
}
} finally {
pool.shutdownNow();
}
verify(pushMonitorDao).save(any(Monitor.class));
}
/**
* A request may read no entry for a pair and only then claim the creation, by which time
* another request may have created that pair and cleared its claim. Without a second lookup
* once the claim is won, this request goes on to create the same pair again, leaving two
* monitors for one pair and a slot of the cap spent for good.
*
* <p>The interleaving is forced rather than raced: the map hands back a miss, and while it
* does, a competing push runs its whole creation.
*/
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void testStaleMissDoesNotCreateTwoMonitorsForOnePair() throws Exception {
final PushGatewayServiceImpl service = createService(2, 1024, 100);
final Field trackedPairs = PushGatewayServiceImpl.class.getDeclaredField("jobInstanceMap");
trackedPairs.setAccessible(true);
final AtomicBoolean competed = new AtomicBoolean();
final Map probing = new ConcurrentHashMap() {
@Override
public Object get(Object key) {
final Object value = super.get(key);
if (value == null && competed.compareAndSet(false, true)) {
service.pushPrometheusMetrics(createBody(BODY), "job1", "instance1");
}
return value;
}
};
trackedPairs.set(service, probing);
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job1", "instance1"));
verify(pushMonitorDao, times(1)).save(any(Monitor.class));
assertEquals(1, probing.size());
// The slot the duplicate would have taken is still there for another pair
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job2", "instance2"));
}
@Test
void testMonitorsLoadedAtStartupCountTowardsTheLimit() {
lenient().when(pushMonitorDao.findMonitorsByType((byte) 1)).thenReturn(List.of(
Monitor.builder().id(1L).app("job1").name("instance1").build()));
final PushGatewayServiceImpl service = createService(1, 1024, 100);
assertFalse(service.pushPrometheusMetrics(createBody(BODY), "job2", "instance2"));
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job1", "instance1"));
verify(pushMonitorDao, never()).save(any(Monitor.class));
}
/**
* A separator carries no meaning inside a job or an instance name, so the two names must not
* be joined into a single key: ("job", "a_b") and ("job_a", "b") are different monitors, and
* the second pair must not push its samples into the monitor the first one created.
*/
@Test
void testPairsSharingTheSeparatorAreDistinctMonitors() {
final PushGatewayServiceImpl service = createService(10, 1024, 100);
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job", "a_b"));
assertTrue(service.pushPrometheusMetrics(createBody(BODY), "job_a", "b"));
verify(pushMonitorDao, times(2)).save(any(Monitor.class));
final ArgumentCaptor<CollectRep.MetricsData> pushed =
ArgumentCaptor.forClass(CollectRep.MetricsData.class);
verify(commonDataQueue, times(2)).sendMetricsData(pushed.capture());
assertNotEquals(pushed.getAllValues().get(0).getId(), pushed.getAllValues().get(1).getId());
}
/**
* Colliding pairs must not collapse into one entry while the monitors are loaded either,
* which would let the cap count fewer monitors than the database actually holds.
*/
@Test
void testPairsSharingTheSeparatorCountSeparatelyAtStartup() {
lenient().when(pushMonitorDao.findMonitorsByType((byte) 1)).thenReturn(List.of(
Monitor.builder().id(1L).app("job").name("a_b").build(),
Monitor.builder().id(2L).app("job_a").name("b").build()));
final PushGatewayServiceImpl service = createService(2, 1024, 100);
assertFalse(service.pushPrometheusMetrics(createBody(BODY), "job3", "instance3"));
verify(pushMonitorDao, never()).save(any(Monitor.class));
}
@Test
void testBoundedStreamStopsAtTheLimit() throws Exception {
final PushGatewayServiceImpl.BoundedInputStream stream =
new PushGatewayServiceImpl.BoundedInputStream(createBody("abcdef"), 3);
assertEquals('a', stream.read());
assertEquals('b', stream.read());
assertEquals('c', stream.read());
assertThrows(PushGatewayServiceImpl.BodyTooLargeException.class, stream::read);
}
/**
* A body over the limit is a rejection rather than a failure, so it must be distinguishable
* from a read that genuinely broke: the route is anonymous, and answering every oversized
* body with an error trace lets a caller fill the log at will.
*/
@Test
void testBodyOverTheByteLimitIsRejectedNotFailed() {
final PushGatewayServiceImpl.BoundedInputStream stream =
new PushGatewayServiceImpl.BoundedInputStream(createBody(BODY.repeat(100)), 16);
final IOException raised = assertThrows(IOException.class, stream::readAllBytes);
assertInstanceOf(PushGatewayServiceImpl.BodyTooLargeException.class, raised);
}
}
@@ -373,3 +373,8 @@ hertzbeat:
concurrency-limit: 256 concurrency-limit: 256
reject-when-limit-reached: true reject-when-limit-reached: true
task-termination-timeout: 5000 task-termination-timeout: 5000
# Bounds on what a single request to the push gateway may consume.
push:
max-auto-created-monitors: ${HERTZBEAT_PUSH_MAX_AUTO_CREATED_MONITORS:10000}
max-body-bytes: ${HERTZBEAT_PUSH_MAX_BODY_BYTES:5242880}
max-samples: ${HERTZBEAT_PUSH_MAX_SAMPLES:10000}
+5
View File
@@ -373,3 +373,8 @@ hertzbeat:
concurrency-limit: 256 concurrency-limit: 256
reject-when-limit-reached: true reject-when-limit-reached: true
task-termination-timeout: 5000 task-termination-timeout: 5000
# Bounds on what a single request to the push gateway may consume.
push:
max-auto-created-monitors: ${HERTZBEAT_PUSH_MAX_AUTO_CREATED_MONITORS:10000}
max-body-bytes: ${HERTZBEAT_PUSH_MAX_BODY_BYTES:5242880}
max-samples: ${HERTZBEAT_PUSH_MAX_SAMPLES:10000}
@@ -109,7 +109,7 @@ spring:
hibernate: hibernate:
format_sql: true format_sql: true
dialect: org.hibernate.dialect.MySQLDialect dialect: org.hibernate.dialect.MySQLDialect
flyway: flyway:
enabled: true enabled: true
clean-disabled: true clean-disabled: true
@@ -117,7 +117,7 @@ spring:
baseline-version: 1 baseline-version: 1
locations: locations:
- classpath:db/migration/mysql - classpath:db/migration/mysql
# Not Require, Please config if you need email notify # Not Require, Please config if you need email notify
mail: mail:
# Attention: this is mail server address. # Attention: this is mail server address.
@@ -138,7 +138,7 @@ common:
queue: queue:
# memory or kafka # memory or kafka
type: memory type: memory
warehouse: warehouse:
store: store:
# store history metrics data, enable only one below # store history metrics data, enable only one below
@@ -222,7 +222,7 @@ alerter:
region: AWS_REGION_FOR_END_USER_MESSAGING region: AWS_REGION_FOR_END_USER_MESSAGING
twilio: twilio:
account-sid: YOUR_ACCOUNT_SID account-sid: YOUR_ACCOUNT_SID
auth-token: YOUR_AUTH_TOKEN auth-token: YOUR_AUTH_TOKEN
twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER
scheduler: scheduler:
server: server:
@@ -273,3 +273,8 @@ hertzbeat:
concurrency-limit: 256 concurrency-limit: 256
reject-when-limit-reached: true reject-when-limit-reached: true
task-termination-timeout: 5000 task-termination-timeout: 5000
# Bounds on what a single request to the push gateway may consume
push:
max-auto-created-monitors: ${HERTZBEAT_PUSH_MAX_AUTO_CREATED_MONITORS:10000}
max-body-bytes: ${HERTZBEAT_PUSH_MAX_BODY_BYTES:5242880}
max-samples: ${HERTZBEAT_PUSH_MAX_SAMPLES:10000}
@@ -109,7 +109,7 @@ spring:
hibernate: hibernate:
format_sql: true format_sql: true
dialect: org.hibernate.dialect.MySQLDialect dialect: org.hibernate.dialect.MySQLDialect
flyway: flyway:
enabled: true enabled: true
clean-disabled: true clean-disabled: true
@@ -117,7 +117,7 @@ spring:
baseline-version: 1 baseline-version: 1
locations: locations:
- classpath:db/migration/mysql - classpath:db/migration/mysql
# Not Require, Please config if you need email notify # Not Require, Please config if you need email notify
mail: mail:
# Attention: this is mail server address. # Attention: this is mail server address.
@@ -138,7 +138,7 @@ common:
queue: queue:
# memory or kafka # memory or kafka
type: memory type: memory
warehouse: warehouse:
store: store:
# store history metrics data, enable only one below # store history metrics data, enable only one below
@@ -219,7 +219,7 @@ alerter:
region: AWS_REGION_FOR_END_USER_MESSAGING region: AWS_REGION_FOR_END_USER_MESSAGING
twilio: twilio:
account-sid: YOUR_ACCOUNT_SID account-sid: YOUR_ACCOUNT_SID
auth-token: YOUR_AUTH_TOKEN auth-token: YOUR_AUTH_TOKEN
twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER
scheduler: scheduler:
server: server:
@@ -270,3 +270,8 @@ hertzbeat:
concurrency-limit: 256 concurrency-limit: 256
reject-when-limit-reached: true reject-when-limit-reached: true
task-termination-timeout: 5000 task-termination-timeout: 5000
# Bounds on what a single request to the push gateway may consume.
push:
max-auto-created-monitors: ${HERTZBEAT_PUSH_MAX_AUTO_CREATED_MONITORS:10000}
max-body-bytes: ${HERTZBEAT_PUSH_MAX_BODY_BYTES:5242880}
max-samples: ${HERTZBEAT_PUSH_MAX_SAMPLES:10000}
@@ -109,7 +109,7 @@ spring:
hibernate: hibernate:
format_sql: true format_sql: true
dialect: org.hibernate.dialect.MySQLDialect dialect: org.hibernate.dialect.MySQLDialect
flyway: flyway:
enabled: true enabled: true
clean-disabled: true clean-disabled: true
@@ -117,7 +117,7 @@ spring:
baseline-version: 1 baseline-version: 1
locations: locations:
- classpath:db/migration/mysql - classpath:db/migration/mysql
# Not Require, Please config if you need email notify # Not Require, Please config if you need email notify
mail: mail:
# Attention: this is mail server address. # Attention: this is mail server address.
@@ -142,7 +142,7 @@ warehouse:
expire-time: 1h expire-time: 1h
victoria-metrics: victoria-metrics:
enabled: true enabled: true
url: http://victoria-metrics:8428 url: http://victoria-metrics:8428
username: root username: root
password: root password: root
insert: insert:
@@ -222,7 +222,7 @@ alerter:
region: AWS_REGION_FOR_END_USER_MESSAGING region: AWS_REGION_FOR_END_USER_MESSAGING
twilio: twilio:
account-sid: YOUR_ACCOUNT_SID account-sid: YOUR_ACCOUNT_SID
auth-token: YOUR_AUTH_TOKEN auth-token: YOUR_AUTH_TOKEN
twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER
scheduler: scheduler:
server: server:
@@ -273,3 +273,8 @@ hertzbeat:
concurrency-limit: 256 concurrency-limit: 256
reject-when-limit-reached: true reject-when-limit-reached: true
task-termination-timeout: 5000 task-termination-timeout: 5000
# Bounds on what a single request to the push gateway may consume.
push:
max-auto-created-monitors: ${HERTZBEAT_PUSH_MAX_AUTO_CREATED_MONITORS:10000}
max-body-bytes: ${HERTZBEAT_PUSH_MAX_BODY_BYTES:5242880}
max-samples: ${HERTZBEAT_PUSH_MAX_SAMPLES:10000}
@@ -108,7 +108,7 @@ spring:
hibernate: hibernate:
format_sql: true format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
flyway: flyway:
enabled: true enabled: true
clean-disabled: true clean-disabled: true
@@ -116,7 +116,7 @@ spring:
baseline-version: 1 baseline-version: 1
locations: locations:
- classpath:db/migration/postgresql - classpath:db/migration/postgresql
# Not Require, Please config if you need email notify # Not Require, Please config if you need email notify
mail: mail:
# Attention: this is mail server address. # Attention: this is mail server address.
@@ -219,7 +219,7 @@ alerter:
region: AWS_REGION_FOR_END_USER_MESSAGING region: AWS_REGION_FOR_END_USER_MESSAGING
twilio: twilio:
account-sid: YOUR_ACCOUNT_SID account-sid: YOUR_ACCOUNT_SID
auth-token: YOUR_AUTH_TOKEN auth-token: YOUR_AUTH_TOKEN
twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER
scheduler: scheduler:
server: server:
@@ -270,3 +270,8 @@ hertzbeat:
concurrency-limit: 256 concurrency-limit: 256
reject-when-limit-reached: true reject-when-limit-reached: true
task-termination-timeout: 5000 task-termination-timeout: 5000
# Bounds on what a single request to the push gateway may consume.
push:
max-auto-created-monitors: ${HERTZBEAT_PUSH_MAX_AUTO_CREATED_MONITORS:10000}
max-body-bytes: ${HERTZBEAT_PUSH_MAX_BODY_BYTES:5242880}
max-samples: ${HERTZBEAT_PUSH_MAX_SAMPLES:10000}
@@ -108,7 +108,7 @@ spring:
hibernate: hibernate:
format_sql: true format_sql: true
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
flyway: flyway:
enabled: true enabled: true
clean-disabled: true clean-disabled: true
@@ -116,7 +116,7 @@ spring:
baseline-version: 1 baseline-version: 1
locations: locations:
- classpath:db/migration/postgresql - classpath:db/migration/postgresql
# Not Require, Please config if you need email notify # Not Require, Please config if you need email notify
mail: mail:
# Attention: this is mail server address. # Attention: this is mail server address.
@@ -221,7 +221,7 @@ alerter:
region: AWS_REGION_FOR_END_USER_MESSAGING region: AWS_REGION_FOR_END_USER_MESSAGING
twilio: twilio:
account-sid: YOUR_ACCOUNT_SID account-sid: YOUR_ACCOUNT_SID
auth-token: YOUR_AUTH_TOKEN auth-token: YOUR_AUTH_TOKEN
twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER twilio-phone-number: YOUR_TWILIO_PHONE_NUMBER
scheduler: scheduler:
server: server:
@@ -272,3 +272,8 @@ hertzbeat:
concurrency-limit: 256 concurrency-limit: 256
reject-when-limit-reached: true reject-when-limit-reached: true
task-termination-timeout: 5000 task-termination-timeout: 5000
# Bounds on what a single request to the push gateway may consume.
push:
max-auto-created-monitors: ${HERTZBEAT_PUSH_MAX_AUTO_CREATED_MONITORS:10000}
max-body-bytes: ${HERTZBEAT_PUSH_MAX_BODY_BYTES:5242880}
max-samples: ${HERTZBEAT_PUSH_MAX_SAMPLES:10000}