This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new c63c9bfa79fd fix(metrics): do not drop the whole CloudWatch batch on
one unmappable metric name (#19476)
c63c9bfa79fd is described below
commit c63c9bfa79fdb5a606fd7452fdfb3510f2f5739a
Author: Ranga Reddy <[email protected]>
AuthorDate: Tue Aug 4 21:06:57 2026 +0530
fix(metrics): do not drop the whole CloudWatch batch on one unmappable
metric name (#19476)
* fix(metrics): do not drop the whole CloudWatch batch on one unmappable
metric name
stageMetricDatum derives the CloudWatch Table dimension from the part of
the metric
name before the first dot, so a name without one cannot be mapped. It threw
for
that case, and report() stages every metric into one list before calling
putMetricData, so throwing part-way through meant the request was never
sent: one
unmappable name cost every metric in the interval. ScheduledReporter then
suppresses the exception, so the user saw a log line and an empty dashboard.
Such names still reach the reporter on master.
HoodieMetadataMetrics#setMetric
registers gauges with no prefix, unlike Metrics#registerGauges, so getStats
contributes a bare partitionCount and BaseTableMetadata a bare
lookup_meta_index_bloom_filters_file_count.
Skip the metric that cannot be mapped and publish the rest, logging the
name once
rather than every interval. The intent of the previous check is kept - the
metric is
still not reported under a wrong table, and is now named in a warning -
without
taking the other metrics down with it. Fail-fast was never reachable here
anyway,
since ScheduledReporter suppresses whatever report() throws.
* fix(metrics): also skip metrics whose table name is empty, and cover the
log-once path
Review feedback.
The headline example in this PR was unreachable and I have replaced it.
partitionCount
comes from HoodieMetadataMetrics.getStats only when detailed == true, while
the
gauge-registering path calls getStats(false, ...); the only detailed=true
caller is
HoodieBackedTableMetadata.stats(), whose sole consumer prints the map in
hudi-cli and
registers nothing. The fixture and javadoc now use
lookup_meta_index_bloom_filters_file_count, which BaseTableMetadata
registers on the
normal bloom-index read path.
An empty first segment was still losing the batch, which is the same bug
class this PR
exists to fix. hoodie.metrics.reporter.metricsname.prefix defaults to "" and
Metrics#registerGauges still joins it with a dot, so ".foo" splits into two
parts, passed
the length check, and asked CloudWatch for an empty Table dimension value -
which it
rejects for the entire PutMetricData request. The guard now also rejects an
empty first
segment.
The warning no longer claims a <table>.<metric> convention that Hudi does
not follow: no
metadata metric carries a table name, and an operator has no knob that
changes the names
being skipped. It now names the prefix config and points at #19507 for the
producer side.
Three tests added: the empty-table-name case, an interval where every
metric is
unmappable asserting that no empty PutMetricData request is sent, and one
that reports
twice and asserts a single WARN, so the once-per-name set is no longer
uncovered. Both new
guards fail the suite when reverted.
---
.../aws/metrics/cloudwatch/CloudWatchReporter.java | 26 +++-
.../metrics/cloudwatch/TestCloudWatchReporter.java | 145 +++++++++++++++++++--
2 files changed, 158 insertions(+), 13 deletions(-)
diff --git
a/hudi-aws/src/main/java/org/apache/hudi/aws/metrics/cloudwatch/CloudWatchReporter.java
b/hudi-aws/src/main/java/org/apache/hudi/aws/metrics/cloudwatch/CloudWatchReporter.java
index ba9abc55bef7..470fda3dfa1f 100644
---
a/hudi-aws/src/main/java/org/apache/hudi/aws/metrics/cloudwatch/CloudWatchReporter.java
+++
b/hudi-aws/src/main/java/org/apache/hudi/aws/metrics/cloudwatch/CloudWatchReporter.java
@@ -20,7 +20,7 @@ package org.apache.hudi.aws.metrics.cloudwatch;
import org.apache.hudi.aws.credentials.HoodieAWSCredentialsProviderFactory;
import org.apache.hudi.common.util.Option;
-import org.apache.hudi.common.util.ValidationUtils;
+import org.apache.hudi.common.util.StringUtils;
import com.codahale.metrics.Clock;
import com.codahale.metrics.Counter;
@@ -45,7 +45,9 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
+import java.util.Set;
import java.util.SortedMap;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
@@ -66,6 +68,8 @@ public class CloudWatchReporter extends ScheduledReporter {
private final String prefix;
private final String namespace;
private final int maxDatumsPerRequest;
+ /** Metric names already reported as unmappable, so the warning is logged
once rather than every interval. */
+ private final Set<String> unmappableMetricNames =
ConcurrentHashMap.newKeySet();
public static Builder forRegistry(MetricRegistry registry) {
return new Builder(registry);
@@ -276,8 +280,24 @@ public class CloudWatchReporter extends ScheduledReporter {
long timestampMilliSec,
List<MetricDatum> metricData) {
String[] metricNameParts = metricName.split("\\.", 2);
- ValidationUtils.checkArgument(metricNameParts.length >= 2,
- "metricName doesn't follow the naming convention and doesn't
contain a dot as splitter! metricName:" + metricName);
+ if (metricNameParts.length < 2 ||
StringUtils.isNullOrEmpty(metricNameParts[0])) {
+ // The table dimension comes from the part before the first dot, so a
name without one, or one whose
+ // first segment is empty, cannot be mapped. An empty first segment is
reachable:
+ // hoodie.metrics.reporter.metricsname.prefix defaults to "" and
Metrics#registerGauges still joins it
+ // with a dot, producing ".foo" - and CloudWatch rejects a whole
PutMetricData request whose dimension
+ // value is empty, which would lose the batch again.
+ //
+ // Skip just this metric rather than throwing: ScheduledReporter
suppresses whatever report() throws,
+ // so failing here dropped every metric staged in the same interval and
left no metrics in CloudWatch
+ // at all.
+ if (unmappableMetricNames.add(metricName)) {
+ log.warn("Not reporting metric \"{}\" to CloudWatch: no table name can
be derived for the Table "
+ + "dimension. Metric names normally carry
hoodie.metrics.reporter.metricsname.prefix, but some "
+ + "Hudi-internal metadata metrics do not (see HUDI issue #19507).
Other metrics in this batch "
+ + "are unaffected, and this is logged once per metric name.",
metricName);
+ }
+ return;
+ }
String tableName = metricNameParts[0];
metricData.add(MetricDatum.builder()
diff --git
a/hudi-aws/src/test/java/org/apache/hudi/aws/metrics/cloudwatch/TestCloudWatchReporter.java
b/hudi-aws/src/test/java/org/apache/hudi/aws/metrics/cloudwatch/TestCloudWatchReporter.java
index 0073f3687db2..d6fb7a4cfb7e 100644
---
a/hudi-aws/src/test/java/org/apache/hudi/aws/metrics/cloudwatch/TestCloudWatchReporter.java
+++
b/hudi-aws/src/test/java/org/apache/hudi/aws/metrics/cloudwatch/TestCloudWatchReporter.java
@@ -27,6 +27,12 @@ import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricFilter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.Timer;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.config.LoggerConfig;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -43,6 +49,8 @@ import
software.amazon.awssdk.services.cloudwatch.model.MetricDatum;
import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataRequest;
import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataResponse;
+import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;
@@ -54,7 +62,6 @@ import static
org.apache.hudi.aws.metrics.cloudwatch.CloudWatchReporter.DIMENSIO
import static
org.apache.hudi.aws.metrics.cloudwatch.CloudWatchReporter.DIMENSION_METRIC_TYPE_KEY;
import static
org.apache.hudi.aws.metrics.cloudwatch.CloudWatchReporter.DIMENSION_TABLE_NAME_KEY;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
@ExtendWith(MockitoExtension.class)
public class TestCloudWatchReporter {
@@ -168,21 +175,139 @@ public class TestCloudWatchReporter {
Mockito.verify(cloudWatchAsync).close();
}
+ /**
+ * A metric name with no dot has no table name to report under, and such
names do reach the reporter:
+ * {@code HoodieMetadataMetrics#setMetric} registers gauges without the
metrics-name prefix, so
+ * {@code BaseTableMetadata#getBloomFilters} contributes a bare
+ * {@code lookup_meta_index_bloom_filters_file_count} on the normal
bloom-index read path. This used to
+ * throw, and {@link com.codahale.metrics.ScheduledReporter} suppresses
whatever {@code report()} throws,
+ * so no metrics reached CloudWatch at all - which is what #12182 and #13051
report. The unmappable metric
+ * is now skipped and the rest of the batch is still published. See HUDI
issue #19507 for the producer side.
+ */
@Test
- public void testReportOnMetricsWithoutTableName() {
+ public void testReportSkipsMetricsWithoutTableNameAndPublishesTheRest() {
SortedMap<String, Gauge> gauges = new TreeMap<>();
- Gauge<Long> gauge1 = () -> 100L;
- Gauge<Double> gauge2 = () -> 100.1;
- gauges.put("gauge1", gauge1);
- gauges.put(TABLE_NAME + ".gauge2", gauge2);
+ Gauge<Long> unmappable = () -> 7L;
+ Gauge<Double> wellFormed = () -> 100.1;
+ gauges.put("lookup_meta_index_bloom_filters_file_count", unmappable);
+ gauges.put(TABLE_NAME + ".gauge2", wellFormed);
Mockito.when(metricRegistry.getGauges(MetricFilter.ALL)).thenReturn(gauges);
- // should fail if metric name doesn't have at least two parts
- assertThrows(IllegalArgumentException.class, () -> reporter.report());
+ reporter.report();
- reporter.stop();
- Mockito.verify(cloudWatchAsync).close();
+ Mockito.verify(cloudWatchAsync,
Mockito.times(1)).putMetricData(putMetricDataRequestCaptor.capture());
+ List<MetricDatum> metricData =
putMetricDataRequestCaptor.getValue().metricData();
+ assertEquals(1, metricData.size(),
+ "The unmappable metric should be skipped and the well-formed one still
published");
+ assertEquals(PREFIX + ".gauge2", metricData.get(0).metricName());
+ assertEquals(wellFormed.getValue(), metricData.get(0).value());
+ assertDimensions(metricData.get(0).dimensions(),
DIMENSION_GAUGE_TYPE_VALUE);
+ }
+
+ /**
+ * An empty first segment is reachable: {@code
hoodie.metrics.reporter.metricsname.prefix} defaults to
+ * {@code ""} and {@code Metrics#registerGauges} still joins it with a dot,
giving {@code ".foo"}. That
+ * splits into two parts and so passed the length check, then asked
CloudWatch for an empty {@code Table}
+ * dimension value, which it rejects for the whole PutMetricData request -
losing the batch again.
+ */
+ @Test
+ public void testReportSkipsMetricsWithAnEmptyTableName() {
+ SortedMap<String, Gauge> gauges = new TreeMap<>();
+ gauges.put(".gauge1", (Gauge<Long>) () -> 7L);
+ gauges.put(TABLE_NAME + ".gauge2", (Gauge<Long>) () -> 100L);
+
+
Mockito.when(metricRegistry.getGauges(MetricFilter.ALL)).thenReturn(gauges);
+
+ reporter.report();
+
+ Mockito.verify(cloudWatchAsync,
Mockito.times(1)).putMetricData(putMetricDataRequestCaptor.capture());
+ List<MetricDatum> metricData =
putMetricDataRequestCaptor.getValue().metricData();
+ assertEquals(1, metricData.size(), "a metric whose table name is empty
should be skipped");
+ assertEquals(PREFIX + ".gauge2", metricData.get(0).metricName());
+ }
+
+ /**
+ * An interval in which every metric is unmappable leaves nothing staged.
CloudWatch rejects an empty
+ * PutMetricData request, so the reporter must not send one.
+ */
+ @Test
+ public void testReportSendsNothingWhenEveryMetricIsUnmappable() {
+ SortedMap<String, Gauge> gauges = new TreeMap<>();
+ gauges.put("lookup_meta_index_bloom_filters_file_count", (Gauge<Long>) ()
-> 7L);
+ gauges.put("bootstrap_error", (Gauge<Long>) () -> 1L);
+
+
Mockito.when(metricRegistry.getGauges(MetricFilter.ALL)).thenReturn(gauges);
+
+ reporter.report();
+
+ Mockito.verify(cloudWatchAsync,
Mockito.never()).putMetricData(ArgumentMatchers.any(PutMetricDataRequest.class));
+ }
+
+ /**
+ * The unmappable-name set exists so a persistent offender is logged once
rather than every reporting
+ * interval. Without this, deleting the set and logging unconditionally
would pass the suite.
+ */
+ @Test
+ public void testUnmappableMetricIsLoggedOncePerName() {
+ SortedMap<String, Gauge> gauges = new TreeMap<>();
+ gauges.put("lookup_meta_index_bloom_filters_file_count", (Gauge<Long>) ()
-> 7L);
+
Mockito.when(metricRegistry.getGauges(MetricFilter.ALL)).thenReturn(gauges);
+
+ CapturingAppender appender =
CapturingAppender.attachTo(CloudWatchReporter.class);
+ try {
+ reporter.report();
+ reporter.report();
+ } finally {
+ appender.detach();
+ }
+
+ assertEquals(1,
appender.warningsContaining("lookup_meta_index_bloom_filters_file_count"),
+ "a persistent unmappable name should be warned about once, not once
per interval");
+ }
+
+ /** Captures WARN events from a single logger, so "logged once" can be
asserted. */
+ private static final class CapturingAppender extends AbstractAppender {
+ private final List<String> warnings = Collections.synchronizedList(new
ArrayList<>());
+ private final LoggerConfig loggerConfig;
+ private final Level previousLevel;
+
+ private CapturingAppender(LoggerConfig loggerConfig) {
+ super("CapturingAppender", null, null, true, null);
+ this.loggerConfig = loggerConfig;
+ this.previousLevel = loggerConfig.getLevel();
+ }
+
+ static CapturingAppender attachTo(Class<?> loggerFor) {
+ LoggerContext context = (LoggerContext) LogManager.getContext(false);
+ LoggerConfig loggerConfig =
context.getConfiguration().getLoggerConfig(loggerFor.getName());
+ CapturingAppender appender = new CapturingAppender(loggerConfig);
+ appender.start();
+ loggerConfig.addAppender(appender, Level.WARN, null);
+ loggerConfig.setLevel(Level.WARN);
+ context.updateLoggers();
+ return appender;
+ }
+
+ void detach() {
+ loggerConfig.removeAppender(getName());
+ loggerConfig.setLevel(previousLevel);
+ ((LoggerContext) LogManager.getContext(false)).updateLoggers();
+ stop();
+ }
+
+ long warningsContaining(String needle) {
+ synchronized (warnings) {
+ return warnings.stream().filter(m -> m.contains(needle)).count();
+ }
+ }
+
+ @Override
+ public void append(LogEvent event) {
+ if (event.getLevel().isMoreSpecificThan(Level.WARN)) {
+ warnings.add(event.getMessage().getFormattedMessage());
+ }
+ }
}
private void assertDimensions(List<Dimension> actualDimensions, String
metricTypeDimensionVal) {