This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new bbad1a0f3a [core][spark] Support pluggable fixed-bucket functions 
(#10662)
bbad1a0f3a is described below

commit bbad1a0f3a62d07eb02f15fb0e1a576ce1740fc6
Author: Luo Zhengjie <[email protected]>
AuthorDate: Tue Oct 27 13:12:55 2026 +0800

    [core][spark] Support pluggable fixed-bucket functions (#10662)
---
 docs/generated/core_configuration.html             |   6 +
 .../main/java/org/apache/paimon/CoreOptions.java   |  15 +++
 .../org/apache/paimon/AppendOnlyFileStore.java     |   6 +-
 .../java/org/apache/paimon/KeyValueFileStore.java  |   2 +-
 .../org/apache/paimon/bucket/BucketFunction.java   |  18 +++
 .../paimon/bucket/BucketFunctionFactory.java       |  45 ++++++++
 .../paimon/operation/BucketSelectConverter.java    |  31 ++++-
 .../apache/paimon/operation/BucketSelector.java    |  36 +++++-
 .../org/apache/paimon/schema/SchemaValidation.java |   8 ++
 .../paimon/bucket/BucketFunctionProviderTest.java  |  46 ++++++++
 .../bucket/ConcurrentBucketFunctionFactory.java    |  67 +++++++++++
 .../paimon/bucket/TestBucketFunctionFactory.java   |  38 +++++++
 .../paimon/operation/BucketSelectorTest.java       |  23 ++++
 .../apache/paimon/table/BucketFilterScanTest.java  |  56 +++++++++
 .../table/sink/FixedBucketRowKeyExtractorTest.java |  29 +++++
 .../services/org.apache.paimon.factories.Factory   |   2 +
 .../scala/org/apache/paimon/spark/PaimonScan.scala |   7 +-
 .../spark/sql/CustomBucketFunction33Test.scala     |  21 ++++
 .../scala/org/apache/paimon/spark/PaimonScan.scala |   3 +-
 .../apache/paimon/spark/PaimonSparkTableBase.scala |   1 +
 .../paimon/spark/commands/PaimonSparkWriter.scala  |   5 +-
 .../paimon/spark/ShiftedBucketFunctionFactory.java |  43 +++++++
 .../services/org.apache.paimon.factories.Factory   |  18 +++
 .../spark/sql/CustomBucketFunctionTest.scala       | 125 +++++++++++++++++++++
 24 files changed, 634 insertions(+), 17 deletions(-)

diff --git a/docs/generated/core_configuration.html 
b/docs/generated/core_configuration.html
index 05585ef5a4..ef54534e46 100644
--- a/docs/generated/core_configuration.html
+++ b/docs/generated/core_configuration.html
@@ -140,6 +140,12 @@ under the License.
             <td>Boolean</td>
             <td>Whether to ignore the order of the buckets when reading data 
from an append-only table.</td>
         </tr>
+        <tr>
+            <td><h5>bucket-function.provider</h5></td>
+            <td style="word-wrap: break-word;">(none)</td>
+            <td>String</td>
+            <td>Optional identifier of a BucketFunctionFactory discovered from 
the classpath. When configured, the provider is used by both writers and bucket 
predicate pruning and takes precedence over bucket-function.type.</td>
+        </tr>
         <tr>
             <td><h5>bucket-function.type</h5></td>
             <td style="word-wrap: break-word;">default</td>
diff --git a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java 
b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
index 97b91e4f29..87fc141839 100644
--- a/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
+++ b/paimon-api/src/main/java/org/apache/paimon/CoreOptions.java
@@ -163,6 +163,17 @@ public class CoreOptions implements Serializable {
                     .defaultValue(BucketFunctionType.DEFAULT)
                     .withDescription("The bucket function for paimon bucket.");
 
+    @Immutable
+    public static final ConfigOption<String> BUCKET_FUNCTION_PROVIDER =
+            key("bucket-function.provider")
+                    .stringType()
+                    .noDefaultValue()
+                    .withDescription(
+                            "Optional identifier of a BucketFunctionFactory 
discovered from the "
+                                    + "classpath. When configured, the 
provider is used by both "
+                                    + "writers and bucket predicate pruning 
and takes precedence "
+                                    + "over bucket-function.type.");
+
     /** Paimon bucket function type. */
     public enum BucketFunctionType implements DescribedEnum {
         DEFAULT(
@@ -3276,6 +3287,10 @@ public class CoreOptions implements Serializable {
         return options.get(BUCKET_FUNCTION_TYPE);
     }
 
+    public Optional<String> bucketFunctionProvider() {
+        return options.getOptional(BUCKET_FUNCTION_PROVIDER);
+    }
+
     public Path path() {
         return path(options.toMap());
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/AppendOnlyFileStore.java 
b/paimon-core/src/main/java/org/apache/paimon/AppendOnlyFileStore.java
index ac3a7e7bc9..b3310d5377 100644
--- a/paimon-core/src/main/java/org/apache/paimon/AppendOnlyFileStore.java
+++ b/paimon-core/src/main/java/org/apache/paimon/AppendOnlyFileStore.java
@@ -146,11 +146,7 @@ public class AppendOnlyFileStore extends 
AbstractFileStore<InternalRow> {
     public AppendOnlyFileStoreScan newScan() {
         BucketSelectConverter bucketSelectConverter =
                 new BucketSelectConverter(
-                        bucketMode(),
-                        options.bucketFunctionType(),
-                        rowType,
-                        partitionType,
-                        bucketKeyType);
+                        bucketMode(), options, rowType, partitionType, 
bucketKeyType);
 
         if (options().dataEvolutionEnabled()) {
             return new DataEvolutionFileStoreScan(
diff --git a/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java 
b/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java
index 4b19e78eae..7675fb7525 100644
--- a/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java
+++ b/paimon-core/src/main/java/org/apache/paimon/KeyValueFileStore.java
@@ -227,7 +227,7 @@ public class KeyValueFileStore extends 
AbstractFileStore<KeyValue> {
         BucketSelectConverter bucketSelectConverter =
                 new BucketSelectConverter(
                         bucketMode(),
-                        options.bucketFunctionType(),
+                        options,
                         schema.logicalRowType(),
                         partitionType,
                         schema.logicalBucketKeyType());
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/bucket/BucketFunction.java 
b/paimon-core/src/main/java/org/apache/paimon/bucket/BucketFunction.java
index 6d5149dd6f..d9480a7c74 100644
--- a/paimon-core/src/main/java/org/apache/paimon/bucket/BucketFunction.java
+++ b/paimon-core/src/main/java/org/apache/paimon/bucket/BucketFunction.java
@@ -20,9 +20,11 @@ package org.apache.paimon.bucket;
 
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.factories.FactoryUtil;
 import org.apache.paimon.types.RowType;
 
 import java.io.Serializable;
+import java.util.Optional;
 
 /** Bucket function. */
 public interface BucketFunction extends Serializable {
@@ -30,6 +32,22 @@ public interface BucketFunction extends Serializable {
     int bucket(BinaryRow row, int numBuckets);
 
     static BucketFunction create(CoreOptions options, RowType bucketKeyType) {
+        Optional<String> provider = options.bucketFunctionProvider();
+        if (provider.isPresent()) {
+            ClassLoader classLoader = 
Thread.currentThread().getContextClassLoader();
+            if (classLoader == null) {
+                classLoader = BucketFunction.class.getClassLoader();
+            }
+            BucketFunction function =
+                    FactoryUtil.discoverFactory(
+                                    classLoader, BucketFunctionFactory.class, 
provider.get())
+                            .create(bucketKeyType, options.toConfiguration());
+            if (function == null) {
+                throw new IllegalArgumentException(
+                        "Bucket function provider '" + provider.get() + "' 
returned null.");
+            }
+            return function;
+        }
         CoreOptions.BucketFunctionType type = options.bucketFunctionType();
         return create(type, bucketKeyType);
     }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/bucket/BucketFunctionFactory.java 
b/paimon-core/src/main/java/org/apache/paimon/bucket/BucketFunctionFactory.java
new file mode 100644
index 0000000000..a48b1ece9e
--- /dev/null
+++ 
b/paimon-core/src/main/java/org/apache/paimon/bucket/BucketFunctionFactory.java
@@ -0,0 +1,45 @@
+/*
+ * 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.paimon.bucket;
+
+import org.apache.paimon.factories.Factory;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.RowType;
+
+/**
+ * Factory for a bucket function shared by table writers and predicate pruning.
+ *
+ * <p>The identifier is persisted as part of the table's physical layout. 
Implementations must
+ * produce the same bucket for the same logical key and bucket count across 
processes and releases.
+ * Incompatible algorithm changes require a new identifier.
+ */
+public interface BucketFunctionFactory extends Factory {
+
+    /**
+     * Creates the bucket function for a table.
+     *
+     * <p>The function must not retain or modify the supplied key row and must 
return a bucket in
+     * {@code [0, numBuckets)}. A factory can be cached by the runtime; it 
must create functions
+     * without relying on per-call mutable factory state. Returned functions 
are not assumed to be
+     * safe to share between concurrent callers.
+     *
+     * @return a non-null bucket function
+     */
+    BucketFunction create(RowType bucketKeyType, Options tableOptions);
+}
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelectConverter.java
 
b/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelectConverter.java
index e441641f7e..2989107383 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelectConverter.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelectConverter.java
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.operation;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.CoreOptions.BucketFunctionType;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.predicate.Predicate;
@@ -25,6 +26,7 @@ import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.TriFilter;
 
+import java.util.Collections;
 import java.util.Optional;
 import java.util.Set;
 
@@ -34,19 +36,41 @@ import static 
org.apache.paimon.predicate.PredicateVisitor.collectFieldNames;
 public class BucketSelectConverter {
 
     private final BucketMode bucketMode;
-    private final BucketFunctionType bucketFunctionType;
+    private final CoreOptions coreOptions;
     private final RowType rowType;
     private final RowType partitionType;
     private final RowType bucketKeyType;
 
+    /**
+     * @deprecated Use {@link #BucketSelectConverter(BucketMode, CoreOptions, 
RowType, RowType,
+     *     RowType)} so custom bucket function providers are preserved.
+     */
+    @Deprecated
     public BucketSelectConverter(
             BucketMode bucketMode,
             BucketFunctionType bucketFunctionType,
             RowType rowType,
             RowType partitionType,
             RowType bucketKeyType) {
+        this(
+                bucketMode,
+                new CoreOptions(
+                        Collections.singletonMap(
+                                CoreOptions.BUCKET_FUNCTION_TYPE.key(),
+                                bucketFunctionType.toString())),
+                rowType,
+                partitionType,
+                bucketKeyType);
+    }
+
+    public BucketSelectConverter(
+            BucketMode bucketMode,
+            CoreOptions coreOptions,
+            RowType rowType,
+            RowType partitionType,
+            RowType bucketKeyType) {
         this.bucketMode = bucketMode;
-        this.bucketFunctionType = bucketFunctionType;
+        this.coreOptions = coreOptions;
         this.rowType = rowType;
         this.partitionType = partitionType;
         this.bucketKeyType = bucketKeyType;
@@ -67,7 +91,6 @@ public class BucketSelectConverter {
         }
 
         return Optional.of(
-                new BucketSelector(
-                        predicate, bucketFunctionType, rowType, partitionType, 
bucketKeyType));
+                new BucketSelector(predicate, coreOptions, rowType, 
partitionType, bucketKeyType));
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelector.java 
b/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelector.java
index 0eeb0b314e..7f5592cccd 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelector.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/BucketSelector.java
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.operation;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.CoreOptions.BucketFunctionType;
 import org.apache.paimon.bucket.BucketFunction;
 import org.apache.paimon.data.BinaryRow;
@@ -38,6 +39,7 @@ import 
org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableSet;
 import javax.annotation.concurrent.ThreadSafe;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
@@ -59,7 +61,7 @@ public class BucketSelector implements ManifestBucketFilter {
 
     public static final int MAX_VALUES = 1000;
 
-    private final BucketFunctionType bucketFunctionType;
+    private final CoreOptions coreOptions;
     private final RowType rowType;
     private final RowType partitionType;
     private final RowType bucketKeyType;
@@ -67,14 +69,36 @@ public class BucketSelector implements ManifestBucketFilter 
{
     private final Map<BinaryRow, Optional<PartitionSelector>> 
partitionSelectors;
     private final Optional<PartitionSelector> manifestSelector;
 
+    /**
+     * @deprecated Use {@link #BucketSelector(Predicate, CoreOptions, RowType, 
RowType, RowType)} so
+     *     custom bucket function providers are preserved.
+     */
+    @Deprecated
     public BucketSelector(
             Predicate predicate,
             BucketFunctionType bucketFunctionType,
             RowType rowType,
             RowType partitionType,
             RowType bucketKeyType) {
+        this(
+                predicate,
+                new CoreOptions(
+                        Collections.singletonMap(
+                                CoreOptions.BUCKET_FUNCTION_TYPE.key(),
+                                bucketFunctionType.toString())),
+                rowType,
+                partitionType,
+                bucketKeyType);
+    }
+
+    public BucketSelector(
+            Predicate predicate,
+            CoreOptions coreOptions,
+            RowType rowType,
+            RowType partitionType,
+            RowType bucketKeyType) {
         this.predicate = predicate;
-        this.bucketFunctionType = bucketFunctionType;
+        this.coreOptions = coreOptions;
         this.rowType = rowType;
         this.partitionType = partitionType;
         this.bucketKeyType = bucketKeyType;
@@ -136,7 +160,9 @@ public class BucketSelector implements ManifestBucketFilter 
{
         @SuppressWarnings("unchecked")
         List<Object>[] bucketValues = new List[bucketKeyType.getFieldCount()];
 
-        BucketFunction bucketFunction = 
BucketFunction.create(bucketFunctionType, bucketKeyType);
+        // Factory discovery is cached by ClassLoader; functions need not be 
safe to share
+        // across concurrent partition selectors.
+        BucketFunction bucketFunction = BucketFunction.create(coreOptions, 
bucketKeyType);
 
         nextAnd:
         for (Predicate andPredicate : splitAnd(bucketPredicate)) {
@@ -243,7 +269,9 @@ public class BucketSelector implements ManifestBucketFilter 
{
                     .contains(bucket);
         }
 
-        private Set<Integer> createBucketSet(int numBucket) {
+        // Different bucket counts can miss the cache concurrently. The 
provider function is
+        // confined to this selector, but is not required to support 
overlapping calls.
+        private synchronized Set<Integer> createBucketSet(int numBucket) {
             ImmutableSet.Builder<Integer> builder = new 
ImmutableSet.Builder<>();
             for (BinaryRow key : bucketKeys) {
                 builder.add(bucketFunction.bucket(key, numBucket));
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java 
b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
index c98600f7ee..e279d64795 100644
--- a/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
+++ b/paimon-core/src/main/java/org/apache/paimon/schema/SchemaValidation.java
@@ -23,6 +23,7 @@ import org.apache.paimon.CoreOptions.ChangelogProducer;
 import org.apache.paimon.CoreOptions.MapStorageLayout;
 import org.apache.paimon.CoreOptions.MergeEngine;
 import org.apache.paimon.TableType;
+import org.apache.paimon.bucket.BucketFunction;
 import org.apache.paimon.data.shredding.MapSharedShreddingUtils;
 import org.apache.paimon.factories.FactoryUtil;
 import org.apache.paimon.fileindex.FileIndexOptions;
@@ -1521,6 +1522,13 @@ public class SchemaValidation {
 
     private static void validateBucket(TableSchema schema, CoreOptions 
options) {
         int bucket = options.bucket();
+        if (options.bucketFunctionProvider().isPresent()) {
+            checkArgument(
+                    bucket > 0,
+                    "A custom bucket-function.provider requires a fixed 
positive bucket count.");
+            // Validate provider discovery and bucket-key compatibility before 
creating the table.
+            BucketFunction.create(options, schema.logicalBucketKeyType());
+        }
         if (bucket == -1) {
             if (options.toMap().get(BUCKET_KEY.key()) != null) {
                 throw new IllegalArgumentException(
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/bucket/BucketFunctionProviderTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/bucket/BucketFunctionProviderTest.java
new file mode 100644
index 0000000000..98279d7b4b
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/bucket/BucketFunctionProviderTest.java
@@ -0,0 +1,46 @@
+/*
+ * 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.paimon.bucket;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.factories.FactoryException;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for bucket function provider discovery. */
+class BucketFunctionProviderTest {
+
+    @Test
+    void missingProviderDoesNotFallBackToDefault() {
+        CoreOptions options =
+                new CoreOptions(
+                        Collections.singletonMap(
+                                "bucket-function.provider", 
"missing-test-provider"));
+        RowType keyType = DataTypes.ROW(DataTypes.FIELD(0, "id", 
DataTypes.INT()));
+
+        assertThatThrownBy(() -> BucketFunction.create(options, keyType))
+                .isInstanceOf(FactoryException.class);
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/bucket/ConcurrentBucketFunctionFactory.java
 
b/paimon-core/src/test/java/org/apache/paimon/bucket/ConcurrentBucketFunctionFactory.java
new file mode 100644
index 0000000000..9f31da22b9
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/bucket/ConcurrentBucketFunctionFactory.java
@@ -0,0 +1,67 @@
+/*
+ * 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.paimon.bucket;
+
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.RowType;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/** A deterministic bucket provider which detects concurrent use of a function 
instance. */
+public class ConcurrentBucketFunctionFactory implements BucketFunctionFactory {
+
+    public static final String IDENTIFIER = "test-concurrent-bucket";
+    public static final String CHECK_CONCURRENCY = 
"test.bucket-function.check-concurrency";
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+
+    @Override
+    public BucketFunction create(RowType bucketKeyType, Options tableOptions) {
+        if (!"true".equals(tableOptions.toMap().get(CHECK_CONCURRENCY))) {
+            return (row, numBuckets) -> numBuckets - 1;
+        }
+        AtomicBoolean active = new AtomicBoolean();
+        AtomicBoolean firstCall = new AtomicBoolean(true);
+        CountDownLatch overlap = new CountDownLatch(1);
+        return (row, numBuckets) -> {
+            if (!active.compareAndSet(false, true)) {
+                overlap.countDown();
+                throw new IllegalStateException("Concurrent bucket function 
invocation");
+            }
+            try {
+                if (firstCall.getAndSet(false)) {
+                    // Give another manifest worker time to request a 
different bucket count.
+                    // Correctly serialized callers cannot enter until this 
bounded wait ends.
+                    overlap.await(1, TimeUnit.SECONDS);
+                }
+                return numBuckets - 1;
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new IllegalStateException(e);
+            } finally {
+                active.set(false);
+            }
+        };
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/bucket/TestBucketFunctionFactory.java
 
b/paimon-core/src/test/java/org/apache/paimon/bucket/TestBucketFunctionFactory.java
new file mode 100644
index 0000000000..f3d03d933b
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/bucket/TestBucketFunctionFactory.java
@@ -0,0 +1,38 @@
+/*
+ * 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.paimon.bucket;
+
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.RowType;
+
+/** Bucket function factory used to verify provider discovery in tests. */
+public class TestBucketFunctionFactory implements BucketFunctionFactory {
+
+    public static final String IDENTIFIER = "test-last-bucket";
+
+    @Override
+    public String identifier() {
+        return IDENTIFIER;
+    }
+
+    @Override
+    public BucketFunction create(RowType bucketKeyType, Options tableOptions) {
+        return (row, numBuckets) -> numBuckets - 1;
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/operation/BucketSelectorTest.java 
b/paimon-core/src/test/java/org/apache/paimon/operation/BucketSelectorTest.java
index 16da67c541..e7762f52e6 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/operation/BucketSelectorTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/operation/BucketSelectorTest.java
@@ -18,7 +18,9 @@
 
 package org.apache.paimon.operation;
 
+import org.apache.paimon.CoreOptions;
 import org.apache.paimon.CoreOptions.BucketFunctionType;
+import org.apache.paimon.bucket.TestBucketFunctionFactory;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.manifest.BucketFilter;
 import org.apache.paimon.manifest.ManifestFileMeta;
@@ -30,6 +32,7 @@ import org.apache.paimon.types.RowType;
 import org.junit.jupiter.api.Test;
 
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.Set;
 
@@ -60,6 +63,26 @@ public class BucketSelectorTest {
         assertThat(selected).hasSize(1);
     }
 
+    @Test
+    public void testCustomProviderIsUsedByPredicatePruning() {
+        RowType rowType = DataTypes.ROW(DataTypes.FIELD(0, "k", 
DataTypes.INT()));
+        RowType partType = RowType.of();
+        Predicate predicate = new PredicateBuilder(rowType).equal(0, 5);
+        CoreOptions options =
+                new CoreOptions(
+                        Collections.singletonMap(
+                                CoreOptions.BUCKET_FUNCTION_PROVIDER.key(),
+                                TestBucketFunctionFactory.IDENTIFIER));
+
+        BucketSelector selector =
+                new BucketSelector(predicate, options, rowType, partType, 
rowType);
+
+        assertThat(selectedBuckets(selector, BinaryRow.EMPTY_ROW, NUM_BUCKETS))
+                .containsExactly(NUM_BUCKETS - 1);
+        assertThat(selector.mayContain(0, NUM_BUCKETS - 2, 
NUM_BUCKETS)).isFalse();
+        assertThat(selector.mayContain(NUM_BUCKETS - 1, NUM_BUCKETS - 1, 
NUM_BUCKETS)).isTrue();
+    }
+
     @Test
     public void testPostponeBucketIsNeverPruned() {
         RowType rowType = DataTypes.ROW(DataTypes.FIELD(0, "k", 
DataTypes.INT()));
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/BucketFilterScanTest.java 
b/paimon-core/src/test/java/org/apache/paimon/table/BucketFilterScanTest.java
index 94494e0406..b9031c13c3 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/BucketFilterScanTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/BucketFilterScanTest.java
@@ -18,16 +18,25 @@
 
 package org.apache.paimon.table;
 
+import org.apache.paimon.bucket.ConcurrentBucketFunctionFactory;
 import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
 import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
 import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.types.DataTypes;
 
 import org.junit.jupiter.api.Test;
 
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -61,6 +70,53 @@ public class BucketFilterScanTest extends TableTestBase {
         testCompositeBucketFilterWithCompoundPredicate(true);
     }
 
+    @Test
+    public void testProviderIsConfinedWhenScanningOverwriteHistory() throws 
Exception {
+        Identifier tableId = identifier("provider_overwrite_history");
+        catalog.createTable(
+                tableId,
+                Schema.newBuilder()
+                        .column("id", DataTypes.INT())
+                        .option(BUCKET.key(), "2")
+                        .option(BUCKET_KEY.key(), "id")
+                        .option(
+                                "bucket-function.provider",
+                                ConcurrentBucketFunctionFactory.IDENTIFIER)
+                        .option("manifest.merge-min-count", "100")
+                        .option("scan.manifest.parallelism", "4")
+                        .build(),
+                false);
+
+        // Committed DELETE manifests retain the old bucket counts during a 
filtered scan.
+        for (int buckets = 2; buckets <= 4; buckets++) {
+            catalog.alterTable(
+                    tableId,
+                    
Collections.singletonList(SchemaChange.setOption(BUCKET.key(), "" + buckets)),
+                    false);
+            Table table = catalog.getTable(tableId);
+            BatchWriteBuilder builder = 
table.newBatchWriteBuilder().withOverwrite();
+            try (BatchTableWrite write = builder.newWrite();
+                    BatchTableCommit commit = builder.newCommit()) {
+                write.write(GenericRow.of(17));
+                commit.commit(write.prepareCommit());
+            }
+        }
+
+        Table table =
+                catalog.getTable(tableId)
+                        .copy(
+                                Collections.singletonMap(
+                                        
ConcurrentBucketFunctionFactory.CHECK_CONCURRENCY, "true"));
+        ReadBuilder read =
+                table.newReadBuilder()
+                        .withFilter(new 
PredicateBuilder(table.rowType()).equal(0, 17));
+        List<Integer> ids = new ArrayList<>();
+        read.newRead()
+                .createReader(read.newScan().plan())
+                .forEachRemaining(row -> ids.add(row.getInt(0)));
+        assertThat(ids).containsExactly(17);
+    }
+
     /**
      * Tests bucket filtering with compound predicates on a single-field 
bucket key.
      *
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/sink/FixedBucketRowKeyExtractorTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/sink/FixedBucketRowKeyExtractorTest.java
index 5c551dac70..39d69dd31a 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/table/sink/FixedBucketRowKeyExtractorTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/sink/FixedBucketRowKeyExtractorTest.java
@@ -19,6 +19,7 @@
 package org.apache.paimon.table.sink;
 
 import org.apache.paimon.bucket.DefaultBucketFunction;
+import org.apache.paimon.bucket.TestBucketFunctionFactory;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.data.InternalRow;
@@ -41,6 +42,7 @@ import java.util.Map;
 import java.util.concurrent.ThreadLocalRandom;
 
 import static org.apache.paimon.CoreOptions.BUCKET;
+import static org.apache.paimon.CoreOptions.BUCKET_FUNCTION_PROVIDER;
 import static org.apache.paimon.CoreOptions.BUCKET_KEY;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -71,6 +73,33 @@ public class FixedBucketRowKeyExtractorTest {
         assertThat(bucket(extractor("", "a,b,c"), row)).isEqualTo(40);
     }
 
+    @Test
+    public void testCustomBucketFunctionProvider() {
+        RowType rowType =
+                new RowType(
+                        Arrays.asList(
+                                new DataField(0, "a", new IntType()),
+                                new DataField(1, "b", new IntType()),
+                                new DataField(2, "c", new IntType())));
+        List<DataField> fields = TableSchema.newFields(rowType);
+        Map<String, String> options = new HashMap<>();
+        options.put(BUCKET_KEY.key(), "a");
+        options.put(BUCKET.key(), "100");
+        options.put(BUCKET_FUNCTION_PROVIDER.key(), 
TestBucketFunctionFactory.IDENTIFIER);
+        FixedBucketRowKeyExtractor extractor =
+                new FixedBucketRowKeyExtractor(
+                        new TableSchema(
+                                0,
+                                fields,
+                                RowType.currentHighestFieldId(fields),
+                                Collections.emptyList(),
+                                Collections.singletonList("a"),
+                                options,
+                                ""));
+
+        assertThat(bucket(extractor, GenericRow.of(5, 6, 7))).isEqualTo(99);
+    }
+
     @Test
     public void testIllegalBucket() {
         GenericRow row = GenericRow.of(5, 6, 7);
diff --git 
a/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
 
b/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
index f22e050a9d..22ea0ab4fc 100644
--- 
a/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
+++ 
b/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
@@ -16,6 +16,8 @@
 org.apache.paimon.mergetree.compact.aggregate.TestCustomAggFactory
 org.apache.paimon.mergetree.compact.aggregate.TestMapOnlyAggFactory
 org.apache.paimon.rest.auth.CustomTestDLFTokenLoaderFactory
+org.apache.paimon.bucket.TestBucketFunctionFactory
+org.apache.paimon.bucket.ConcurrentBucketFunctionFactory
 org.apache.paimon.iceberg.RecordingIcebergMetadataCommitter$Factory
 
 org.apache.paimon.catalog.TestCatalogLockFactory
diff --git 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index bb674f3dd4..b3f19e73f7 100644
--- 
a/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++ 
b/paimon-spark/paimon-spark-3.3/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -18,6 +18,7 @@
 
 package org.apache.paimon.spark
 
+import org.apache.paimon.CoreOptions.BucketFunctionType
 import org.apache.paimon.partition.PartitionPredicate
 import org.apache.paimon.predicate.{FullTextSearch, HybridSearch, Predicate, 
TopN, VectorSearch}
 import org.apache.paimon.spark.read.{BinPackingSplits, VariantExtractionInfo}
@@ -51,7 +52,11 @@ case class PaimonScan(
     table match {
       case fileStoreTable: FileStoreTable =>
         val bucketSpec = fileStoreTable.bucketSpec()
-        if (bucketSpec.getBucketMode != BucketMode.HASH_FIXED) {
+        if (
+          bucketSpec.getBucketMode != BucketMode.HASH_FIXED ||
+          coreOptions.bucketFunctionProvider().isPresent ||
+          coreOptions.bucketFunctionType() != BucketFunctionType.DEFAULT
+        ) {
           None
         } else if (bucketSpec.getBucketKeys.size() > 1) {
           None
diff --git 
a/paimon-spark/paimon-spark-3.3/src/test/scala/org/apache/paimon/spark/sql/CustomBucketFunction33Test.scala
 
b/paimon-spark/paimon-spark-3.3/src/test/scala/org/apache/paimon/spark/sql/CustomBucketFunction33Test.scala
new file mode 100644
index 0000000000..d6bb61935d
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-3.3/src/test/scala/org/apache/paimon/spark/sql/CustomBucketFunction33Test.scala
@@ -0,0 +1,21 @@
+/*
+ * 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.paimon.spark.sql
+
+class CustomBucketFunction33Test extends CustomBucketFunctionTest
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
index 96d65bbcb7..24895b3821 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonScan.scala
@@ -61,7 +61,8 @@ case class PaimonScan(
         val bucketSpec = fileStoreTable.bucketSpec()
         // todo introduce bucket transform for different bucket function type
         if (
-          bucketSpec.getBucketMode != BucketMode.HASH_FIXED || coreOptions
+          bucketSpec.getBucketMode != BucketMode.HASH_FIXED ||
+          coreOptions.bucketFunctionProvider().isPresent || coreOptions
             .bucketFunctionType() != BucketFunctionType.DEFAULT
         ) {
           None
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
index d139ed025b..968e761d78 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/PaimonSparkTableBase.scala
@@ -54,6 +54,7 @@ abstract class PaimonSparkTableBase(val table: Table)
   }
 
   private def supportsV2Write: Boolean = {
+    !coreOptions.bucketFunctionProvider().isPresent &&
     coreOptions.bucketFunctionType() == BucketFunctionType.DEFAULT && {
       table match {
         case storeTable: FileStoreTable =>
diff --git 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
index 132b8bfe47..605284ec2e 100644
--- 
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
+++ 
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/PaimonSparkWriter.scala
@@ -356,7 +356,10 @@ case class PaimonSparkWriter(
         writeWithoutBucket(input)
 
       case HASH_FIXED =>
-        if (paimonExtensionEnabled(sparkSession) && 
BucketFunction.supportsTable(table)) {
+        if (
+          paimonExtensionEnabled(sparkSession) &&
+          !coreOptions.bucketFunctionProvider().isPresent && 
BucketFunction.supportsTable(table)
+        ) {
           // Topology: input -> shuffle by partition & bucket
           val bucketNumber = coreOptions.bucket()
           val bucketKeyCol = tableSchema
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/ShiftedBucketFunctionFactory.java
 
b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/ShiftedBucketFunctionFactory.java
new file mode 100644
index 0000000000..863f2aaf90
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/java/org/apache/paimon/spark/ShiftedBucketFunctionFactory.java
@@ -0,0 +1,43 @@
+/*
+ * 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.paimon.spark;
+
+import org.apache.paimon.bucket.BucketFunction;
+import org.apache.paimon.bucket.BucketFunctionFactory;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.types.RowType;
+
+import java.util.Collections;
+
+/** A schema-sensitive, non-default bucket function for Spark integration 
tests. */
+public class ShiftedBucketFunctionFactory implements BucketFunctionFactory {
+
+    @Override
+    public String identifier() {
+        return "spark-test-shifted";
+    }
+
+    @Override
+    public BucketFunction create(RowType bucketKeyType, Options tableOptions) {
+        if 
(!bucketKeyType.getFieldNames().equals(Collections.singletonList("id"))) {
+            throw new IllegalArgumentException("Expected the schema's id 
bucket key");
+        }
+        return (row, numBuckets) -> Math.floorMod(row.getInt(0) + 1, 
numBuckets);
+    }
+}
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
 
b/paimon-spark/paimon-spark-ut/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
new file mode 100644
index 0000000000..626d6a62f4
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
@@ -0,0 +1,18 @@
+# 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.
+
+org.apache.paimon.spark.ShiftedBucketFunctionFactory
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CustomBucketFunctionTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CustomBucketFunctionTest.scala
new file mode 100644
index 0000000000..5c18e93176
--- /dev/null
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CustomBucketFunctionTest.scala
@@ -0,0 +1,125 @@
+/*
+ * 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.paimon.spark.sql
+
+import org.apache.paimon.CoreOptions.BucketFunctionType
+import org.apache.paimon.spark.{PaimonSparkTestBase, SparkTable}
+
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.connector.catalog.TableCapability
+import 
org.apache.spark.sql.connector.read.partitioning.{KeyGroupedPartitioning, 
UnknownPartitioning}
+import org.apache.spark.sql.execution.CommandResultExec
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.datasources.v2.AppendDataExecV1
+
+class CustomBucketFunctionTest extends PaimonSparkTestBase with 
AdaptiveSparkPlanHelper {
+
+  private def createTable(name: String, primaryKey: Boolean, custom: Boolean = 
true): Unit = {
+    val key = if (primaryKey) "'primary-key' = 'pt,id'," else ""
+    val provider = if (custom) {
+      "'bucket-function.provider' = 'spark-test-shifted',"
+    } else {
+      ""
+    }
+    sql(s"""CREATE TABLE $name (pt STRING, payload STRING, id INT)
+           |PARTITIONED BY (pt) TBLPROPERTIES (
+           |$key $provider 'bucket' = '4', 'bucket-key' = 'id')""".stripMargin)
+  }
+
+  test("custom provider does not advertise V2 write with the default bucket 
enum") {
+    withSparkSQLConf("spark.paimon.write.use-v2-write" -> "true") {
+      withTable("custom_bucket", "default_bucket") {
+        createTable("custom_bucket", primaryKey = true)
+        createTable("default_bucket", primaryKey = true, custom = false)
+        val table = loadTable("custom_bucket")
+        assert(table.coreOptions().bucketFunctionType() == 
BucketFunctionType.DEFAULT)
+        assert(SparkTable(loadTable("default_bucket")).useV2Write == 
gteqSpark3_4)
+        assert(!SparkTable(table).useV2Write)
+        
assert(!SparkTable(table).capabilities().contains(TableCapability.BATCH_WRITE))
+      }
+    }
+  }
+
+  for (primaryKey <- Seq(false, true); useV2 <- Seq(false, true)) {
+    test(s"custom provider assigns persisted buckets: primaryKey=$primaryKey, 
useV2=$useV2") {
+      withSparkSQLConf("spark.paimon.write.use-v2-write" -> useV2.toString) {
+        withTable("custom_bucket") {
+          createTable("custom_bucket", primaryKey)
+          val inserted = sql("""INSERT INTO custom_bucket VALUES
+                               |('p', 'a', 0), ('p', 'b', 1),
+                               |('q', 'c', 2), ('q', 'd', 3)""".stripMargin)
+          val plan = inserted.queryExecution.executedPlan
+            .asInstanceOf[CommandResultExec]
+            .commandPhysicalPlan
+          assert(collect(plan) { case append: AppendDataExecV1 => append 
}.size == 1)
+          // The provider maps id 0,1,2,3 to buckets 1,2,3,0, respectively.
+          checkAnswer(
+            sql("SELECT id, __paimon_bucket FROM custom_bucket"),
+            Seq(Row(0, 1), Row(1, 2), Row(2, 3), Row(3, 0)))
+          // Equality pruning must agree with the writer's provider-based 
assignment.
+          checkAnswer(sql("SELECT payload FROM custom_bucket WHERE id = 0"), 
Row("a"))
+        }
+      }
+    }
+  }
+
+  test("custom provider scan does not advertise default bucket partitioning") {
+    assume(gteqSpark3_3)
+    withSparkSQLConf(
+      "spark.paimon.write.use-v2-write" -> "false",
+      "spark.paimon.scan.preserve-data-grouping" -> "true",
+      "spark.sql.sources.v2.bucketing.enabled" -> "true") {
+      withTable("custom_bucket", "default_bucket") {
+        createTable("custom_bucket", primaryKey = true)
+        createTable("default_bucket", primaryKey = true, custom = false)
+        Seq("custom_bucket", "default_bucket").foreach {
+          name => sql(s"INSERT INTO $name VALUES ('p', 'a', 0), ('p', 'b', 1)")
+        }
+        assert(
+          getPaimonScan("SELECT * FROM default_bucket").outputPartitioning
+            .isInstanceOf[KeyGroupedPartitioning])
+        val scan = getPaimonScan("SELECT * FROM custom_bucket")
+        assert(scan.outputPartitioning.isInstanceOf[UnknownPartitioning])
+        if (gteqSpark3_4) {
+          assert(scan.outputOrdering().isEmpty)
+        }
+      }
+    }
+  }
+
+  for (bucketFunction <- Seq("mod", "hive")) {
+    test(s"$bucketFunction scan does not advertise default bucket 
partitioning") {
+      assume(gteqSpark3_3)
+      withSparkSQLConf(
+        "spark.paimon.write.use-v2-write" -> "false",
+        "spark.paimon.scan.preserve-data-grouping" -> "true",
+        "spark.sql.sources.v2.bucketing.enabled" -> "true") {
+        withTable("typed_bucket") {
+          sql(s"""CREATE TABLE typed_bucket (id INT, payload STRING) 
TBLPROPERTIES (
+                 |'primary-key' = 'id', 'bucket' = '4',
+                 |'bucket-function.type' = '$bucketFunction')""".stripMargin)
+          sql("INSERT INTO typed_bucket VALUES (0, 'a'), (1, 'b'), (2, 'c'), 
(3, 'd')")
+          val scan = getPaimonScan("SELECT * FROM typed_bucket")
+          assert(scan.outputPartitioning.isInstanceOf[UnknownPartitioning])
+          checkAnswer(sql("SELECT payload FROM typed_bucket WHERE id = 1"), 
Row("b"))
+        }
+      }
+    }
+  }
+}

Reply via email to