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 989badc3952e fix(common): normalize hudi table base path in 
implicit-key lock providers (#18814)
989badc3952e is described below

commit 989badc3952e05052754dc1f028800c660646aee
Author: Davis-Zhang-Onehouse 
<[email protected]>
AuthorDate: Mon Aug 17 10:21:10 2026 -0700

    fix(common): normalize hudi table base path in implicit-key lock providers 
(#18814)
    
    * fix(common): normalize hudi table base path in implicit-key lock providers
    
    The implicit-key lock providers - 
DynamoDBBasedImplicitPartitionKeyLockProvider
    and ZookeeperBasedImplicitBasePathLockProvider - hash the hudi table base 
path
    to derive a DynamoDB partition key / Zookeeper znode for the lock. The hash
    function (XXH64) is avalanche: any byte-level difference in the input 
produces
    a completely different output. Today the only normalization applied before
    hashing is s3aToS3 (s3a:// -> s3://). Trailing slashes, repeated slashes, 
and
    surrounding whitespace pass through unchanged.
    
    When two writers for the same hudi table disagree on those benign formatting
    details - e.g. one engine supplies "s3://bucket/table" while another 
supplies
    "s3://bucket/table/" - they end up acquiring different lock rows / znodes 
and
    lose mutual exclusion, even though they are targeting the same table. Two
    writers that should serialize can then write concurrently and corrupt the
    hudi timeline.
    
    Fix: introduce FSUtils.normalizeBasePathForLocking() as the single source of
    truth for canonicalization before hashing:
    
      1. Reject null / empty / whitespace-only basePath.
      2. trim() surrounding whitespace.
      3. Apply existing s3aToS3 (case-insensitive s3a:// -> s3://).
      4. Strip all trailing '/' then add exactly one.
    
    Both implicit-key lock providers now route through it. The Dynamo provider
    gains a small public static derivePartitionKey(String) so the formula is
    testable without a DynamoDB client.
    
    Inner consecutive slashes (s3://b//x vs s3://b/x) are intentionally NOT
    collapsed - they can resolve to a legitimate S3 key.
    
    Tests:
      - TestFSUtils#testNormalizeBasePathForLocking exercises the normalization
        rules directly: trailing slash, multi-slash, whitespace, s3a/s3 schemes,
        inner-slash preservation, null/empty rejection.
      - TestDynamoDBBasedImplicitPartitionKeyLockProvider verifies that all
        trailing-slash, multi-slash, whitespace, and s3a variants of the same
        base path produce the same DynamoDB partition key.
      - TestZookeeperBasedImplicitBasePathLockProvider verifies the same
        invariant for the Zookeeper lock base path.
    
    Compatibility note: locks taken under the previous (no-trailing-slash or
    whitespace-sensitive) form effectively orphan at deploy time, since the new
    code looks at a different lock row / znode for the same logical table.
    Deploys should be coordinated across all writers that share a hudi table,
    or accept a brief writer quiesce while rolling out.
    
    * Address review: reject scheme-only paths, SLF4J logging, IT fix
    
    - normalizeBasePathForLocking: reject scheme-only inputs (s3://, s3a:///)
      and all-slash inputs that strip to nothing meaningful to hash
    - DynamoDBBasedImplicitPartitionKeyLockProvider#derivePartitionKey:
      switch String.format to SLF4J parameterized logging (consistent with
      the sibling ZK provider); javadoc note explaining the static helper
      accepts raw input (super-constructor ordering precludes using the
      instance field)
    - ZookeeperBasedImplicitBasePathLockProvider#getLockBasePath: same
      javadoc note for symmetry
    - ITTestDynamoDBBasedLockProvider#testAcquireLock: compare against the
      normalized (trailing-slash) form of the hash input — the previous
      assertion would have failed after the canonicalization change
    - TestFSUtils: cover s3:///, s3a:///, all-slash, and a special-char path
    
    * Address review: rename field to normalizedHudiTableBasePath
    
    The private field stores the post-normalization basePath; rename it so
    the name reflects that storage shape, matching what the constructor
    actually assigns.
    
    * Address review: narrow scheme-only reject so S3 keys ending in ':' pass
    
    The previous heuristic rejected any input whose stripped form ended with
    ':', which inadvertently rejected legitimate S3 keys like
    "s3://bucket/foo:" or "s3://bucket/foo:bar:" — S3 object keys are
    allowed to contain ':'. That was a silent behavior change vs the
    pre-PR s3aToS3-only code.
    
    Narrow the rejection: only treat ":"-terminating inputs as scheme-only
    when no '/' character remains in the stripped form. Scheme-only inputs
    ("s3://", "s3a:///") collapse to "<scheme>:" with no '/' and are still
    rejected; legitimate paths retain the '/' characters from the "://"
    separator and pass through.
    
    Tests cover both the new accept-path (final segment ending in ':') and
    the still-rejected scheme-only cases.
    
    * Address review: canonicalize base path without trailing slash
    
    Per @yihua's review: previous releases hashed the basePath after only
    s3aToS3 (no trailing slash), and most callers supply a basePath without
    a trailing slash. Forcing exactly one trailing slash therefore changed
    the derived lock key for the common case vs the prior release.
    
    Strip all trailing slashes instead of appending one. This keeps the
    canonical form (and the resulting DynamoDB partition key / ZK znode)
    identical to the prior release for the common no-trailing-slash callers,
    while still collapsing trailing-slash / multi-slash / whitespace / s3a
    variants of the same table to a single lock key.
    
    Updated TestFSUtils#testNormalizeBasePathForLocking and the
    ITTestDynamoDBBasedLockProvider assertion to the no-trailing-slash form.
    
    * review(18814): fix normalizer idempotence, pin lock keys, cover contention
    
    - normalizeBasePathForLocking now strips trailing '/' and whitespace in a 
single
      pass. trim-then-strip left a trailing space on "s3://b/t /", which hashed 
to a
      different lock than "s3://b/t" and broke the idempotence both providers 
document.
    - Pin the derived DynamoDB partition key and ZK znode to golden literals, 
and assert
      the no-trailing-slash form still equals hash(s3aToS3(basePath)), so the
      upgrade-stability claim is enforced somewhere that actually runs. The 
only prior
      golden assertion lives in an IT that has been @Disabled since HUDI-7475.
    - Add a two-writer contention test on the existing TestingServer harness: 
base paths
      differing only by a trailing slash must contend for one znode. Fails on 
master.
    - Route InProcessLockProvider through the same helper. Degenerate paths 
such as
      HoodieTimeGeneratorConfig.defaultConfig("") fall back to the raw string.
    - Collapse the duplicated provider tests into parameterized ones and drop 
the
      s3a-scheme cases, which passed with or without the fix.
    - Document the rollout for trailing-slash tables and what is deliberately 
left
      un-normalized. Revert the no-op @Disabled IT hunk. Plain ASCII.
    
    * review(18814): drop the InProcessLockProvider change from this PR
    
    It is a distinct bug in a distinct provider (in-process map, no rollout
    implications), so it belongs in its own PR rather than riding along with the
    implicit-key lock provider fix.
    
    It also does not apply here. Master's package reorg (#19193, 3cc8fd128b40) 
moved
    the real class to org.apache.hudi.core.transaction.lock and left a 
@Deprecated
    @CompatAlias shim at the old path, so this edit both conflicted with master 
and
    would have landed on the shim. The follow-up needs to be authored against
    core/transaction/lock/InProcessLockProvider.java:79, which still keys
    LOCK_INSTANCE_PER_BASEPATH on the raw base path.
    
    * review(18814): fix checkstyle breakage and close the residual idempotence 
hole
    
    Round-2 review of the previous commit turned up two build blockers and one 
real
    correctness gap.
    
    - Two new test methods were named `aBasePath...`, which violates checkstyle
      MethodName (^[a-z][a-z0-9][a-zA-Z0-9_]*$) and, since checkstyle is bound 
to the
      compile phase with failOnViolation and includeTestSourceDirectory, failed 
the
      build for hudi-aws and hudi-client-common. Renamed.
    - The strip loop used Character.isWhitespace while the preceding trim() uses
      c <= ' '. Those are different sets: U+0000-U+0008 and U+000E-U+001B are 
stripped
      by trim but not by isWhitespace, so a path ending in one of those 
followed by a
      slash still normalized to a value that itself normalized further, i.e. it 
was
      not idempotent. The loop now uses c <= ' ' to match trim exactly. Fuzzing 
all
      inputs up to length 4 over an alphabet of slash, every whitespace class, 
control
      chars, colon and letters: 44266 accepted, 0 non-idempotent, 0 that reject 
their
      own output. Added control-char cases to the idempotence test.
    - Documented that scheme roots are rejected for every scheme, not just s3, 
and
      that this is a behaviour change rather than tightened validation: those 
inputs
      previously hashed to a working key and now fail at provider construction. 
Added
      file:/, file://, file:///, hdfs:/ and gs:/// to the reject test.
    - Documented the trailing-whitespace trade-off: a base path differing only 
by
      trailing whitespace now shares a lock. Over-serializing is the safe 
direction.
    - Restored the import order in 
DynamoDBBasedImplicitPartitionKeyLockProvider to
      match master, which fixed it in #18886 and raised ImportOrder to 
severity=error.
      Without this the file fails checkstyle once rebased.
    - Nested the contention test's two providers so writerA's curator client is 
closed
      if writerB's constructor throws.
    
    Verified with checkstyle 9.3 against both this branch's config and master's
    stricter one: 0 violations in all 7 changed files.
    
    * review(18814): warn when a lock key actually moves, and correct two 
javadocs
    
    Round-3 review items.
    
    - Nothing signalled when a writer's lock key had actually changed. Both 
providers
      logged raw + normalized at INFO on every construction, so the writers 
that must
      not do a rolling upgrade looked identical in the logs to the ones that 
are fine.
      Both now emit a WARN, naming the old and new key, exactly when the 
canonical
      form differs from the pre-change s3aToS3 form. Verified it stays silent 
for
      s3a:// inputs, whose key did not move.
    - The javadoc justified leaving inner consecutive slashes and scheme 
spelling
      alone on the grounds that collapsing them "could map unrelated tables 
onto one
      lock". That is false: HoodieTableMetaClient:196 wraps the base path in a
      StoragePath, which collapses both, so s3://b/x//t and s3://b/x/t are the 
same
      table and still derive different lock keys. Reworded as the known 
residual gap
      it is, with the reason it is out of scope here. URL encoding stays a 
genuine
      deliberate exclusion.
    - The ZK provider's javadoc pointed at a caller that does not exist: the
      normalizedHudiTableBasePath field is only ever read by 
generateLogSuffixString
      and is never passed back into getLockBasePath. Dropped the parenthetical.
    - Marked derivePartitionKey @VisibleForTesting; its only out-of-class 
caller is
      the same-package test.
    
    Checkstyle 9.3 against both this branch's config and master's: 0 violations.
    
    * review(18814): drop the @VisibleForTesting annotation to keep the merge 
clean
    
    The annotation's import was the only line making this file's hudi import 
group
    differ from master's, which #18886 had already rewritten. Both sides 
editing the
    same block made the merge conflict even though the results were compatible. 
The
    annotation was cosmetic (the only out-of-class caller is a same-package 
test), so
    dropping it is cheaper than asking a committer to hand-resolve an import 
block.
    
    ---------
    
    Co-authored-by: voon <[email protected]>
---
 ...amoDBBasedImplicitPartitionKeyLockProvider.java |  55 ++++++++---
 ...amoDBBasedImplicitPartitionKeyLockProvider.java |  90 ++++++++++++++++++
 ...ZookeeperBasedImplicitBasePathLockProvider.java |  46 ++++++++--
 .../TestZookeeperBasedLockProvider.java            |  53 +++++++++++
 ...ZookeeperBasedImplicitBasePathLockProvider.java |  90 ++++++++++++++++++
 .../java/org/apache/hudi/common/fs/FSUtils.java    |  80 ++++++++++++++++
 .../org/apache/hudi/common/fs/TestFSUtils.java     | 101 ++++++++++++++++++++-
 7 files changed, 493 insertions(+), 22 deletions(-)

diff --git 
a/hudi-aws/src/main/java/org/apache/hudi/aws/transaction/lock/DynamoDBBasedImplicitPartitionKeyLockProvider.java
 
b/hudi-aws/src/main/java/org/apache/hudi/aws/transaction/lock/DynamoDBBasedImplicitPartitionKeyLockProvider.java
index 1ada2c42949a..11fd1ba7ec58 100644
--- 
a/hudi-aws/src/main/java/org/apache/hudi/aws/transaction/lock/DynamoDBBasedImplicitPartitionKeyLockProvider.java
+++ 
b/hudi-aws/src/main/java/org/apache/hudi/aws/transaction/lock/DynamoDBBasedImplicitPartitionKeyLockProvider.java
@@ -30,6 +30,7 @@ import 
software.amazon.awssdk.services.dynamodb.DynamoDbClient;
 
 import javax.annotation.concurrent.NotThreadSafe;
 
+import static org.apache.hudi.common.fs.FSUtils.normalizeBasePathForLocking;
 import static org.apache.hudi.common.fs.FSUtils.s3aToS3;
 
 /**
@@ -41,8 +42,8 @@ import static org.apache.hudi.common.fs.FSUtils.s3aToS3;
 public class DynamoDBBasedImplicitPartitionKeyLockProvider extends 
DynamoDBBasedLockProviderBase {
   protected static final Logger LOG = 
LoggerFactory.getLogger(DynamoDBBasedImplicitPartitionKeyLockProvider.class);
 
-  private final String hudiTableBasePath;
- 
+  private final String normalizedHudiTableBasePath;
+
   public DynamoDBBasedImplicitPartitionKeyLockProvider(final LockConfiguration 
lockConfiguration, final StorageConfiguration<?> conf) {
     this(lockConfiguration, conf, null);
   }
@@ -50,24 +51,56 @@ public class DynamoDBBasedImplicitPartitionKeyLockProvider 
extends DynamoDBBased
   public DynamoDBBasedImplicitPartitionKeyLockProvider(
       final LockConfiguration lockConfiguration, final StorageConfiguration<?> 
conf, DynamoDbClient dynamoDB) {
     super(lockConfiguration, conf, dynamoDB);
-    hudiTableBasePath = 
s3aToS3(lockConfiguration.getConfig().getString(HoodieCommonConfig.BASE_PATH.key()));
+    normalizedHudiTableBasePath = normalizeBasePathForLocking(
+        
lockConfiguration.getConfig().getString(HoodieCommonConfig.BASE_PATH.key()));
+  }
+
+  /**
+   * Compute the DynamoDB partition key for a given Hudi table base path. 
Exposed as a static
+   * helper so that the formula is testable without standing up a DynamoDB 
client.
+   *
+   * <p>Accepts a raw basePath - normalization is applied here. {@code 
normalizeBasePathForLocking}
+   * is idempotent, so passing an already-normalized path is safe. Note that 
the instance field
+   * {@code normalizedHudiTableBasePath} cannot be used here: the parent 
constructor invokes this
+   * through {@code getDynamoDBPartitionKey} before the subclass has a chance 
to assign the field.
+   * That ordering is also why the path is normalized twice per construction - 
once here and once
+   * for the field; it is a pure function over a short string, run once per 
provider.
+   *
+   * <p>ROLLOUT: for a table whose configured {@code hoodie.base.path} ends in 
'/' or carries
+   * surrounding whitespace, this returns a different DynamoDB partition key 
than releases before
+   * HUDI's normalization fix. Such a table must have all of its writers 
upgraded together - a
+   * rolling upgrade would leave old and new writers on two different lock 
rows for the same
+   * table, losing mutual exclusion. Base paths without a trailing slash are 
unaffected.
+   */
+  public static String derivePartitionKey(String hudiTableBasePath) {
+    String normalized = normalizeBasePathForLocking(hudiTableBasePath);
+    String partitionKey = HashID.generateXXHashAsString(normalized, 
HashID.Size.BITS_64);
+    LOG.info("The DynamoDB partition key of the lock provider for the base 
path {} (normalized: {}) is {}",
+        hudiTableBasePath, normalized, partitionKey);
+    // Releases before this change hashed s3aToS3(basePath) directly. When the 
canonical form
+    // differs, this writer has moved to a new lock row and cannot exclude a 
writer still running
+    // the old code, so say so loudly rather than leaving it to be inferred 
from the INFO line.
+    String legacyForm = s3aToS3(hudiTableBasePath);
+    if (!legacyForm.equals(normalized)) {
+      LOG.warn("DynamoDB partition key for base path {} moved from {} to {}. 
Every writer of this "
+              + "table must be upgraded together; a writer still on the 
previous release locks on "
+              + "the old partition key and will NOT be excluded by this one.",
+          hudiTableBasePath,
+          HashID.generateXXHashAsString(legacyForm, HashID.Size.BITS_64),
+          partitionKey);
+    }
+    return partitionKey;
   }
 
   @Override
   public String getDynamoDBPartitionKey(LockConfiguration lockConfiguration) {
-    // Ensure consistent format for S3 URI.
-    String hudiTableBasePathNormalized = 
s3aToS3(lockConfiguration.getConfig().getString(
-        HoodieCommonConfig.BASE_PATH.key()));
-    String partitionKey = 
HashID.generateXXHashAsString(hudiTableBasePathNormalized, HashID.Size.BITS_64);
-    LOG.info(String.format("The DynamoDB partition key of the lock provider 
for the base path %s is %s",
-        hudiTableBasePathNormalized, partitionKey));
-    return partitionKey;
+    return 
derivePartitionKey(lockConfiguration.getConfig().getString(HoodieCommonConfig.BASE_PATH.key()));
   }
 
   @Override
   protected String generateLogSuffixString() {
     return StringUtils.join("DynamoDb table = ", tableName,
         ", partition key = ", dynamoDBPartitionKey,
-        ", hudi table base path = ", hudiTableBasePath);
+        ", hudi table base path = ", normalizedHudiTableBasePath);
   }
 }
diff --git 
a/hudi-aws/src/test/java/org/apache/hudi/aws/transaction/lock/TestDynamoDBBasedImplicitPartitionKeyLockProvider.java
 
b/hudi-aws/src/test/java/org/apache/hudi/aws/transaction/lock/TestDynamoDBBasedImplicitPartitionKeyLockProvider.java
new file mode 100644
index 000000000000..7030b69c9b8a
--- /dev/null
+++ 
b/hudi-aws/src/test/java/org/apache/hudi/aws/transaction/lock/TestDynamoDBBasedImplicitPartitionKeyLockProvider.java
@@ -0,0 +1,90 @@
+/*
+ * 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.hudi.aws.transaction.lock;
+
+import org.apache.hudi.common.util.hash.HashID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Exercises {@link 
DynamoDBBasedImplicitPartitionKeyLockProvider#derivePartitionKey} as a pure
+ * function - no DynamoDB client required.
+ *
+ * <p>Two writers on the same Hudi table must derive the same DynamoDB 
partition key, or they take
+ * independent locks and lose mutual exclusion. This class pins both halves of 
that contract: the
+ * exact key produced for the canonical base path (so the formula cannot move 
silently), and the
+ * set of benign formatting variants that must fold onto it.
+ */
+class TestDynamoDBBasedImplicitPartitionKeyLockProvider {
+
+  private static final String CANONICAL_BASE_PATH = 
"s3://my-bucket/my_lake/my_table";
+
+  /**
+   * Golden value. Deliberately a literal rather than a recomputation: an 
equality-only test
+   * ({@code key(a).equals(key(b))}) still passes if the whole derivation 
changes, which is
+   * exactly how a lock-key scheme change ships undetected.
+   */
+  private static final String CANONICAL_PARTITION_KEY = "C0E15D0CE1AD11CC";
+
+  @Test
+  void derivesThePinnedPartitionKeyForTheCanonicalBasePath() {
+    assertEquals(CANONICAL_PARTITION_KEY,
+        
DynamoDBBasedImplicitPartitionKeyLockProvider.derivePartitionKey(CANONICAL_BASE_PATH));
+  }
+
+  @Test
+  void basePathWithoutTrailingSlashKeepsThePreNormalizationKey() {
+    // Releases before the normalization fix hashed s3aToS3(basePath) 
directly. For the common
+    // no-trailing-slash form the canonicalized input is byte-identical, so 
the partition key must
+    // not move - otherwise every deployed lock row is orphaned on upgrade.
+    assertEquals(HashID.generateXXHashAsString(CANONICAL_BASE_PATH, 
HashID.Size.BITS_64),
+        
DynamoDBBasedImplicitPartitionKeyLockProvider.derivePartitionKey(CANONICAL_BASE_PATH));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "s3://my-bucket/my_lake/my_table/",
+      "s3://my-bucket/my_lake/my_table//",
+      "s3://my-bucket/my_lake/my_table///",
+      "  s3://my-bucket/my_lake/my_table  ",
+      "\ts3://my-bucket/my_lake/my_table/\n",
+      // Whitespace in front of the trailing slash: the strip must consume 
both, otherwise a
+      // trailing space survives and this hashes to a different row than the 
canonical form.
+      "s3://my-bucket/my_lake/my_table /",
+      "s3a://my-bucket/my_lake/my_table",
+      "s3a://my-bucket/my_lake/my_table/",
+      "S3A://my-bucket/my_lake/my_table//",
+  })
+  void benignFormattingVariantsFoldOntoTheCanonicalPartitionKey(String 
basePath) {
+    assertEquals(CANONICAL_PARTITION_KEY,
+        
DynamoDBBasedImplicitPartitionKeyLockProvider.derivePartitionKey(basePath));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"", "   ", "/", "///", "s3://", "s3a:///"})
+  void unlockableBasePathsAreRejected(String basePath) {
+    assertThrows(IllegalArgumentException.class,
+        () -> 
DynamoDBBasedImplicitPartitionKeyLockProvider.derivePartitionKey(basePath));
+  }
+}
diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/ZookeeperBasedImplicitBasePathLockProvider.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/ZookeeperBasedImplicitBasePathLockProvider.java
index e3e26e58c598..587ece951769 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/ZookeeperBasedImplicitBasePathLockProvider.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/transaction/lock/ZookeeperBasedImplicitBasePathLockProvider.java
@@ -22,7 +22,6 @@ import org.apache.hudi.common.config.HoodieCommonConfig;
 import org.apache.hudi.common.config.LockConfiguration;
 import org.apache.hudi.common.lock.LockProvider;
 import org.apache.hudi.common.util.StringUtils;
-import org.apache.hudi.common.util.ValidationUtils;
 import org.apache.hudi.common.util.hash.HashID;
 import org.apache.hudi.storage.StorageConfiguration;
 
@@ -30,6 +29,7 @@ import lombok.extern.slf4j.Slf4j;
 
 import javax.annotation.concurrent.NotThreadSafe;
 
+import static org.apache.hudi.common.fs.FSUtils.normalizeBasePathForLocking;
 import static org.apache.hudi.common.fs.FSUtils.s3aToS3;
 
 /**
@@ -44,25 +44,51 @@ import static org.apache.hudi.common.fs.FSUtils.s3aToS3;
 public class ZookeeperBasedImplicitBasePathLockProvider extends 
BaseZookeeperBasedLockProvider {
 
   public static final String LOCK_KEY = "lock_key";
-  private final String hudiTableBasePath;
+  private final String normalizedHudiTableBasePath;
 
+  /**
+   * Compute the Zookeeper lock base path for a given Hudi table base path.
+   *
+   * <p>Accepts a raw basePath - normalization is applied here. {@code 
normalizeBasePathForLocking}
+   * is idempotent, so an already-normalized value can be passed through 
without harm.
+   *
+   * <p>ROLLOUT: for a table whose configured {@code hoodie.base.path} ends in 
'/' or carries
+   * surrounding whitespace, this returns a different znode than releases 
before HUDI's
+   * normalization fix. Such a table must have all of its writers upgraded 
together - a rolling
+   * upgrade would leave old and new writers holding two different znodes for 
the same table,
+   * losing mutual exclusion. Base paths without a trailing slash are 
unaffected.
+   */
   public static String getLockBasePath(String hudiTableBasePath) {
-    // Ensure consistent format for S3 URI.
-    String lockBasePath = "/tmp/" + 
HashID.generateXXHashAsString(s3aToS3(hudiTableBasePath), HashID.Size.BITS_64);
-    log.info("The Zookeeper lock key for the base path {} is {}", 
hudiTableBasePath, lockBasePath);
+    String normalized = normalizeBasePathForLocking(hudiTableBasePath);
+    String lockBasePath = "/tmp/" + HashID.generateXXHashAsString(normalized, 
HashID.Size.BITS_64);
+    log.info("The Zookeeper lock key for the base path {} (normalized: {}) is 
{}",
+        hudiTableBasePath, normalized, lockBasePath);
+    // Releases before this change hashed s3aToS3(basePath) directly. When the 
canonical form
+    // differs, this writer has moved to a new znode and cannot exclude a 
writer still running
+    // the old code, so say so loudly rather than leaving it to be inferred 
from the INFO line.
+    String legacyForm = s3aToS3(hudiTableBasePath);
+    if (!legacyForm.equals(normalized)) {
+      log.warn("Zookeeper lock key for base path {} moved from {} to {}. Every 
writer of this "
+              + "table must be upgraded together; a writer still on the 
previous release locks "
+              + "on the old znode and will NOT be excluded by this one.",
+          hudiTableBasePath,
+          "/tmp/" + HashID.generateXXHashAsString(legacyForm, 
HashID.Size.BITS_64),
+          lockBasePath);
+    }
     return lockBasePath;
   }
 
   public ZookeeperBasedImplicitBasePathLockProvider(final LockConfiguration 
lockConfiguration, final StorageConfiguration<?> conf) {
     super(lockConfiguration, conf);
-    hudiTableBasePath = 
s3aToS3(lockConfiguration.getConfig().getString(HoodieCommonConfig.BASE_PATH.key()));
+    normalizedHudiTableBasePath = normalizeBasePathForLocking(
+        
lockConfiguration.getConfig().getString(HoodieCommonConfig.BASE_PATH.key()));
   }
 
   @Override
   protected String getZkBasePath(LockConfiguration lockConfiguration) {
-    String hudiTableBasePath = 
lockConfiguration.getConfig().getString(HoodieCommonConfig.BASE_PATH.key());
-    ValidationUtils.checkArgument(hudiTableBasePath != null);
-    return getLockBasePath(hudiTableBasePath);
+    // No explicit null check: TypedProperties#getString already throws 
IllegalArgumentException
+    // for a missing key, and getLockBasePath rejects null/blank/unlockable 
paths.
+    return 
getLockBasePath(lockConfiguration.getConfig().getString(HoodieCommonConfig.BASE_PATH.key()));
   }
 
   @Override
@@ -73,6 +99,6 @@ public class ZookeeperBasedImplicitBasePathLockProvider 
extends BaseZookeeperBas
   @Override
   protected String generateLogSuffixString() {
     return StringUtils.join("ZkBasePath = ", zkBasePath,
-        ", lock key = ", lockKey, ", hudi table base path = ", 
hudiTableBasePath);
+        ", lock key = ", lockKey, ", hudi table base path = ", 
normalizedHudiTableBasePath);
   }
 }
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestZookeeperBasedLockProvider.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestZookeeperBasedLockProvider.java
index fa1a8329b65f..8bd4a79e272d 100644
--- 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestZookeeperBasedLockProvider.java
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/TestZookeeperBasedLockProvider.java
@@ -165,6 +165,59 @@ public class TestZookeeperBasedLockProvider {
     Assertions.assertEquals(IllegalArgumentException.class, 
ex.getCause().getCause().getClass());
   }
 
+  /**
+   * Build an implicit-provider lock config for {@code hudiTableBasePath}, 
independent of the
+   * shared fixtures above (those are mutated progressively in {@link 
#setup()}).
+   */
+  private static LockConfiguration implicitLockConfig(String 
hudiTableBasePath) {
+    Properties props = new Properties();
+    props.setProperty(ZK_CONNECT_URL_PROP_KEY, server.getConnectString());
+    props.setProperty(LOCK_ACQUIRE_RETRY_WAIT_TIME_IN_MILLIS_PROP_KEY, "1000");
+    props.setProperty(LOCK_ACQUIRE_RETRY_MAX_WAIT_TIME_IN_MILLIS_PROP_KEY, 
"3000");
+    props.setProperty(LOCK_ACQUIRE_CLIENT_NUM_RETRIES_PROP_KEY, "3");
+    props.setProperty(LOCK_ACQUIRE_NUM_RETRIES_PROP_KEY, "3");
+    props.setProperty(ZK_SESSION_TIMEOUT_MS_PROP_KEY, "10000");
+    props.setProperty(ZK_CONNECTION_TIMEOUT_MS_PROP_KEY, "10000");
+    props.setProperty(LOCK_ACQUIRE_WAIT_TIMEOUT_MS_PROP_KEY, "1000");
+    props.setProperty(HoodieCommonConfig.BASE_PATH.key(), hudiTableBasePath);
+    props.setProperty(HoodieTableConfig.HOODIE_TABLE_NAME_KEY, 
"ma_po_tofu_is_awesome");
+    return new LockConfiguration(props);
+  }
+
+  /**
+   * Two writers on the SAME table whose base paths differ only by a trailing 
slash must contend
+   * for one znode. Before the base path was canonicalized they hashed to two 
different znodes and
+   * both acquired, silently losing mutual exclusion and letting concurrent 
writers corrupt the
+   * timeline. This is the end-to-end guard for that; the pure-function 
coverage lives in
+   * {@code TestZookeeperBasedImplicitBasePathLockProvider}.
+   */
+  @Test
+  void testTrailingSlashBasePathContendsForTheSameLock() {
+    String tableBasePath = 
"s3://my-bucket-8b2a4b30/1718662238400/be715573/my_lake/contended_table";
+    // Each constructor starts its own curator client, so they are nested 
rather than created
+    // side by side: if writerB's constructor throws, writerA still gets 
closed.
+    ZookeeperBasedImplicitBasePathLockProvider writerA =
+        new 
ZookeeperBasedImplicitBasePathLockProvider(implicitLockConfig(tableBasePath), 
null);
+    try {
+      ZookeeperBasedImplicitBasePathLockProvider writerB =
+          new 
ZookeeperBasedImplicitBasePathLockProvider(implicitLockConfig(tableBasePath + 
"/"), null);
+      try {
+        Assertions.assertTrue(writerA.tryLock(1000, TimeUnit.MILLISECONDS));
+        // BaseZookeeperBasedLockProvider#tryLock throws rather than returning 
false when the
+        // mutex cannot be acquired within the timeout.
+        Assertions.assertThrows(HoodieLockException.class,
+            () -> writerB.tryLock(1000, TimeUnit.MILLISECONDS),
+            "Writer B derived a different znode for the same table and lost 
mutual exclusion");
+      } finally {
+        // close() releases the lock if held and then shuts the curator client 
down, so it
+        // covers unlock() as well and never throws.
+        writerB.close();
+      }
+    } finally {
+      writerA.close();
+    }
+  }
+
   @Test
   public void testUnLock() {
     ZookeeperBasedLockProvider zookeeperBasedLockProvider = new 
ZookeeperBasedLockProvider(zkConfWithZkBasePathAndLockKeyLock, null);
diff --git 
a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestZookeeperBasedImplicitBasePathLockProvider.java
 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestZookeeperBasedImplicitBasePathLockProvider.java
new file mode 100644
index 000000000000..af6bcd3783c5
--- /dev/null
+++ 
b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/client/transaction/lock/TestZookeeperBasedImplicitBasePathLockProvider.java
@@ -0,0 +1,90 @@
+/*
+ * 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.hudi.client.transaction.lock;
+
+import org.apache.hudi.common.util.hash.HashID;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Exercises {@link 
ZookeeperBasedImplicitBasePathLockProvider#getLockBasePath} as a pure function
+ * - no Zookeeper server required. See
+ * {@code 
TestZookeeperBasedLockProvider#testTrailingSlashBasePathContendsForTheSameLock} 
for the
+ * end-to-end proof that two providers deriving the same znode actually 
exclude each other.
+ *
+ * <p>The lock base path is a znode that deployed clusters hold, so this pins 
the exact string
+ * rather than only asserting that variants agree with each other.
+ */
+class TestZookeeperBasedImplicitBasePathLockProvider {
+
+  private static final String CANONICAL_BASE_PATH = 
"s3://my-bucket/my_lake/my_table";
+
+  /**
+   * Golden value. Deliberately a literal rather than a recomputation: an 
equality-only test
+   * ({@code path(a).equals(path(b))}) still passes if the whole derivation 
changes, which is
+   * exactly how a lock-key scheme change ships undetected.
+   */
+  private static final String CANONICAL_LOCK_BASE_PATH = 
"/tmp/C0E15D0CE1AD11CC";
+
+  @Test
+  void derivesThePinnedLockBasePathForTheCanonicalBasePath() {
+    assertEquals(CANONICAL_LOCK_BASE_PATH,
+        
ZookeeperBasedImplicitBasePathLockProvider.getLockBasePath(CANONICAL_BASE_PATH));
+  }
+
+  @Test
+  void basePathWithoutTrailingSlashKeepsThePreNormalizationZnode() {
+    // Releases before the normalization fix hashed s3aToS3(basePath) 
directly. For the common
+    // no-trailing-slash form the canonicalized input is byte-identical, so 
the znode must not
+    // move - otherwise every in-flight lock is orphaned on upgrade.
+    assertEquals("/tmp/" + HashID.generateXXHashAsString(CANONICAL_BASE_PATH, 
HashID.Size.BITS_64),
+        
ZookeeperBasedImplicitBasePathLockProvider.getLockBasePath(CANONICAL_BASE_PATH));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "s3://my-bucket/my_lake/my_table/",
+      "s3://my-bucket/my_lake/my_table//",
+      "s3://my-bucket/my_lake/my_table///",
+      "  s3://my-bucket/my_lake/my_table  ",
+      "\ts3://my-bucket/my_lake/my_table/\n",
+      // Whitespace in front of the trailing slash: the strip must consume 
both, otherwise a
+      // trailing space survives and this hashes to a different znode than the 
canonical form.
+      "s3://my-bucket/my_lake/my_table /",
+      "s3a://my-bucket/my_lake/my_table",
+      "s3a://my-bucket/my_lake/my_table/",
+      "S3A://my-bucket/my_lake/my_table//",
+  })
+  void benignFormattingVariantsFoldOntoTheCanonicalLockBasePath(String 
basePath) {
+    assertEquals(CANONICAL_LOCK_BASE_PATH,
+        ZookeeperBasedImplicitBasePathLockProvider.getLockBasePath(basePath));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"", "   ", "/", "///", "s3://", "s3a:///"})
+  void unlockableBasePathsAreRejected(String basePath) {
+    assertThrows(IllegalArgumentException.class,
+        () -> 
ZookeeperBasedImplicitBasePathLockProvider.getLockBasePath(basePath));
+  }
+}
diff --git a/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java 
b/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java
index 448438aee017..de9b61988a25 100644
--- a/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java
+++ b/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java
@@ -789,6 +789,86 @@ public class FSUtils {
     return s3aUrl.replaceFirst("(?i)^s3a://", "s3://");
   }
 
+  /**
+   * Canonicalize a Hudi table base path for use as the input to implicit 
lock-key derivation.
+   *
+   * <p>Implicit lock providers (DynamoDB and Zookeeper variants) hash this 
string to choose the
+   * lock row / znode for a table. Two callers writing to the same table must 
produce the same
+   * hash, so any benign formatting drift in the basePath has to be eliminated 
before hashing.
+   * This method normalizes s3a:// to s3://, then strips every trailing '/' 
and whitespace
+   * character in a single pass.
+   *
+   * <p>The single pass is what makes the result idempotent. Trimming first 
and stripping
+   * slashes afterwards leaves a trailing space behind on an input like {@code 
"s3://b/t /"},
+   * which would then hash differently from {@code "s3://b/t"} - the very 
drift this method
+   * exists to remove.
+   *
+   * <p>No trailing slash is appended: most callers already supply a basePath 
without one, so
+   * the canonical form matches what previous releases hashed (which applied 
only s3aToS3) for
+   * those callers, keeping the derived lock key stable across the upgrade. 
Callers that did
+   * supply a trailing slash (or surrounding whitespace) DO move to a new lock 
key, so all
+   * writers of such a table must be upgraded together rather than one at a 
time.
+   *
+   * <p>NOT normalized here, and for two different reasons:
+   * <ul>
+   *   <li>Inner consecutive slashes ({@code "s3://b//x"} vs {@code 
"s3://b/x"}) and scheme
+   *       spelling ({@code "file:///x"} vs {@code "file:/x"}) are a KNOWN 
RESIDUAL GAP, not a
+   *       safety choice. {@code HoodieTableMetaClient} wraps the base path in 
a
+   *       {@code StoragePath}, which collapses both, so those spellings 
genuinely address the
+   *       same table while still deriving different lock keys. Left alone 
only to keep this
+   *       change's rollout surface to the drift actually seen in the field; 
closing it moves
+   *       more keys and wants its own change.</li>
+   *   <li>URL encoding is deliberately left alone - Hudi does not re-encode 
paths internally,
+   *       and an encoded and decoded spelling are not interchangeable to the 
storage layer.</li>
+   * </ul>
+   *
+   * <p>Scheme-root inputs for any scheme (e.g. {@code "s3://"}, {@code 
"s3a:///"},
+   * {@code "file:///"}, {@code "hdfs:/"}) and all-slash inputs (e.g. {@code 
"/"},
+   * {@code "///"}) are rejected - stripping the trailing slashes from those 
leaves nothing
+   * meaningful to lock against. Note this is a behaviour change: those inputs 
previously
+   * hashed to a working lock key, and now fail the writer at provider 
construction with
+   * {@link IllegalArgumentException}. Paths whose final key segment 
legitimately ends with
+   * {@code ':'} (e.g. {@code "s3://bucket/foo:/"}) are preserved - S3 object 
keys are allowed
+   * to contain {@code ':'}.
+   *
+   * <p>One consequence of stripping trailing whitespace: a base path that 
differs from another
+   * only by trailing whitespace shares its lock. Such a key is legal in S3 
but not something
+   * Hudi can address, since {@code StoragePath} keeps it distinct while the 
rest of the config
+   * plumbing does not. Over-serializing two such tables is the safe direction 
to err in.
+   */
+  public static String normalizeBasePathForLocking(String basePath) {
+    if (basePath == null) {
+      throw new IllegalArgumentException("Hudi table base path cannot be 
null");
+    }
+    String trimmed = basePath.trim();
+    if (trimmed.isEmpty()) {
+      throw new IllegalArgumentException("Hudi table base path cannot be 
empty");
+    }
+    String schemeNormalized = s3aToS3(trimmed);
+    // Strip trailing slashes and whitespace together, not in two separate 
passes - see the
+    // idempotence note above. The whitespace test is deliberately `<= ' '` 
rather than
+    // Character.isWhitespace: it has to match String#trim above exactly. 
isWhitespace is a
+    // different set (it excludes U+0000-U+0008 and U+000E-U+001B, which trim 
does strip), and
+    // any disagreement between the two reopens the non-idempotence this loop 
exists to close.
+    int end = schemeNormalized.length();
+    while (end > 0
+        && (schemeNormalized.charAt(end - 1) == '/'
+            || schemeNormalized.charAt(end - 1) <= ' ')) {
+      end--;
+    }
+    // Reject "///"-style inputs (nothing left after stripping) and 
scheme-only inputs
+    // like "s3://" / "s3a:///" - those collapse to just "<scheme>:" with no 
'/' character
+    // remaining. Real paths that end with ':' (e.g. "s3://bucket/foo:/") keep 
the '/'
+    // characters from the scheme's "://" separator, so they pass this check.
+    if (end == 0
+        || (schemeNormalized.charAt(end - 1) == ':'
+            && schemeNormalized.lastIndexOf('/', end - 1) < 0)) {
+      throw new IllegalArgumentException(
+          "Hudi table base path is not a valid lockable path: '" + basePath + 
"'");
+    }
+    return schemeNormalized.substring(0, end);
+  }
+
   public static StoragePathInfo toStoragePathInfo(HoodieFileStatus fileStatus) 
{
     if (null == fileStatus) {
       return null;
diff --git 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/fs/TestFSUtils.java 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/fs/TestFSUtils.java
index 45daacb9eeb2..97263c70b5b4 100644
--- 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/fs/TestFSUtils.java
+++ 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/fs/TestFSUtils.java
@@ -49,7 +49,9 @@ import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.MethodSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.IOException;
@@ -790,11 +792,108 @@ public class TestFSUtils extends HoodieCommonTestHarness 
{
       "gs://my-bucket/path/to/s3a://object",
       "gs://my-bucket s3a://my-object",
   })
-  
   void testUriDoesNotChange(String uri) {
     assertEquals(uri, FSUtils.s3aToS3(uri));
   }
 
+  static Stream<Arguments> normalizeBasePathForLockingCases() {
+    return Stream.of(
+        // Canonical form strips all trailing slashes (none is appended).
+        Arguments.of("s3://my-bucket/path", "s3://my-bucket/path"),
+        Arguments.of("s3://my-bucket/path/", "s3://my-bucket/path"),
+        Arguments.of("s3://my-bucket/path///", "s3://my-bucket/path"),
+        // s3a:// is normalized to s3:// (delegates to s3aToS3), 
case-insensitively.
+        Arguments.of("s3a://my-bucket/path", "s3://my-bucket/path"),
+        Arguments.of("S3A://my-bucket/path/", "s3://my-bucket/path"),
+        // Whitespace surrounding the path is stripped.
+        Arguments.of("  s3://my-bucket/path  ", "s3://my-bucket/path"),
+        Arguments.of("\ts3a://my-bucket/path/\n", "s3://my-bucket/path"),
+        // Whitespace BETWEEN the path and its trailing slashes must be 
stripped too. Trimming
+        // first and stripping slashes afterwards leaves "s3://my-bucket/path 
" here, which
+        // hashes to a different lock than the canonical form and is not 
idempotent.
+        Arguments.of("s3://my-bucket/path /", "s3://my-bucket/path"),
+        Arguments.of("s3://my-bucket/path // ", "s3://my-bucket/path"),
+        // Non-S3 schemes pass through (still get trailing-slash stripping).
+        Arguments.of("gs://my-bucket/path", "gs://my-bucket/path"),
+        Arguments.of("gs://my-bucket/path//", "gs://my-bucket/path"),
+        // Inner consecutive slashes are intentionally NOT touched (could be a 
real S3 key).
+        Arguments.of("s3://my-bucket//inner/path", 
"s3://my-bucket//inner/path"),
+        // S3 object keys are allowed to end with ':' - a final-segment colon 
must NOT be
+        // mis-classified as the "scheme-only" case. The trailing ':' is part 
of the key and
+        // is preserved after any trailing slashes are stripped.
+        Arguments.of("s3://my-bucket/foo:", "s3://my-bucket/foo:"),
+        Arguments.of("s3://my-bucket/foo:/", "s3://my-bucket/foo:"),
+        Arguments.of("s3://my-bucket/foo:bar:/", "s3://my-bucket/foo:bar:"),
+        Arguments.of("s3a://my-bucket/foo:///", "s3://my-bucket/foo:"));
+  }
+
+  /**
+   * Every benign formatting variant of one table's base path must 
canonicalize to the same
+   * string, since implicit lock providers hash this to pick a lock row / 
znode.
+   */
+  @ParameterizedTest
+  @MethodSource("normalizeBasePathForLockingCases")
+  void testNormalizeBasePathForLocking(String input, String expected) {
+    assertEquals(expected, FSUtils.normalizeBasePathForLocking(input));
+  }
+
+  @Test
+  void testNormalizeBasePathForLockingPreservesUnusualCharacters() {
+    // URL-unsafe and equals/colon/plus/hash/ampersand/space characters pass 
through unchanged
+    // except for the trailing-slash and s3a-scheme rules. Hudi does not 
re-encode paths
+    // internally so the lock key must be byte-stable across these characters.
+    assertEquals(
+        
"s3://my-bucket/datalake/db=foo:bar/dt=2024-01-01T00:00:00+05:30/region=us 
east/category=a&b=c/vehicle#1/file",
+        FSUtils.normalizeBasePathForLocking(
+            
"s3a://my-bucket/datalake/db=foo:bar/dt=2024-01-01T00:00:00+05:30/region=us 
east/category=a&b=c/vehicle#1/file"));
+  }
+
+  /**
+   * The canonical form must be a fixed point. Both implicit lock providers 
document that this
+   * helper is idempotent and pass already-normalized values back through it.
+   */
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "s3://my-bucket/path",
+      "s3://my-bucket/path/",
+      "  s3://my-bucket/path  ",
+      "s3://my-bucket/path /",
+      "s3://my-bucket/path /// ",
+      // Control characters that String#trim strips but Character#isWhitespace 
does not. The
+      // strip loop has to use the same rule as trim, or these reopen the 
non-idempotence.
+      "s3://my-bucket/path\u0001/",
+      "s3://my-bucket/path\u001b//",
+      "\u0002s3a://my-bucket/path\u0008/",
+      "s3a://my-bucket/path//",
+      "s3://my-bucket//inner/path/",
+      "s3://my-bucket/foo:/",
+      "gs://my-bucket/path//",
+  })
+  void testNormalizeBasePathForLockingIsIdempotent(String input) {
+    String once = FSUtils.normalizeBasePathForLocking(input);
+    assertEquals(once, FSUtils.normalizeBasePathForLocking(once));
+  }
+
+  @Test
+  void testNormalizeBasePathForLockingRejectsNull() {
+    assertThrows(IllegalArgumentException.class, () -> 
FSUtils.normalizeBasePathForLocking(null));
+  }
+
+  /**
+   * Empty, all-slash and scheme-only inputs are rejected - stripping leaves 
nothing meaningful
+   * to lock against, and hashing them would collapse unrelated tables onto 
one lock.
+   */
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "", "   ", "/", "///", " / ",
+      // Scheme roots are rejected for every scheme, not just s3. These 
previously hashed to a
+      // working lock key, so this is a behaviour change, not just tightened 
validation.
+      "s3://", "s3:///", "s3a://", "s3a:///", "file:/", "file://", "file:///", 
"hdfs:/", "gs:///",
+  })
+  void testNormalizeBasePathForLockingRejectsUnlockablePaths(String basePath) {
+    assertThrows(IllegalArgumentException.class, () -> 
FSUtils.normalizeBasePathForLocking(basePath));
+  }
+
   private StoragePath getHoodieTempDir() {
     return new StoragePath(baseUri.toString(), ".hoodie/.temp");
   }

Reply via email to