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 c4b38935db04 fix(metrics): route the reflection failures a CloudWatch 
skew actually produces (#19477)
c4b38935db04 is described below

commit c4b38935db0462eca6b352db60bfc0155a4cf0c8
Author: Ranga Reddy <[email protected]>
AuthorDate: Thu Aug 6 10:02:29 2026 +0530

    fix(metrics): route the reflection failures a CloudWatch skew actually 
produces (#19477)
    
    * fix(metrics): tell a CloudWatch reporter version mismatch apart from a 
missing bundle
    
    ReflectionUtils.loadClass collapses NoSuchMethodException,
    InvocationTargetException, InstantiationException and 
IllegalAccessException into
    one HoodieException with a fixed message, so a hudi-aws jar built against a
    different Hudi version surfaced only as "Unable to instantiate class
    org.apache.hudi.metrics.cloudwatch.CloudWatchMetricsReporter". Working out 
that the
    class had resolved and only its constructor had not matched meant digging 
the
    buried NoSuchMethodException out of the stack.
    
    Translate that cause too: report that the class was found but has no
    (HoodieMetricsConfig, MetricRegistry) constructor, and that the remedy is a
    hudi-aws-bundle matching the Hudi bundle in use. That is deliberately 
different from
    the missing-module message, which says to add the bundle - absent and 
mismatched
    need different fixes, and conflating them is what made the message 
unhelpful.
    
    metricsReporterFactoryLeavesNonClassNotFoundFailuresUntouched used a
    NoSuchMethodException to stand for "some other failure", which is now 
handled, so it
    switches to an InvocationTargetException and is renamed accordingly.
    
    * test(metrics): fix a dropped word in an assertion message
    
    Review nit: "A failure that is neither cause must not be rewritten" was 
missing
    the two cases it refers to.
    
    * fix(metrics): route the reflection failures a CloudWatch skew actually 
produces
    
    Review found that the constructor-mismatch branch cannot fire for the case 
its message
    described, and I reproduced it: with a released hudi-aws-bundle-1.1.0-rc1 
on the
    classpath against this branch's hudi-common,
    
      STEP1 Class.forName OK -> class 
org.apache.hudi.aws.metrics.cloudwatch.CloudWatchMetricsReporter
      STEP2 getConstructor THREW java.lang.NoClassDefFoundError:
              org/apache/hudi/config/metrics/HoodieMetricsConfig
              is NoSuchMethodException? false
              is Error (escapes catch(Exception))? true
    
    Class#getConstructor resolves the parameter types of every public 
constructor, not just
    the requested one, and #19193 moved HoodieMetricsConfig out of
    org.apache.hudi.config.metrics with no stub there. So a clean older bundle 
dies on the
    vanished type, as an Error that ReflectionUtils never wraps, and the 
previous
    catch (HoodieException) never saw it. NoSuchMethodException only arrives 
when the
    classpath carries a stale or duplicate copy - which is also what #12902 
actually was:
    that report had matching 0.15.0 artifacts, and 0.15.0 did declare the 
requested
    constructor.
    
    Three causes, three remedies:
    - ClassNotFoundException -> hudi-aws absent, add the bundle.
    - NoClassDefFoundError -> built against a different Hudi; the message 
quotes the type
      that vanished, which is the strongest evidence of skew available.
    - NoSuchMethodException -> stale or duplicate copy; look for more than one 
Hudi version,
      a leftover hudi-common or hudi-client-common being the usual culprit.
    
    Parameter types are printed fully qualified. The released bundle declares
    (org.apache.hudi.config.metrics.HoodieMetricsConfig,
    org.apache.hudi.com.codahale.metrics.MetricRegistry) while this Hudi 
requests
    (org.apache.hudi.common.config.metrics.HoodieMetricsConfig,
    com.codahale.metrics.MetricRegistry) - under simple names both read as
    (HoodieMetricsConfig, MetricRegistry) and the error looks like it is lying.
    
    Tests: both ClassNotFound tests now assert their own distinguishing phrase, 
since every
    string they checked also appears in the mismatch message; the mismatch test 
pins the
    NoSuchMethodException into the cause chain, which nothing did before; and 
one test drives
    the real ReflectionUtils with a fixture whose only public constructor does 
not match, so
    the suite no longer asserts only the shapes the production code assumes. 
That gap is how
    the NoClassDefFoundError case stayed invisible.
---
 .../hudi/metrics/MetricsReporterFactory.java       |  45 ++++++++-
 .../hudi/metrics/TestMetricsReporterFactory.java   | 107 ++++++++++++++++++++-
 2 files changed, 143 insertions(+), 9 deletions(-)

diff --git 
a/hudi-common/src/main/java/org/apache/hudi/metrics/MetricsReporterFactory.java 
b/hudi-common/src/main/java/org/apache/hudi/metrics/MetricsReporterFactory.java
index 403d25b9ead6..9bc7f30fcd27 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/metrics/MetricsReporterFactory.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/metrics/MetricsReporterFactory.java
@@ -106,13 +106,33 @@ public class MetricsReporterFactory {
 
   /**
    * The CloudWatch reporter ships in the optional {@code hudi-aws} module and 
so is loaded reflectively.
-   * Not every engine bundle shades that module, in which case class loading 
fails without pointing at a
-   * remedy. Translate that into an actionable error.
+   * Reflection collapses several unrelated failures into the same opaque 
{@link HoodieException}. Three of
+   * them have distinct remedies - the module is absent, it was built against 
a Hudi that has since moved a
+   * class, or the classpath carries a stale duplicate - so translate those 
three, and leave every other
+   * failure untouched.
    */
   private static MetricsReporter createCloudWatchReporter(HoodieMetricsConfig 
metricsConfig, MetricRegistry registry) {
+    return createCloudWatchReporter(CLOUDWATCH_REPORTER_CLASS, metricsConfig, 
registry);
+  }
+
+  @VisibleForTesting
+  static MetricsReporter createCloudWatchReporter(String reporterClass, 
HoodieMetricsConfig metricsConfig,
+                                                 MetricRegistry registry) {
     try {
-      return (MetricsReporter) 
ReflectionUtils.loadClass(CLOUDWATCH_REPORTER_CLASS,
+      return (MetricsReporter) ReflectionUtils.loadClass(reporterClass,
           new Class[] {HoodieMetricsConfig.class, MetricRegistry.class}, 
metricsConfig, registry);
+    } catch (NoClassDefFoundError e) {
+      // Class#getConstructor resolves the parameter types of every public 
constructor, not just the one
+      // asked for, so a jar built against an older Hudi fails here on a type 
that has since moved - not
+      // with a missing-constructor error. NoClassDefFoundError is an Error, 
so ReflectionUtils never wraps
+      // it and it arrives here uncaught. Its message names the type that 
vanished, which is the strongest
+      // evidence of skew available.
+      throw new HoodieException(String.format(
+          "Cannot report metrics to CloudWatch: %s was found on the classpath 
but was built against a "
+              + "different Hudi version - resolving its constructors needs %s, 
which this Hudi no longer "
+              + "provides. Use a hudi-aws-bundle of the same version as the 
engine bundle, or set %s to a "
+              + "different reporter type.",
+          reporterClass, e.getMessage(), 
HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()), e);
     } catch (HoodieException e) {
       if (e.getCause() instanceof ClassNotFoundException) {
         throw new HoodieException(String.format(
@@ -120,7 +140,24 @@ public class MetricsReporterFactory {
                 + "optional hudi-aws module, which not every engine bundle 
includes. Add the "
                 + "hudi-aws-bundle jar matching your Hudi version to the 
classpath, or set %s to a "
                 + "different reporter type.",
-            CLOUDWATCH_REPORTER_CLASS, 
HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()), e);
+            reporterClass, 
HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()), e);
+      }
+      if (e.getCause() instanceof NoSuchMethodException) {
+        // The class resolved and so did every constructor's parameter types, 
yet none matched. A jar built
+        // against an older Hudi fails earlier, in the NoClassDefFoundError 
branch above, so what reaches
+        // here is a classpath carrying a stale or duplicate copy of this 
class or of its parameter types.
+        // Fully qualified names, because the package move and the 
shaded-codahale relocation are exactly
+        // what distinguishes the declared constructor from the requested one 
- under simple names both read
+        // as (HoodieMetricsConfig, MetricRegistry) and the error looks wrong.
+        throw new HoodieException(String.format(
+            "Cannot report metrics to CloudWatch: %s was found on the 
classpath but does not declare a "
+                + "(%s, %s) constructor. Some jar on the classpath is 
supplying a stale or duplicate copy "
+                + "of this class or of its parameter types. Check for more 
than one Hudi version on the "
+                + "classpath - a leftover hudi-common or hudi-client-common is 
the usual culprit - and "
+                + "align every Hudi artifact, including the engine bundle, to 
one version. Or set %s to a "
+                + "different reporter type.",
+            reporterClass, HoodieMetricsConfig.class.getName(),
+            MetricRegistry.class.getName(), 
HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()), e);
       }
       throw e;
     }
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/metrics/TestMetricsReporterFactory.java
 
b/hudi-common/src/test/java/org/apache/hudi/metrics/TestMetricsReporterFactory.java
index d1bcee365873..29af024ed4eb 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/metrics/TestMetricsReporterFactory.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/metrics/TestMetricsReporterFactory.java
@@ -37,6 +37,7 @@ import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 import org.mockito.junit.jupiter.MockitoExtension;
 
+import java.lang.reflect.InvocationTargetException;
 import java.util.Properties;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -114,6 +115,10 @@ class TestMetricsReporterFactory {
         () -> "The failure should name the bundle that provides the reporter, 
but was: " + message);
     
assertTrue(message.contains(HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()),
         () -> "The failure should name the config to change, but was: " + 
message);
+    // Every string above also appears in the constructor-mismatch message, so 
without this the two branches
+    // could be merged or reordered and both tests would still pass.
+    assertTrue(message.contains("was not found on the classpath"),
+        () -> "A missing class must not be reported as a constructor mismatch, 
but was: " + message);
   }
 
   /**
@@ -133,26 +138,107 @@ class TestMetricsReporterFactory {
           () -> MetricsReporterFactory.createReporter(metricsConfig, 
registry));
       assertTrue(exception.getMessage().contains("hudi-aws-bundle"),
           () -> "Expected the remedy to be named, but was: " + 
exception.getMessage());
+      assertTrue(exception.getMessage().contains("was not found on the 
classpath"),
+          () -> "Expected this branch's own phrase, not one shared with the 
mismatch branch, but was: "
+              + exception.getMessage());
     }
   }
 
   /**
-   * The other direction, and the branch most likely to regress: a failure 
that is not a missing class must
-   * pass through untouched, so an unrelated instantiation error is never 
rewritten into "add hudi-aws-bundle".
+   * A jar built against an older Hudi does not reach this branch - resolving 
its constructors fails first
+   * with {@link NoClassDefFoundError}, covered below. What reaches here is a 
classpath carrying a stale or
+   * duplicate copy, so that is the remedy the message has to give.
    */
   @Test
-  void metricsReporterFactoryLeavesNonClassNotFoundFailuresUntouched() {
+  void metricsReporterFactoryExplainsAConstructorMismatch() {
+    HoodieException exception = captureCloudWatchFailure(
+        new HoodieException("Unable to instantiate class " + 
MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS,
+            new NoSuchMethodException("<init>")));
+
+    String message = exception.getMessage();
+    assertTrue(message.contains("constructor"),
+        () -> "The failure should say the constructor did not match, but was: 
" + message);
+    assertTrue(message.contains("stale or duplicate copy"),
+        () -> "The failure should name a stale duplicate as the cause, but 
was: " + message);
+    
assertTrue(message.contains(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS),
+        () -> "The failure should name the reporter class, but was: " + 
message);
+    
assertTrue(message.contains(HoodieMetricsConfig.METRICS_REPORTER_TYPE_VALUE.key()),
+        () -> "The failure should name the config to change, but was: " + 
message);
+    assertTrue(message.contains(HoodieMetricsConfig.class.getName())
+            && message.contains(MetricRegistry.class.getName()),
+        () -> "Fully qualified parameter types are what distinguish the 
requested constructor from the "
+            + "declared one, but was: " + message);
+    // Positive rather than an assertFalse on the other branch's prose: a 
vacuous assertFalse never fails.
+    assertTrue(message.contains("was found on the classpath but"),
+        () -> "A class that resolved must not be reported as missing, but was: 
" + message);
+    assertEquals(NoSuchMethodException.class, 
exception.getCause().getCause().getClass(),
+        "The original NoSuchMethodException must stay in the chain - it is the 
evidence #12902 needed");
+  }
+
+  /**
+   * The clean version-skew case, and the one the mismatch message used to 
claim. {@code getConstructor}
+   * resolves the parameter types of every public constructor, so a jar built 
against an older Hudi dies on a
+   * type that has since moved. That is an {@link Error}, so {@code 
ReflectionUtils} never wraps it and it
+   * reaches the factory uncaught.
+   */
+  @Test
+  void metricsReporterFactoryExplainsAVanishedParameterType() {
+    HoodieException exception = captureCloudWatchFailure(
+        new 
NoClassDefFoundError("org/apache/hudi/config/metrics/HoodieMetricsConfig"));
+
+    String message = exception.getMessage();
+    assertTrue(message.contains("built against a different Hudi version"),
+        () -> "The failure should name version skew, but was: " + message);
+    
assertTrue(message.contains("org/apache/hudi/config/metrics/HoodieMetricsConfig"),
+        () -> "The failure should name the type that vanished, but was: " + 
message);
+    assertEquals(NoClassDefFoundError.class, exception.getCause().getClass(),
+        "The Error must stay in the chain - its message is the evidence of 
skew");
+  }
+
+  /**
+   * The gap every mocked test above shares: none of them proves that real 
reflection produces the shapes the
+   * production code branches on. This drives the translation through the real 
{@code ReflectionUtils} with a
+   * fixture whose only public constructor does not match, and asserts on the 
actual throwable.
+   */
+  @Test
+  void metricsReporterFactoryTranslatesARealReflectionFailure() {
+    HoodieException exception = assertThrows(HoodieException.class,
+        () -> MetricsReporterFactory.createCloudWatchReporter(
+            MismatchedReporter.class.getName(), metricsConfig, registry));
+
+    String message = exception.getMessage();
+    assertTrue(message.contains("stale or duplicate copy"),
+        () -> "A real non-matching constructor should reach the mismatch 
branch, but was: " + message);
+    assertEquals(NoSuchMethodException.class, 
exception.getCause().getCause().getClass(),
+        "and the real NoSuchMethodException should be chained");
+  }
+
+  /** Public, with a single constructor that deliberately does not match 
(HoodieMetricsConfig, MetricRegistry). */
+  public static class MismatchedReporter {
+    public MismatchedReporter(String somethingElse) {
+      // never called; exists so getConstructor has a public constructor to 
reject
+    }
+  }
+
+  /**
+   * The other direction, and the branch most likely to regress: a failure 
that is neither a missing class
+   * nor a missing constructor must pass through untouched, so an error raised 
by the reporter's own
+   * constructor is never rewritten into a classpath diagnosis.
+   */
+  @Test
+  void metricsReporterFactoryLeavesOtherFailuresUntouched() {
     
when(metricsConfig.getMetricsReporterType()).thenReturn(MetricsReporterType.CLOUDWATCH);
     try (MockedStatic<ReflectionUtils> mockedStatic = 
Mockito.mockStatic(ReflectionUtils.class)) {
       mockedStatic.when(() -> ReflectionUtils.loadClass(
           eq(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS), 
any(Class[].class), eq(metricsConfig), eq(registry)))
           .thenThrow(new HoodieException("Unable to instantiate class " + 
MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS,
-              new NoSuchMethodException("<init>")));
+              new InvocationTargetException(new IllegalStateException("no AWS 
region configured"))));
 
       HoodieException exception = assertThrows(HoodieException.class,
           () -> MetricsReporterFactory.createReporter(metricsConfig, 
registry));
       assertEquals("Unable to instantiate class " + 
MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS,
-          exception.getMessage(), "A non-ClassNotFound failure must not be 
rewritten");
+          exception.getMessage(),
+          "A failure that is neither a missing class nor a missing constructor 
must not be rewritten");
     }
   }
 
@@ -177,6 +263,17 @@ class TestMetricsReporterFactory {
     assertThrows(HoodieException.class, () -> 
MetricsReporterFactory.createReporter(metricsConfig, registry));
   }
 
+  private HoodieException captureCloudWatchFailure(Throwable 
reflectionFailure) {
+    
when(metricsConfig.getMetricsReporterType()).thenReturn(MetricsReporterType.CLOUDWATCH);
+    try (MockedStatic<ReflectionUtils> mockedStatic = 
Mockito.mockStatic(ReflectionUtils.class)) {
+      mockedStatic.when(() -> ReflectionUtils.loadClass(
+          eq(MetricsReporterFactory.CLOUDWATCH_REPORTER_CLASS), 
any(Class[].class), eq(metricsConfig), eq(registry)))
+          .thenThrow(reflectionFailure);
+      return assertThrows(HoodieException.class,
+          () -> MetricsReporterFactory.createReporter(metricsConfig, 
registry));
+    }
+  }
+
   public static class DummyMetricsReporter extends CustomizableMetricsReporter 
{
 
     public DummyMetricsReporter(Properties props, MetricRegistry registry) {

Reply via email to