diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/DescribeBucketsProcedure.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/DescribeBucketsProcedure.java
new file mode 100644
index 0000000000..26852d476a
--- /dev/null
+++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/DescribeBucketsProcedure.java
@@ -0,0 +1,167 @@
+/*
+ * 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.fluss.flink.procedure;
+
+import org.apache.fluss.metadata.BucketInfo;
+import org.apache.fluss.metadata.PartitionSpec;
+import org.apache.fluss.metadata.ResolvedPartitionSpec;
+import org.apache.fluss.metadata.TablePath;
+
+import org.apache.flink.table.annotation.ArgumentHint;
+import org.apache.flink.table.annotation.DataTypeHint;
+import org.apache.flink.table.annotation.ProcedureHint;
+import org.apache.flink.table.procedure.ProcedureContext;
+import org.apache.flink.types.Row;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+import java.util.OptionalLong;
+
+/**
+ * Procedure to describe bucket metadata for a table.
+ *
+ *
Usage examples:
+ *
+ *
+ * CALL sys.describe_buckets('db.table');
+ * CALL sys.describe_buckets('db.table', 'partition_key=partition_value');
+ *
+ */
+public class DescribeBucketsProcedure extends ProcedureBase {
+
+ private static final String OUTPUT_TYPE =
+ "ROW, isr ARRAY>";
+
+ @ProcedureHint(
+ argument = {@ArgumentHint(name = "table_path", type = @DataTypeHint("STRING"))},
+ output = @DataTypeHint(OUTPUT_TYPE))
+ public Row[] call(ProcedureContext context, String tablePath) throws Exception {
+ TablePath parsedTablePath = parseTablePath(tablePath);
+ return toRows(admin.describeBuckets(parsedTablePath).get());
+ }
+
+ @ProcedureHint(
+ argument = {
+ @ArgumentHint(name = "table_path", type = @DataTypeHint("STRING")),
+ @ArgumentHint(name = "partition_spec", type = @DataTypeHint("STRING"))
+ },
+ output = @DataTypeHint(OUTPUT_TYPE))
+ public Row[] call(ProcedureContext context, String tablePath, String partitionSpec)
+ throws Exception {
+ TablePath parsedTablePath = parseTablePath(tablePath);
+ return toRows(
+ admin.describeBuckets(parsedTablePath, parsePartitionSpec(partitionSpec)).get());
+ }
+
+ private static Row[] toRows(List bucketInfos) {
+ return bucketInfos.stream().map(DescribeBucketsProcedure::toRow).toArray(Row[]::new);
+ }
+
+ private static Row toRow(BucketInfo bucketInfo) {
+ return Row.of(
+ bucketInfo.getTablePath().toString(),
+ bucketInfo.getTableId(),
+ optionalLong(bucketInfo.getPartitionId()),
+ bucketInfo.getPartitionName(),
+ bucketInfo.getBucketId(),
+ optionalInt(bucketInfo.getLeaderId()),
+ optionalInt(bucketInfo.getLeaderEpoch()),
+ optionalInt(bucketInfo.getBucketEpoch()),
+ bucketInfo.getReplicas().toArray(new Integer[0]),
+ bucketInfo.getIsr().toArray(new Integer[0]));
+ }
+
+ private static Long optionalLong(OptionalLong value) {
+ return value.isPresent() ? value.getAsLong() : null;
+ }
+
+ private static Integer optionalInt(OptionalInt value) {
+ return value.isPresent() ? value.getAsInt() : null;
+ }
+
+ private static TablePath parseTablePath(String tablePath) {
+ if (tablePath == null || tablePath.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "table_path cannot be null or empty. Expected format is 'database.table'.");
+ }
+
+ String normalizedTablePath = tablePath.trim();
+ String[] parts = normalizedTablePath.split("\\.", -1);
+ if (parts.length != 2 || parts[0].isEmpty() || parts[1].isEmpty()) {
+ throw new IllegalArgumentException(
+ "Invalid table_path '" + tablePath + "'. Expected format is 'database.table'.");
+ }
+ TablePath parsedTablePath = TablePath.of(parts[0], parts[1]);
+ parsedTablePath.validate();
+ return parsedTablePath;
+ }
+
+ private static PartitionSpec parsePartitionSpec(String partitionSpec) {
+ if (partitionSpec == null || partitionSpec.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "partition_spec cannot be null or empty. Expected format is "
+ + "'key=value[/key=value...]'.");
+ }
+
+ String normalizedPartitionSpec = partitionSpec.trim();
+ ResolvedPartitionSpec resolvedPartitionSpec;
+ try {
+ String[] keyValuePairs = normalizedPartitionSpec.split("/", -1);
+ for (String keyValuePair : keyValuePairs) {
+ if (keyValuePair.isEmpty()) {
+ throw new IllegalArgumentException("Empty partition key-value pair.");
+ }
+ }
+ resolvedPartitionSpec =
+ ResolvedPartitionSpec.fromPartitionQualifiedName(normalizedPartitionSpec);
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException(
+ "Invalid partition_spec '"
+ + partitionSpec
+ + "'. Expected format is 'key=value[/key=value...]'.",
+ e);
+ }
+
+ Map spec = new LinkedHashMap<>();
+ List partitionKeys = resolvedPartitionSpec.getPartitionKeys();
+ List partitionValues = resolvedPartitionSpec.getPartitionValues();
+ for (int i = 0; i < partitionKeys.size(); i++) {
+ String partitionKey = partitionKeys.get(i);
+ if (partitionKey.trim().isEmpty()) {
+ throw new IllegalArgumentException(
+ "Invalid partition_spec '"
+ + partitionSpec
+ + "': partition key cannot be empty.");
+ }
+ if (spec.containsKey(partitionKey)) {
+ throw new IllegalArgumentException(
+ "Duplicate partition key '"
+ + partitionKey
+ + "' in partition_spec '"
+ + partitionSpec
+ + "'.");
+ }
+ spec.put(partitionKey, partitionValues.get(i));
+ }
+ return new PartitionSpec(spec);
+ }
+}
diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/ProcedureManager.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/ProcedureManager.java
index d7eeba7b42..311162679c 100644
--- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/ProcedureManager.java
+++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/procedure/ProcedureManager.java
@@ -68,6 +68,7 @@ private static Map> initProcedureMap() {
private enum ProcedureEnum {
ADD_ACL("sys.add_acl", AddAclProcedure.class),
+ DESCRIBE_BUCKETS("sys.describe_buckets", DescribeBucketsProcedure.class),
DROP_ACL("sys.drop_acl", DropAclProcedure.class),
List_ACL("sys.list_acl", ListAclProcedure.class),
SET_CLUSTER_CONFIGS("sys.set_cluster_configs", SetClusterConfigsProcedure.class),
diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/DescribeBucketsProcedureTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/DescribeBucketsProcedureTest.java
new file mode 100644
index 0000000000..42754dcc09
--- /dev/null
+++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/DescribeBucketsProcedureTest.java
@@ -0,0 +1,183 @@
+/*
+ * 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.fluss.flink.procedure;
+
+import org.apache.fluss.flink.sink.testutils.TestAdminAdapter;
+import org.apache.fluss.metadata.BucketInfo;
+import org.apache.fluss.metadata.PartitionSpec;
+import org.apache.fluss.metadata.TablePath;
+
+import org.apache.flink.types.Row;
+import org.junit.jupiter.api.Test;
+
+import javax.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Test for {@link DescribeBucketsProcedure}. */
+class DescribeBucketsProcedureTest {
+
+ private static final TablePath TABLE_PATH = TablePath.of("test_db", "test_table");
+
+ @Test
+ void testDescribeAllBucketsAndConvertNullableFields() throws Exception {
+ BucketInfo bucketInfo =
+ new BucketInfo(
+ TABLE_PATH,
+ 10L,
+ null,
+ null,
+ 0,
+ null,
+ null,
+ null,
+ Arrays.asList(1, 2, 3),
+ Collections.emptyList());
+ TestingAdmin admin = new TestingAdmin(Collections.singletonList(bucketInfo));
+ DescribeBucketsProcedure procedure = newProcedure(admin);
+
+ Row[] rows = procedure.call(null, " test_db.test_table ");
+
+ assertThat(admin.requestedTablePath).isEqualTo(TABLE_PATH);
+ assertThat(admin.requestedPartitionSpec).isNull();
+ assertThat(rows).hasSize(1);
+ Row row = rows[0];
+ assertThat(row.getArity()).isEqualTo(10);
+ assertThat(row.getField(0)).isEqualTo("test_db.test_table");
+ assertThat(row.getField(1)).isEqualTo(10L);
+ assertThat(row.getField(2)).isNull();
+ assertThat(row.getField(3)).isNull();
+ assertThat(row.getField(4)).isEqualTo(0);
+ assertThat(row.getField(5)).isNull();
+ assertThat(row.getField(6)).isNull();
+ assertThat(row.getField(7)).isNull();
+ assertThat((Integer[]) row.getField(8)).containsExactly(1, 2, 3);
+ assertThat((Integer[]) row.getField(9)).isEmpty();
+ }
+
+ @Test
+ void testDescribeBucketsWithPartitionSpec() throws Exception {
+ BucketInfo bucketInfo =
+ new BucketInfo(
+ TABLE_PATH,
+ 10L,
+ 100L,
+ "cn$2026-09-16",
+ 1,
+ 2,
+ 3,
+ -1,
+ Arrays.asList(1, 2, 3),
+ Arrays.asList(2, 3));
+ TestingAdmin admin = new TestingAdmin(Collections.singletonList(bucketInfo));
+ DescribeBucketsProcedure procedure = newProcedure(admin);
+
+ Row[] rows = procedure.call(null, "test_db.test_table", "region=cn/dt=2026-09-16");
+
+ assertThat(admin.requestedTablePath).isEqualTo(TABLE_PATH);
+ assertThat(admin.requestedPartitionSpec).isNotNull();
+ assertThat(admin.requestedPartitionSpec.getSpecMap())
+ .containsEntry("region", "cn")
+ .containsEntry("dt", "2026-09-16");
+ assertThat(rows).hasSize(1);
+ Row row = rows[0];
+ assertThat(row.getField(2)).isEqualTo(100L);
+ assertThat(row.getField(3)).isEqualTo("cn$2026-09-16");
+ assertThat(row.getField(4)).isEqualTo(1);
+ assertThat(row.getField(5)).isEqualTo(2);
+ assertThat(row.getField(6)).isEqualTo(3);
+ assertThat(row.getField(7)).isEqualTo(-1);
+ assertThat((Integer[]) row.getField(8)).containsExactly(1, 2, 3);
+ assertThat((Integer[]) row.getField(9)).containsExactly(2, 3);
+ }
+
+ @Test
+ void testEmptyAdminResult() throws Exception {
+ DescribeBucketsProcedure procedure =
+ newProcedure(new TestingAdmin(Collections.emptyList()));
+
+ assertThat(procedure.call(null, "test_db.test_table")).isEmpty();
+ }
+
+ @Test
+ void testInvalidTablePath() {
+ DescribeBucketsProcedure procedure =
+ newProcedure(new TestingAdmin(Collections.emptyList()));
+
+ for (String tablePath :
+ Arrays.asList(null, "", " ", "test_db", ".test_table", "test_db.", "a.b.c")) {
+ assertThatThrownBy(() -> procedure.call(null, tablePath))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("table_path");
+ }
+ }
+
+ @Test
+ void testInvalidPartitionSpec() {
+ DescribeBucketsProcedure procedure =
+ newProcedure(new TestingAdmin(Collections.emptyList()));
+
+ for (String partitionSpec :
+ Arrays.asList(null, "", " ", "region", "=cn", "region=cn/", "region=cn//dt=d1")) {
+ assertThatThrownBy(() -> procedure.call(null, "test_db.test_table", partitionSpec))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("partition_spec");
+ }
+
+ assertThatThrownBy(() -> procedure.call(null, "test_db.test_table", "region=cn/region=us"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Duplicate partition key 'region'");
+ }
+
+ private static DescribeBucketsProcedure newProcedure(TestingAdmin admin) {
+ DescribeBucketsProcedure procedure = new DescribeBucketsProcedure();
+ procedure.withAdmin(admin);
+ return procedure;
+ }
+
+ private static class TestingAdmin extends TestAdminAdapter {
+ private final List bucketInfos;
+ private @Nullable TablePath requestedTablePath;
+ private @Nullable PartitionSpec requestedPartitionSpec;
+
+ private TestingAdmin(List bucketInfos) {
+ this.bucketInfos = bucketInfos;
+ }
+
+ @Override
+ public CompletableFuture> describeBuckets(TablePath tablePath) {
+ requestedTablePath = tablePath;
+ requestedPartitionSpec = null;
+ return CompletableFuture.completedFuture(bucketInfos);
+ }
+
+ @Override
+ public CompletableFuture> describeBuckets(
+ TablePath tablePath, PartitionSpec partitionSpec) {
+ requestedTablePath = tablePath;
+ requestedPartitionSpec = partitionSpec;
+ return CompletableFuture.completedFuture(bucketInfos);
+ }
+ }
+}
diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/FlinkProcedureITCase.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/FlinkProcedureITCase.java
index b2575e0c33..b8fa085b34 100644
--- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/FlinkProcedureITCase.java
+++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/procedure/FlinkProcedureITCase.java
@@ -27,8 +27,11 @@
import org.apache.fluss.config.Configuration;
import org.apache.fluss.config.MemorySize;
import org.apache.fluss.exception.AuthorizationException;
+import org.apache.fluss.exception.InvalidPartitionException;
import org.apache.fluss.exception.NoRebalanceInProgressException;
import org.apache.fluss.exception.SecurityDisabledException;
+import org.apache.fluss.exception.TableNotExistException;
+import org.apache.fluss.exception.TableNotPartitionedException;
import org.apache.fluss.metadata.DataLakeFormat;
import org.apache.fluss.metadata.PartitionInfo;
import org.apache.fluss.metadata.TablePath;
@@ -51,8 +54,12 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import javax.annotation.Nullable;
+
import java.time.Duration;
+import java.util.ArrayList;
import java.util.Arrays;
+import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -142,6 +149,7 @@ void testShowProcedures() throws Exception {
List expectedShowProceduresResult =
Arrays.asList(
"+I[sys.add_acl]",
+ "+I[sys.describe_buckets]",
"+I[sys.drop_acl]",
"+I[sys.get_cluster_configs]",
"+I[sys.list_acl]",
@@ -223,6 +231,109 @@ void testListPartitionInfos() throws Exception {
}
}
+ @Test
+ @MultiVersionTest
+ void testDescribeBuckets() throws Exception {
+ String tableName = "describe_buckets_table";
+ tEnv.executeSql(
+ String.format(
+ "create table %s (id int, name string) "
+ + "with ('bucket.num' = '2')",
+ tableName))
+ .await();
+ TablePath tablePath = TablePath.of(DEFAULT_DB, tableName);
+ long tableId = admin.getTableInfo(tablePath).get().getTableId();
+
+ List tableBucketRows = waitAndDescribeBuckets(tablePath.toString(), null, 2);
+ assertBucketRows(tableBucketRows, tablePath.toString(), tableId, 2);
+ assertThat(tableBucketRows).extracting(row -> row.getField(2)).containsOnlyNulls();
+ assertThat(tableBucketRows).extracting(row -> row.getField(3)).containsOnlyNulls();
+ assertThat(tableBucketRows)
+ .extracting(row -> row.getField(4))
+ .containsExactlyInAnyOrder(0, 1);
+
+ String partitionedTableName = "describe_buckets_partitioned_table";
+ tEnv.executeSql(
+ String.format(
+ "create table %s (id int, region string, dt string) "
+ + "partitioned by (region, dt) "
+ + "with ('bucket.num' = '2')",
+ partitionedTableName))
+ .await();
+ TablePath partitionedTablePath = TablePath.of(DEFAULT_DB, partitionedTableName);
+ long partitionedTableId = admin.getTableInfo(partitionedTablePath).get().getTableId();
+ writeRows(
+ conn,
+ partitionedTablePath,
+ Arrays.asList(
+ row(1, "cn", "2026-09-15"),
+ row(2, "cn", "2026-09-16"),
+ row(3, "us", "2026-09-16")),
+ true);
+
+ List allPartitionBucketRows =
+ waitAndDescribeBuckets(partitionedTablePath.toString(), null, 6);
+ assertBucketRows(
+ allPartitionBucketRows, partitionedTablePath.toString(), partitionedTableId, 6);
+ assertThat(allPartitionBucketRows).extracting(row -> row.getField(2)).doesNotContainNull();
+ assertThat(allPartitionBucketRows)
+ .extracting(row -> row.getField(3))
+ .containsExactlyInAnyOrder(
+ "cn$2026-09-15",
+ "cn$2026-09-15",
+ "cn$2026-09-16",
+ "cn$2026-09-16",
+ "us$2026-09-16",
+ "us$2026-09-16");
+
+ List cnBucketRows =
+ waitAndDescribeBuckets(partitionedTablePath.toString(), "region=cn", 4);
+ assertBucketRows(cnBucketRows, partitionedTablePath.toString(), partitionedTableId, 4);
+ assertThat(cnBucketRows)
+ .extracting(row -> row.getField(3))
+ .containsOnly("cn$2026-09-15", "cn$2026-09-16");
+
+ List exactPartitionBucketRows =
+ waitAndDescribeBuckets(
+ partitionedTablePath.toString(), "region=cn/dt=2026-09-16", 2);
+ assertBucketRows(
+ exactPartitionBucketRows, partitionedTablePath.toString(), partitionedTableId, 2);
+ assertThat(exactPartitionBucketRows)
+ .extracting(row -> row.getField(3))
+ .containsOnly("cn$2026-09-16");
+ assertThat(exactPartitionBucketRows)
+ .extracting(row -> row.getField(4))
+ .containsExactlyInAnyOrder(0, 1);
+
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ String.format(
+ "call %s.sys.describe_buckets('%s.missing_table')",
+ CATALOG_NAME, DEFAULT_DB))
+ .await())
+ .rootCause()
+ .isInstanceOf(TableNotExistException.class);
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ String.format(
+ "call %s.sys.describe_buckets('%s', 'region=cn')",
+ CATALOG_NAME, tablePath))
+ .await())
+ .rootCause()
+ .isInstanceOf(TableNotPartitionedException.class);
+ assertThatThrownBy(
+ () ->
+ tEnv.executeSql(
+ String.format(
+ "call %s.sys.describe_buckets('%s', 'unknown=value')",
+ CATALOG_NAME, partitionedTablePath))
+ .await())
+ .rootCause()
+ .isInstanceOf(InvalidPartitionException.class);
+ }
+
@MultiVersionTest
@Test
void testIndexArgument() throws Exception {
@@ -1343,6 +1454,79 @@ private static Configuration initConfig() {
return conf;
}
+ private List waitAndDescribeBuckets(
+ String tablePath, @Nullable String partitionSpec, int expectedBucketCount)
+ throws Exception {
+ retry(
+ Duration.ofMinutes(1),
+ () -> {
+ List rows = describeBuckets(tablePath, partitionSpec);
+ assertThat(rows).hasSize(expectedBucketCount);
+ assertThat(rows)
+ .allSatisfy(
+ row -> {
+ assertThat(row.getArity()).isEqualTo(10);
+ assertThat(row.getField(5)).isNotNull();
+ assertThat(row.getField(6)).isNotNull();
+ assertThat((Integer) row.getField(7))
+ .isGreaterThanOrEqualTo(0);
+ assertThat(toIntegerList(row.getField(8))).isNotEmpty();
+ assertThat(toIntegerList(row.getField(9))).isNotEmpty();
+ });
+ });
+ return describeBuckets(tablePath, partitionSpec);
+ }
+
+ private List describeBuckets(String tablePath, @Nullable String partitionSpec)
+ throws Exception {
+ String sql =
+ partitionSpec == null
+ ? String.format(
+ "call %s.sys.describe_buckets('%s')", CATALOG_NAME, tablePath)
+ : String.format(
+ "call %s.sys.describe_buckets('%s', '%s')",
+ CATALOG_NAME, tablePath, partitionSpec);
+ try (CloseableIterator resultIterator = tEnv.executeSql(sql).collect()) {
+ return CollectionUtil.iteratorToList(resultIterator);
+ }
+ }
+
+ private static void assertBucketRows(
+ List rows, String tablePath, long tableId, int expectedBucketCount) {
+ assertThat(rows).hasSize(expectedBucketCount);
+ assertThat(rows).extracting(row -> row.getField(0)).containsOnly(tablePath);
+ assertThat(rows).extracting(row -> row.getField(1)).containsOnly(tableId);
+ rows.forEach(
+ row -> {
+ Integer leaderId = (Integer) row.getField(5);
+ List replicas = toIntegerList(row.getField(8));
+ List isr = toIntegerList(row.getField(9));
+ assertThat(replicas).hasSize(3).containsAll(isr);
+ assertThat(isr).contains(leaderId);
+ });
+ }
+
+ private static List toIntegerList(Object value) {
+ if (value instanceof int[]) {
+ int[] values = (int[]) value;
+ List result = new ArrayList<>(values.length);
+ for (int intValue : values) {
+ result.add(intValue);
+ }
+ return result;
+ } else if (value instanceof Object[]) {
+ return Arrays.stream((Object[]) value)
+ .map(number -> ((Number) number).intValue())
+ .collect(Collectors.toList());
+ } else if (value instanceof Collection) {
+ return ((Collection>) value)
+ .stream()
+ .map(number -> ((Number) number).intValue())
+ .collect(Collectors.toList());
+ }
+ throw new AssertionError("Unsupported ARRAY value: " + value);
+ }
+
private static void assertCallResult(CloseableIterator rows, String[] expected) {
List actual =
CollectionUtil.iteratorToList(rows).stream()
diff --git a/website/docs/engine-flink/procedures.md b/website/docs/engine-flink/procedures.md
index e6a0a79678..e4094b0394 100644
--- a/website/docs/engine-flink/procedures.md
+++ b/website/docs/engine-flink/procedures.md
@@ -646,6 +646,74 @@ CALL sys.cancel_rebalance();
CALL sys.cancel_rebalance('rebalance-12345');
```
+## Bucket Procedures
+
+Fluss provides procedures to inspect the replica placement and leader state of table buckets.
+
+### describe_buckets
+
+Describe the bucket metadata of a table. For a non-partitioned table, the procedure returns all
+table buckets. For a partitioned table, it returns all partition buckets unless a partition spec is
+provided.
+
+**Syntax:**
+
+```sql
+-- Describe a non-partitioned table or every partition of a partitioned table
+CALL [catalog_name.]sys.describe_buckets(
+ table_path => 'database.table'
+)
+
+-- Describe buckets matching a complete or partial partition spec
+CALL [catalog_name.]sys.describe_buckets(
+ table_path => 'database.table',
+ partition_spec => 'key1=value1/key2=value2'
+)
+```
+
+**Parameters:**
+
+- `table_path` (required): The table path in `database.table` format.
+- `partition_spec` (optional): A complete or partial partition spec in
+ `key=value[/key=value...]` format. This parameter is only valid for partitioned tables.
+
+**Returns:** One row per bucket, containing:
+
+- `table_path`: The table path.
+- `table_id`: The unique table ID.
+- `partition_id`: The partition ID, or `NULL` for a non-partitioned table.
+- `partition_name`: The partition name, or `NULL` for a non-partitioned table.
+- `bucket_id`: The bucket ID.
+- `leader_id`: The leader TabletServer ID, or `NULL` if no leader is elected.
+- `leader_epoch`: The leader epoch, or `NULL` if no leader is elected.
+- `bucket_epoch`: The generation of the complete leader/ISR state. `NULL` indicates legacy
+ metadata, while `-1` indicates that no leader/ISR state exists.
+- `replicas`: An `ARRAY` containing the replica TabletServer IDs.
+- `isr`: An `ARRAY` containing the in-sync replica TabletServer IDs.
+
+**Important Notes:**
+
+- The caller must have `DESCRIBE` permission on the table.
+- A partial partition spec matches every partition containing the specified key-value pairs.
+- For a table with many partitions, provide a partition spec to avoid returning metadata for every
+ bucket in a single call.
+
+**Example:**
+
+```sql title="Flink SQL"
+-- Use the Fluss catalog (replace 'fluss_catalog' with your catalog name if different)
+USE fluss_catalog;
+
+-- Describe every bucket of my_db.orders
+CALL sys.describe_buckets('my_db.orders');
+
+-- Describe buckets in every CN partition
+CALL sys.describe_buckets('my_db.orders', 'region=cn');
+
+-- Describe buckets in one specific partition
+CALL sys.describe_buckets('my_db.orders', 'region=cn/dt=2026-09-16');
+```
+
## Partition Procedures
Fluss provides procedures to inspect partition-level metadata of partitioned tables.