hudi-agent commented on code in PR #20009:
URL: https://github.com/apache/hudi/pull/20009#discussion_r4056064975


##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/upgrade/UpgradeDowngrade.java:
##########
@@ -175,6 +176,10 @@ public boolean needsUpgrade(HoodieTableVersion 
toWriteVersion) {
    * @param instantTime current instant time that should not be touched.
    */
   public void run(HoodieTableVersion toVersion, String instantTime) {
+    // Fail fast, before any rollback/compaction work is attempted, if the 
target version exceeds the
+    // configured table version pin.
+    metaClient.getTableConfig().validateVersionPin(toVersion);

Review Comment:
   🤖 This does fail before the rollback/compaction inside `run`, but the main 
writer entry point `BaseHoodieWriteClient.tryUpgrade` (L1690–1702) calls 
`needsUpgrade` and then eagerly rolls back all failed/inflight writes *before* 
it ever reaches `run`, so a pinned table with a higher 
`hoodie.write.table.version` will still get those rollbacks and only then hit 
the pin. Would it make sense to also check the pin in 
`needsUpgrade`/`needsUpgradeOrDowngrade` (or at the top of `tryUpgrade`) so the 
writer really does no work first?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java:
##########
@@ -793,9 +806,79 @@ public static String 
getPartitionFieldWithoutKeyGenPartitionType(String partitio
    * This function returns the hoodie.table.version from hoodie.properties 
file.
    */
   public static HoodieTableVersion getTableVersion(HoodieConfig config) {
-    return contains(VERSION, config)
+    HoodieTableVersion version = contains(VERSION, config)
         ? HoodieTableVersion.fromVersionCode(config.getInt(VERSION))
         : VERSION.defaultValue();
+    enforceVersionPin(config, version);
+    return version;
+  }
+
+  private static boolean isBlank(String value) {
+    return value == null || value.trim().isEmpty();
+  }
+
+  /**
+   * Resolves the configured {@link #MAX_ALLOWED_TABLE_VERSION} (falling back 
to the JVM system property of the
+   * same key) into a version code, or {@link Option#empty()} if the pin is 
disabled (unset, blank, or
+   * {@code UN_PINNED}).
+   */
+  private static Option<Integer> resolvePinnedVersionCode(HoodieConfig config) 
{
+    String rawValue = contains(MAX_ALLOWED_TABLE_VERSION, config)
+        ? config.getString(MAX_ALLOWED_TABLE_VERSION)
+        : null;
+    // A blank table-level value counts as unset rather than as an explicit 
opt-out, so that it cannot
+    // silently shadow a fleet-wide pin supplied through the system property.
+    if (isBlank(rawValue)) {
+      rawValue = System.getProperty(MAX_ALLOWED_TABLE_VERSION.key());
+    }
+    if (isBlank(rawValue) || 
MAX_ALLOWED_TABLE_VERSION.defaultValue().equalsIgnoreCase(rawValue.trim())) {
+      return Option.empty();
+    }
+    try {
+      int versionCode = Integer.parseInt(rawValue.trim());
+      if (versionCode < 0) {
+        throw new NumberFormatException("Table version pin must not be 
negative: " + versionCode);
+      }
+      // Validates that the code corresponds to a recognized table version.
+      HoodieTableVersion.fromVersionCode(versionCode);

Review Comment:
   🤖 If a fleet-wide pin (system property) is set to a version code that this 
particular binary doesn't know yet (e.g. pin=11 while an older reader in the 
same fleet only knows up to 10), this throws and the table becomes unloadable 
for that reader, even though its actual version is fine. Since a pin above the 
newest version this binary knows can never be exceeded, would it be safer to 
treat that case as satisfied (or clamp to the max known version) rather than 
fail?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java:
##########
@@ -793,9 +806,79 @@ public static String 
getPartitionFieldWithoutKeyGenPartitionType(String partitio
    * This function returns the hoodie.table.version from hoodie.properties 
file.
    */
   public static HoodieTableVersion getTableVersion(HoodieConfig config) {
-    return contains(VERSION, config)
+    HoodieTableVersion version = contains(VERSION, config)
         ? HoodieTableVersion.fromVersionCode(config.getInt(VERSION))
         : VERSION.defaultValue();
+    enforceVersionPin(config, version);
+    return version;
+  }
+
+  private static boolean isBlank(String value) {
+    return value == null || value.trim().isEmpty();
+  }
+
+  /**
+   * Resolves the configured {@link #MAX_ALLOWED_TABLE_VERSION} (falling back 
to the JVM system property of the
+   * same key) into a version code, or {@link Option#empty()} if the pin is 
disabled (unset, blank, or
+   * {@code UN_PINNED}).
+   */
+  private static Option<Integer> resolvePinnedVersionCode(HoodieConfig config) 
{
+    String rawValue = contains(MAX_ALLOWED_TABLE_VERSION, config)
+        ? config.getString(MAX_ALLOWED_TABLE_VERSION)
+        : null;
+    // A blank table-level value counts as unset rather than as an explicit 
opt-out, so that it cannot
+    // silently shadow a fleet-wide pin supplied through the system property.
+    if (isBlank(rawValue)) {
+      rawValue = System.getProperty(MAX_ALLOWED_TABLE_VERSION.key());
+    }
+    if (isBlank(rawValue) || 
MAX_ALLOWED_TABLE_VERSION.defaultValue().equalsIgnoreCase(rawValue.trim())) {
+      return Option.empty();
+    }
+    try {
+      int versionCode = Integer.parseInt(rawValue.trim());
+      if (versionCode < 0) {
+        throw new NumberFormatException("Table version pin must not be 
negative: " + versionCode);
+      }
+      // Validates that the code corresponds to a recognized table version.
+      HoodieTableVersion.fromVersionCode(versionCode);
+      return Option.of(versionCode);
+    } catch (NumberFormatException | HoodieException e) {
+      throw new HoodieTableVersionPinExceededException(
+          "Invalid value for " + MAX_ALLOWED_TABLE_VERSION.key() + ": '" + 
rawValue
+              + "'. Must be '" + MAX_ALLOWED_TABLE_VERSION.defaultValue() + "' 
or a recognized table version code.", e);
+    }
+  }
+
+  /**
+   * Throws {@link HoodieTableVersionPinExceededException} and emits a metric 
when {@code version} exceeds the
+   * configured {@link #MAX_ALLOWED_TABLE_VERSION} ceiling. A version at or 
below the pin is a no-op. Disabled
+   * entirely (no-op, no metric) when the pin is unset/{@code UN_PINNED}.
+   */
+  private static void enforceVersionPin(HoodieConfig config, 
HoodieTableVersion version) {
+    Option<Integer> pinnedVersionCode = resolvePinnedVersionCode(config);
+    if (!pinnedVersionCode.isPresent() || version.versionCode() <= 
pinnedVersionCode.get()) {
+      return;
+    }
+    String tableName = contains(NAME, config) ? config.getString(NAME) : 
"unknown";
+    emitVersionPinExceededMetric(tableName);
+    throw new HoodieTableVersionPinExceededException(String.format(
+        "Table version %s (code %d) for table '%s' exceeds the pin %d 
configured via '%s'. Refusing to proceed "
+            + "to avoid silently upgrading a table past its pinned version.",
+        version, version.versionCode(), tableName, pinnedVersionCode.get(), 
MAX_ALLOWED_TABLE_VERSION.key()));
+  }
+
+  /**
+   * Emits a per-table counter metric via {@link Registry} when a table 
version pin violation is detected. Failures
+   * emitting the metric are swallowed so they can never mask the {@link 
HoodieTableVersionPinExceededException}
+   * thrown by the caller.
+   */
+  private static void emitVersionPinExceededMetric(String tableName) {
+    try {
+      Registry.getRegistryOfClass(tableName, "hoodie.table.version.pin", 
LocalRegistry.class.getName())

Review Comment:
   🤖 The description says any configured `MetricsReporter` picks this up, but 
the only consumers of `Registry.getAllMetrics` are 
`Metrics.flush()`/`shutdown()`, which exist only when a write client has 
metrics enabled and run at commit/JVM-exit. On the read path (or a failed write 
that never reaches a flush) the counter just accumulates in the static 
`REGISTRY_MAP`. Is that the intended visibility, or should this be documented 
as best-effort?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java:
##########
@@ -180,6 +183,16 @@ public class HoodieTableConfig extends HoodieConfig {
           + " to identify what upgrade/downgrade paths happened on the table. 
This is only configured "
           + "when the table is initially setup.");
 
+  public static final ConfigProperty<String> MAX_ALLOWED_TABLE_VERSION = 
ConfigProperty
+      .key("hoodie.table.version.pinned")
+      .defaultValue("UN_PINNED")
+      .withDocumentation("Ceiling on hoodie.table.version: loading or writing 
a table whose version exceeds "
+          + "this value throws immediately, so an accidental upgrade is caught 
before it silently makes the "
+          + "table unreadable by older readers. A version at or below the pin 
is unaffected. Also honored as "
+          + "a JVM system property of the same key (this config value takes 
precedence over the system "

Review Comment:
   🤖 Agree this is worth reconsidering. The system property is consulted on 
every `getTableVersion()` call inside hudi-common, so one long-lived JVM 
(shared Spark driver, Flink JM, timeline server) with the property set would 
apply the pin to every table it touches, including reads of unrelated tables 
above the pin. It also inverts the usual precedence here since any table's 
`hoodie.properties` can set `UN_PINNED` to opt out of the fleet-wide pin. A 
write-config option flowing through `HoodieWriteConfig` would fit the existing 
pattern better.



##########
hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java:
##########
@@ -180,6 +183,16 @@ public class HoodieTableConfig extends HoodieConfig {
           + " to identify what upgrade/downgrade paths happened on the table. 
This is only configured "
           + "when the table is initially setup.");
 
+  public static final ConfigProperty<String> MAX_ALLOWED_TABLE_VERSION = 
ConfigProperty
+      .key("hoodie.table.version.pinned")
+      .defaultValue("UN_PINNED")
+      .withDocumentation("Ceiling on hoodie.table.version: loading or writing 
a table whose version exceeds "

Review Comment:
   🤖 +1, and there's a concrete reason to scope this to the upgrade path only: 
`UpgradeDowngrade.run()` calls `getTableVersion()` to get `fromVersion`, and 
that now throws whenever the current version is above the pin. So a table that 
has already slipped past the pin can't be downgraded back — which is the 
rollback scenario the PR description says it wants to protect. Gating only 
`needsUpgrade`/the upgrade branch (and leaving `getTableVersion` alone) would 
avoid that.



##########
hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java:
##########
@@ -180,6 +183,16 @@ public class HoodieTableConfig extends HoodieConfig {
           + " to identify what upgrade/downgrade paths happened on the table. 
This is only configured "
           + "when the table is initially setup.");
 
+  public static final ConfigProperty<String> MAX_ALLOWED_TABLE_VERSION = 
ConfigProperty

Review Comment:
   🤖 nit: `MAX_ALLOWED_TABLE_VERSION` doesn't match the config key 
`hoodie.table.version.pinned` — a reader searching for the pin field by name 
would look for something like `TABLE_VERSION_PIN` first. Consider renaming the 
constant to match the key/concept used elsewhere (e.g. `TABLE_VERSION_PINNED`).
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



##########
hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableConfig.java:
##########
@@ -793,9 +806,79 @@ public static String 
getPartitionFieldWithoutKeyGenPartitionType(String partitio
    * This function returns the hoodie.table.version from hoodie.properties 
file.
    */
   public static HoodieTableVersion getTableVersion(HoodieConfig config) {
-    return contains(VERSION, config)
+    HoodieTableVersion version = contains(VERSION, config)
         ? HoodieTableVersion.fromVersionCode(config.getInt(VERSION))
         : VERSION.defaultValue();
+    enforceVersionPin(config, version);
+    return version;
+  }
+
+  private static boolean isBlank(String value) {
+    return value == null || value.trim().isEmpty();
+  }
+
+  /**
+   * Resolves the configured {@link #MAX_ALLOWED_TABLE_VERSION} (falling back 
to the JVM system property of the
+   * same key) into a version code, or {@link Option#empty()} if the pin is 
disabled (unset, blank, or
+   * {@code UN_PINNED}).
+   */
+  private static Option<Integer> resolvePinnedVersionCode(HoodieConfig config) 
{
+    String rawValue = contains(MAX_ALLOWED_TABLE_VERSION, config)
+        ? config.getString(MAX_ALLOWED_TABLE_VERSION)
+        : null;
+    // A blank table-level value counts as unset rather than as an explicit 
opt-out, so that it cannot
+    // silently shadow a fleet-wide pin supplied through the system property.
+    if (isBlank(rawValue)) {
+      rawValue = System.getProperty(MAX_ALLOWED_TABLE_VERSION.key());
+    }
+    if (isBlank(rawValue) || 
MAX_ALLOWED_TABLE_VERSION.defaultValue().equalsIgnoreCase(rawValue.trim())) {
+      return Option.empty();
+    }
+    try {
+      int versionCode = Integer.parseInt(rawValue.trim());
+      if (versionCode < 0) {
+        throw new NumberFormatException("Table version pin must not be 
negative: " + versionCode);
+      }
+      // Validates that the code corresponds to a recognized table version.
+      HoodieTableVersion.fromVersionCode(versionCode);
+      return Option.of(versionCode);
+    } catch (NumberFormatException | HoodieException e) {
+      throw new HoodieTableVersionPinExceededException(
+          "Invalid value for " + MAX_ALLOWED_TABLE_VERSION.key() + ": '" + 
rawValue
+              + "'. Must be '" + MAX_ALLOWED_TABLE_VERSION.defaultValue() + "' 
or a recognized table version code.", e);
+    }
+  }
+
+  /**
+   * Throws {@link HoodieTableVersionPinExceededException} and emits a metric 
when {@code version} exceeds the
+   * configured {@link #MAX_ALLOWED_TABLE_VERSION} ceiling. A version at or 
below the pin is a no-op. Disabled
+   * entirely (no-op, no metric) when the pin is unset/{@code UN_PINNED}.
+   */
+  private static void enforceVersionPin(HoodieConfig config, 
HoodieTableVersion version) {
+    Option<Integer> pinnedVersionCode = resolvePinnedVersionCode(config);
+    if (!pinnedVersionCode.isPresent() || version.versionCode() <= 
pinnedVersionCode.get()) {
+      return;
+    }

Review Comment:
   🤖 nit: `resolvePinnedVersionCode` mixes resolution, validation, and 
error-throwing for malformed values in one method — could you split 
parsing/validation into a small helper so the happy-path resolution logic reads 
more linearly?
   
   <sub><i>⚠️ AI-generated; verify before applying. React 👍/👎 to flag 
quality.</i></sub>



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to