Skip to content

feat(common): add hoodie.table.version.pinned ceiling guard - #20009

Open
shangxinli wants to merge 1 commit into
apache:masterfrom
shangxinli:feat/table-version-pin
Open

shangxinli wants to merge 1 commit into
apache:masterfrom
shangxinli:feat/table-version-pin

Conversation

@shangxinli

@shangxinli shangxinli commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Describe the issue this Pull Request addresses

There is no way to stop a table from being upgraded past a known-good table version. During a staged 0.14 -> 1.x rollout an accidental auto-upgrade can silently make a table unreadable by older readers, or block a rollback, and it is usually only noticed later when a reader fails.

Summary and Changelog

Adds an opt-in ceiling on hoodie.table.version. Exceeding the pin throws HoodieTableVersionPinExceededException and increments a per-table counter; at or below the pin is unaffected. Default UN_PINNED disables the check.

  • New config hoodie.table.version.pinned, settable per table in hoodie.properties or as a JVM system property of the same key for a fleet-wide pin. The table value wins; a blank one falls through instead of shadowing it.
  • Enforced from one helper at three points: HoodieTableConfig.getTableVersion (reads), HoodieTableConfig.setTableVersion (backstop for all writers, including table creation), and UpgradeDowngrade.run, which checks up front so it fails before any rollback or compaction.
  • A pin violation from the nested metadata table upgrade is rethrown unwrapped, since it is a config decision rather than a metadata table failure.
  • Counter goes through common.metrics.Registry, so any configured MetricsReporter picks it up. Emission failures are swallowed and cannot mask the exception.

Impact

None by default — UN_PINNED leaves all existing paths unchanged. When set, reads and writes above the pin fail fast by design.

Risk Level

low

Gated behind a config that defaults to disabled. 19 new tests cover ceiling semantics, disabled paths, system-property precedence, malformed/negative/blank values, pinning an existing table, and metric emission. Full hudi-common, hudi-client-common and hudi-hadoop-common suites pass unchanged.

Documentation Update

The new config carries a full withDocumentation description and is picked up by the generated config docs. No other website change needed.

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

Adds an opt-in ceiling on hoodie.table.version so an accidental upgrade is
caught before it silently makes a table unreadable by older readers or blocks
a rollback. Loading or writing a table whose version exceeds the pin throws
HoodieTableVersionPinExceededException and increments a per-table counter;
a version at or below the pin is unaffected.

The pin can be set per table in hoodie.properties, or as a JVM system property
of the same key to apply it fleet-wide without editing every table. The table
value takes precedence, and a blank table-level value falls through to the
system property rather than silently shadowing it. The default UN_PINNED
disables the check, so there is no behavior change unless it is set.

Enforcement lives in a single helper invoked from three points:
 - HoodieTableConfig.getTableVersion(HoodieConfig), covering read paths
 - HoodieTableConfig.setTableVersion, the backstop for every writer,
   including new-table creation via HoodieTableMetaClient.TableBuilder
 - UpgradeDowngrade.run, which validates the target version up front so it
   fails before any rollback or compaction work is attempted

A pin violation raised by the nested metadata table upgrade is rethrown as-is
rather than wrapped, since it is a configuration decision to honor and not a
metadata table failure that disabling the metadata table would resolve.

The counter is emitted through org.apache.hudi.common.metrics.Registry, so any
configured MetricsReporter picks it up without extra wiring; emission failures
are swallowed and can never mask the thrown exception.
@github-actions github-actions Bot added the size:L PR with lines of changes in (300, 1000] label Sep 19, 2026
@codecov-commenter

codecov-commenter commented Sep 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.13043% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.35%. Comparing base (f3a334e) to head (3cd655d).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...rg/apache/hudi/common/table/HoodieTableConfig.java 92.30% 2 Missing and 1 partial ⚠️
...rg/apache/hudi/table/upgrade/UpgradeDowngrade.java 33.33% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #20009      +/-   ##
============================================
+ Coverage     80.34%   80.35%   +0.01%     
- Complexity    34748    34765      +17     
============================================
  Files          2545     2546       +1     
  Lines        142493   142539      +46     
  Branches      17312    17317       +5     
============================================
+ Hits         114489   114544      +55     
+ Misses        20107    20098       -9     
  Partials       7897     7897              
Components Coverage Δ
hudi-common 83.91% <93.02%> (+0.01%) ⬆️
hudi-client 83.38% <33.33%> (+0.01%) ⬆️
hudi-flink 85.71% <ø> (ø)
hudi-spark-datasource 73.77% <ø> (-0.01%) ⬇️
hudi-utilities 78.18% <ø> (ø)
hudi-cli 69.99% <ø> (ø)
hudi-hadoop 70.91% <ø> (+0.01%) ⬆️
hudi-sync 76.02% <ø> (ø)
hudi-io 81.61% <ø> (+0.09%) ⬆️
hudi-timeline-service 83.67% <ø> (+0.32%) ⬆️
hudi-cloud 81.00% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 52.21% <89.13%> (+0.01%) ⬆️
flink-integration-tests 49.45% <32.60%> (-0.01%) ⬇️
hadoop-mr-java-client 43.92% <32.60%> (+0.04%) ⬆️
integration-tests 13.47% <26.08%> (+<0.01%) ⬆️
spark-client-hadoop-common 38.58% <67.39%> (+0.01%) ⬆️
spark-java-tests 52.35% <89.13%> (+0.03%) ⬆️
spark-scala-tests 46.97% <34.78%> (+0.02%) ⬆️
utilities 36.83% <32.60%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...eption/HoodieTableVersionPinExceededException.java 100.00% <100.00%> (ø)
...rg/apache/hudi/table/upgrade/UpgradeDowngrade.java 80.41% <33.33%> (-0.74%) ⬇️
...rg/apache/hudi/common/table/HoodieTableConfig.java 94.59% <92.30%> (-0.14%) ⬇️

... and 14 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

@danny0405

Copy link
Copy Markdown
Contributor

There is no way to stop a table from being upgraded past a known-good table version

Since 1.0.0, Hudi introduced a write option hoodie.write.auto.upgrade to enable flexible upgrade/downgrade, we can cherry pick this function into 0.x-branch if that is your needs.

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 "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loading or writing a table whose version exceeds this value throws immediately

Instead of upgrade first then validate and throw, I would suggest we disable the upgrade in the very first place which is more clear and also user-friendly.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 +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.

.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 "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a JVM system property of the same key (this config value takes precedence over the system

looks risky and might cause config leaks.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for working on this! The PR adds an opt-in hoodie.table.version.pinned ceiling enforced in HoodieTableConfig.getTableVersion/setTableVersion and up front in UpgradeDowngrade.run, with a new exception type and a per-table counter. A few things worth double-checking in the inline comments: the fail-fast guarantee doesn't cover the eager rollback that BaseHoodieWriteClient.tryUpgrade performs before run is entered, a pin value above this binary's newest known version currently makes the table unloadable rather than being trivially satisfied, and the counter is only consumed by Metrics.flush()/shutdown() so it may never reach a reporter. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here. A few naming/consistency nits below (mostly around the "pin" terminology), otherwise the change is well-structured and tested.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

throw new NumberFormatException("Table version pin must not be negative: " + versionCode);
}
// Validates that the code corresponds to a recognized table version.
HoodieTableVersion.fromVersionCode(versionCode);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

*/
private static void emitVersionPinExceededMetric(String tableName) {
try {
Registry.getRegistryOfClass(tableName, "hoodie.table.version.pin", LocalRegistry.class.getName())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

+ " 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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).

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

Option<Integer> pinnedVersionCode = resolvePinnedVersionCode(config);
if (!pinnedVersionCode.isPresent() || version.versionCode() <= pinnedVersionCode.get()) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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?

⚠️ AI-generated; verify before applying. React 👍/👎 to flag quality.

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 "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 +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.

.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 "

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L PR with lines of changes in (300, 1000]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants