From 917013c0d2ff874fb68b8c828ad3c84ef6c3f790 Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Thu, 27 Aug 2026 19:48:09 +0800 Subject: [PATCH 01/16] [server][client] Support per-partition bucket count for partitioned tables --- .../apache/fluss/client/admin/FlussAdmin.java | 98 +- .../client/lookup/AbstractLookupQuery.java | 18 +- .../fluss/client/lookup/AbstractLookuper.java | 14 + .../fluss/client/lookup/LookupBatch.java | 10 +- .../fluss/client/lookup/LookupClient.java | 15 +- .../fluss/client/lookup/LookupQuery.java | 16 +- .../fluss/client/lookup/LookupSender.java | 14 +- .../client/lookup/PrefixKeyLookuper.java | 13 +- .../client/lookup/PrefixLookupBatch.java | 10 +- .../client/lookup/PrefixLookupQuery.java | 9 +- .../client/lookup/PrimaryKeyLookuper.java | 61 +- .../fluss/client/table/scanner/TableScan.java | 2 +- .../table/scanner/batch/KvBatchScanner.java | 9 + .../scanner/batch/LimitBatchScanner.java | 10 + .../client/table/scanner/log/LogFetcher.java | 10 + .../client/utils/ClientRpcMessageUtils.java | 46 +- .../fluss/client/utils/MetadataUtils.java | 48 +- .../client/write/DynamicPartitionCreator.java | 112 +- .../fluss/client/write/RecordAccumulator.java | 21 +- .../org/apache/fluss/client/write/Sender.java | 25 +- .../apache/fluss/client/write/WriteBatch.java | 12 + .../fluss/client/write/WriterClient.java | 114 +- .../fluss/client/admin/FlussAdminITCase.java | 11 +- .../fluss/client/lookup/LookupSenderTest.java | 62 + ...rtitionBucketCountActualRescaleITCase.java | 514 ++++++++ .../client/table/PartitionedTableITCase.java | 22 +- .../utils/ClientRpcMessageUtilsTest.java | 57 + .../apache/fluss/client/write/SenderTest.java | 56 +- .../org/apache/fluss/cluster/Cluster.java | 107 +- .../fluss/lake/lakestorage/LakeCatalog.java | 6 + .../lake/lakestorage/LakeTableLookuper.java | 14 +- .../fluss/lake/writer/WriterInitContext.java | 8 + .../apache/fluss/metadata/PartitionInfo.java | 39 +- .../org/apache/fluss/metadata/TableInfo.java | 75 +- .../lake/writer/WriterInitContextTest.java | 5 + .../fluss/flink/FlinkConnectorOptions.java | 11 +- .../flink/action/orphan/OrphanCleanUtils.java | 2 +- .../fluss/flink/lake/LakeSplitGenerator.java | 75 +- .../sink/undo/RecoveryOffsetManager.java | 15 +- .../enumerator/FlinkSourceEnumerator.java | 43 +- .../FlussOnlyBatchSplitGenerator.java | 13 +- .../tiering/source/TieringSplitReader.java | 28 + .../source/TieringWriterInitContext.java | 28 +- .../source/split/TieringSplitGenerator.java | 51 +- .../fluss/flink/utils/PushdownUtils.java | 10 +- .../flink/catalog/FlinkCatalogITCase.java | 8 +- .../flink/lake/LakeSplitGeneratorTest.java | 158 +++ .../sink/undo/RecoveryOffsetManagerTest.java | 117 +- .../enumerator/FlinkSourceEnumeratorTest.java | 177 ++- .../source/TieringWriterInitContextTest.java | 92 +- .../fluss/flink/utils/FlinkTestBase.java | 3 +- .../lake/hudi/tiering/HudiTieringTest.java | 5 + .../lake/iceberg/IcebergLakeCatalog.java | 7 + .../lake/iceberg/IcebergLakeCatalogTest.java | 2 +- .../iceberg/tiering/IcebergTieringTest.java | 5 + .../lake/lance/tiering/LanceTieringTest.java | 5 + .../fluss/lake/paimon/PaimonLakeCatalog.java | 55 +- .../lookup/PaimonLakeTableLookuper.java | 94 +- .../lake/paimon/tiering/PaimonLakeWriter.java | 34 +- .../tiering/append/AppendOnlyWriter.java | 13 +- .../lake/paimon/PaimonLakeCatalogTest.java | 50 + .../FlinkUnionReadRescaleBucketITCase.java | 506 ++++++++ .../lookup/HistoricalPartitionITCase.java | 227 ++++ .../paimon/tiering/PaimonTieringTest.java | 139 +++ .../org/apache/fluss/rpc/protocol/Errors.java | 8 +- fluss-rpc/src/main/proto/FlussApi.proto | 20 + fluss-rust/bindings/python/test/conftest.py | 16 +- fluss-rust/crates/fluss/proto/FlussApi.proto | 20 + .../crates/fluss/src/client/table/scanner.rs | 1 + .../crates/fluss/src/client/write/sender.rs | 1 + .../crates/fluss/src/metadata/partition.rs | 1 + .../crates/fluss/src/metadata/table_stats.rs | 1 + fluss-rust/crates/fluss/src/proto/fluss.rs | 35 + .../fluss/src/rpc/message/limit_scan.rs | 1 + .../fluss/src/rpc/message/list_offsets.rs | 1 + .../crates/fluss/src/rpc/message/lookup.rs | 1 + .../fluss/src/rpc/message/prefix_lookup.rs | 1 + .../fluss/src/rpc/message/produce_log.rs | 1 + .../crates/fluss/src/rpc/message/put_kv.rs | 1 + .../apache/fluss/server/RpcServiceBase.java | 32 +- .../coordinator/AutoPartitionManager.java | 51 +- .../CoordinatorEventProcessor.java | 18 + .../coordinator/CoordinatorRequestBatch.java | 61 +- .../coordinator/CoordinatorService.java | 42 +- .../server/coordinator/MetadataManager.java | 498 +++++++- .../server/entity/NotifyLeaderAndIsrData.java | 27 + .../metadata/CoordinatorMetadataProvider.java | 7 +- .../server/metadata/PartitionMetadata.java | 19 + .../metadata/ServerMetadataSnapshot.java | 42 +- .../metadata/TabletServerMetadataCache.java | 112 +- .../metadata/ZkBasedMetadataProvider.java | 3 +- .../apache/fluss/server/replica/Replica.java | 60 + .../fluss/server/replica/ReplicaManager.java | 65 + .../HistoricalLakeLookupManager.java | 8 +- .../fluss/server/tablet/TabletService.java | 103 ++ .../server/utils/ServerRpcMessageUtils.java | 51 +- .../fluss/server/zk/ZooKeeperClient.java | 140 ++- .../server/zk/data/PartitionRegistration.java | 56 +- .../data/PartitionRegistrationJsonSerde.java | 15 +- .../server/zk/data/TableRegistration.java | 68 +- .../zk/data/TableRegistrationJsonSerde.java | 14 +- .../coordinator/AlterBucketNumTest.java | 1046 +++++++++++++++++ .../coordinator/AutoPartitionManagerTest.java | 100 +- .../CoordinatorEventProcessorTest.java | 22 +- .../CoordinatorRequestBatchTest.java | 21 + .../server/coordinator/TableManagerTest.java | 3 +- .../event/watcher/TableChangeWatcherTest.java | 20 +- .../log/remote/RemoteLogManagerTest.java | 4 +- .../server/log/remote/RemoteLogTestBase.java | 9 +- .../TabletServerMetadataCacheTest.java | 141 +++ .../metadata/ZkBasedMetadataProviderTest.java | 12 +- .../fluss/server/replica/AdjustIsrTest.java | 8 +- .../server/replica/ReplicaManagerTest.java | 28 +- .../replica/ReplicaRoutingStateTest.java | 143 +++ .../fluss/server/replica/ReplicaTest.java | 29 +- .../fluss/server/replica/ReplicaTestBase.java | 13 +- .../fetcher/ReplicaFetcherManagerTest.java | 8 +- .../fetcher/ReplicaFetcherThreadTest.java | 8 +- .../HistoricalPartitionManagerTest.java | 12 +- .../server/tablet/TabletServiceITCase.java | 73 +- .../testutils/FlussClusterExtension.java | 10 +- .../testutils/PartitionMetadataAssert.java | 6 + .../fluss/server/zk/ZooKeeperClientTest.java | 19 +- .../PartitionRegistrationJsonSerdeTest.java | 27 +- .../data/TableRegistrationJsonSerdeTest.java | 6 +- .../spark/read/FlussMicroBatchStream.scala | 17 +- .../fluss/spark/read/SplitPlanner.scala | 18 +- website/docs/engine-flink/ddl.md | 4 + website/docs/engine-flink/options.md | 2 +- .../data-distribution/bucketing.md | 2 +- 130 files changed, 6621 insertions(+), 427 deletions(-) create mode 100644 fluss-client/src/test/java/org/apache/fluss/client/table/PartitionBucketCountActualRescaleITCase.java create mode 100644 fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/lake/LakeSplitGeneratorTest.java create mode 100644 fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/flink/FlinkUnionReadRescaleBucketITCase.java create mode 100644 fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java create mode 100644 fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java diff --git a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java index a0909ceec73..36d46add514 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java @@ -34,6 +34,7 @@ import org.apache.fluss.config.cluster.ConfigEntry; import org.apache.fluss.exception.FlussRuntimeException; import org.apache.fluss.exception.LeaderNotAvailableException; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.DatabaseChange; import org.apache.fluss.metadata.DatabaseDescriptor; import org.apache.fluss.metadata.DatabaseInfo; @@ -93,6 +94,7 @@ import org.apache.fluss.rpc.messages.ListTablesResponse; import org.apache.fluss.rpc.messages.PbAlterConfig; import org.apache.fluss.rpc.messages.PbListOffsetsRespForBucket; +import org.apache.fluss.rpc.messages.PbPartitionInfo; import org.apache.fluss.rpc.messages.PbPartitionSpec; import org.apache.fluss.rpc.messages.PbTablePath; import org.apache.fluss.rpc.messages.PbTableStatsRespForBucket; @@ -344,7 +346,8 @@ public CompletableFuture getTableInfo(TablePath tablePath) { // clusters do not include the remote data dir r.hasRemoteDataDir() ? r.getRemoteDataDir() : null, r.getCreatedTime(), - r.getModifiedTime())); + r.getModifiedTime(), + r.hasBucketLayoutEpoch() ? r.getBucketLayoutEpoch() : 0L)); } @Override @@ -393,7 +396,54 @@ public CompletableFuture> listPartitionInfos( } return readOnlyGateway .listPartitionInfos(request) - .thenApply(ClientRpcMessageUtils::toPartitionInfos); + .thenCompose( + response -> { + boolean allHaveBucketCount = + response.getPartitionsInfosList().stream() + .allMatch(PbPartitionInfo::hasBucketCountActual); + if (allHaveBucketCount) { + // Every partition already carries its own bucket count, so skip + // the extra getTableInfo RPC (the -1 default is never used). + return CompletableFuture.completedFuture( + ClientRpcMessageUtils.toPartitionInfos(response, -1)); + } + // Upgrade contract for the per-partition bucket count fallback: + // 1) upgrade clients first; + // 2) upgrade the CoordinatorServer before (or together with) the + // TabletServers; a TabletServer newer than its Coordinator + // fails leader activation with UnsupportedVersionException; + // 3) prohibit ALTER bucket.num during the server rolling upgrade; + // 4) a fully old cluster omits the partition count, while the + // table-level count is still safe (bucketLayoutEpoch == 0); + // 5) after every server is upgraded, ListPartitionInfosResponse + // must return an explicit partitionId and bucketCountActual; + // 6) if a new server still returns a missing count, fail loud + // instead of calling getTableInfo to guess it. + // TODO: a future FIP should enforce server-side rejection of + // bucket-layout ALTERs during a rolling upgrade so clients never + // observe a half-upgraded cluster. + return getTableInfo(tablePath) + .thenApply( + tableInfo -> { + long epoch = tableInfo.getBucketLayoutEpoch(); + // Post-ALTER server must return the count; + // missing means inconsistency, so fail loud. + if (epoch > 0) { + throw new StaleMetadataException( + "Server omitted the per-partition " + + "bucket count for table " + + tablePath + + " at bucketLayoutEpoch " + + epoch + + " > 0; refusing to fall " + + "back to the table-level " + + "count."); + } + // epoch == 0: table-level count is safe. + return ClientRpcMessageUtils.toPartitionInfos( + response, tableInfo.getNumBuckets()); + }); + }); } /** @@ -549,30 +599,34 @@ public CompletableFuture getTableStats(TablePath tablePath) { metadataUpdater.updateTableOrPartitionMetadata(tablePath, null); TableInfo tableInfo = getTableInfo(tablePath).join(); try { - int bucketCount = tableInfo.getNumBuckets(); + int tableBucketCount = tableInfo.getNumBuckets(); List partitionInfos; if (tableInfo.isPartitioned()) { partitionInfos = listPartitionInfos(tablePath).get(); } else { partitionInfos = Collections.singletonList(null); } - // create all TableBuckets for each partition and bucket combination - Map> bucketToRowCountMap = new HashMap<>(); + List tableBuckets = new ArrayList<>(); for (PartitionInfo partitionInfo : partitionInfos) { - for (int bucket = 0; bucket < bucketCount; bucket++) { - TableBucket tb = + int bucketCountActual = + PartitionInfo.bucketCountActualOrDefault(partitionInfo, tableBucketCount); + for (int bucket = 0; bucket < bucketCountActual; bucket++) { + tableBuckets.add( new TableBucket( tableInfo.getTableId(), partitionInfo == null ? null : partitionInfo.getPartitionId(), - bucket); - bucketToRowCountMap.put(tb, new CompletableFuture<>()); + bucket)); } } + long tableId = tableInfo.getTableId(); + Map> bucketToRowCountMap = new HashMap<>(); + for (TableBucket tb : tableBuckets) { + bucketToRowCountMap.put(tb, new CompletableFuture<>()); + } Map requestMap = prepareTableStatsRequests( metadataUpdater, bucketToRowCountMap.keySet(), tablePath); - sendTableStatsRequest( - metadataUpdater, tableInfo.getTableId(), requestMap, bucketToRowCountMap); + sendTableStatsRequest(metadataUpdater, tableId, requestMap, bucketToRowCountMap); return FutureUtils.combineAll(bucketToRowCountMap.values()) .thenApply( counts -> { @@ -606,13 +660,13 @@ private ListOffsetsResult listOffsets( buckets, offsetSpec, tableInfo.getTablePath()); - Map> bucketToOffsetMap = new ConcurrentHashMap<>(); + + Map> resultMap = new ConcurrentHashMap<>(); for (int bucket : buckets) { - bucketToOffsetMap.put(bucket, new CompletableFuture<>()); + resultMap.put(bucket, new CompletableFuture<>()); } - - sendListOffsetsRequest(metadataUpdater, requestMap, bucketToOffsetMap); - return new ListOffsetsResult(bucketToOffsetMap); + sendListOffsetsRequest(metadataUpdater, requestMap, resultMap); + return new ListOffsetsResult(resultMap); } @Override @@ -818,7 +872,10 @@ private static Map prepareTableStatsRequests( Map requests = new HashMap<>(); nodeForBucketList.forEach( - (leader, tbs) -> requests.put(leader, makeGetTableStatsRequest(tbs))); + (leader, tbs) -> + requests.put( + leader, + makeGetTableStatsRequest(tbs, metadataUpdater.getCluster()))); return requests; } @@ -890,7 +947,12 @@ private static Map prepareListOffsetsRequests( (leader, ids) -> listOffsetsRequests.put( leader, - makeListOffsetsRequest(tableId, partitionId, ids, offsetSpec))); + makeListOffsetsRequest( + tableId, + partitionId, + ids, + offsetSpec, + metadataUpdater.getCluster()))); return listOffsetsRequests; } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookupQuery.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookupQuery.java index 19bea07bf20..0da292e6823 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookupQuery.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookupQuery.java @@ -39,11 +39,12 @@ public abstract class AbstractLookupQuery { */ private final @Nullable String originalPartitionName; + private final int bucketCountActual; private int retries; private long nextRetryTimeMs; public AbstractLookupQuery(TablePath tablePath, TableBucket tableBucket, byte[] key) { - this(tablePath, tableBucket, key, null); + this(tablePath, tableBucket, key, null, 0); } public AbstractLookupQuery( @@ -51,10 +52,20 @@ public AbstractLookupQuery( TableBucket tableBucket, byte[] key, @Nullable String originalPartitionName) { + this(tablePath, tableBucket, key, originalPartitionName, 0); + } + + public AbstractLookupQuery( + TablePath tablePath, + TableBucket tableBucket, + byte[] key, + @Nullable String originalPartitionName, + int bucketCountActual) { this.tablePath = tablePath; this.tableBucket = tableBucket; this.key = key; this.originalPartitionName = originalPartitionName; + this.bucketCountActual = bucketCountActual; this.retries = 0; this.nextRetryTimeMs = 0; } @@ -75,6 +86,11 @@ public TableBucket tableBucket() { return originalPartitionName; } + /** The bucket count used to calculate this lookup's bucketId, or 0 if unknown (legacy). */ + public int bucketCountActual() { + return bucketCountActual; + } + public int retries() { return retries; } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java index fd8ec59a4f4..094784cdaa8 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java @@ -23,6 +23,7 @@ import org.apache.fluss.metadata.SchemaGetter; import org.apache.fluss.metadata.SchemaInfo; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.row.InternalRow; import org.apache.fluss.row.decode.FixedSchemaDecoder; import org.apache.fluss.row.encode.KvValueLayout; @@ -76,6 +77,19 @@ abstract class AbstractLookuper implements Lookuper { tableInfo.getTableConfig().getKvFormat(), tableInfo.getSchema())); } + /** + * Resolves the effective bucket count for the target partition: the per-partition bucket count + * when the cluster metadata has it, falling back to the table-level bucket count otherwise + * (non-partitioned tables or partitions created by older versions). + */ + protected int resolvePartitionBucketCountActual( + TablePartition tablePartition, int tableLevelNumBuckets) { + return metadataUpdater + .getCluster() + .getBucketCountActual(tablePartition) + .orElse(tableLevelNumBuckets); + } + protected void handleLookupResponse( List result, CompletableFuture lookupFuture) { List valueList = new ArrayList<>(result.size()); diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupBatch.java index 0d378ab4f21..b2752514645 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupBatch.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupBatch.java @@ -34,9 +34,12 @@ public class LookupBatch { private final List lookups; - LookupBatch(LookupBatchKey lookupBatchKey) { + private final int bucketCountActual; + + LookupBatch(LookupBatchKey lookupBatchKey, int bucketCountActual) { this.lookupBatchKey = lookupBatchKey; this.lookups = new ArrayList<>(); + this.bucketCountActual = bucketCountActual; } public void addLookup(LookupQuery lookup) { @@ -55,6 +58,11 @@ public TableBucket tableBucket() { return lookupBatchKey.originalPartitionName(); } + /** The bucket count the bucketId was calculated with, or 0 if unknown (legacy). */ + public int getBucketCountActual() { + return bucketCountActual; + } + LookupBatchKey lookupBatchKey() { return lookupBatchKey; } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupClient.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupClient.java index f467b2b5f5c..1fb418f5a1b 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupClient.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupClient.java @@ -113,17 +113,24 @@ public CompletableFuture lookup( TableBucket tableBucket, byte[] keyBytes, boolean insertIfNotExists, - @Nullable String originalPartitionName) { + @Nullable String originalPartitionName, + int bucketCountActual) { LookupQuery lookup = new LookupQuery( - tablePath, tableBucket, keyBytes, insertIfNotExists, originalPartitionName); + tablePath, + tableBucket, + keyBytes, + insertIfNotExists, + originalPartitionName, + bucketCountActual); lookupQueue.appendLookup(lookup); return lookup.future(); } public CompletableFuture> prefixLookup( - TablePath tablePath, TableBucket tableBucket, byte[] keyBytes) { - PrefixLookupQuery prefixLookup = new PrefixLookupQuery(tablePath, tableBucket, keyBytes); + TablePath tablePath, TableBucket tableBucket, byte[] keyBytes, int bucketCountActual) { + PrefixLookupQuery prefixLookup = + new PrefixLookupQuery(tablePath, tableBucket, keyBytes, bucketCountActual); lookupQueue.appendLookup(prefixLookup); return prefixLookup.future(); } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupQuery.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupQuery.java index 3f0218d6d25..3dce5e32757 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupQuery.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupQuery.java @@ -41,15 +41,25 @@ public class LookupQuery extends AbstractLookupQuery { TableBucket tableBucket, byte[] key, boolean insertIfNotExists, - @Nullable String originalPartitionName) { - super(tablePath, tableBucket, key, originalPartitionName); + @Nullable String originalPartitionName, + int bucketCountActual) { + super(tablePath, tableBucket, key, originalPartitionName, bucketCountActual); this.future = new CompletableFuture<>(); this.insertIfNotExists = insertIfNotExists; } + LookupQuery( + TablePath tablePath, + TableBucket tableBucket, + byte[] key, + boolean insertIfNotExists, + @Nullable String originalPartitionName) { + this(tablePath, tableBucket, key, insertIfNotExists, originalPartitionName, 0); + } + @VisibleForTesting LookupQuery(TablePath tablePath, TableBucket tableBucket, byte[] key) { - this(tablePath, tableBucket, key, false, null); + this(tablePath, tableBucket, key, false, null, 0); } @Override diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupSender.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupSender.java index e608494e43b..3c87a6bad44 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupSender.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupSender.java @@ -39,6 +39,7 @@ import org.apache.fluss.rpc.messages.PrefixLookupRequest; import org.apache.fluss.rpc.messages.PrefixLookupResponse; import org.apache.fluss.rpc.protocol.ApiError; +import org.apache.fluss.rpc.protocol.Errors; import org.apache.fluss.utils.ExponentialBackoff; import org.apache.fluss.utils.types.Tuple2; @@ -221,7 +222,8 @@ private void sendLookupRequest( LookupBatchKey batchKey = new LookupBatchKey(tb, lookup.originalPartitionName()); lookupByTableId .computeIfAbsent(tableId, k -> new LinkedHashMap<>()) - .computeIfAbsent(batchKey, k -> new LookupBatch(batchKey)) + .computeIfAbsent( + batchKey, k -> new LookupBatch(batchKey, lookup.bucketCountActual())) .addLookup(lookup); } @@ -299,7 +301,8 @@ private void sendPrefixLookupRequest( long tableId = tb.getTableId(); lookupByTableId .computeIfAbsent(tableId, k -> new HashMap<>()) - .computeIfAbsent(tb, k -> new PrefixLookupBatch(tb)) + .computeIfAbsent( + tb, k -> new PrefixLookupBatch(tb, prefixLookup.bucketCountActual())) .addLookup(prefixLookup); } @@ -565,6 +568,13 @@ private void handleLookupError( invalidTableOrPartitions(tableOrPartitions); } + if (error.error() == Errors.STALE_METADATA) { + for (AbstractLookupQuery lookup : lookups) { + lookup.future().completeExceptionally(exception); + } + return; + } + for (AbstractLookupQuery lookup : lookups) { String originalPartitionNameMsg = lookup.originalPartitionName() == null diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java index 85b9aa5ecd7..11dc7ba0eaf 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java @@ -25,6 +25,7 @@ import org.apache.fluss.metadata.SchemaGetter; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.row.InternalRow; import org.apache.fluss.row.encode.KeyEncoder; import org.apache.fluss.types.RowType; @@ -165,9 +166,9 @@ public CompletableFuture lookup(InternalRow prefixKey) { prefixKeyEncoder == bucketKeyEncoder ? prefixKeyBytes : bucketKeyEncoder.encodeKey(prefixKey); - int bucketId = bucketingFunction.bucketing(bucketKeyBytes, numBuckets); Long partitionId = null; + int bucketCountActual = numBuckets; if (partitionGetter != null) { try { partitionId = @@ -176,15 +177,23 @@ public CompletableFuture lookup(InternalRow prefixKey) { partitionGetter, tableInfo.getTablePath(), metadataUpdater); + bucketCountActual = + resolvePartitionBucketCountActual( + new TablePartition(tableInfo.getTableId(), partitionId), + numBuckets); } catch (PartitionNotExistException e) { return CompletableFuture.completedFuture(new LookupResult(Collections.emptyList())); } } + // Compute bucket ID after partition resolution — needs per-partition bucket count + int bucketId = bucketingFunction.bucketing(bucketKeyBytes, bucketCountActual); + CompletableFuture lookupFuture = new CompletableFuture<>(); TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucketId); lookupClient - .prefixLookup(tableInfo.getTablePath(), tableBucket, prefixKeyBytes) + .prefixLookup( + tableInfo.getTablePath(), tableBucket, prefixKeyBytes, bucketCountActual) .whenComplete( (result, error) -> { if (error != null) { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupBatch.java index 0932fcfbbfa..711ad7eb02a 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupBatch.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupBatch.java @@ -34,10 +34,13 @@ public class PrefixLookupBatch { /** The table bucket that the lookup operations should fall into. */ private final TableBucket tableBucket; + private final int bucketCountActual; + private final List prefixLookups; - public PrefixLookupBatch(TableBucket tableBucket) { + public PrefixLookupBatch(TableBucket tableBucket, int bucketCountActual) { this.tableBucket = tableBucket; + this.bucketCountActual = bucketCountActual; this.prefixLookups = new ArrayList<>(); } @@ -53,6 +56,11 @@ public TableBucket tableBucket() { return tableBucket; } + /** The bucket count the bucketId was calculated with, or 0 if unknown (legacy). */ + public int getBucketCountActual() { + return bucketCountActual; + } + public void complete(List> values) { if (values.size() != prefixLookups.size()) { completeExceptionally( diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupQuery.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupQuery.java index 5246aedca20..fc1191efee1 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupQuery.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupQuery.java @@ -32,11 +32,16 @@ public class PrefixLookupQuery extends AbstractLookupQuery> { private final CompletableFuture> future; - PrefixLookupQuery(TablePath tablePath, TableBucket tableBucket, byte[] prefixKey) { - super(tablePath, tableBucket, prefixKey); + PrefixLookupQuery( + TablePath tablePath, TableBucket tableBucket, byte[] prefixKey, int bucketCountActual) { + super(tablePath, tableBucket, prefixKey, null, bucketCountActual); this.future = new CompletableFuture<>(); } + PrefixLookupQuery(TablePath tablePath, TableBucket tableBucket, byte[] prefixKey) { + this(tablePath, tableBucket, prefixKey, 0); + } + @Override public CompletableFuture> future() { return future; diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java index d235649e036..865c9cff4c9 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java @@ -26,6 +26,7 @@ import org.apache.fluss.metadata.SchemaGetter; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.row.InternalRow; import org.apache.fluss.row.encode.KeyEncoder; import org.apache.fluss.types.RowType; @@ -127,10 +128,11 @@ public CompletableFuture lookup(InternalRow lookupKey) { int bucketId = bucketingFunction.bucketing(bkBytes, numBuckets); Long partitionId = null; String originalPartitionName = null; + int bucketCountActual = numBuckets; if (partitionGetter != null) { originalPartitionName = partitionGetter.getPartition(lookupKey); if (confirmedHistoricalPartitions.contains(originalPartitionName)) { - return historicalLookup(bucketId, pkBytes, originalPartitionName); + return historicalLookup(bkBytes, pkBytes, originalPartitionName); } try { partitionId = @@ -139,20 +141,37 @@ public CompletableFuture lookup(InternalRow lookupKey) { partitionGetter, tableInfo.getTablePath(), metadataUpdater); + bucketCountActual = + resolvePartitionBucketCountActual( + new TablePartition(tableInfo.getTableId(), partitionId), + numBuckets); } catch (PartitionNotExistException e) { - return mayFallbackToHistoricalLookup(bucketId, pkBytes, originalPartitionName); + return mayFallbackToHistoricalLookup(bkBytes, pkBytes, originalPartitionName); } } + // A partition created before ALTER bucket.num keeps its own layout, so re-route by the + // partition's actual count. The historical lookups above are routed by the historical + // partition's own count on their own path. + if (bucketCountActual != numBuckets) { + bucketId = bucketingFunction.bucketing(bkBytes, bucketCountActual); + } TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucketId); - return lookupBucket(tableBucket, pkBytes, insertIfNotExists, false, originalPartitionName); + return lookupBucket( + tableBucket, + bkBytes, + pkBytes, + insertIfNotExists, + false, + originalPartitionName, + bucketCountActual); } /** * Falls back to historical lookup when the normal partition is missing and fallback is enabled. */ private CompletableFuture mayFallbackToHistoricalLookup( - int bucketId, byte[] keyBytes, String originalPartitionName) { + byte[] bucketKeyBytes, byte[] keyBytes, String originalPartitionName) { // Clear the stale normal-partition route before deciding whether to fall back so that a // partition created later can be discovered by the next lookup. metadataUpdater.invalidPhysicalTableBucketAndPartitionMeta( @@ -171,11 +190,11 @@ private CompletableFuture mayFallbackToHistoricalLookup( return CompletableFuture.completedFuture(new LookupResult(Collections.emptyList())); } confirmedHistoricalPartitions.add(originalPartitionName); - return historicalLookup(bucketId, keyBytes, originalPartitionName); + return historicalLookup(bucketKeyBytes, keyBytes, originalPartitionName); } private CompletableFuture historicalLookup( - int bucketId, byte[] keyBytes, String originalPartitionName) { + byte[] bucketKeyBytes, byte[] keyBytes, String originalPartitionName) { if (insertIfNotExists) { return completedExceptionally( new UnsupportedOperationException( @@ -190,9 +209,24 @@ private CompletableFuture historicalLookup( } Long historicalPartitionId = metadataUpdater.getPartitionIdOrElseThrow(historicalPartitionPath); + // Route by the historical partition's own count, which an ALTER bucket.num does not + // change. The bucket the lake data lives in is resolved on the server. + int historicalBucketCount = + resolvePartitionBucketCountActual( + new TablePartition(tableInfo.getTableId(), historicalPartitionId), + numBuckets); + int routingBucketId = + bucketingFunction.bucketing(bucketKeyBytes, historicalBucketCount); TableBucket tableBucket = - new TableBucket(tableInfo.getTableId(), historicalPartitionId, bucketId); - return lookupBucket(tableBucket, keyBytes, false, true, originalPartitionName); + new TableBucket(tableInfo.getTableId(), historicalPartitionId, routingBucketId); + return lookupBucket( + tableBucket, + bucketKeyBytes, + keyBytes, + false, + true, + originalPartitionName, + historicalBucketCount); } catch (Throwable t) { return completedExceptionally(t); } @@ -200,10 +234,12 @@ private CompletableFuture historicalLookup( private CompletableFuture lookupBucket( TableBucket tableBucket, + byte[] bucketKeyBytes, byte[] keyBytes, boolean insertIfNotExists, boolean historicalLookup, - @Nullable String originalPartitionName) { + @Nullable String originalPartitionName, + int bucketCountActual) { CompletableFuture lookupFuture = new CompletableFuture<>(); lookupClient .lookup( @@ -211,7 +247,8 @@ private CompletableFuture lookupBucket( tableBucket, keyBytes, insertIfNotExists, - historicalLookup ? originalPartitionName : null) + historicalLookup ? originalPartitionName : null, + bucketCountActual) .whenComplete( (result, error) -> { if (error != null) { @@ -227,9 +264,7 @@ private CompletableFuture lookupBucket( } mayFallbackToHistoricalLookup( - tableBucket.getBucket(), - keyBytes, - originalPartitionName) + bucketKeyBytes, keyBytes, originalPartitionName) .whenComplete( (historicalResult, historicalError) -> { if (historicalError != null) { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/TableScan.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/TableScan.java index 796f6da3f33..0790c6fdacb 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/TableScan.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/TableScan.java @@ -257,7 +257,7 @@ public BatchScanner createBatchScanner() throws IOException { partitionInfos.stream() .flatMap( partitionInfo -> - IntStream.range(0, bucketCount) + IntStream.range(0, partitionInfo.getBucketCountActual()) .mapToObj( bucketId -> new TableBucket( diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/KvBatchScanner.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/KvBatchScanner.java index 2062f9c1fc5..9681bb55e5e 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/KvBatchScanner.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/KvBatchScanner.java @@ -20,10 +20,12 @@ import org.apache.fluss.annotation.Internal; import org.apache.fluss.annotation.VisibleForTesting; import org.apache.fluss.client.metadata.MetadataUpdater; +import org.apache.fluss.cluster.Cluster; import org.apache.fluss.exception.LeaderNotAvailableException; import org.apache.fluss.metadata.SchemaGetter; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.record.DefaultValueRecordBatch; import org.apache.fluss.record.ValueRecord; @@ -177,12 +179,19 @@ private void openScanner() { "Leader for bucket " + bucket + " is not available. Please retry the scan."); } + Cluster cluster = metadataUpdater.getCluster(); PbScanReqForBucket bucketReq = new PbScanReqForBucket() .setTableId(bucket.getTableId()) .setBucketId(bucket.getBucket()); if (bucket.getPartitionId() != null) { bucketReq.setPartitionId(bucket.getPartitionId()); + cluster.getBucketCountActual( + new TablePartition(bucket.getTableId(), bucket.getPartitionId())) + .ifPresent(bucketReq::setRoutingBucketCount); + } else { + cluster.getBucketCountForTable(bucket.getTableId()) + .ifPresent(bucketReq::setRoutingBucketCount); } ScanKvRequest request = diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java index b5a381ad156..1b0a50fc315 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java @@ -18,11 +18,13 @@ package org.apache.fluss.client.table.scanner.batch; import org.apache.fluss.client.metadata.MetadataUpdater; +import org.apache.fluss.cluster.Cluster; import org.apache.fluss.exception.LeaderNotAvailableException; import org.apache.fluss.metadata.KvFormat; import org.apache.fluss.metadata.SchemaGetter; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.record.DefaultValueRecordBatch; import org.apache.fluss.record.LogRecord; import org.apache.fluss.record.LogRecordBatch; @@ -97,6 +99,7 @@ public LimitBatchScanner( this.fieldGetters[i] = InternalRow.createDeepFieldGetter(rowType.getTypeAt(i), i); } + Cluster cluster = metadataUpdater.getCluster(); LimitScanRequest limitScanRequest = new LimitScanRequest() .setTableId(tableBucket.getTableId()) @@ -105,7 +108,14 @@ public LimitBatchScanner( if (tableBucket.getPartitionId() != null) { limitScanRequest.setPartitionId(tableBucket.getPartitionId()); + cluster.getBucketCountActual( + new TablePartition( + tableBucket.getTableId(), tableBucket.getPartitionId())) + .ifPresent(limitScanRequest::setRoutingBucketCount); metadataUpdater.checkAndUpdateMetadata(tableInfo.getTablePath(), tableBucket); + } else { + cluster.getBucketCountForTable(tableBucket.getTableId()) + .ifPresent(limitScanRequest::setRoutingBucketCount); } // because that rocksdb is not suitable to projection, thus do it in client. diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java index 77a8504ca33..a7d825b4ce4 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java @@ -598,6 +598,16 @@ Map prepareFetchLogRequests(List fetchabl .setMaxFetchBytes(maxBucketFetchBytes); if (tb.getPartitionId() != null) { fetchLogReqForBucket.setPartitionId(tb.getPartitionId()); + metadataUpdater + .getCluster() + .getBucketCountActual( + new TablePartition(tb.getTableId(), tb.getPartitionId())) + .ifPresent(fetchLogReqForBucket::setRoutingBucketCount); + } else { + metadataUpdater + .getCluster() + .getBucketCountForTable(tb.getTableId()) + .ifPresent(fetchLogReqForBucket::setRoutingBucketCount); } fetchReqsByLeaderAndTable .computeIfAbsent(leader, k -> new HashMap<>()) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java index 45b867e89c1..29df7e29e41 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java @@ -31,6 +31,7 @@ import org.apache.fluss.client.metadata.RemoteLogManifestInfo; import org.apache.fluss.client.write.KvWriteBatch; import org.apache.fluss.client.write.ReadyWriteBatch; +import org.apache.fluss.cluster.Cluster; import org.apache.fluss.cluster.rebalance.RebalancePlanForBucket; import org.apache.fluss.cluster.rebalance.RebalanceProgress; import org.apache.fluss.cluster.rebalance.RebalanceResultForBucket; @@ -49,6 +50,7 @@ import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableChange; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.rpc.messages.AcquireKvSnapshotLeaseRequest; import org.apache.fluss.rpc.messages.AcquireKvSnapshotLeaseResponse; @@ -143,6 +145,8 @@ public static ProduceLogRequest makeProduceLogRequest( PbProduceLogReqForBucket pbProduceLogReqForBucket = request.addBucketsReq() .setBucketId(tableBucket.getBucket()) + .setRoutingBucketCount( + readyBatch.writeBatch().getBucketCountActual()) .setRecordsBytesView(readyBatch.writeBatch().build()); if (tableBucket.getPartitionId() != null) { pbProduceLogReqForBucket.setPartitionId(tableBucket.getPartitionId()); @@ -202,6 +206,8 @@ public static PutKvRequest makePutKvRequest( PbPutKvReqForBucket pbPutKvReqForBucket = request.addBucketsReq() .setBucketId(tableBucket.getBucket()) + .setRoutingBucketCount( + readyBatch.writeBatch().getBucketCountActual()) .setRecordsBytesView(readyBatch.writeBatch().build()); if (tableBucket.getPartitionId() != null) { pbPutKvReqForBucket.setPartitionId(tableBucket.getPartitionId()); @@ -235,6 +241,11 @@ public static LookupRequest makeLookupRequest( if (tb.getPartitionId() != null) { pbLookupReqForBucket.setPartitionId(tb.getPartitionId()); } + // Carry the bucket count the bucketId was calculated with so the server can + // validate it; 0 means unknown (legacy) and leaves the field unset. + if (batch.getBucketCountActual() > 0) { + pbLookupReqForBucket.setRoutingBucketCount(batch.getBucketCountActual()); + } if (batch.originalPartitionName() != null) { pbLookupReqForBucket.setOriginalPartitionName( batch.originalPartitionName()); @@ -255,6 +266,12 @@ public static PrefixLookupRequest makePrefixLookupRequest( if (tb.getPartitionId() != null) { pbPrefixLookupReqForBucket.setPartitionId(tb.getPartitionId()); } + // Carry the bucket count the bucketId was calculated with so the server can + // validate it; 0 means unknown (legacy) and leaves the field unset. + if (batch.getBucketCountActual() > 0) { + pbPrefixLookupReqForBucket.setRoutingBucketCount( + batch.getBucketCountActual()); + } batch.lookups().forEach(get -> pbPrefixLookupReqForBucket.addKey(get.key())); }); return request; @@ -357,7 +374,8 @@ public static ListOffsetsRequest makeListOffsetsRequest( long tableId, @Nullable Long partitionId, List bucketIdList, - OffsetSpec offsetSpec) { + OffsetSpec offsetSpec, + Cluster cluster) { ListOffsetsRequest listOffsetsRequest = new ListOffsetsRequest(); listOffsetsRequest .setFollowerServerId(-1) // -1 indicate the request from client. @@ -365,6 +383,11 @@ public static ListOffsetsRequest makeListOffsetsRequest( .setBucketIds(bucketIdList.stream().mapToInt(Integer::intValue).toArray()); if (partitionId != null) { listOffsetsRequest.setPartitionId(partitionId); + cluster.getBucketCountActual(new TablePartition(tableId, partitionId)) + .ifPresent(listOffsetsRequest::setRoutingBucketCount); + } else { + cluster.getBucketCountForTable(tableId) + .ifPresent(listOffsetsRequest::setRoutingBucketCount); } if (offsetSpec instanceof OffsetSpec.EarliestSpec) { @@ -643,7 +666,8 @@ private static RebalancePlanForBucket toRebalancePlanForBucket( Arrays.stream(rebalancePlan.getNewReplicas()).boxed().collect(Collectors.toList())); } - public static List toPartitionInfos(ListPartitionInfosResponse response) { + public static List toPartitionInfos( + ListPartitionInfosResponse response, int defaultBucketCount) { return response.getPartitionsInfosList().stream() .map( pbPartitionInfo -> @@ -654,7 +678,12 @@ public static List toPartitionInfos(ListPartitionInfosResponse re // clusters do not include the remote data dir pbPartitionInfo.hasRemoteDataDir() ? pbPartitionInfo.getRemoteDataDir() - : null)) + : null, + // old clusters do not send the per-partition bucket count; + // resolve to the table-level count here + pbPartitionInfo.hasBucketCountActual() + ? pbPartitionInfo.getBucketCountActual() + : defaultBucketCount)) .collect(Collectors.toList()); } @@ -865,7 +894,8 @@ public static List toDatabaseSummaries(ListDatabasesResponse re return databaseSummaries; } - public static GetTableStatsRequest makeGetTableStatsRequest(List buckets) { + public static GetTableStatsRequest makeGetTableStatsRequest( + List buckets, Cluster cluster) { if (buckets.isEmpty()) { throw new IllegalArgumentException("Buckets list cannot be empty"); } @@ -883,6 +913,14 @@ public static GetTableStatsRequest makeGetTableStatsRequest(List bu .setBucketId(bucket.getBucket()); if (bucket.getPartitionId() != null) { pbBucket.setPartitionId(bucket.getPartitionId()); + cluster.getBucketCountActual( + new TablePartition( + bucket.getTableId(), + bucket.getPartitionId())) + .ifPresent(pbBucket::setRoutingBucketCount); + } else { + cluster.getBucketCountForTable(bucket.getTableId()) + .ifPresent(pbBucket::setRoutingBucketCount); } return pbBucket; }) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/utils/MetadataUtils.java b/fluss-client/src/main/java/org/apache/fluss/client/utils/MetadataUtils.java index 2990054999b..15f3b7efee3 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/utils/MetadataUtils.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/utils/MetadataUtils.java @@ -24,6 +24,7 @@ import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.rpc.GatewayClientProxy; import org.apache.fluss.rpc.RpcClient; @@ -121,6 +122,8 @@ public static Cluster sendMetadataRequestAndRebuildCluster( Map newTablePathToTableId; Map> newBucketLocations; Map newPartitionIdByPath; + Map newBucketCountActualByPartition; + Map newBucketCountByTable; NewTableMetadata newTableMetadata = getTableMetadataToUpdate(originCluster, response); @@ -134,10 +137,18 @@ public static Cluster sendMetadataRequestAndRebuildCluster( new HashMap<>(originCluster.getBucketLocationsByPath()); newPartitionIdByPath = new HashMap<>(originCluster.getPartitionIdByPath()); + newBucketCountActualByPartition = + new HashMap<>( + originCluster.getBucketCountActualByPartition()); + newBucketCountByTable = + new HashMap<>(originCluster.getBucketCountByTable()); newTablePathToTableId.putAll(newTableMetadata.tablePathToTableId); newBucketLocations.putAll(newTableMetadata.bucketLocations); newPartitionIdByPath.putAll(newTableMetadata.partitionIdByPath); + newBucketCountActualByPartition.putAll( + newTableMetadata.bucketCountActualByPartition); + newBucketCountByTable.putAll(newTableMetadata.bucketCountByTable); } else { // If full update, we will clear all tables info out ot the origin @@ -145,6 +156,9 @@ public static Cluster sendMetadataRequestAndRebuildCluster( newTablePathToTableId = newTableMetadata.tablePathToTableId; newBucketLocations = newTableMetadata.bucketLocations; newPartitionIdByPath = newTableMetadata.partitionIdByPath; + newBucketCountActualByPartition = + newTableMetadata.bucketCountActualByPartition; + newBucketCountByTable = newTableMetadata.bucketCountByTable; } return new Cluster( @@ -152,7 +166,9 @@ public static Cluster sendMetadataRequestAndRebuildCluster( coordinatorServer, newBucketLocations, newTablePathToTableId, - newPartitionIdByPath); + newPartitionIdByPath, + newBucketCountActualByPartition, + newBucketCountByTable); }) .get(30, TimeUnit.SECONDS); // TODO currently, we don't have timeout logic in // RpcClient, it will let the get() block forever. So we @@ -164,6 +180,8 @@ private static NewTableMetadata getTableMetadataToUpdate( Map newTablePathToTableId = new HashMap<>(); Map> newBucketLocations = new HashMap<>(); Map newPartitionIdByPath = new HashMap<>(); + Map newBucketCountActualByPartition = new HashMap<>(); + Map newBucketCountByTable = new HashMap<>(); // iterate all table metadata List pbTableMetadataList = metadataResponse.getTableMetadatasList(); @@ -185,6 +203,11 @@ private static NewTableMetadata getTableMetadataToUpdate( PhysicalTablePath.of(tablePath), toBucketLocations( tablePath, tableId, null, null, pbBucketMetadataList)); + // An empty bucket list means the assignment is not generated yet; keeping the + // entry out lets callers fall back to the table-level count instead of 0. + if (!pbBucketMetadataList.isEmpty()) { + newBucketCountByTable.put(tableId, pbBucketMetadataList.size()); + } }); List pbPartitionMetadataList = @@ -208,24 +231,43 @@ private static NewTableMetadata getTableMetadataToUpdate( pbPartitionMetadata.getPartitionId(), pbPartitionMetadata.getPartitionName(), pbPartitionMetadata.getBucketMetadatasList())); + // a non-positive count is not a valid bucket layout (an old server omits the + // field, a new one may still report 0 before the assignment exists), so keep + // the entry out and let callers fall back to the table-level count + if (pbPartitionMetadata.hasBucketCountActual() + && pbPartitionMetadata.getBucketCountActual() > 0) { + newBucketCountActualByPartition.put( + new TablePartition(tableId, pbPartitionMetadata.getPartitionId()), + pbPartitionMetadata.getBucketCountActual()); + } }); return new NewTableMetadata( - newTablePathToTableId, newBucketLocations, newPartitionIdByPath); + newTablePathToTableId, + newBucketLocations, + newPartitionIdByPath, + newBucketCountActualByPartition, + newBucketCountByTable); } private static final class NewTableMetadata { private final Map tablePathToTableId; private final Map> bucketLocations; private final Map partitionIdByPath; + private final Map bucketCountActualByPartition; + private final Map bucketCountByTable; public NewTableMetadata( Map tablePathToTableId, Map> bucketLocations, - Map partitionIdByPath) { + Map partitionIdByPath, + Map bucketCountActualByPartition, + Map bucketCountByTable) { this.tablePathToTableId = tablePathToTableId; this.bucketLocations = bucketLocations; this.partitionIdByPath = partitionIdByPath; + this.bucketCountActualByPartition = bucketCountActualByPartition; + this.bucketCountByTable = bucketCountByTable; } } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java b/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java index a6a73354f9d..1e51ba9e511 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java @@ -19,11 +19,13 @@ import org.apache.fluss.client.admin.Admin; import org.apache.fluss.client.metadata.MetadataUpdater; +import org.apache.fluss.cluster.Cluster; import org.apache.fluss.exception.FlussRuntimeException; import org.apache.fluss.exception.PartitionNotExistException; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.ResolvedPartitionSpec; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.utils.AutoPartitionStrategy; import org.apache.fluss.utils.ExceptionUtils; @@ -33,7 +35,10 @@ import javax.annotation.concurrent.ThreadSafe; +import java.time.Duration; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -54,37 +59,47 @@ public class DynamicPartitionCreator { private final Consumer fatalErrorHandler; private final Set inflightPartitionsToCreate = ConcurrentHashMap.newKeySet(); + private final Map partitionCreationFailures = + new ConcurrentHashMap<>(); + private final Duration metadataWaitTimeout; public DynamicPartitionCreator( MetadataUpdater metadataUpdater, Admin admin, boolean dynamicPartitionEnabled, + Duration metadataWaitTimeout, Consumer fatalErrorHandler) { this.metadataUpdater = metadataUpdater; this.admin = admin; this.dynamicPartitionEnabled = dynamicPartitionEnabled; + this.metadataWaitTimeout = metadataWaitTimeout; this.fatalErrorHandler = fatalErrorHandler; } - public void checkAndCreatePartitionAsync( + /** + * Ensures the partition of the given path exists and returns a metadata snapshot containing its + * partition id and actual bucket count, creating the partition dynamically if enabled. + */ + public Cluster checkAndCreatePartition( PhysicalTablePath physicalTablePath, TableInfo tableInfo) { String partitionName = physicalTablePath.getPartitionName(); if (partitionName == null) { - // no need to check and create partition - return; + return metadataUpdater.getCluster(); } - Optional partitionIdOpt = metadataUpdater.getPartitionId(physicalTablePath); - // first try to update metadata info if not exists. - boolean idExist = partitionIdOpt.isPresent(); - if (!idExist) { + Cluster cluster = metadataUpdater.getCluster(); + if (isPartitionMetadataAvailable(cluster, physicalTablePath)) { + return cluster; + } + + if (!cluster.getPartitionId(physicalTablePath).isPresent()) { if (inflightPartitionsToCreate.contains(physicalTablePath)) { - // if the partition is already in inflightPartitionsToCreate, we should skip - // creating it. - LOG.debug("Partition {} is already being created, skipping.", physicalTablePath); + LOG.debug("Partition {} is already being created, waiting.", physicalTablePath); } else if (forceCheckPartitionExist(physicalTablePath)) { - // if the partition exists, we should skip creating it. - LOG.debug("Partition {} already exists, skipping.", physicalTablePath); + LOG.debug( + "Partition {} already exists, waiting for routing metadata.", + physicalTablePath); + partitionCreationFailures.remove(physicalTablePath); } else { // Validate early, before touching any state. The strategy is only resolved here, // on the partition-creation path, not on the common "already exists" path. @@ -104,17 +119,78 @@ public void checkAndCreatePartitionAsync( // if the partition is not in inflightPartitionsToCreate, we should create it. // this means that the partition is not being created by other threads. LOG.info("Dynamically creating partition for {}", physicalTablePath); + partitionCreationFailures.remove(physicalTablePath); createPartition(physicalTablePath, partitionKeys); } else { - // if the partition is already in inflightPartitionsToCreate, we should skip - // creating it. - LOG.debug( - "Partition {} is already being created, skipping.", physicalTablePath); + LOG.debug("Partition {} is already being created, waiting.", physicalTablePath); + } + } + } + return waitForPartitionMetadata(physicalTablePath); + } + + /** + * Returns the metadata snapshot once the partition id and actual bucket count are available. + */ + private Cluster waitForPartitionMetadata(PhysicalTablePath physicalTablePath) { + long deadlineNanos = System.nanoTime() + metadataWaitTimeout.toNanos(); + long backoffMs = 100; + while (true) { + Throwable creationFailure = partitionCreationFailures.get(physicalTablePath); + if (creationFailure != null) { + throw new FlussRuntimeException( + "Failed to dynamically create partition " + physicalTablePath, + creationFailure); + } + + Cluster cluster = metadataUpdater.getCluster(); + if (isPartitionMetadataAvailable(cluster, physicalTablePath)) { + partitionCreationFailures.remove(physicalTablePath); + return cluster; + } + + try { + metadataUpdater.updatePhysicalTableMetadata( + Collections.singleton(physicalTablePath)); + } catch (Exception e) { + Throwable t = ExceptionUtils.stripExecutionException(e); + if (!(t instanceof PartitionNotExistException)) { + throw new FlussRuntimeException(e.getMessage(), e); } } + + cluster = metadataUpdater.getCluster(); + if (isPartitionMetadataAvailable(cluster, physicalTablePath)) { + partitionCreationFailures.remove(physicalTablePath); + return cluster; + } + if (System.nanoTime() >= deadlineNanos) { + throw new FlussRuntimeException( + String.format( + "Timed out after %s waiting for metadata of partition %s. The " + + "record is not written; retry once the partition " + + "metadata is available.", + metadataWaitTimeout, physicalTablePath)); + } + try { + Thread.sleep(backoffMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FlussRuntimeException( + "Interrupted while waiting for metadata of partition " + physicalTablePath, + e); + } + backoffMs = Math.min(backoffMs * 2, 1000); } } + private boolean isPartitionMetadataAvailable( + Cluster cluster, PhysicalTablePath physicalTablePath) { + Optional tablePartition = cluster.getTablePartition(physicalTablePath); + return tablePartition.isPresent() + && cluster.getBucketCountActual(tablePartition.get()).isPresent(); + } + private boolean forceCheckPartitionExist(PhysicalTablePath physicalTablePath) { boolean idExist = false; // force an IO to check whether the partition exists @@ -159,13 +235,13 @@ private void createPartition(PhysicalTablePath physicalTablePath, List p private void onPartitionCreationSuccess(PhysicalTablePath physicalTablePath) { inflightPartitionsToCreate.remove(physicalTablePath); - // TODO: trigger to update metadata here when metadataUpdater supports async update - // metadataUpdater.checkAndUpdatePartitionMetadata(physicalTablePath); + // waiters in waitForPartitionMetadata poll and refresh the metadata themselves. LOG.info("Successfully created partition {}", physicalTablePath); } private void onPartitionCreationFailed( PhysicalTablePath physicalTablePath, Throwable throwable) { + partitionCreationFailures.put(physicalTablePath, stripCompletionException(throwable)); inflightPartitionsToCreate.remove(physicalTablePath); fatalErrorHandler.accept( new FlussRuntimeException( diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java b/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java index f90096ae2fb..9c86f54ea7d 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java @@ -202,6 +202,17 @@ public RecordAppendResult append( int bucketId, boolean abortIfBatchFull) throws Exception { + return append(writeRecord, callback, cluster, bucketId, 0, abortIfBatchFull); + } + + public RecordAppendResult append( + WriteRecord writeRecord, + WriteCallback callback, + Cluster cluster, + int bucketId, + int bucketCountActual, + boolean abortIfBatchFull) + throws Exception { PhysicalTablePath physicalTablePath = writeRecord.getPhysicalTablePath(); TableInfo tableInfo = writeRecord.getTableInfo(); // The metadata may return null for the partition id, but it is fine to pass null here, @@ -242,7 +253,13 @@ public RecordAppendResult append( synchronized (dq) { RecordAppendResult appendResult = appendNewBatch( - writeRecord, callback, bucketId, tableInfo, dq, memorySegments); + writeRecord, + callback, + bucketId, + bucketCountActual, + tableInfo, + dq, + memorySegments); if (appendResult.newBatchCreated) { memorySegments = Collections.emptyList(); } @@ -770,6 +787,7 @@ private RecordAppendResult appendNewBatch( WriteRecord writeRecord, WriteCallback callback, int bucketId, + int bucketCountActual, TableInfo tableInfo, Deque deque, List segments) @@ -809,6 +827,7 @@ private RecordAppendResult appendNewBatch( schemaId, isHistoricalPartition); + batch.setBucketCountActual(bucketCountActual); batch.tryAppend(writeRecord, callback); deque.addLast(batch); incomplete.add(batch); diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java index 504e7ba8bbf..41012ee06ba 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java @@ -55,6 +55,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Consumer; import static org.apache.fluss.client.utils.ClientRpcMessageUtils.makeProduceLogRequest; import static org.apache.fluss.client.utils.ClientRpcMessageUtils.makePutKvRequest; @@ -114,6 +115,13 @@ public class Sender implements Runnable { private final WriterMetricGroup writerMetricGroup; + /** + * Called when a write batch receives STALE_METADATA so the owning {@link WriterClient} can + * remove the stale {@link BucketAssigner}. The next {@code send} will refresh metadata and + * create a new assigner with the updated bucket count. + */ + private final Consumer bucketAssignerInvalidator; + public Sender( RecordAccumulator accumulator, int maxRequestTimeoutMs, @@ -122,7 +130,8 @@ public Sender( int retries, MetadataUpdater metadataUpdater, IdempotenceManager idempotenceManager, - WriterMetricGroup writerMetricGroup) { + WriterMetricGroup writerMetricGroup, + Consumer bucketAssignerInvalidator) { this.accumulator = accumulator; this.maxRequestSize = maxRequestSize; this.maxRequestTimeoutMs = maxRequestTimeoutMs; @@ -136,6 +145,7 @@ public Sender( this.idempotenceManager = idempotenceManager; this.writerMetricGroup = writerMetricGroup; + this.bucketAssignerInvalidator = bucketAssignerInvalidator; // TODO add retry logic while send failed. See FLUSS-56364375 } @@ -642,6 +652,19 @@ private Set handleWriteBatchException( // re-enqueues the batch. accumulator.updateThrottle(readyWriteBatch.tableBucket(), 1.0f); } + if (error.error() == Errors.STALE_METADATA) { + // The bucketId in this batch was calculated with a stale bucket count. Fail the + // batch (do not re-enqueue — the bucketId is fixed), invalidate metadata, and remove + // the BucketAssigner so the next send creates a new one with the updated count. + LOG.warn( + "Received STALE_METADATA error in write request on table bucket {}. " + + "Failing batch and invalidating BucketAssigner.", + readyWriteBatch.tableBucket()); + failBatch(readyWriteBatch, error.exception(), false); + invalidMetadataTables.add(writeBatch.physicalTablePath()); + bucketAssignerInvalidator.accept(readyWriteBatch.tableBucket()); + return invalidMetadataTables; + } if (error.error() == Errors.DUPLICATE_SEQUENCE_EXCEPTION) { // If we have received a duplicate batch sequence error, it means that the batch // sequence has advanced beyond the sequence of the current batch. diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java index de322244bab..2200057c4c5 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java @@ -52,6 +52,10 @@ public abstract class WriteBatch { private final WriteFormat writeFormat; private final int bucketId; + // The bucket count used to calculate this batch's bucketId; carried into the request + // so the TabletServer can validate it against the actual count (STALE_METADATA on mismatch). + private int bucketCountActual; + protected final List callbacks = new ArrayList<>(); private final AtomicReference finalState = new AtomicReference<>(null); private final AtomicInteger attempts = new AtomicInteger(0); @@ -196,6 +200,14 @@ public int bucketId() { return bucketId; } + public int getBucketCountActual() { + return bucketCountActual; + } + + public void setBucketCountActual(int bucketCountActual) { + this.bucketCountActual = bucketCountActual; + } + public long tableId() { return tableId; } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java index 2239b98d95e..c226ec83623 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java @@ -30,7 +30,9 @@ import org.apache.fluss.exception.IllegalConfigurationException; import org.apache.fluss.exception.PartitionNotExistException; import org.apache.fluss.metadata.PhysicalTablePath; +import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.rpc.gateway.TabletServerGateway; import org.apache.fluss.rpc.metrics.ClientMetricGroup; import org.apache.fluss.utils.AutoPartitionStrategy; @@ -96,7 +98,11 @@ public class WriterClient { private final Sender sender; private final ExecutorService ioThreadPool; private final MetadataUpdater metadataUpdater; - private final Map bucketAssignerMap = new CopyOnWriteMap<>(); + // BucketAssigner cache keyed by TablePartition (partitioned tables) or tableId (non- + // partitioned tables). + private final Map partitionBucketAssigners = + new CopyOnWriteMap<>(); + private final Map tableBucketAssigners = new CopyOnWriteMap<>(); private final IdempotenceManager idempotenceManager; private final WriterMetricGroup writerMetricGroup; private final DynamicPartitionCreator dynamicPartitionCreator; @@ -140,6 +146,7 @@ public WriterClient( metadataUpdater, admin, conf.get(ConfigOptions.CLIENT_WRITER_DYNAMIC_CREATE_PARTITION_ENABLED), + conf.get(ConfigOptions.CLIENT_REQUEST_TIMEOUT), this::maybeAbortBatches); } catch (Throwable t) { LOG.error("Failed to construct writer.", t); @@ -199,33 +206,78 @@ private void doSend(WriteRecord record, WriteCallback callback) { TableInfo tableInfo = record.getTableInfo(); PhysicalTablePath physicalTablePath = record.getPhysicalTablePath(); - // Skip the call entirely on non-partitioned tables; there is no partition to create. + Cluster cluster; + // The path the record is physically written to. A retired partition's records land in + // the historical partition, whose own bucket count must drive the assignment. + PhysicalTablePath routingPath = physicalTablePath; if (tableInfo.isPartitioned()) { boolean historicalPartitionEnabled = accumulator.checkAndCacheHistoricalPartitionEnabled(tableInfo); if (historicalPartitionEnabled && mayBeExpiredHistoricalPartition( physicalTablePath, tableInfo, Instant.now())) { - resolveHistoricalWriteTarget(physicalTablePath); + routingPath = resolveHistoricalWriteTarget(physicalTablePath); + cluster = metadataUpdater.getCluster(); } else { - dynamicPartitionCreator.checkAndCreatePartitionAsync( - physicalTablePath, tableInfo); + cluster = + dynamicPartitionCreator.checkAndCreatePartition( + physicalTablePath, tableInfo); } + } else { + cluster = metadataUpdater.getCluster(); } // maybe create bucket assigner. - Cluster cluster = metadataUpdater.getCluster(); - BucketAssigner bucketAssigner = - bucketAssignerMap.computeIfAbsent( - physicalTablePath, - k -> createBucketAssigner(tableInfo, physicalTablePath, conf)); + long tableId = tableInfo.getTableId(); + BucketAssigner bucketAssigner; + int bucketCountActual; + if (tableInfo.isPartitioned()) { + PhysicalTablePath assignerPath = routingPath; + TablePartition tablePartition = + cluster.getTablePartition(routingPath) + .orElseThrow( + () -> + new FlussRuntimeException( + "Partition metadata not available for " + + assignerPath)); + bucketCountActual = + cluster.getBucketCountActual(tablePartition) + .orElseThrow( + () -> + new FlussRuntimeException( + "Actual bucket count not available for " + + assignerPath)); + bucketAssigner = + partitionBucketAssigners.computeIfAbsent( + tablePartition, + k -> + createBucketAssigner( + tableInfo, assignerPath, bucketCountActual, conf)); + } else { + bucketCountActual = + cluster.getBucketCountForTable(tableId).orElse(tableInfo.getNumBuckets()); + bucketAssigner = + tableBucketAssigners.computeIfAbsent( + tableId, + k -> + createBucketAssigner( + tableInfo, + physicalTablePath, + bucketCountActual, + conf)); + } // Append the record to the accumulator. int bucketId = bucketAssigner.assignBucket(record.getBucketKey(), cluster); RecordAppendResult result = accumulator.append( - record, callback, cluster, bucketId, bucketAssigner.abortIfBatchFull()); + record, + callback, + cluster, + bucketId, + bucketCountActual, + bucketAssigner.abortIfBatchFull()); if (result.abortRecordForNewBatch) { int prevBucketId = bucketId; @@ -236,7 +288,9 @@ && mayBeExpiredHistoricalPartition( physicalTablePath, bucketId, prevBucketId); - result = accumulator.append(record, callback, cluster, bucketId, false); + result = + accumulator.append( + record, callback, cluster, bucketId, bucketCountActual, false); } if (result.batchIsFull || result.newBatchCreated) { @@ -291,13 +345,14 @@ static boolean mayBeExpiredHistoricalPartition( return partitionName.compareTo(earliestRetainedPartition) < 0; } - private void resolveHistoricalWriteTarget(PhysicalTablePath originalPath) { + /** Returns the path the records of this original partition are physically written to. */ + private PhysicalTablePath resolveHistoricalWriteTarget(PhysicalTablePath originalPath) { // Keep refreshing while the target is still the original partition so its retirement can // be detected before more records are appended to the stale route. Ideally, the Client // should learn the server-authoritative partition status without synchronously refreshing // metadata on the per-record path; see https://github.com/apache/fluss/issues/4161. if (accumulator.hasHistoricalWriteTarget(originalPath)) { - return; + return PhysicalTablePath.of(originalPath.getTablePath(), HISTORICAL_PARTITION_VALUE); } PhysicalTablePath targetPath = originalPath; @@ -324,6 +379,7 @@ private void resolveHistoricalWriteTarget(PhysicalTablePath originalPath) { accumulator.routeWritesTo( originalPath, targetPath, metadataUpdater.getPartitionIdOrElseThrow(targetPath)); + return targetPath; } private void maybeAbortBatches(Throwable t) { @@ -408,7 +464,8 @@ private Sender newSender(short acks, int retries) { retries, metadataUpdater, idempotenceManager, - writerMetricGroup); + writerMetricGroup, + this::invalidateBucketAssigner); } public void close(Duration timeout) { @@ -461,22 +518,39 @@ private ExecutorService createThreadPool() { return Executors.newFixedThreadPool(1, new ExecutorThreadFactory(SENDER_THREAD_PREFIX)); } + /** + * Removes the {@link BucketAssigner} associated with the given table bucket. Called by {@link + * Sender} when a write batch receives STALE_METADATA so the next {@code send} creates a new + * assigner with the refreshed bucket count. + */ + private void invalidateBucketAssigner(TableBucket tableBucket) { + Long partitionId = tableBucket.getPartitionId(); + if (partitionId != null) { + partitionBucketAssigners.remove( + new TablePartition(tableBucket.getTableId(), partitionId)); + } else { + tableBucketAssigners.remove(tableBucket.getTableId()); + } + } + private BucketAssigner createBucketAssigner( - TableInfo tableInfo, PhysicalTablePath physicalTablePath, Configuration conf) { - int bucketNumber = tableInfo.getNumBuckets(); + TableInfo tableInfo, + PhysicalTablePath physicalTablePath, + int bucketCountActual, + Configuration conf) { List bucketKeys = tableInfo.getBucketKeys(); if (!bucketKeys.isEmpty()) { BucketingFunction function = BucketingFunction.of( tableInfo.getTableConfig().getDataLakeFormat().orElse(null)); - return new HashBucketAssigner(bucketNumber, function); + return new HashBucketAssigner(bucketCountActual, function); } else { ConfigOptions.NoKeyAssigner noKeyAssigner = conf.get(ConfigOptions.CLIENT_WRITER_BUCKET_NO_KEY_ASSIGNER); if (noKeyAssigner == ROUND_ROBIN) { - return new RoundRobinBucketAssigner(physicalTablePath, bucketNumber); + return new RoundRobinBucketAssigner(physicalTablePath, bucketCountActual); } else if (noKeyAssigner == STICKY) { - return new StickyBucketAssigner(physicalTablePath, bucketNumber); + return new StickyBucketAssigner(physicalTablePath, bucketCountActual); } else { throw new IllegalArgumentException( "Unsupported append only row bucket assigner: " + noKeyAssigner); diff --git a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java index 70ad760f0e6..756356a1362 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/admin/FlussAdminITCase.java @@ -25,6 +25,7 @@ import org.apache.fluss.client.table.Table; import org.apache.fluss.client.table.writer.AppendWriter; import org.apache.fluss.client.table.writer.UpsertWriter; +import org.apache.fluss.cluster.Cluster; import org.apache.fluss.cluster.ServerNode; import org.apache.fluss.cluster.rebalance.ServerTag; import org.apache.fluss.config.AutoPartitionTimeUnit; @@ -166,11 +167,9 @@ void testMultiClient() throws Exception { Admin admin1 = conn.getAdmin(); Admin admin2 = conn.getAdmin(); assertThat(admin1).isEqualTo(admin2); - TableInfo t1 = admin1.getTableInfo(DEFAULT_TABLE_PATH).get(); TableInfo t2 = admin2.getTableInfo(DEFAULT_TABLE_PATH).get(); assertThat(t1).isEqualTo(t2); - admin1.close(); admin2.close(); } @@ -1247,13 +1246,11 @@ void testKvSnapshotLeaseAfterCoordinatorServerRestart() throws Exception { // Restart the coordinator server so that the lease uses a stale cached address. restartCoordinatorServer(zkClient); - lease.acquireSnapshots(snapshots).get(); assertThat(zkClient.getKvSnapshotLeaseMetadata(lease.leaseId())).isPresent(); // Verify that release also refreshes metadata and retries against the new coordinator. restartCoordinatorServer(zkClient); - lease.releaseSnapshots(Collections.singleton(tableBucket)).get(); assertThat(zkClient.getKvSnapshotLeaseMetadata(lease.leaseId())).isNotPresent(); @@ -2925,7 +2922,11 @@ public CompletableFuture listOffsets( ListOffsetsRequest request = makeListOffsetsRequest( - 1L, null, Arrays.asList(0, 1, 2), new OffsetSpec.LatestSpec()); + 1L, + null, + Arrays.asList(0, 1, 2), + new OffsetSpec.LatestSpec(), + Cluster.empty()); Map leaderToRequestMap = new HashMap<>(); leaderToRequestMap.put(1, request); diff --git a/fluss-client/src/test/java/org/apache/fluss/client/lookup/LookupSenderTest.java b/fluss-client/src/test/java/org/apache/fluss/client/lookup/LookupSenderTest.java index 03b62d52e65..be6138d1198 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/lookup/LookupSenderTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/lookup/LookupSenderTest.java @@ -599,6 +599,68 @@ void testMultipleConcurrentLookupsWithRetries() throws Exception { .isGreaterThanOrEqualTo(2); // at least 1 failure + 1 success for the batch } + @Test + void testLookupRequestCarriesPinnedRoutingBucketCount() throws Exception { + // TOCTOU: the bucket count pinned at T1 (lookup time) must be carried to T2 (send time) + // as the request's routing_bucket_count, not re-read from cluster metadata at T2. + List receivedRequests = Collections.synchronizedList(new ArrayList<>()); + gateway.setLookupHandler( + request -> { + receivedRequests.add(request); + return createSuccessResponse(request, "value".getBytes()); + }); + + // T1: create query with bucketCountActual=4 (the partition's actual count at lookup time) + LookupQuery query = + new LookupQuery(DATA1_TABLE_PATH_PK, TABLE_BUCKET, bytes("key"), false, null, 4); + // The pinned value is visible on the query object + assertThat(query.bucketCountActual()).isEqualTo(4); + + lookupSender.sendLookups(1, LookupType.LOOKUP, Collections.singletonList(query)); + + // T2: the request must carry the T1-pinned count as routing_bucket_count + assertThat(receivedRequests).hasSize(1); + LookupRequest request = receivedRequests.get(0); + assertThat(request.getBucketsReqAt(0).hasRoutingBucketCount()).isTrue(); + assertThat(request.getBucketsReqAt(0).getRoutingBucketCount()).isEqualTo(4); + + // A legacy query (bucketCountActual=0) must not set routing_bucket_count at all, letting + // the server's epoch check decide. + receivedRequests.clear(); + LookupQuery legacyQuery = new LookupQuery(DATA1_TABLE_PATH_PK, TABLE_BUCKET, bytes("key")); + assertThat(legacyQuery.bucketCountActual()).isEqualTo(0); + + lookupSender.sendLookups(1, LookupType.LOOKUP, Collections.singletonList(legacyQuery)); + + assertThat(receivedRequests).hasSize(1); + assertThat(receivedRequests.get(0).getBucketsReqAt(0).hasRoutingBucketCount()).isFalse(); + } + + @Test + void testPrefixLookupRequestCarriesPinnedRoutingBucketCount() throws Exception { + // TOCTOU: same anchoring for prefix lookup path. + List receivedRequests = + Collections.synchronizedList(new ArrayList<>()); + gateway.setPrefixLookupHandler( + request -> { + receivedRequests.add(request); + return createSuccessPrefixLookupResponse(request); + }); + + // T1: create prefix query with bucketCountActual=4 + PrefixLookupQuery query = + new PrefixLookupQuery(DATA1_TABLE_PATH_PK, TABLE_BUCKET, bytes("prefix"), 4); + assertThat(query.bucketCountActual()).isEqualTo(4); + + lookupSender.sendLookups(1, LookupType.PREFIX_LOOKUP, Collections.singletonList(query)); + + // T2: the request must carry the T1-pinned count as routing_bucket_count + assertThat(receivedRequests).hasSize(1); + PrefixLookupRequest request = receivedRequests.get(0); + assertThat(request.getBucketsReqAt(0).hasRoutingBucketCount()).isTrue(); + assertThat(request.getBucketsReqAt(0).getRoutingBucketCount()).isEqualTo(4); + } + // Helper methods private CompletableFuture createPartitionNameEchoResponse( diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionBucketCountActualRescaleITCase.java b/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionBucketCountActualRescaleITCase.java new file mode 100644 index 00000000000..857728dc3dd --- /dev/null +++ b/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionBucketCountActualRescaleITCase.java @@ -0,0 +1,514 @@ +/* + * 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.client.table; + +import org.apache.fluss.client.ConnectionFactory; +import org.apache.fluss.client.admin.ClientToServerITCaseBase; +import org.apache.fluss.client.lookup.Lookuper; +import org.apache.fluss.client.table.scanner.ScanRecord; +import org.apache.fluss.client.table.scanner.batch.BatchScanner; +import org.apache.fluss.client.table.scanner.log.LogScanner; +import org.apache.fluss.client.table.scanner.log.ScanRecords; +import org.apache.fluss.client.table.writer.AppendWriter; +import org.apache.fluss.client.table.writer.UpsertWriter; +import org.apache.fluss.config.ConfigOptions; +import org.apache.fluss.metadata.PartitionInfo; +import org.apache.fluss.metadata.Schema; +import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TableChange; +import org.apache.fluss.metadata.TableDescriptor; +import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePath; +import org.apache.fluss.row.InternalRow; +import org.apache.fluss.types.DataTypes; +import org.apache.fluss.types.RowType; +import org.apache.fluss.utils.CloseableIterator; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.apache.fluss.testutils.DataTestUtils.row; +import static org.apache.fluss.testutils.InternalRowAssert.assertThatRow; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end IT case verifying that reads and writes route by the per-partition bucket count after + * an ALTER TABLE ... SET ('bucket.num' = N): old partitions keep their original bucket count while + * partitions created after the ALTER use the new count, and data written to each partition is read + * back correctly through its own bucket range. + */ +class PartitionBucketCountActualRescaleITCase extends ClientToServerITCaseBase { + + private static final int OLD_BUCKET_NUM = 2; + private static final int NEW_BUCKET_NUM = 4; + private static final int RECORDS_PER_PARTITION = 12; + private static final List OLD_NEW_PARTITIONS = Arrays.asList("old", "new"); + + @Test + void testLogTableReadWriteAcrossRescale() throws Exception { + // Write records to partitions with different bucket counts (old partition keeps + // OLD_BUCKET_NUM, new partition uses NEW_BUCKET_NUM). Routing by the wrong (table-level) + // count would miss rows when reading back each partition's own bucket range. + + TablePath tablePath = TablePath.of("test_db_1", "test_rescale_log_table"); + Schema schema = logSchema(); + createPartitionedTable(tablePath, schema); + List partitionInfos = setupOldNewPartitions(tablePath); + Map idByName = partitionIdByName(partitionInfos); + + // append RECORDS_PER_PARTITION rows to each partition + Table table = conn.getTable(tablePath); + AppendWriter appendWriter = table.newAppend().createWriter(); + Map> expectedByPartitionId = new HashMap<>(); + for (String partitionName : OLD_NEW_PARTITIONS) { + long partitionId = idByName.get(partitionName); + for (int j = 0; j < RECORDS_PER_PARTITION; j++) { + InternalRow r = row(j, "v" + j, partitionName); + appendWriter.append(r); + expectedByPartitionId.computeIfAbsent(partitionId, k -> new ArrayList<>()).add(r); + } + } + appendWriter.flush(); + + // read back by subscribing EACH partition's own bucket range [0, bucketCountActual) + Map> actualByPartitionId = + scanAllBucketsPerPartition(table, partitionInfos); + + assertRowsPerPartition(schema.getRowType(), actualByPartitionId, expectedByPartitionId); + } + + @Test + void testPkTableReadPathsAcrossRescale() throws Exception { + TablePath tablePath = TablePath.of("test_db_1", "test_rescale_pk_read_paths"); + Schema schema = pkSchema("a", "c"); + createPartitionedTable(tablePath, schema); + List partitionInfos = setupOldNewPartitions(tablePath); + Map idByName = partitionIdByName(partitionInfos); + Map bucketCountActualByName = bucketCountActualByName(partitionInfos); + + Table table = conn.getTable(tablePath); + long tableId = table.getTableInfo().getTableId(); + upsertRowsToOldAndNew(table); + + // 1. Lookup: write routing N must equal lookup routing N per partition, otherwise the + // lookup would hit the wrong bucket and miss the row. + Lookuper lookuper = table.newLookup().createLookuper(); + for (String partitionName : OLD_NEW_PARTITIONS) { + for (int j = 0; j < RECORDS_PER_PARTITION; j++) { + InternalRow expected = row(j, "v" + j, partitionName); + InternalRow looked = lookuper.lookup(row(j, partitionName)).get().getSingletonRow(); + assertThatRow(looked).withSchema(schema.getRowType()).isEqualTo(expected); + } + } + + // 2. PK stream read: LogScanner subscribes by per-partition bucket count. Each partition's + // total polled records must equal the writes; if routing used the wrong count, the + // subscribed bucket range would miss rows. + Map perBucketCount = + pollRecordCountPerBucket(table, partitionInfos, 2 * RECORDS_PER_PARTITION); + Map streamCountsPerPartition = new HashMap<>(); + perBucketCount.forEach( + (tb, c) -> streamCountsPerPartition.merge(tb.getPartitionId(), c, Integer::sum)); + for (String partitionName : OLD_NEW_PARTITIONS) { + assertThat(streamCountsPerPartition.get(idByName.get(partitionName))) + .as("PK stream count for partition %s", partitionName) + .isEqualTo(RECORDS_PER_PARTITION); + } + + // 3. Batch read: for each partition, trigger a KV snapshot on every bucket, then scan back + // per (partition, bucket) with BatchScanner using the partition's own bucket count. + for (String partitionName : OLD_NEW_PARTITIONS) { + long partitionId = idByName.get(partitionName); + int bucketCountActual = bucketCountActualByName.get(partitionName); + int partitionSum = 0; + for (int bucketId = 0; bucketId < bucketCountActual; bucketId++) { + TableBucket tb = new TableBucket(tableId, partitionId, bucketId); + long snapshotId = + FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb).getSnapshotID(); + try (BatchScanner batchScanner = + table.newScan().createBatchScanner(tb, snapshotId)) { + while (true) { + CloseableIterator it = + batchScanner.pollBatch(Duration.ofSeconds(10)); + if (it == null) { + break; + } + try { + while (it.hasNext()) { + it.next(); + partitionSum++; + } + } finally { + it.close(); + } + } + } + } + assertThat(partitionSum) + .as("PK batch count for partition %s", partitionName) + .isEqualTo(RECORDS_PER_PARTITION); + } + + // 4. count(*) must use each partition's own bucket count, not the table-level count + // (which would enumerate out-of-range buckets for old partitions and skew the total). + assertThat(admin.getTableStats(tablePath).get().getRowCount()) + .isEqualTo(2L * RECORDS_PER_PARTITION); + } + + @Test + void testSameValueBucketNumAlterIsNoOp() throws Exception { + // SET ('bucket.num' = currentValue) does not change the bucket layout, so it must not + // advance bucketLayoutEpoch: legacy-client routing and historical lookup both read + // epoch > 0 as evidence that mixed bucket layouts may exist. + TablePath tablePath = TablePath.of("test_db_1", "test_same_value_bucket_num_alter"); + createPartitionedTable(tablePath, logSchema()); + + TableInfo before = admin.getTableInfo(tablePath).get(); + assertThat(before.getNumBuckets()).isEqualTo(OLD_BUCKET_NUM); + + alterBucketNum(tablePath, OLD_BUCKET_NUM); + + TableInfo sameValued = admin.getTableInfo(tablePath).get(); + assertThat(sameValued.getNumBuckets()).isEqualTo(OLD_BUCKET_NUM); + assertThat(sameValued.getBucketLayoutEpoch()).isEqualTo(before.getBucketLayoutEpoch()); + + // A real rescale does advance the epoch, which also proves the assertions above observe a + // value that actually moves. + alterBucketNum(tablePath, NEW_BUCKET_NUM); + + TableInfo rescaled = admin.getTableInfo(tablePath).get(); + assertThat(rescaled.getNumBuckets()).isEqualTo(NEW_BUCKET_NUM); + assertThat(rescaled.getBucketLayoutEpoch()).isGreaterThan(before.getBucketLayoutEpoch()); + + // Repeating the ALTER at the new count is a no-op too, so the comparison is against the + // current bucket count rather than the one the table was created with. + alterBucketNum(tablePath, NEW_BUCKET_NUM); + + TableInfo after = admin.getTableInfo(tablePath).get(); + assertThat(after.getNumBuckets()).isEqualTo(NEW_BUCKET_NUM); + assertThat(after.getBucketLayoutEpoch()).isEqualTo(rescaled.getBucketLayoutEpoch()); + } + + @Test + void testDynamicallyCreatedPartitionUsesPostAlterBucketCountActual() throws Exception { + // A partition created dynamically by the WRITER after an ALTER must use the new bucket + // count and be readable through that range. + clientConf.set(ConfigOptions.CLIENT_WRITER_DYNAMIC_CREATE_PARTITION_ENABLED, true); + conn.close(); + conn = ConnectionFactory.createConnection(clientConf); + admin = conn.getAdmin(); + + TablePath tablePath = TablePath.of("test_db_1", "test_rescale_dynamic_create"); + Schema schema = logSchema(); + createPartitionedTable(tablePath, schema); + alterBucketNum(tablePath, NEW_BUCKET_NUM); + + // write to a partition that does not exist yet; the writer creates it dynamically + Table table = conn.getTable(tablePath); + AppendWriter appendWriter = table.newAppend().createWriter(); + Map> expectedByPartitionId = new HashMap<>(); + List expectedRows = new ArrayList<>(); + for (int j = 0; j < RECORDS_PER_PARTITION; j++) { + InternalRow r = row(j, "v" + j, "auto"); + appendWriter.append(r); + expectedRows.add(r); + } + appendWriter.flush(); + + // the dynamically created partition carries the post-ALTER bucket count + List partitionInfos = admin.listPartitionInfos(tablePath).get(); + PartitionInfo autoPartition = + partitionInfos.stream() + .filter(p -> "auto".equals(p.getPartitionName())) + .findFirst() + .orElseThrow(() -> new AssertionError("dynamic partition was not created")); + assertThat(autoPartition.getBucketCountActual()).isEqualTo(NEW_BUCKET_NUM); + expectedByPartitionId.put(autoPartition.getPartitionId(), expectedRows); + + // all rows are readable through the partition's own bucket range + Map> actualByPartitionId = + scanAllBucketsPerPartition(table, Collections.singletonList(autoPartition)); + assertRowsPerPartition(schema.getRowType(), actualByPartitionId, expectedByPartitionId); + } + + @Test + void testStaleTableHandleWritesToDynamicallyCreatedPartitionAfterAlter() throws Exception { + // Old writers hold a stale table-level bucket count; new partitions must route by + // their actual (post-ALTER) count, otherwise lookups miss. + clientConf.set(ConfigOptions.CLIENT_WRITER_DYNAMIC_CREATE_PARTITION_ENABLED, true); + conn.close(); + conn = ConnectionFactory.createConnection(clientConf); + admin = conn.getAdmin(); + + TablePath tablePath = TablePath.of("test_db_1", "test_rescale_stale_handle"); + Schema schema = pkSchema("a", "c"); + createPartitionedTable(tablePath, schema); + + // open the handle BEFORE the ALTER, then rescale on the server side + Table staleTable = conn.getTable(tablePath); + UpsertWriter upsertWriter = staleTable.newUpsert().createWriter(); + alterBucketNum(tablePath, NEW_BUCKET_NUM); + + // write through the stale handle into a partition that does not exist yet + for (int j = 0; j < RECORDS_PER_PARTITION; j++) { + upsertWriter.upsert(row(j, "v" + j, "auto")); + } + upsertWriter.flush(); + + // the dynamically created partition carries the post-ALTER bucket count + List partitionInfos = admin.listPartitionInfos(tablePath).get(); + assertThat(bucketCountActualByName(partitionInfos)).containsEntry("auto", NEW_BUCKET_NUM); + + // every key must be found: write routing and lookup routing must agree on the + // partition's actual bucket count + Lookuper lookuper = staleTable.newLookup().createLookuper(); + for (int j = 0; j < RECORDS_PER_PARTITION; j++) { + InternalRow expected = row(j, "v" + j, "auto"); + InternalRow looked = lookuper.lookup(row(j, "auto")).get().getSingletonRow(); + assertThatRow(looked).withSchema(schema.getRowType()).isEqualTo(expected); + } + } + + @Test + void testPrefixLookupAcrossPartitionsWithDifferentBucketCountActuals() throws Exception { + // Prefix lookup must resolve the bucket with the correct per-partition count; a mismatch + // would query the wrong bucket and miss rows. + TablePath tablePath = TablePath.of("test_db_1", "test_rescale_prefix_lookup"); + Schema schema = pkSchema("a", "b", "c"); + createPartitionedTable(tablePath, schema, "a"); + setupOldNewPartitions(tablePath); + + int aCardinality = 8; + int bPerA = 3; + Table table = conn.getTable(tablePath); + UpsertWriter upsertWriter = table.newUpsert().createWriter(); + for (String partitionName : OLD_NEW_PARTITIONS) { + for (int a = 0; a < aCardinality; a++) { + for (int k = 0; k < bPerA; k++) { + upsertWriter.upsert(row(a, "b" + k, partitionName)); + } + } + } + upsertWriter.flush(); + + Lookuper prefixLookuper = + table.newLookup().lookupBy(Arrays.asList("a", "c")).createLookuper(); + for (String partitionName : OLD_NEW_PARTITIONS) { + for (int a = 0; a < aCardinality; a++) { + List rows = + prefixLookuper.lookup(row(a, partitionName)).get().getRowList(); + assertThat(rows) + .as("prefix (a=%d, c=%s) should return %d rows", a, partitionName, bPerA) + .hasSize(bPerA); + } + } + } + + // ==================== helpers ==================== + + private static Schema logSchema() { + return Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("b", DataTypes.STRING()) + .column("c", DataTypes.STRING()) + .build(); + } + + private static Schema pkSchema(String... primaryKeys) { + return Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("b", DataTypes.STRING()) + .column("c", DataTypes.STRING()) + .primaryKey(primaryKeys) + .build(); + } + + /** Creates a table partitioned by "c" with OLD_BUCKET_NUM buckets and given bucket keys. */ + private void createPartitionedTable(TablePath tablePath, Schema schema, String... bucketKeys) + throws Exception { + createTable( + tablePath, + TableDescriptor.builder() + .schema(schema) + .distributedBy(OLD_BUCKET_NUM, bucketKeys) + .partitionedBy("c") + .build(), + true); + } + + /** + * Creates the "old" partition, ALTERs bucket.num to NEW_BUCKET_NUM, creates the "new" + * partition, and asserts the reported per-partition bucket counts. + */ + private List setupOldNewPartitions(TablePath tablePath) throws Exception { + // old partition (created before ALTER -> OLD_BUCKET_NUM buckets) + admin.createPartition(tablePath, newPartitionSpec("c", "old"), false).get(); + alterBucketNum(tablePath, NEW_BUCKET_NUM); + // new partition (created after ALTER -> NEW_BUCKET_NUM buckets) + admin.createPartition(tablePath, newPartitionSpec("c", "new"), false).get(); + + List partitionInfos = admin.listPartitionInfos(tablePath).get(); + assertThat(bucketCountActualByName(partitionInfos)) + .containsEntry("old", OLD_BUCKET_NUM) + .containsEntry("new", NEW_BUCKET_NUM); + return partitionInfos; + } + + /** Upserts RECORDS_PER_PARTITION rows (j, "v"+j, partition) into "old" and "new". */ + private static void upsertRowsToOldAndNew(Table table) throws Exception { + UpsertWriter upsertWriter = table.newUpsert().createWriter(); + for (String partitionName : OLD_NEW_PARTITIONS) { + for (int j = 0; j < RECORDS_PER_PARTITION; j++) { + upsertWriter.upsert(row(j, "v" + j, partitionName)); + } + } + upsertWriter.flush(); + } + + private void alterBucketNum(TablePath tablePath, int newBucketNum) throws Exception { + admin.alterTable( + tablePath, + Collections.singletonList( + TableChange.set("bucket.num", String.valueOf(newBucketNum))), + false) + .get(); + } + + private static Map bucketCountActualByName( + List partitionInfos) { + Map map = new HashMap<>(); + for (PartitionInfo p : partitionInfos) { + map.put(p.getPartitionName(), p.getBucketCountActual()); + } + return map; + } + + private static Map partitionIdByName(List partitionInfos) { + Map map = new HashMap<>(); + for (PartitionInfo p : partitionInfos) { + map.put(p.getPartitionName(), p.getPartitionId()); + } + return map; + } + + private static void subscribeAllBuckets( + LogScanner logScanner, List partitionInfos) { + for (PartitionInfo partitionInfo : partitionInfos) { + for (int bucketId = 0; bucketId < partitionInfo.getBucketCountActual(); bucketId++) { + logScanner.subscribeFromBeginning(partitionInfo.getPartitionId(), bucketId); + } + } + } + + /** + * Subscribes every bucket of every partition using that partition's own bucket count and polls + * until {@code expectedTotal} records arrive, returning the record count per bucket. If write + * routing used the wrong (table-level) bucket count for a partition, the scan of its real + * bucket range would miss rows and the expected count would never be reached. + */ + private static Map pollRecordCountPerBucket( + Table table, List partitionInfos, int expectedTotal) throws Exception { + Map perBucketCount = new HashMap<>(); + int scanned = 0; + try (LogScanner logScanner = table.newScan().createLogScanner()) { + subscribeAllBuckets(logScanner, partitionInfos); + long deadline = System.currentTimeMillis() + Duration.ofMinutes(1).toMillis(); + while (scanned < expectedTotal && System.currentTimeMillis() < deadline) { + ScanRecords scanRecords = logScanner.poll(Duration.ofSeconds(1)); + for (TableBucket scanBucket : scanRecords.buckets()) { + int c = 0; + for (ScanRecord ignored : scanRecords.records(scanBucket)) { + c++; + } + perBucketCount.merge(scanBucket, c, Integer::sum); + scanned += c; + } + } + } + assertThat(scanned).isEqualTo(expectedTotal); + return perBucketCount; + } + + /** + * Subscribes every bucket of every partition using that partition's own bucket count and + * collects all rows grouped by partition id. If write routing used the wrong (table-level) + * bucket count for a partition, the scan of its real bucket range would miss rows and the + * expected count would never be reached. + */ + private static Map> scanAllBucketsPerPartition( + Table table, List partitionInfos) throws Exception { + int totalExpected = partitionInfos.size() * RECORDS_PER_PARTITION; + Map> actual = new HashMap<>(); + int scanned = 0; + try (LogScanner logScanner = table.newScan().createLogScanner()) { + subscribeAllBuckets(logScanner, partitionInfos); + long deadline = System.currentTimeMillis() + Duration.ofMinutes(1).toMillis(); + while (scanned < totalExpected && System.currentTimeMillis() < deadline) { + ScanRecords scanRecords = logScanner.poll(Duration.ofSeconds(1)); + for (TableBucket scanBucket : scanRecords.buckets()) { + for (ScanRecord record : scanRecords.records(scanBucket)) { + actual.computeIfAbsent(scanBucket.getPartitionId(), k -> new ArrayList<>()) + .add(record.getRow()); + } + } + scanned += scanRecords.count(); + } + } + assertThat(scanned).isEqualTo(totalExpected); + return actual; + } + + private static void assertRowsPerPartition( + RowType rowType, + Map> actual, + Map> expected) { + assertThat(actual.keySet()).isEqualTo(expected.keySet()); + for (Map.Entry> entry : expected.entrySet()) { + List actualRows = actual.get(entry.getKey()); + List expectedRows = entry.getValue(); + // rows from different buckets of the same partition may interleave, so compare as a + // multiset: same size and same elements regardless of order. + assertThat(actualRows).hasSameSizeAs(expectedRows); + for (InternalRow expectedRow : expectedRows) { + boolean found = + actualRows.stream() + .anyMatch( + a -> { + try { + assertThatRow(a) + .withSchema(rowType) + .isEqualTo(expectedRow); + return true; + } catch (AssertionError e) { + return false; + } + }); + assertThat(found) + .as("expected row %s present in partition rows", expectedRow) + .isTrue(); + } + } + } +} diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionedTableITCase.java b/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionedTableITCase.java index d204d087a4d..9154c83a68e 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionedTableITCase.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionedTableITCase.java @@ -45,7 +45,6 @@ import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH_PK; import static org.apache.fluss.testutils.DataTestUtils.row; import static org.apache.fluss.testutils.InternalRowAssert.assertThatRow; -import static org.apache.fluss.testutils.common.CommonTestUtils.retry; import static org.apache.fluss.testutils.common.CommonTestUtils.waitValue; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -221,19 +220,14 @@ void testCreatePartitionExceedMaxPartitionNumber() throws Exception { upsertWriter.upsert(row).get(); } - // add one row will not throw TooManyPartitionsException immediately. - upsertWriter.upsert(row(10, "a" + 10, "10")); - - // add another rows will throw TooManyPartitionsException final. - retry( - Duration.ofMinutes(1), - () -> - assertThatThrownBy(() -> upsertWriter.upsert(row(10, "a" + 10, "10")).get()) - .rootCause() - .isInstanceOf(TooManyPartitionsException.class) - .hasMessageContaining( - "Exceed the maximum number of partitions for table " - + "test_db_1.test_pk_table_1, only allow 10 partitions.")); + // Dynamic partition creation is synchronous (the bucket id needs the partition's own + // bucket count), so a partition that cannot be created fails the record right away. + assertThatThrownBy(() -> upsertWriter.upsert(row(10, "a" + 10, "10")).get()) + .rootCause() + .isInstanceOf(TooManyPartitionsException.class) + .hasMessageContaining( + "Exceed the maximum number of partitions for table " + + "test_db_1.test_pk_table_1, only allow 10 partitions."); } private Schema createPartitionedTable(TablePath tablePath, boolean isPrimaryTable) diff --git a/fluss-client/src/test/java/org/apache/fluss/client/utils/ClientRpcMessageUtilsTest.java b/fluss-client/src/test/java/org/apache/fluss/client/utils/ClientRpcMessageUtilsTest.java index 14f341fb7fd..687f09e4d28 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/utils/ClientRpcMessageUtilsTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/utils/ClientRpcMessageUtilsTest.java @@ -22,13 +22,20 @@ import org.apache.fluss.memory.MemorySegment; import org.apache.fluss.memory.PreAllocatedPagedOutputView; import org.apache.fluss.metadata.KvFormat; +import org.apache.fluss.metadata.PartitionInfo; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.rpc.messages.ListPartitionInfosResponse; +import org.apache.fluss.rpc.messages.PbKeyValue; +import org.apache.fluss.rpc.messages.PbPartitionInfo; +import org.apache.fluss.rpc.messages.PbPartitionSpec; import org.apache.fluss.rpc.messages.PutKvRequest; import org.apache.fluss.rpc.protocol.MergeMode; import org.junit.jupiter.api.Test; +import javax.annotation.Nullable; + import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -126,6 +133,56 @@ void testMakePutKvRequestWithSingleBatch() throws Exception { assertThat(request.getAggMode()).isEqualTo(MergeMode.OVERWRITE.getProtoValue()); } + @Test + void testToPartitionInfosParsesBucketCountActual() { + // one partition with bucket_count_actual set, one without (simulating an old cluster / old + // partition that did not persist per-partition bucket count) + ListPartitionInfosResponse response = + new ListPartitionInfosResponse() + .addAllPartitionsInfos( + Arrays.asList( + makePbPartitionInfo(1L, "20240101", "file://dir1", 8), + makePbPartitionInfo(2L, "20240102", null, null))); + + List partitionInfos = ClientRpcMessageUtils.toPartitionInfos(response, 4); + + assertThat(partitionInfos).hasSize(2); + + PartitionInfo withBucketCountActual = partitionInfos.get(0); + assertThat(withBucketCountActual.getPartitionId()).isEqualTo(1L); + assertThat(withBucketCountActual.getPartitionName()).isEqualTo("20240101"); + assertThat(withBucketCountActual.getRemoteDataDir()).isEqualTo("file://dir1"); + assertThat(withBucketCountActual.getBucketCountActual()).isEqualTo(8); + + // backward compatibility: missing bucket_count_actual must resolve to the given table-level + // default, not the proto default 0 + PartitionInfo withoutBucketCountActual = partitionInfos.get(1); + assertThat(withoutBucketCountActual.getPartitionId()).isEqualTo(2L); + assertThat(withoutBucketCountActual.getPartitionName()).isEqualTo("20240102"); + assertThat(withoutBucketCountActual.getRemoteDataDir()).isNull(); + assertThat(withoutBucketCountActual.getBucketCountActual()).isEqualTo(4); + } + + private static PbPartitionInfo makePbPartitionInfo( + long partitionId, + String partitionValue, + @Nullable String remoteDataDir, + @Nullable Integer bucketCountActual) { + PbPartitionSpec partitionSpec = new PbPartitionSpec(); + PbKeyValue keyValue = new PbKeyValue().setKey("dt").setValue(partitionValue); + partitionSpec.addAllPartitionKeyValues(Collections.singletonList(keyValue)); + + PbPartitionInfo pbPartitionInfo = + new PbPartitionInfo().setPartitionId(partitionId).setPartitionSpec(partitionSpec); + if (remoteDataDir != null) { + pbPartitionInfo.setRemoteDataDir(remoteDataDir); + } + if (bucketCountActual != null) { + pbPartitionInfo.setBucketCountActual(bucketCountActual); + } + return pbPartitionInfo; + } + private KvWriteBatch createKvWriteBatch(int bucketId, MergeMode mergeMode) throws Exception { MemorySegment segment = MemorySegment.allocateHeapMemory(1024); PreAllocatedPagedOutputView outputView = diff --git a/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java b/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java index 132da6cc112..f5dcaf053fe 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java @@ -30,6 +30,7 @@ import org.apache.fluss.exception.NetworkException; import org.apache.fluss.exception.OutOfOrderSequenceException; import org.apache.fluss.exception.PartitionNotExistException; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.exception.TableNotExistException; import org.apache.fluss.exception.TimeoutException; import org.apache.fluss.metadata.DataLakeFormat; @@ -74,6 +75,7 @@ import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import static org.apache.fluss.record.LogRecordBatchFormat.NO_WRITER_ID; import static org.apache.fluss.record.TestData.DATA1_ROW_TYPE; @@ -1469,6 +1471,57 @@ void testSendWhenTableIdChanges() throws Exception { assertThat(future2.get()).isNull(); } + @Test + void testStaleMetadataFailsBatchAndInvalidatesBucketAssigner() throws Exception { + // Recreate sender with a tracking bucketAssignerInvalidator. + IdempotenceManager idempotenceManager = createIdempotenceManager(false); + Configuration conf = new Configuration(); + conf.set(ConfigOptions.CLIENT_WRITER_BUFFER_MEMORY_SIZE, new MemorySize(TOTAL_MEMORY_SIZE)); + conf.set(ConfigOptions.CLIENT_WRITER_BATCH_SIZE, new MemorySize(BATCH_SIZE)); + conf.set(ConfigOptions.CLIENT_WRITER_BUFFER_PAGE_SIZE, new MemorySize(PAGE_SIZE)); + conf.set(ConfigOptions.CLIENT_WRITER_BATCH_TIMEOUT, Duration.ofMillis(0)); + accumulator = + new RecordAccumulator( + conf, idempotenceManager, writerMetricGroup, SystemClock.getInstance()); + AtomicReference invalidatedBucket = new AtomicReference<>(); + Sender staleSender = + new Sender( + accumulator, + REQUEST_TIMEOUT, + MAX_REQUEST_SIZE, + ACKS_ALL, + Integer.MAX_VALUE, + metadataUpdater, + idempotenceManager, + writerMetricGroup, + invalidatedBucket::set); + + // Append one record and send it. + CompletableFuture future = new CompletableFuture<>(); + appendToAccumulator(tb1, row(1, "a"), (tb, leo, e) -> future.complete(e)); + staleSender.runOnce(); + assertThat(staleSender.numOfInFlightBatches(tb1)).isEqualTo(1); + + // Server rejects with STALE_METADATA — the bucketId was computed with a stale count. + Cluster clusterBeforeError = metadataUpdater.getCluster(); + finishRequest(tb1, 0, createProduceLogResponse(tb1, Errors.STALE_METADATA)); + + // The batch is failed (not re-enqueued for retry — the bucketId is stale and must not be + // reused). + assertThat(staleSender.numOfInFlightBatches(tb1)).isEqualTo(0); + + // The BucketAssigner for this bucket was invalidated so the next send rebuilds it with + // the refreshed bucket count. + assertThat(invalidatedBucket.get()).isEqualTo(tb1); + + // The table's bucket metadata was invalidated so the next send requests it again. + assertThat(metadataUpdater.getCluster()).isNotSameAs(clusterBeforeError); + + // The write callback receives the StaleMetadataException. + Exception exception = future.get(); + assertThat(exception).isInstanceOf(StaleMetadataException.class); + } + private TestingMetadataUpdater initializeMetadataUpdater() { Map tableInfos = new HashMap<>(); tableInfos.put(DATA1_TABLE_PATH, DATA1_TABLE_INFO); @@ -1772,7 +1825,8 @@ private Sender setupWithIdempotenceState( reties, metadataUpdater, idempotenceManager, - writerMetricGroup); + writerMetricGroup, + tb -> {}); } private IdempotenceManager createIdempotenceManager(boolean idempotenceEnabled) { diff --git a/fluss-common/src/main/java/org/apache/fluss/cluster/Cluster.java b/fluss-common/src/main/java/org/apache/fluss/cluster/Cluster.java index f78ade43b2d..4deea5df045 100644 --- a/fluss-common/src/main/java/org/apache/fluss/cluster/Cluster.java +++ b/fluss-common/src/main/java/org/apache/fluss/cluster/Cluster.java @@ -21,6 +21,7 @@ import org.apache.fluss.exception.PartitionNotExistException; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import javax.annotation.Nullable; @@ -28,6 +29,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -52,6 +54,8 @@ public final class Cluster { private final Map pathByTableId; private final Map partitionsIdByPath; private final Map partitionNameById; + private final Map bucketCountActualByPartition; + private final Map bucketCountByTable; public Cluster( Map aliveTabletServersById, @@ -59,12 +63,33 @@ public Cluster( Map> bucketLocationsByPath, Map tableIdByPath, Map partitionsIdByPath) { + this( + aliveTabletServersById, + coordinatorServer, + bucketLocationsByPath, + tableIdByPath, + partitionsIdByPath, + Collections.emptyMap(), + Collections.emptyMap()); + } + + public Cluster( + Map aliveTabletServersById, + @Nullable ServerNode coordinatorServer, + Map> bucketLocationsByPath, + Map tableIdByPath, + Map partitionsIdByPath, + Map bucketCountActualByPartition, + Map bucketCountByTable) { this.coordinatorServer = coordinatorServer; this.aliveTabletServersById = Collections.unmodifiableMap(aliveTabletServersById); this.aliveTabletServers = Collections.unmodifiableList(new ArrayList<>(aliveTabletServersById.values())); this.tableIdByPath = Collections.unmodifiableMap(tableIdByPath); this.partitionsIdByPath = Collections.unmodifiableMap(partitionsIdByPath); + this.bucketCountActualByPartition = + Collections.unmodifiableMap(bucketCountActualByPartition); + this.bucketCountByTable = Collections.unmodifiableMap(bucketCountByTable); // Index the bucket locations by table path, and index bucket location by bucket. // Note that this code is performance sensitive if there are a large number of buckets, @@ -127,12 +152,44 @@ public Cluster invalidPhysicalTableBucketMeta(Set physicalTab new ArrayList<>(tablePathAndBucketLocations.getValue())); } } + // resolve the invalid partition ids so the TablePartition-keyed count map can be filtered + Set invalidPartitionIds = new HashSet<>(); + for (PhysicalTablePath path : physicalTablesToInvalid) { + Long pid = partitionsIdByPath.get(path); + if (pid != null) { + invalidPartitionIds.add(pid); + } + } + Map newBucketCountActualByPartition = new HashMap<>(); + for (Map.Entry entry : bucketCountActualByPartition.entrySet()) { + if (!invalidPartitionIds.contains(entry.getKey().getPartitionId())) { + newBucketCountActualByPartition.put(entry.getKey(), entry.getValue()); + } + } + // filter bucketCountByTable for non-partitioned tables whose path is in the invalid set + Set invalidTableIds = new HashSet<>(); + for (PhysicalTablePath path : physicalTablesToInvalid) { + if (path.getPartitionName() == null) { + Long tid = tableIdByPath.get(path.getTablePath()); + if (tid != null) { + invalidTableIds.add(tid); + } + } + } + Map newBucketCountByTable = new HashMap<>(); + for (Map.Entry entry : bucketCountByTable.entrySet()) { + if (!invalidTableIds.contains(entry.getKey())) { + newBucketCountByTable.put(entry.getKey(), entry.getValue()); + } + } return new Cluster( new HashMap<>(aliveTabletServersById), coordinatorServer, newBucketLocationsByPath, new HashMap<>(tableIdByPath), - new HashMap<>(partitionsIdByPath)); + new HashMap<>(partitionsIdByPath), + newBucketCountActualByPartition, + newBucketCountByTable); } /** Invalidates bucket metadata and partition ID mappings for the given physical table paths. */ @@ -148,7 +205,9 @@ public Cluster invalidPhysicalTableBucketAndPartitionMeta( coordinatorServer, new HashMap<>(cluster.availableLocationsByPath), new HashMap<>(tableIdByPath), - newPartitionsIdByPath); + newPartitionsIdByPath, + new HashMap<>(cluster.bucketCountActualByPartition), + new HashMap<>(cluster.bucketCountByTable)); } @Nullable @@ -226,6 +285,24 @@ public Optional getPartitionId(PhysicalTablePath physicalTablePath) { return Optional.ofNullable(partitionsIdByPath.get(physicalTablePath)); } + /** + * Resolve a {@link PhysicalTablePath} to its current {@link TablePartition} (tableId + + * partitionId) from this snapshot. Retained for name resolution; the actual bucket-count lookup + * uses {@link #getBucketCountActual(TablePartition)}. Resolving both ids from the same snapshot + * avoids combining a stale tableId/partitionId with a newer one after a replacement. + */ + public Optional getTablePartition(PhysicalTablePath physicalTablePath) { + Long partitionId = partitionsIdByPath.get(physicalTablePath); + if (partitionId == null) { + return Optional.empty(); + } + Long tableId = tableIdByPath.get(physicalTablePath.getTablePath()); + if (tableId == null) { + return Optional.empty(); + } + return Optional.of(new TablePartition(tableId, partitionId)); + } + public TableBucket getTableBucket( long tableId, PhysicalTablePath physicalTablePath, int bucketId) { if (physicalTablePath.getPartitionName() != null) { @@ -274,6 +351,32 @@ public Map getPartitionIdByPath() { return partitionsIdByPath; } + /** + * Get the actual bucket count for the given table partition. Returns empty if the bucket count + * is not known (old metadata without bucket count). + */ + public Optional getBucketCountActual(TablePartition tablePartition) { + return Optional.ofNullable(bucketCountActualByPartition.get(tablePartition)); + } + + /** Get the table partition to bucket count map. */ + public Map getBucketCountActualByPartition() { + return bucketCountActualByPartition; + } + + /** + * Get the bucket count for a non-partitioned table by tableId. Returns empty if not known (old + * metadata without the count). + */ + public Optional getBucketCountForTable(long tableId) { + return Optional.ofNullable(bucketCountByTable.get(tableId)); + } + + /** Get the tableId to bucket count map for non-partitioned tables. */ + public Map getBucketCountByTable() { + return bucketCountByTable; + } + /** Create an empty cluster instance with no nodes and no table-buckets. */ public static Cluster empty() { return new Cluster( diff --git a/fluss-common/src/main/java/org/apache/fluss/lake/lakestorage/LakeCatalog.java b/fluss-common/src/main/java/org/apache/fluss/lake/lakestorage/LakeCatalog.java index bc64cdf93fb..e00cb5f87bd 100644 --- a/fluss-common/src/main/java/org/apache/fluss/lake/lakestorage/LakeCatalog.java +++ b/fluss-common/src/main/java/org/apache/fluss/lake/lakestorage/LakeCatalog.java @@ -51,6 +51,12 @@ void createTable(TablePath tablePath, TableDescriptor tableDescriptor, Context c /** * Alter a table in lake. * + *

A {@code SetOption} of the Fluss property {@code bucket.num} is Fluss's coordinator- + * orchestrated bucket count rescale: implementations supporting rescale must apply it to their + * bucket layout option, others should throw {@link UnsupportedOperationException}. User-facing + * changes to the lake-native bucket option (e.g. Paimon {@code bucket}) keep being rejected to + * prevent the two systems from diverging. + * * @param tablePath path of the table to be altered * @param tableChanges The changes to be applied to the table * @param context contextual information needed for alter table diff --git a/fluss-common/src/main/java/org/apache/fluss/lake/lakestorage/LakeTableLookuper.java b/fluss-common/src/main/java/org/apache/fluss/lake/lakestorage/LakeTableLookuper.java index 6c5f6cd2849..6c93d9226e4 100644 --- a/fluss-common/src/main/java/org/apache/fluss/lake/lakestorage/LakeTableLookuper.java +++ b/fluss-common/src/main/java/org/apache/fluss/lake/lakestorage/LakeTableLookuper.java @@ -74,7 +74,7 @@ default void requestRefresh() { /** Context for a lake table point lookup. */ final class LookupContext { private final ResolvedPartitionSpec partitionSpec; - private final int bucketId; + private final @Nullable Integer bucketId; private final short schemaId; private final RowType valueRowType; private final LookupMetricRecorder lookupMetricRecorder; @@ -83,14 +83,15 @@ final class LookupContext { * Creates a lookup context. * * @param partitionSpec resolved Fluss partition spec for the lookup - * @param bucketId target bucket id in the lake table + * @param bucketId target bucket id in the lake table, or null when the caller cannot + * determine it and the implementation has to resolve it from the lake metadata * @param schemaId schema id to encode the returned Fluss value with * @param valueRowType row type to encode the returned Fluss value with * @param lookupMetricRecorder recorder for lake table point lookup metrics */ public LookupContext( ResolvedPartitionSpec partitionSpec, - int bucketId, + @Nullable Integer bucketId, short schemaId, RowType valueRowType, LookupMetricRecorder lookupMetricRecorder) { @@ -107,8 +108,11 @@ public ResolvedPartitionSpec partitionSpec() { return partitionSpec; } - /** Returns the target bucket id in the lake table. */ - public int bucketId() { + /** + * Returns the target bucket id in the lake table, or null when the implementation has to + * resolve it from the lake metadata. + */ + public @Nullable Integer bucketId() { return bucketId; } diff --git a/fluss-common/src/main/java/org/apache/fluss/lake/writer/WriterInitContext.java b/fluss-common/src/main/java/org/apache/fluss/lake/writer/WriterInitContext.java index f268a5c48af..7ac5c4606df 100644 --- a/fluss-common/src/main/java/org/apache/fluss/lake/writer/WriterInitContext.java +++ b/fluss-common/src/main/java/org/apache/fluss/lake/writer/WriterInitContext.java @@ -105,4 +105,12 @@ default long tieringRoundTimestamp() { default String[] ioTmpDirs() { return null; } + + /** + * Returns the actual bucket count of the target partition, or the table-level count for + * non-partitioned tables. After an ALTER bucket.num old partitions keep their original count, + * so lake writers must stamp bucket layouts with this value instead of the lake table's current + * schema-level bucket setting. + */ + int bucketCountActual(); } diff --git a/fluss-common/src/main/java/org/apache/fluss/metadata/PartitionInfo.java b/fluss-common/src/main/java/org/apache/fluss/metadata/PartitionInfo.java index d8845fc03f8..e2a96b872c0 100644 --- a/fluss-common/src/main/java/org/apache/fluss/metadata/PartitionInfo.java +++ b/fluss-common/src/main/java/org/apache/fluss/metadata/PartitionInfo.java @@ -35,11 +35,22 @@ public class PartitionInfo { private final ResolvedPartitionSpec partitionSpec; private final @Nullable String remoteDataDir; + /** + * The bucket count of this partition. Always resolved: for partitions created by older versions + * that did not persist a per-partition bucket count, the table-level bucket count is filled in + * at construction time. + */ + private final int bucketCountActual; + public PartitionInfo( - long partitionId, ResolvedPartitionSpec partitionSpec, @Nullable String remoteDataDir) { + long partitionId, + ResolvedPartitionSpec partitionSpec, + @Nullable String remoteDataDir, + int bucketCountActual) { this.partitionId = partitionId; this.partitionSpec = partitionSpec; this.remoteDataDir = remoteDataDir; + this.bucketCountActual = bucketCountActual; } /** Get the partition id. The id is globally unique in the Fluss cluster. */ @@ -68,6 +79,25 @@ public String getRemoteDataDir() { return remoteDataDir; } + /** + * Get the bucket count of this partition. For partitions created by older versions without a + * persisted per-partition bucket count, this is the table-level bucket count. + */ + public int getBucketCountActual() { + return bucketCountActual; + } + + /** + * Resolves the effective bucket count for a (possibly absent) partition: returns the + * partition's bucket count when {@code partitionInfo} is non-null, otherwise the table-level + * bucket count. The null case represents a non-partitioned table or a partition whose + * PartitionInfo is not available. + */ + public static int bucketCountActualOrDefault( + @Nullable PartitionInfo partitionInfo, int tableBucketCount) { + return partitionInfo != null ? partitionInfo.getBucketCountActual() : tableBucketCount; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -79,12 +109,13 @@ public boolean equals(Object o) { PartitionInfo that = (PartitionInfo) o; return partitionId == that.partitionId && Objects.equals(partitionSpec, that.partitionSpec) - && Objects.equals(remoteDataDir, that.remoteDataDir); + && Objects.equals(remoteDataDir, that.remoteDataDir) + && bucketCountActual == that.bucketCountActual; } @Override public int hashCode() { - return Objects.hash(partitionId, partitionSpec, remoteDataDir); + return Objects.hash(partitionId, partitionSpec, remoteDataDir, bucketCountActual); } @Override @@ -96,6 +127,8 @@ public String toString() { + partitionId + ", remoteDataDir=" + remoteDataDir + + ", bucketCountActual=" + + bucketCountActual + '}'; } } diff --git a/fluss-common/src/main/java/org/apache/fluss/metadata/TableInfo.java b/fluss-common/src/main/java/org/apache/fluss/metadata/TableInfo.java index 76ef538de41..d7b61859bbb 100644 --- a/fluss-common/src/main/java/org/apache/fluss/metadata/TableInfo.java +++ b/fluss-common/src/main/java/org/apache/fluss/metadata/TableInfo.java @@ -68,6 +68,7 @@ public final class TableInfo { private final long createdTime; private final long modifiedTime; + private final long bucketLayoutEpoch; private int[] cachedStatsIndexMapping = null; @@ -85,6 +86,38 @@ public TableInfo( @Nullable String comment, long createdTime, long modifiedTime) { + this( + tablePath, + tableId, + schemaId, + schema, + bucketKeys, + partitionKeys, + numBuckets, + properties, + customProperties, + remoteDataDir, + comment, + createdTime, + modifiedTime, + 0L); + } + + public TableInfo( + TablePath tablePath, + long tableId, + int schemaId, + Schema schema, + List bucketKeys, + List partitionKeys, + int numBuckets, + Configuration properties, + Configuration customProperties, + @Nullable String remoteDataDir, + @Nullable String comment, + long createdTime, + long modifiedTime, + long bucketLayoutEpoch) { this.tablePath = tablePath; this.tableId = tableId; this.schemaId = schemaId; @@ -102,6 +135,7 @@ public TableInfo( this.comment = comment; this.createdTime = createdTime; this.modifiedTime = modifiedTime; + this.bucketLayoutEpoch = bucketLayoutEpoch; } /** @@ -403,6 +437,14 @@ public long getModifiedTime() { return modifiedTime; } + /** + * Returns the bucket layout epoch of the table. New tables start at 0; every committed + * bucket.num change increments it (see {@code TableRegistration#withBucketCount(int)}). + */ + public long getBucketLayoutEpoch() { + return bucketLayoutEpoch; + } + /** * Converts this table info to a {@link TableDescriptor}. * @@ -431,6 +473,30 @@ public static TableInfo of( String remoteDataDir, long createdTime, long modifiedTime) { + return of( + tablePath, + tableId, + schemaId, + tableDescriptor, + remoteDataDir, + createdTime, + modifiedTime, + 0L); + } + + /** + * Creates a {@link TableInfo} from a {@link TableDescriptor} and other metadata, including the + * bucket layout epoch. + */ + public static TableInfo of( + TablePath tablePath, + long tableId, + int schemaId, + TableDescriptor tableDescriptor, + String remoteDataDir, + long createdTime, + long modifiedTime, + long bucketLayoutEpoch) { Schema schema = tableDescriptor.getSchema(); int numBuckets = tableDescriptor @@ -453,7 +519,8 @@ public static TableInfo of( remoteDataDir, tableDescriptor.getComment().orElse(null), createdTime, - modifiedTime); + modifiedTime, + bucketLayoutEpoch); } @Override @@ -466,6 +533,7 @@ public boolean equals(Object o) { return tableId == that.tableId && schemaId == that.schemaId && numBuckets == that.numBuckets + && bucketLayoutEpoch == that.bucketLayoutEpoch && Objects.equals(tablePath, that.tablePath) && Objects.equals(rowType, that.rowType) && Objects.equals(primaryKeys, that.primaryKeys) @@ -494,7 +562,8 @@ public int hashCode() { properties, customProperties, remoteDataDir, - comment); + comment, + bucketLayoutEpoch); } @Override @@ -529,6 +598,8 @@ public String toString() { + createdTime + ", modifiedTime=" + modifiedTime + + ", bucketLayoutEpoch=" + + bucketLayoutEpoch + '}'; } diff --git a/fluss-common/src/test/java/org/apache/fluss/lake/writer/WriterInitContextTest.java b/fluss-common/src/test/java/org/apache/fluss/lake/writer/WriterInitContextTest.java index f6618907602..d3395f13859 100644 --- a/fluss-common/src/test/java/org/apache/fluss/lake/writer/WriterInitContextTest.java +++ b/fluss-common/src/test/java/org/apache/fluss/lake/writer/WriterInitContextTest.java @@ -54,6 +54,11 @@ public String partition() { public TableInfo tableInfo() { return null; } + + @Override + public int bucketCountActual() { + throw new UnsupportedOperationException("not used in this test"); + } }; assertThat(context.splitIndex()).isEqualTo(WriterInitContext.UNKNOWN_SPLIT_INDEX); diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/FlinkConnectorOptions.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/FlinkConnectorOptions.java index 8f2ce7cbea0..35011b27512 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/FlinkConnectorOptions.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/FlinkConnectorOptions.java @@ -54,7 +54,10 @@ public class FlinkConnectorOptions { ConfigOptions.key("bucket.num") .intType() .noDefaultValue() - .withDescription("The number of buckets of a Fluss table."); + .withDescription( + "The target number of buckets for a Fluss table. " + + "For partitioned tables, this value applies to newly created " + + "partitions; existing partitions retain their original bucket count."); public static final ConfigOption BUCKET_KEY = ConfigOptions.key("bucket.key") @@ -266,11 +269,7 @@ public class FlinkConnectorOptions { // -------------------------------------------------------------------------------------------- public static final List ALTER_DISALLOW_OPTIONS = - Arrays.asList( - AUTO_INCREMENT_FIELDS.key(), - BUCKET_NUMBER.key(), - BUCKET_KEY.key(), - BOOTSTRAP_SERVERS.key()); + Arrays.asList(AUTO_INCREMENT_FIELDS.key(), BUCKET_KEY.key(), BOOTSTRAP_SERVERS.key()); // ------------------------------------------------------------------------------------------- // Only used internally to support materialized table diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/action/orphan/OrphanCleanUtils.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/action/orphan/OrphanCleanUtils.java index 24381ab7527..ce15dd6a663 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/action/orphan/OrphanCleanUtils.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/action/orphan/OrphanCleanUtils.java @@ -64,7 +64,7 @@ public static PhysicalTablePath physicalPath( */ public static List enumerateBuckets( TableInfo tableInfo, @Nullable PartitionInfo partitionInfo) { - int n = tableInfo.getNumBuckets(); + int n = PartitionInfo.bucketCountActualOrDefault(partitionInfo, tableInfo.getNumBuckets()); List buckets = new ArrayList(n); long tableId = tableInfo.getTableId(); for (int b = 0; b < n; b++) { diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/lake/LakeSplitGenerator.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/lake/LakeSplitGenerator.java index f26ebddfdd6..5ce2977d119 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/lake/LakeSplitGenerator.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/lake/LakeSplitGenerator.java @@ -109,14 +109,8 @@ public List generateHybridLakeFlussSplits() throws Exception { Map tableBucketsOffset = lakeSnapshotInfo.getTableBucketsOffset(); if (isPartitioned) { Set partitionInfos = listPartitionSupplier.get(); - Map partitionNameById = - partitionInfos.stream() - .collect( - Collectors.toMap( - PartitionInfo::getPartitionId, - PartitionInfo::getPartitionName)); return generatePartitionTableSplit( - lakeSplits, isLogTable, tableBucketsOffset, partitionNameById); + lakeSplits, isLogTable, tableBucketsOffset, partitionInfos); } else { Map> nonPartitionLakeSplits = lakeSplits.isEmpty() ? null : lakeSplits.values().iterator().next(); @@ -144,14 +138,14 @@ private List generatePartitionTableSplit( Map>> lakeSplits, boolean isLogTable, Map tableBucketSnapshotLogOffset, - Map partitionNameById) { + Set partitionInfos) { List splits = new ArrayList<>(); - Map flussPartitionIdByName = - partitionNameById.entrySet().stream() + Map flussPartitionByName = + partitionInfos.stream() .collect( Collectors.toMap( - Map.Entry::getValue, - Map.Entry::getKey, + PartitionInfo::getPartitionName, + partitionInfo -> partitionInfo, (existing, replacement) -> existing, LinkedHashMap::new)); long lakeSplitPartitionId = -1L; @@ -161,21 +155,23 @@ private List generatePartitionTableSplit( lakeSplits.entrySet()) { String partitionName = lakeSplitEntry.getKey(); Map> lakeSplitsOfPartition = lakeSplitEntry.getValue(); - Long partitionId = flussPartitionIdByName.remove(partitionName); - if (partitionId != null) { + PartitionInfo flussPartition = flussPartitionByName.remove(partitionName); + if (flussPartition != null) { // mean the partition also exist in fluss partition + int partitionBucketCount = flussPartition.getBucketCountActual(); Map bucketEndOffset = stoppingOffsetInitializer.getBucketOffsets( partitionName, - IntStream.range(0, bucketCount) + IntStream.range(0, partitionBucketCount) .boxed() .collect(Collectors.toList()), bucketOffsetsRetriever); splits.addAll( generateSplit( lakeSplitsOfPartition, - partitionId, + flussPartition.getPartitionId(), partitionName, + partitionBucketCount, isLogTable, tableBucketSnapshotLogOffset, bucketEndOffset)); @@ -196,19 +192,22 @@ private List generatePartitionTableSplit( } // iterate remain fluss splits - for (Map.Entry partitionIdByNameEntry : flussPartitionIdByName.entrySet()) { - String partitionName = partitionIdByNameEntry.getKey(); - Long partitionId = partitionIdByNameEntry.getValue(); + for (PartitionInfo flussPartition : flussPartitionByName.values()) { + String partitionName = flussPartition.getPartitionName(); + int partitionBucketCount = flussPartition.getBucketCountActual(); Map bucketEndOffset = stoppingOffsetInitializer.getBucketOffsets( partitionName, - IntStream.range(0, bucketCount).boxed().collect(Collectors.toList()), + IntStream.range(0, partitionBucketCount) + .boxed() + .collect(Collectors.toList()), bucketOffsetsRetriever); splits.addAll( generateSplit( null, - partitionId, + flussPartition.getPartitionId(), partitionName, + partitionBucketCount, isLogTable, // pass empty map since we won't read lake splits Collections.emptyMap(), @@ -221,6 +220,7 @@ private List generateSplit( @Nullable Map> lakeSplits, @Nullable Long partitionId, @Nullable String partitionName, + int numBuckets, boolean isLogTable, Map tableBucketSnapshotLogOffset, Map bucketEndOffset) { @@ -229,7 +229,7 @@ private List generateSplit( if (lakeSplits != null) { splits.addAll(toLakeSnapshotSplits(lakeSplits, partitionName, partitionId)); } - for (int bucket = 0; bucket < bucketCount; bucket++) { + for (int bucket = 0; bucket < numBuckets; bucket++) { TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucket); Long snapshotLogOffset = tableBucketSnapshotLogOffset.get(tableBucket); @@ -259,7 +259,28 @@ private List generateSplit( } } else { // it's primary key table - for (int bucket = 0; bucket < bucketCount; bucket++) { + if (lakeSplits != null) { + // Pairing below only visits buckets in [0, numBuckets), so any lake split with a + // bucket id outside that range would otherwise be lost from the union read. + List outOfRangeBuckets = + lakeSplits.keySet().stream() + .filter(bucket -> bucket >= numBuckets) + .sorted() + .collect(Collectors.toList()); + if (!outOfRangeBuckets.isEmpty()) { + throw new IllegalStateException( + String.format( + "Lake snapshot of table %s partition %s contains buckets %s " + + "outside the enumerated range [0, %d); refusing to " + + "generate union-read splits that would silently " + + "drop lake data.", + tableInfo.getTablePath(), + partitionName, + outOfRangeBuckets, + numBuckets)); + } + } + for (int bucket = 0; bucket < numBuckets; bucket++) { TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucket); Long snapshotLogOffset = tableBucketSnapshotLogOffset.get(tableBucket); @@ -323,6 +344,12 @@ private List generateNoPartitionedTableSplit( IntStream.range(0, bucketCount).boxed().collect(Collectors.toList()), bucketOffsetsRetriever); return generateSplit( - lakeSplits, null, null, isLogTable, tableBucketSnapshotLogOffset, bucketEndOffset); + lakeSplits, + null, + null, + bucketCount, + isLogTable, + tableBucketSnapshotLogOffset, + bucketEndOffset); } } diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManager.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManager.java index 38e22957c81..9e019aa47c7 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManager.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManager.java @@ -489,7 +489,8 @@ private Set getAllBuckets() throws Exception { Set buckets = new HashSet<>(); if (isPartitioned) { for (PartitionInfo partition : getPartitionInfos()) { - for (int bucketId = 0; bucketId < numBuckets; bucketId++) { + int partitionBucketCount = partition.getBucketCountActual(); + for (int bucketId = 0; bucketId < partitionBucketCount; bucketId++) { buckets.add(new TableBucket(tableId, partition.getPartitionId(), bucketId)); } } @@ -625,10 +626,13 @@ private Map fetchAllBucketOffsets() throws Exception { if (isPartitioned) { for (PartitionInfo partition : getPartitionInfos()) { fetchPartitionOffsets( - partition.getPartitionName(), partition.getPartitionId(), offsets); + partition.getPartitionName(), + partition.getPartitionId(), + partition.getBucketCountActual(), + offsets); } } else { - fetchPartitionOffsets(null, null, offsets); + fetchPartitionOffsets(null, null, numBuckets, offsets); } return offsets; } @@ -636,10 +640,11 @@ private Map fetchAllBucketOffsets() throws Exception { private void fetchPartitionOffsets( @Nullable String partitionName, @Nullable Long partitionId, + int bucketCount, Map offsets) throws Exception { - List bucketIds = new ArrayList<>(numBuckets); - for (int i = 0; i < numBuckets; i++) { + List bucketIds = new ArrayList<>(bucketCount); + for (int i = 0; i < bucketCount; i++) { bucketIds.add(i); } ListOffsetsResult result = listOffsets(partitionName, bucketIds); diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java index 0dd5b1b45f1..1da7645351d 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java @@ -967,7 +967,12 @@ private PartitionChange getPartitionChange( Set fetchedPartitionInfos, boolean initialDiscovery) { final Set allNewPartitions = fetchedPartitionInfos.stream() - .map(p -> new Partition(p.getPartitionId(), p.getPartitionName())) + .map( + p -> + new Partition( + p.getPartitionId(), + p.getPartitionName(), + p.getBucketCountActual())) .collect(Collectors.toSet()); final Set removedPartitions = new HashSet<>(); @@ -1058,7 +1063,8 @@ private List initLogTablePartitionSplits( getLogSplit( partition.getPartitionId(), partition.getPartitionName(), - effectiveOffsetsInitializer)); + effectiveOffsetsInitializer, + partition.getBucketCountActual())); } return splits; } @@ -1208,17 +1214,19 @@ private List getSnapshotAndLogSplits( private List getLogSplit( @Nullable Long partitionId, @Nullable String partitionName) { - return getLogSplit(partitionId, partitionName, startingOffsetsInitializer); + return getLogSplit( + partitionId, partitionName, startingOffsetsInitializer, tableInfo.getNumBuckets()); } private List getLogSplit( @Nullable Long partitionId, @Nullable String partitionName, - OffsetsInitializer effectiveStartingOffsetsInitializer) { + OffsetsInitializer effectiveStartingOffsetsInitializer, + int bucketCount) { // always assume the bucket is from 0 to bucket num List splits = new ArrayList<>(); List bucketsNeedInitOffset = new ArrayList<>(); - for (int bucketId = 0; bucketId < tableInfo.getNumBuckets(); bucketId++) { + for (int bucketId = 0; bucketId < bucketCount; bucketId++) { TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucketId); if (ignoreTableBucket(tableBucket)) { @@ -1823,12 +1831,27 @@ public boolean isEmpty() { /** A container class to hold the partition id and partition name. */ private static class Partition { + /** Marks comparison-only instances that do not carry a bucket count. */ + private static final int NO_BUCKET_COUNT = -1; + final long partitionId; final String partitionName; + /** + * The actual bucket count of this partition, already resolved by {@link PartitionInfo}. It + * is {@link #NO_BUCKET_COUNT} only for instances created for diff comparison or removal + * handling, which never generate splits. + */ + final int bucketCountActual; + Partition(long partitionId, String partitionName) { + this(partitionId, partitionName, NO_BUCKET_COUNT); + } + + Partition(long partitionId, String partitionName, int bucketCountActual) { this.partitionId = partitionId; this.partitionName = partitionName; + this.bucketCountActual = bucketCountActual; } public long getPartitionId() { @@ -1839,6 +1862,16 @@ public String getPartitionName() { return partitionName; } + public int getBucketCountActual() { + checkState( + bucketCountActual != NO_BUCKET_COUNT, + "Partition %s (id %s) does not carry a bucket count; comparison-only " + + "instances must not be used to generate splits.", + partitionName, + partitionId); + return bucketCountActual; + } + @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) { diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlussOnlyBatchSplitGenerator.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlussOnlyBatchSplitGenerator.java index 4036f047a52..dc46a4d80d8 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlussOnlyBatchSplitGenerator.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlussOnlyBatchSplitGenerator.java @@ -105,7 +105,11 @@ private List generatePrimaryKeyTableSplits( private List generateLogTableSplits(Collection partitions) { List splits = new ArrayList<>(); for (PartitionInfo partition : partitions) { - splits.addAll(getLogSplits(partition.getPartitionId(), partition.getPartitionName())); + splits.addAll( + getLogSplits( + partition.getPartitionId(), + partition.getPartitionName(), + partition.getBucketCountActual())); } return splits; } @@ -165,9 +169,14 @@ private List getBatchSnapshotAndLogSplits( private List getLogSplits( @Nullable Long partitionId, @Nullable String partitionName) { + return getLogSplits(partitionId, partitionName, tableInfo.getNumBuckets()); + } + + private List getLogSplits( + @Nullable Long partitionId, @Nullable String partitionName, int bucketCount) { List splits = new ArrayList<>(); List bucketsNeedInitOffset = new ArrayList<>(); - for (int bucketId = 0; bucketId < tableInfo.getNumBuckets(); bucketId++) { + for (int bucketId = 0; bucketId < bucketCount; bucketId++) { TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucketId); if (!tableBucketSkipper.test(tableBucket)) { diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java index 60b911b6f74..84fd230c87c 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java @@ -19,12 +19,14 @@ import org.apache.fluss.annotation.VisibleForTesting; import org.apache.fluss.client.Connection; +import org.apache.fluss.client.admin.Admin; import org.apache.fluss.client.table.Table; import org.apache.fluss.client.table.scanner.ScanRecord; import org.apache.fluss.client.table.scanner.log.ArrowScanRecords; import org.apache.fluss.client.table.scanner.log.LogScanner; import org.apache.fluss.client.table.scanner.log.LogScannerImpl; import org.apache.fluss.client.table.scanner.log.ScanRecords; +import org.apache.fluss.exception.FlussRuntimeException; import org.apache.fluss.flink.source.reader.BoundedSplitReader; import org.apache.fluss.flink.source.reader.RecordAndPos; import org.apache.fluss.flink.tiering.source.metrics.TieringMetrics; @@ -37,6 +39,7 @@ import org.apache.fluss.lake.writer.SupportsRecordBatchWrite; import org.apache.fluss.metadata.DataLakeFormat; import org.apache.fluss.metadata.LogFormat; +import org.apache.fluss.metadata.PartitionInfo; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; import org.apache.fluss.metadata.TablePath; @@ -116,6 +119,9 @@ public class TieringSplitReader private final Map currentTableSplitsByBucket; private final Map currentTableStoppingOffsets; + // partition id -> actual bucket count for the current table's partitions + private final Map currentTablePartitionBucketCounts = new HashMap<>(); + private final Map currentTableTieredOffsetAndTimestamp; private final Set currentEmptySplits; @@ -334,6 +340,22 @@ private Table getOrMoveToTable(TieringSplit split) { currentTableInfo.getTableId(), tablePath, split.getTableBucket().getTableId()); + // Snapshot each partition's actual bucket count so lake writers can stamp per-partition + // bucket layouts correctly after an ALTER bucket.num. + if (currentTableInfo.isPartitioned()) { + try { + // the admin is a shared per-connection instance, so it must not be closed here + Admin admin = connection.getAdmin(); + for (PartitionInfo partitionInfo : admin.listPartitionInfos(tablePath).get()) { + currentTablePartitionBucketCounts.put( + partitionInfo.getPartitionId(), + partitionInfo.getBucketCountActual()); + } + } catch (Exception e) { + throw new FlussRuntimeException( + "Failed to list partition infos for table " + tablePath, e); + } + } LOG.info("Start to tier table {} with table id {}.", currentTablePath, currentTableId); } return currentTable; @@ -620,6 +642,10 @@ private LakeWriter getOrCreateLakeWriter( throws IOException { LakeWriter lakeWriter = lakeWriters.get(bucket); if (lakeWriter == null) { + Integer partitionBucketCount = + bucket.getPartitionId() != null + ? currentTablePartitionBucketCounts.get(bucket.getPartitionId()) + : null; lakeWriter = lakeTieringFactory.createLakeWriter( new TieringWriterInitContext( @@ -629,6 +655,7 @@ private LakeWriter getOrCreateLakeWriter( currentTable.getTableInfo(), splitIndex, tieringRoundTimestamp, + partitionBucketCount, ioTmpDirs)); lakeWriters.put(bucket, lakeWriter); } @@ -782,6 +809,7 @@ private void finishCurrentTable() throws IOException { currentTableStoppingOffsets.clear(); currentTableTieredOffsetAndTimestamp.clear(); currentTableSplitsByBucket.clear(); + currentTablePartitionBucketCounts.clear(); } /** diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContext.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContext.java index f67b44176be..8eb83cc6444 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContext.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContext.java @@ -24,6 +24,8 @@ import javax.annotation.Nullable; +import static org.apache.fluss.utils.Preconditions.checkNotNull; + /** The implementation of {@link WriterInitContext}. */ public class TieringWriterInitContext implements WriterInitContext { @@ -33,6 +35,7 @@ public class TieringWriterInitContext implements WriterInitContext { private final TableInfo tableInfo; private final int splitIndex; private final long tieringRoundTimestamp; + private final int bucketCountActual; @Nullable private final String[] ioTmpDirs; public TieringWriterInitContext( @@ -47,7 +50,8 @@ public TieringWriterInitContext( tableInfo, UNKNOWN_SPLIT_INDEX, UNKNOWN_TIERING_ROUND_TIMESTAMP, - (String[]) null); + null, + null); } public TieringWriterInitContext( @@ -64,7 +68,8 @@ public TieringWriterInitContext( tableInfo, splitIndex, tieringRoundTimestamp, - (String[]) null); + null, + null); } public TieringWriterInitContext( @@ -74,6 +79,7 @@ public TieringWriterInitContext( TableInfo tableInfo, int splitIndex, long tieringRoundTimestamp, + @Nullable Integer bucketCountActual, @Nullable String[] ioTmpDirs) { this.tablePath = tablePath; this.tableBucket = tableBucket; @@ -82,6 +88,19 @@ public TieringWriterInitContext( this.splitIndex = splitIndex; this.tieringRoundTimestamp = tieringRoundTimestamp; this.ioTmpDirs = ioTmpDirs; + if (tableBucket.getPartitionId() == null) { + this.bucketCountActual = tableInfo.getNumBuckets(); + } else { + // Writing with a wrong bucket count would silently corrupt the lake table's bucket + // layout metadata, so a missing per-partition count must fail here. + this.bucketCountActual = + checkNotNull( + bucketCountActual, + "No actual bucket count known for partition %s (id %s) of table %s.", + partition, + tableBucket.getPartitionId(), + tablePath); + } } @Override @@ -120,4 +139,9 @@ public long tieringRoundTimestamp() { public String[] ioTmpDirs() { return ioTmpDirs; } + + @Override + public int bucketCountActual() { + return bucketCountActual; + } } diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/split/TieringSplitGenerator.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/split/TieringSplitGenerator.java index 5dd0bad2374..5a217fcffd0 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/split/TieringSplitGenerator.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/split/TieringSplitGenerator.java @@ -28,6 +28,7 @@ import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.utils.ExceptionUtils; @@ -98,6 +99,12 @@ public List generateTableSplits(TableInfo tableInfo) throws Except Collectors.toMap( PartitionInfo::getPartitionId, PartitionInfo::getPartitionName)); + Map bucketCountById = + partitionInfos.stream() + .collect( + Collectors.toMap( + PartitionInfo::getPartitionId, + PartitionInfo::getBucketCountActual)); if (tableInfo.getTableConfig().isHistoricalPartitionEnabled()) { // The internal historical partition is intentionally omitted from // listPartitionInfos(), but tiering must consume it to synchronize historical @@ -109,13 +116,31 @@ public List generateTableSplits(TableInfo tableInfo) throws Except // partition directly. metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath)); metadataUpdater.checkAndUpdatePartitionMetadata(historicalPath); - partitionNameById.put( - metadataUpdater.getPartitionIdOrElseThrow(historicalPath), - HISTORICAL_PARTITION_VALUE); + long historicalPartitionId = + metadataUpdater.getPartitionIdOrElseThrow(historicalPath); + partitionNameById.put(historicalPartitionId, HISTORICAL_PARTITION_VALUE); + // The historical partition keeps the bucket count it was created with, so its + // buckets must be enumerated by that count rather than the table-level one. + bucketCountById.put( + historicalPartitionId, + metadataUpdater + .getCluster() + .getBucketCountActual( + new TablePartition( + tableInfo.getTableId(), historicalPartitionId)) + .orElseThrow( + () -> + new FlinkRuntimeException( + "Actual bucket count not available for " + + historicalPath))); } return generatePartitionTableSplit( - tableInfo, partitionNameById, bucketOffsetsRetriever, lakeSnapshotInfo); + tableInfo, + partitionNameById, + bucketCountById, + bucketOffsetsRetriever, + lakeSnapshotInfo); } else { // non-partitioned table return generateNonPartitionedTableSplit( @@ -127,6 +152,7 @@ public List generateTableSplits(TableInfo tableInfo) throws Except private List generatePartitionTableSplit( TableInfo tableInfo, Map partitionNameById, + Map bucketCountById, BucketOffsetsRetriever bucketOffsetsRetriever, @Nullable LakeSnapshot lakeSnapshotInfo) { List splits = new ArrayList<>(); @@ -134,10 +160,11 @@ private List generatePartitionTableSplit( long partitionId = partitionNameByIdEntry.getKey(); String partitionName = partitionNameByIdEntry.getValue(); boolean historicalPartition = HISTORICAL_PARTITION_VALUE.equals(partitionName); + int partitionBucketCount = bucketCountById.get(partitionId); Map latestBucketsOffset = bucketOffsetsRetriever.latestOffsets( partitionName, - IntStream.range(0, tableInfo.getNumBuckets()) + IntStream.range(0, partitionBucketCount) .boxed() .collect(Collectors.toList())); KvSnapshots latestKvSnapshots = null; @@ -172,6 +199,7 @@ private List generatePartitionTableSplit( tableInfo, partitionId, partitionName, + partitionBucketCount, lakeSnapshotInfo, latestKvSnapshots, latestBucketsOffset)); @@ -204,13 +232,20 @@ private List generateNonPartitionedTableSplit( } return generateTableSplit( - tableInfo, null, null, lakeSnapshotInfo, latestKvSnapshots, latestBucketsOffset); + tableInfo, + null, + null, + tableInfo.getNumBuckets(), + lakeSnapshotInfo, + latestKvSnapshots, + latestBucketsOffset); } private List generateTableSplit( TableInfo tableInfo, @Nullable Long partitionId, @Nullable String partitionName, + int numBuckets, @Nullable LakeSnapshot lakeSnapshotInfo, @Nullable KvSnapshots latestKvSnapshots, Map latestBucketsOffset) { @@ -219,7 +254,7 @@ private List generateTableSplit( if (tableInfo.hasPrimaryKey()) { // it's primary key table checkState(latestKvSnapshots != null); - for (int bucket = 0; bucket < tableInfo.getNumBuckets(); bucket++) { + for (int bucket = 0; bucket < numBuckets; bucket++) { TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucket); Long lastCommittedBucketOffset = @@ -249,7 +284,7 @@ private List generateTableSplit( } else { // it's log table - for (int bucket = 0; bucket < tableInfo.getNumBuckets(); bucket++) { + for (int bucket = 0; bucket < numBuckets; bucket++) { TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucket); Long lastCommittedOffset = diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/PushdownUtils.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/PushdownUtils.java index 1dde6bf3da3..1f6bdd1852a 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/PushdownUtils.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/PushdownUtils.java @@ -389,8 +389,6 @@ private static long countLogTable(Admin flussAdmin, TablePath tablePath) throws "The Fluss cluster doesn't support count(*) on primary key table yet. Please upgrade to newer version (≥ 0.9)."); } int bucketCount = tableInfo.getNumBuckets(); - Collection buckets = - IntStream.range(0, bucketCount).boxed().collect(Collectors.toList()); List partitionInfos; if (tableInfo.isPartitioned()) { partitionInfos = flussAdmin.listPartitionInfos(tablePath).get(); @@ -399,7 +397,7 @@ private static long countLogTable(Admin flussAdmin, TablePath tablePath) throws } List> countFutureList = - offsetLengthes(flussAdmin, tablePath, partitionInfos, buckets); + offsetLengthes(flussAdmin, tablePath, partitionInfos, bucketCount); // wait for all the response CompletableFuture.allOf(countFutureList.toArray(new CompletableFuture[0])).join(); long count = 0; @@ -413,10 +411,14 @@ private static List> offsetLengthes( Admin flussAdmin, TablePath tablePath, List partitionInfos, - Collection buckets) { + int tableBucketCount) { List> list = new ArrayList<>(); for (@Nullable PartitionInfo info : partitionInfos) { String partitionName = info != null ? info.getPartitionName() : null; + int partitionBucketCount = + PartitionInfo.bucketCountActualOrDefault(info, tableBucketCount); + Collection buckets = + IntStream.range(0, partitionBucketCount).boxed().collect(Collectors.toList()); ListOffsetsResult earliestOffsets = listOffsets( flussAdmin, diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogITCase.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogITCase.java index 86bf210dc69..9b4301fe0ae 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogITCase.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/catalog/FlinkCatalogITCase.java @@ -272,12 +272,16 @@ void testAlterTableConfig() throws Exception { .hasMessage( "Currently, auto partition is only supported for partitioned table, please set table property 'table.auto-partition.enabled' to false."); + // altering bucket.num is no longer blocked at the catalog layer; it is rejected by the + // server. This table is non-partitioned, so it fails with the non-partitioned rescale + // message (partitioned-table rescale is supported; non-partitioned is not yet). String unSupportedDml2 = "alter table test_alter_table_append_only set ('bucket.num' = '1000')"; assertThatThrownBy(() -> tEnv.executeSql(unSupportedDml2)) .rootCause() - .isInstanceOf(CatalogException.class) - .hasMessage("The option 'bucket.num' is not supported to alter yet."); + .isInstanceOf(InvalidAlterTableException.class) + .hasMessageContaining("Cannot alter 'bucket.num' on non-partitioned table") + .hasMessageContaining("not yet supported"); String unSupportedDml3 = "alter table test_alter_table_append_only set ('bucket.key' = 'a')"; diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/lake/LakeSplitGeneratorTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/lake/LakeSplitGeneratorTest.java new file mode 100644 index 00000000000..43245967412 --- /dev/null +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/lake/LakeSplitGeneratorTest.java @@ -0,0 +1,158 @@ +/* + * 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.lake; + +import org.apache.fluss.client.admin.Admin; +import org.apache.fluss.client.initializer.OffsetsInitializer; +import org.apache.fluss.client.metadata.LakeSnapshot; +import org.apache.fluss.flink.lake.split.LakeSnapshotAndFlussLogSplit; +import org.apache.fluss.flink.source.split.SourceSplitBase; +import org.apache.fluss.lake.source.LakeSource; +import org.apache.fluss.lake.source.LakeSplit; +import org.apache.fluss.lake.source.Planner; +import org.apache.fluss.metadata.PartitionInfo; +import org.apache.fluss.metadata.ResolvedPartitionSpec; +import org.apache.fluss.metadata.Schema; +import org.apache.fluss.metadata.TableDescriptor; +import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePath; +import org.apache.fluss.types.DataTypes; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit test for the fail-loud guard in {@link LakeSplitGenerator}: for a primary-key table, if the + * lake snapshot of a partition contains a bucket id outside the partition's enumerated bucket range + * (which can only happen if the per-partition bucket count is inconsistent with the tiered data), + * union-read split generation must refuse rather than silently drop the out-of-range lake data. + */ +class LakeSplitGeneratorTest { + + /** Table-level bucket count, kept different from the per-partition counts used below. */ + private static final int TABLE_LEVEL_BUCKET_COUNT = 3; + + /** + * Builds a {@link LakeSplitGenerator} for a partitioned primary-key table (schema: a INT, b + * STRING, c STRING; PK a+c) whose single partition "p" has {@code partitionBucketCountActual} + * enumerated buckets and a single lake split landing in {@code lakeSplitBucket}. + */ + @SuppressWarnings("unchecked") + private static LakeSplitGenerator createGenerator( + int partitionBucketCountActual, int lakeSplitBucket) throws Exception { + TablePath tablePath = TablePath.of("db", "pk_table"); + TableDescriptor descriptor = + TableDescriptor.builder() + .schema( + Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("b", DataTypes.STRING()) + .column("c", DataTypes.STRING()) + .primaryKey("a", "c") + .build()) + .distributedBy(TABLE_LEVEL_BUCKET_COUNT, "a") + .partitionedBy("c") + .build(); + TableInfo tableInfo = TableInfo.of(tablePath, 1L, 1, descriptor, null, 1L, 1L); + + LakeSplit lakeSplit = mock(LakeSplit.class); + when(lakeSplit.partition()).thenReturn(Collections.singletonList("p")); + when(lakeSplit.bucket()).thenReturn(lakeSplitBucket); + + Planner planner = mock(Planner.class); + when(planner.plan()).thenReturn(Collections.singletonList(lakeSplit)); + LakeSource lakeSource = mock(LakeSource.class); + when(lakeSource.createPlanner(any())).thenReturn(planner); + + Admin admin = mock(Admin.class); + when(admin.getReadableLakeSnapshot(tablePath)) + .thenReturn( + CompletableFuture.completedFuture(new LakeSnapshot(1L, new HashMap<>()))); + + OffsetsInitializer.BucketOffsetsRetriever retriever = + mock(OffsetsInitializer.BucketOffsetsRetriever.class); + OffsetsInitializer stoppingOffsetInitializer = mock(OffsetsInitializer.class); + Map stoppingOffsets = new HashMap<>(); + for (int bucket = 0; bucket < partitionBucketCountActual; bucket++) { + stoppingOffsets.put(bucket, 0L); + } + when(stoppingOffsetInitializer.getBucketOffsets(eq("p"), anyList(), any())) + .thenReturn(stoppingOffsets); + + // the partition "p" carries its own bucket count (so an out-of-range lake bucket can be + // detected against the enumerated range [0, partitionBucketCountActual)) + PartitionInfo partitionInfo = + new PartitionInfo( + 7L, + ResolvedPartitionSpec.fromPartitionName(tableInfo.getPartitionKeys(), "p"), + null, + partitionBucketCountActual); + + return new LakeSplitGenerator( + tableInfo, + admin, + lakeSource, + retriever, + stoppingOffsetInitializer, + partitionBucketCountActual, + () -> Collections.singleton(partitionInfo)); + } + + @Test + void testPrimaryKeyOutOfRangeLakeBucketFailsLoud() throws Exception { + // lake split lands in bucket 5, which is outside [0, 2) + LakeSplitGenerator generator = createGenerator(2, 5); + assertThatThrownBy(generator::generateHybridLakeFlussSplits) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("outside the enumerated range") + .hasMessageContaining("refusing to generate union-read splits"); + } + + @Test + void testPrimaryKeyInRangeLakeBucketSucceeds() throws Exception { + // lake split lands in bucket 1, which is within [0, 4) + int partitionBucketCountActual = 4; + LakeSplitGenerator generator = createGenerator(partitionBucketCountActual, 1); + + // no out-of-range bucket: generation succeeds and produces one hybrid lake+log split per + // bucket of the partition's enumerated range [0, partitionBucketCountActual) + List splits = generator.generateHybridLakeFlussSplits(); + assertThat(partitionBucketCountActual).isNotEqualTo(TABLE_LEVEL_BUCKET_COUNT); + assertThat(splits).isNotNull().hasSize(partitionBucketCountActual); + for (int bucket = 0; bucket < partitionBucketCountActual; bucket++) { + SourceSplitBase split = splits.get(bucket); + assertThat(split).isInstanceOf(LakeSnapshotAndFlussLogSplit.class); + assertThat(split.getPartitionName()).isEqualTo("p"); + assertThat(split.getTableBucket().getPartitionId()).isEqualTo(7L); + assertThat(split.getTableBucket().getBucket()).isEqualTo(bucket); + } + } +} diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManagerTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManagerTest.java index a9e3ec66f41..b96c484a158 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManagerTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManagerTest.java @@ -101,7 +101,8 @@ private static RecoveryOffsetManager createManager( private static PartitionInfo createPartitionInfo(long partitionId, String partitionName) { ResolvedPartitionSpec spec = ResolvedPartitionSpec.fromPartitionValue("pt", partitionName); - return new PartitionInfo(partitionId, spec, DEFAULT_REMOTE_DATA_DIR); + // matches the 1-bucket table used by the tests calling this helper + return new PartitionInfo(partitionId, spec, DEFAULT_REMOTE_DATA_DIR, 1); } // ==================== FRESH_START Tests ==================== @@ -804,6 +805,120 @@ void testCleanupOffsetsNonTask0() { assertThat(admin.wasDeleteCalled()).isFalse(); } + @Test + void testCheckpointRecoveryEnumeratesPerPartitionBucketCountActual() throws Exception { + // Two partitions with different bucketCountActual: partition 1 has 2 buckets (old, + // pre-ALTER), partition 2 has 4 buckets (new, post-ALTER). If getAllBuckets used the + // table-level count for both, the old partition would spuriously enumerate buckets 2/3 or + // the new partition would be truncated. + long oldPartitionId = 1L; + long newPartitionId = 2L; + int oldBucketCount = 2; + int newBucketCount = 4; + + Map currentOffsets = new HashMap<>(); + for (int b = 0; b < oldBucketCount; b++) { + currentOffsets.put(new TableBucket(TABLE_ID, oldPartitionId, b), 200L); + } + for (int b = 0; b < newBucketCount; b++) { + currentOffsets.put(new TableBucket(TABLE_ID, newPartitionId, b), 200L); + } + + RecoveryTestAdmin admin = new RecoveryTestAdmin(currentOffsets); + admin.setPartitions( + Arrays.asList( + createPartitionInfoWithBucketCountActual( + oldPartitionId, "old", oldBucketCount), + createPartitionInfoWithBucketCountActual( + newPartitionId, "new", newBucketCount))); + // Table-level bucket count is 4 (post-ALTER). Old partition must be enumerated at 2. + TableInfo tableInfo = createTableInfo(4, true); + RecoveryOffsetManager manager = + new RecoveryOffsetManager( + admin, PRODUCER_ID, 0, 1, 10L, 5000L, TABLE_PATH, tableInfo); + + Map chkOffsets = new HashMap<>(); + for (Map.Entry e : currentOffsets.entrySet()) { + chkOffsets.put(e.getKey(), 100L); + } + WriterState state = new WriterState(chkOffsets); + + RecoveryOffsetManager.RecoveryDecision decision = + manager.determineRecoveryStrategy(Collections.singleton(state)); + + assertThat(decision.getStrategy()) + .isEqualTo(RecoveryOffsetManager.RecoveryStrategy.CHECKPOINT_RECOVERY); + assertThat(decision.getUndoOffsets()).hasSize(oldBucketCount + newBucketCount); + for (int b = 0; b < oldBucketCount; b++) { + assertThat(decision.getUndoOffsets()) + .as("old partition bucket %d must be in decision", b) + .containsKey(new TableBucket(TABLE_ID, oldPartitionId, b)); + } + for (int b = 0; b < newBucketCount; b++) { + assertThat(decision.getUndoOffsets()) + .as("new partition bucket %d must be in decision", b) + .containsKey(new TableBucket(TABLE_ID, newPartitionId, b)); + } + assertThat(decision.getUndoOffsets()) + .doesNotContainKey(new TableBucket(TABLE_ID, oldPartitionId, 2)) + .doesNotContainKey(new TableBucket(TABLE_ID, oldPartitionId, 3)); + } + + @Test + void testProducerOffsetRegistrationUsesPerPartitionBucketCountActual() throws Exception { + // Empty checkpoint on Task0 → registerCurrentOffsets writes ALL buckets it enumerates. + // fetchAllBucketOffsets must enumerate each partition using its own bucketCountActual so + // the registered set is exactly the union of per-partition [0, bucketCountActual) ranges. + long oldPartitionId = 1L; + long newPartitionId = 2L; + int oldBucketCount = 2; + int newBucketCount = 4; + + Map currentOffsets = new HashMap<>(); + long offset = 100L; + for (int b = 0; b < oldBucketCount; b++) { + currentOffsets.put(new TableBucket(TABLE_ID, oldPartitionId, b), offset++); + } + for (int b = 0; b < newBucketCount; b++) { + currentOffsets.put(new TableBucket(TABLE_ID, newPartitionId, b), offset++); + } + + RecoveryTestAdmin admin = new RecoveryTestAdmin(currentOffsets); + admin.setPartitions( + Arrays.asList( + createPartitionInfoWithBucketCountActual( + oldPartitionId, "old", oldBucketCount), + createPartitionInfoWithBucketCountActual( + newPartitionId, "new", newBucketCount))); + admin.setInitialOffsetsForRegistration(currentOffsets); + TableInfo tableInfo = createTableInfo(4, true); + RecoveryOffsetManager manager = + new RecoveryOffsetManager( + admin, PRODUCER_ID, 0, 1, 10L, 5000L, TABLE_PATH, tableInfo); + + // null recoveredState triggers producer-offset recovery on Task0, which internally calls + // fetchAllBucketOffsets to build the registration map. + manager.determineRecoveryStrategy(null); + + Map registered = admin.registeredOffsets; + assertThat(registered).hasSize(oldBucketCount + newBucketCount); + for (int b = 0; b < oldBucketCount; b++) { + assertThat(registered).containsKey(new TableBucket(TABLE_ID, oldPartitionId, b)); + } + for (int b = 0; b < newBucketCount; b++) { + assertThat(registered).containsKey(new TableBucket(TABLE_ID, newPartitionId, b)); + } + assertThat(registered) + .doesNotContainKey(new TableBucket(TABLE_ID, oldPartitionId, 2)) + .doesNotContainKey(new TableBucket(TABLE_ID, oldPartitionId, 3)); + } + + private static PartitionInfo createPartitionInfoWithBucketCountActual( + long partitionId, String partitionName, int bucketCountActual) { + ResolvedPartitionSpec spec = ResolvedPartitionSpec.fromPartitionValue("pt", partitionName); + return new PartitionInfo(partitionId, spec, DEFAULT_REMOTE_DATA_DIR, bucketCountActual); + } + // ==================== Test Admin Implementation ==================== /** diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumeratorTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumeratorTest.java index 22646efabe9..d276c4e96c0 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumeratorTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumeratorTest.java @@ -17,8 +17,10 @@ package org.apache.fluss.flink.source.enumerator; +import org.apache.fluss.client.FlussConnection; import org.apache.fluss.client.admin.OffsetSpec; import org.apache.fluss.client.initializer.OffsetsInitializer; +import org.apache.fluss.client.metadata.MetadataUpdater; import org.apache.fluss.client.table.Table; import org.apache.fluss.client.table.writer.UpsertWriter; import org.apache.fluss.client.write.HashBucketAssigner; @@ -39,16 +41,21 @@ import org.apache.fluss.flink.source.split.SnapshotSplit; import org.apache.fluss.flink.source.split.SourceSplitBase; import org.apache.fluss.flink.source.state.SourceEnumeratorState; +import org.apache.fluss.flink.tiering.source.split.TieringSplit; +import org.apache.fluss.flink.tiering.source.split.TieringSplitGenerator; import org.apache.fluss.flink.utils.FlinkTestBase; import org.apache.fluss.lake.source.LakeSource; import org.apache.fluss.lake.source.LakeSplit; import org.apache.fluss.lake.source.TestingLakeSource; import org.apache.fluss.lake.source.TestingLakeSplit; import org.apache.fluss.metadata.PartitionInfo; +import org.apache.fluss.metadata.PartitionSpec; import org.apache.fluss.metadata.ResolvedPartitionSpec; import org.apache.fluss.metadata.Schema; import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TableChange; import org.apache.fluss.metadata.TableDescriptor; +import org.apache.fluss.metadata.TableInfo; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.predicate.Predicate; import org.apache.fluss.predicate.PredicateBuilder; @@ -406,7 +413,10 @@ void testRestoreFlussOnlySourceWithLakeSourceDoesNotGenerateLakeSplits(@TempDir DEFAULT_BUCKET_NUM, Collections.singletonList( new PartitionInfo( - partitionId, partitionSpec, DEFAULT_REMOTE_DATA_DIR))); + partitionId, + partitionSpec, + DEFAULT_REMOTE_DATA_DIR, + DEFAULT_BUCKET_NUM))); SourceEnumeratorState checkpointState; try (MockSplitEnumeratorContext context = @@ -1567,14 +1577,22 @@ void testPartitionsExpiredInFlussButExistInLake( Collections.singletonList(isPrimaryKeyTable ? "date" : "name"), partitionName); lakePartitionInfos.add( - new PartitionInfo(partitionId, partitionSpec, DEFAULT_REMOTE_DATA_DIR)); + new PartitionInfo( + partitionId, + partitionSpec, + DEFAULT_REMOTE_DATA_DIR, + DEFAULT_BUCKET_NUM)); } ResolvedPartitionSpec partitionSpec = ResolvedPartitionSpec.fromPartitionName( Collections.singletonList(isPrimaryKeyTable ? "date" : "name"), hybridPartitionName); lakePartitionInfos.add( - new PartitionInfo(hybridPartitionId, partitionSpec, DEFAULT_REMOTE_DATA_DIR)); + new PartitionInfo( + hybridPartitionId, + partitionSpec, + DEFAULT_REMOTE_DATA_DIR, + DEFAULT_BUCKET_NUM)); LakeSource lakeSource = new TestingLakeSource(DEFAULT_BUCKET_NUM, lakePartitionInfos); @@ -2030,4 +2048,157 @@ private Map putRows(TablePath tablePath, int rowsNum) throws E } return bucketRows; } + + // ==================== Per-Partition Bucket Count Tests ==================== + + private static final int OLD_BUCKET_NUM = 2; + private static final int NEW_BUCKET_NUM = 4; + + private static final TableDescriptor RESCALE_LOG_TABLE = + TableDescriptor.builder() + .schema( + Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("name", DataTypes.STRING()) + .build()) + .distributedBy(OLD_BUCKET_NUM) + .partitionedBy("name") + .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, false) + .build(); + + /** + * Creates a partitioned log table, creates "old" partition, ALTERs bucket.num to {@link + * #NEW_BUCKET_NUM}, creates "new" partition, writes rows to both. Returns [tablePath, + * tableInfo, oldPartitionId, newPartitionId]. + */ + private Object[] setupRescaledPartitionedTable() throws Exception { + TablePath tablePath = TablePath.of(DEFAULT_DB, "rescale_split_" + System.nanoTime()); + createTable(tablePath, RESCALE_LOG_TABLE); + PartitionSpec oldSpec = new PartitionSpec(Collections.singletonMap("name", "old")); + PartitionSpec newSpec = new PartitionSpec(Collections.singletonMap("name", "new")); + admin.createPartition(tablePath, oldSpec, false).get(); + writeRows(conn, tablePath, genRows(10, "old"), true); + admin.alterTable( + tablePath, + Collections.singletonList( + TableChange.set("bucket.num", String.valueOf(NEW_BUCKET_NUM))), + false) + .get(); + admin.createPartition(tablePath, newSpec, false).get(); + writeRows(conn, tablePath, genRows(20, "new"), true); + List infos = admin.listPartitionInfos(tablePath).get(); + PartitionInfo oldInfo = + infos.stream().filter(i -> "old".equals(i.getPartitionName())).findFirst().get(); + PartitionInfo newInfo = + infos.stream().filter(i -> "new".equals(i.getPartitionName())).findFirst().get(); + assertThat(oldInfo.getBucketCountActual()).isEqualTo(OLD_BUCKET_NUM); + assertThat(newInfo.getBucketCountActual()).isEqualTo(NEW_BUCKET_NUM); + return new Object[] { + tablePath, + admin.getTableInfo(tablePath).get(), + oldInfo.getPartitionId(), + newInfo.getPartitionId() + }; + } + + private static List genRows(int count, String partition) { + List rows = new ArrayList<>(); + for (int i = 0; i < count; i++) { + rows.add(row(i, partition)); + } + return rows; + } + + /** + * Tests that {@link FlinkSourceEnumerator} generates splits using each partition's actual + * bucket count after an ALTER bucket.num, in both streaming and batch modes. + */ + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testFlussSplitsEnumeratePerPartitionBucketCount(boolean streaming) throws Throwable { + Object[] ctx = setupRescaledPartitionedTable(); + try (MockSplitEnumeratorContext context = + new MockSplitEnumeratorContext<>(3); + MockWorkExecutor workExecutor = new MockWorkExecutor(context); + FlinkSourceEnumerator enumerator = + new FlinkSourceEnumerator( + (TablePath) ctx[0], + flussConf, + false, + true, + context, + Collections.emptySet(), + Collections.emptyMap(), + null, + streaming + ? OffsetsInitializer.earliest() + : OffsetsInitializer.full(), + DEFAULT_SCAN_PARTITION_DISCOVERY_INTERVAL_MS, + streaming, + null, + null, + workExecutor, + LeaseContext.DEFAULT, + false)) { + enumerator.start(); + if (streaming) { + runPeriodicPartitionDiscovery(workExecutor); + } else { + workExecutor.runNextOneTimeCallable(); + } + for (int i = 0; i < 3; i++) { + registerReader(context, enumerator, i); + } + long oldId = (long) ctx[2]; + long newId = (long) ctx[3]; + List splits = + getReadersAssignments(context).values().stream() + .flatMap(List::stream) + .collect(Collectors.toList()); + assertThat(splits).allMatch(s -> s instanceof LogSplit); + assertThat( + splits.stream() + .filter(s -> s.getTableBucket().getPartitionId() == oldId) + .map(s -> s.getTableBucket().getBucket()) + .collect(Collectors.toList())) + .containsExactlyInAnyOrder(0, 1); + assertThat( + splits.stream() + .filter(s -> s.getTableBucket().getPartitionId() == newId) + .map(s -> s.getTableBucket().getBucket()) + .collect(Collectors.toList())) + .containsExactlyInAnyOrder(0, 1, 2, 3); + } + } + + /** + * Tests that {@link TieringSplitGenerator} generates tiering splits using each partition's + * actual bucket count after an ALTER bucket.num. + */ + @Test + void testTieringSplitsEnumeratePerPartitionBucketCount() throws Throwable { + Object[] ctx = setupRescaledPartitionedTable(); + TableInfo tableInfo = (TableInfo) ctx[1]; + long oldPartitionId = (long) ctx[2]; + long newPartitionId = (long) ctx[3]; + List splits = + new TieringSplitGenerator(admin, metadataUpdater()).generateTableSplits(tableInfo); + assertThat( + splits.stream() + .filter(s -> s.getTableBucket().getPartitionId() == oldPartitionId) + .map(s -> s.getTableBucket().getBucket()) + .collect(Collectors.toList())) + .containsExactlyInAnyOrder(0, 1); + assertThat( + splits.stream() + .filter(s -> s.getTableBucket().getPartitionId() == newPartitionId) + .map(s -> s.getTableBucket().getBucket()) + .collect(Collectors.toList())) + .containsExactlyInAnyOrder(0, 1, 2, 3); + } + + /** The split generator resolves the internal historical partition through this updater. */ + private static MetadataUpdater metadataUpdater() { + return ((FlussConnection) conn).getMetadataUpdater(); + } } diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContextTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContextTest.java index 8002f476c22..65f7438c198 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContextTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContextTest.java @@ -17,28 +17,108 @@ package org.apache.fluss.flink.tiering.source; +import org.apache.fluss.config.Configuration; +import org.apache.fluss.metadata.Schema; +import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePath; +import org.apache.fluss.types.DataTypes; + import org.junit.jupiter.api.Test; +import java.util.Collections; + +import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** Tests for {@link TieringWriterInitContext}. */ class TieringWriterInitContextTest { + private static final long TABLE_ID = 1L; + private static final TablePath TABLE_PATH = TablePath.of("test_db", "test_table"); + private static final int TABLE_BUCKET_COUNT = 8; + @Test void testIoTmpDir() { TieringWriterInitContext defaultContext = - new TieringWriterInitContext(null, null, null, null); + newContext(new TableBucket(TABLE_ID, 0), null, null, null); TieringWriterInitContext context = - new TieringWriterInitContext( + newContext( + new TableBucket(TABLE_ID, 0), null, null, - null, - null, - 0, - 1L, new String[] {"/flink_tmp_0/fluss", "/flink_tmp_1/fluss"}); assertThat(defaultContext.ioTmpDirs()).isNull(); assertThat(context.ioTmpDirs()).containsExactly("/flink_tmp_0/fluss", "/flink_tmp_1/fluss"); } + + @Test + void testNonPartitionedFallsBackToTableLevelCount() { + // A non-partitioned bucket carries no per-partition count; the table-level count applies. + TieringWriterInitContext context = newContext(new TableBucket(TABLE_ID, 0), null, null); + assertThat(context.bucketCountActual()).isEqualTo(TABLE_BUCKET_COUNT); + } + + @Test + void testPartitionedUsesPerPartitionCount() { + // A partitioned bucket must use its own actual bucket count. + TieringWriterInitContext context = + newContext(new TableBucket(TABLE_ID, 1L, 0), "2024-01", 4); + assertThat(context.bucketCountActual()).isEqualTo(4); + } + + @Test + void testPartitionedWithoutCountFailsLoud() { + // A partitioned bucket with no resolved per-partition count must fail loudly rather than + // silently guessing (which would corrupt the lake table's bucket layout). + assertThatThrownBy(() -> newContext(new TableBucket(TABLE_ID, 1L, 0), "2024-01", null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("actual bucket count"); + } + + private static TieringWriterInitContext newContext( + TableBucket tableBucket, String partition, Integer partitionBucketCount) { + return newContext(tableBucket, partition, partitionBucketCount, null); + } + + private static TieringWriterInitContext newContext( + TableBucket tableBucket, + String partition, + Integer partitionBucketCount, + String[] ioTmpDirs) { + return new TieringWriterInitContext( + TABLE_PATH, + tableBucket, + partition, + createTableInfo(TABLE_BUCKET_COUNT), + 0, + 0L, + partitionBucketCount, + ioTmpDirs); + } + + private static TableInfo createTableInfo(int numBuckets) { + Schema schema = + Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("value", DataTypes.STRING()) + .primaryKey("id") + .build(); + return new TableInfo( + TABLE_PATH, + TABLE_ID, + 0, + schema, + Collections.emptyList(), + Collections.emptyList(), + numBuckets, + new Configuration(), + new Configuration(), + DEFAULT_REMOTE_DATA_DIR, + null, + System.currentTimeMillis(), + System.currentTimeMillis()); + } } diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/utils/FlinkTestBase.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/utils/FlinkTestBase.java index fc8371e27a7..968a71e99e2 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/utils/FlinkTestBase.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/utils/FlinkTestBase.java @@ -247,7 +247,8 @@ public static Map createPartitions( tableInfo.getTableId(), assignment.getBucketAssignments()), zkClient.getDefaultRemoteDataDir(), tablePath, - tableInfo.getTableId()); + tableInfo.getTableId(), + tableInfo.getNumBuckets()); } return newPartitionIds; } diff --git a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java index fca678dcbe8..baf64b3cc54 100644 --- a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java +++ b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java @@ -654,6 +654,11 @@ public int splitIndex() { public long tieringRoundTimestamp() { return tieringRoundTimestamp; } + + @Override + public int bucketCountActual() { + return tableInfo.getNumBuckets(); + } } private static class TestingCommitterInitContext implements CommitterInitContext { diff --git a/fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalog.java b/fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalog.java index 70886c9c839..e30c126221c 100644 --- a/fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalog.java +++ b/fluss-lake/fluss-lake-iceberg/src/main/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalog.java @@ -155,6 +155,13 @@ public void createTable(TablePath tablePath, TableDescriptor tableDescriptor, Co @Override public void alterTable(TablePath tablePath, List tableChanges, Context context) throws TableNotExistException { + for (TableChange change : tableChanges) { + if (change instanceof TableChange.SetOption + && "bucket.num".equals(((TableChange.SetOption) change).getKey())) { + throw new UnsupportedOperationException( + "Bucket count rescale is not supported by the Iceberg lake catalog yet."); + } + } try { Table table = icebergCatalog.loadTable(toIcebergTableIdentifier(tablePath)); diff --git a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java index 868a6bfd47b..fd4fb8a0b9f 100644 --- a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java +++ b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java @@ -1530,7 +1530,7 @@ tablePath, pkTd, new TestingLakeCatalogContext())) * rejected. */ @Test - void testCreateTableFailsWithIncompatiblePartitionBucketCount() { + void testCreateTableFailsWithIncompatiblePartitionBucketCountActual() { String database = "spec_bucket_db"; String tableName = "spec_bucket_table"; TablePath tablePath = TablePath.of(database, tableName); diff --git a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergTieringTest.java b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergTieringTest.java index c3ecc0af390..d42c13b89e5 100644 --- a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergTieringTest.java +++ b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergTieringTest.java @@ -316,6 +316,11 @@ public String partition() { public TableInfo tableInfo() { return tableInfo; } + + @Override + public int bucketCountActual() { + return tableInfo.getNumBuckets(); + } }); } diff --git a/fluss-lake/fluss-lake-lance/src/test/java/org/apache/fluss/lake/lance/tiering/LanceTieringTest.java b/fluss-lake/fluss-lake-lance/src/test/java/org/apache/fluss/lake/lance/tiering/LanceTieringTest.java index 2943d9195f5..63a2ba4accf 100644 --- a/fluss-lake/fluss-lake-lance/src/test/java/org/apache/fluss/lake/lance/tiering/LanceTieringTest.java +++ b/fluss-lake/fluss-lake-lance/src/test/java/org/apache/fluss/lake/lance/tiering/LanceTieringTest.java @@ -407,6 +407,11 @@ public String partition() { public TableInfo tableInfo() { return tableInfo; } + + @Override + public int bucketCountActual() { + return tableInfo.getNumBuckets(); + } }); } diff --git a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/PaimonLakeCatalog.java b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/PaimonLakeCatalog.java index 4cd127f441e..4c3342ae5c2 100644 --- a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/PaimonLakeCatalog.java +++ b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/PaimonLakeCatalog.java @@ -29,6 +29,7 @@ import org.apache.fluss.metadata.TablePath; import org.apache.fluss.utils.IOUtils; +import org.apache.paimon.CoreOptions; import org.apache.paimon.catalog.Catalog; import org.apache.paimon.catalog.CatalogContext; import org.apache.paimon.catalog.CatalogFactory; @@ -45,6 +46,7 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.stream.Collectors; @@ -63,6 +65,10 @@ public class PaimonLakeCatalog implements LakeCatalog { private static final Logger LOG = LoggerFactory.getLogger(PaimonLakeCatalog.class); private static final String PAIMON_PATH_KEY = "paimon.path"; + + /** The Fluss table property carrying a bucket count rescale, see {@link #alterTable}. */ + private static final String BUCKET_NUM_PROPERTY = "bucket.num"; + public static final LinkedHashMap LEGACY_SYSTEM_COLUMNS = new LinkedHashMap<>(); @@ -119,11 +125,39 @@ public void createTable(TablePath tablePath, TableDescriptor tableDescriptor, Co @Override public void alterTable(TablePath tablePath, List tableChanges, Context context) throws TableNotExistException { + // Apply the bucket count rescale separately so the schema-compat branches below cannot + // swallow it. + Integer newBucketCount = null; + List remainingChanges = new ArrayList<>(tableChanges.size()); + for (TableChange tableChange : tableChanges) { + if (tableChange instanceof TableChange.SetOption + && BUCKET_NUM_PROPERTY.equals(((TableChange.SetOption) tableChange).getKey())) { + String value = ((TableChange.SetOption) tableChange).getValue(); + try { + newBucketCount = Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new InvalidAlterTableException( + "Invalid value for '" + BUCKET_NUM_PROPERTY + "': " + value, e); + } + if (newBucketCount <= 0) { + throw new InvalidAlterTableException( + "Invalid value for '" + BUCKET_NUM_PROPERTY + "': " + value); + } + } else { + remainingChanges.add(tableChange); + } + } + if (newBucketCount != null) { + applyBucketCountChange(tablePath, newBucketCount); + } + if (remainingChanges.isEmpty()) { + return; + } try { Table table = paimonCatalog.getTable(toPaimon(tablePath)); FileStoreTable fileStoreTable = (FileStoreTable) table; List changesToApply = - validateAndFilterPaimonPathChanges(fileStoreTable.location(), tableChanges); + validateAndFilterPaimonPathChanges(fileStoreTable.location(), remainingChanges); // Avoid creating a new Paimon schema version for a path-only no-op. if (changesToApply.isEmpty()) { @@ -159,7 +193,7 @@ currentPaimonSchema, toPaimonSchema(context.getExpectedTable()))) { + "rather than applying other table changes: %s.", currentPaimonSchema, context.getCurrentTable().getSchema(), - tableChanges)); + remainingChanges)); } if (!paimonSchemaChanges.isEmpty()) { @@ -291,6 +325,23 @@ private void createDatabase(String databaseName) { } } + private void applyBucketCountChange(TablePath tablePath, int newBucketCount) + throws TableNotExistException { + // Bypass toPaimonSchemaChanges (which rejects Paimon's own BUCKET key via + // PAIMON_UNSETTABLE_OPTIONS) and set BUCKET directly. + List changes = + Collections.singletonList( + SchemaChange.setOption( + CoreOptions.BUCKET.key(), String.valueOf(newBucketCount))); + try { + paimonCatalog.alterTable(toPaimon(tablePath), changes, false); + } catch (Catalog.TableNotExistException e) { + throw new TableNotExistException("Table " + tablePath + " does not exist."); + } catch (Catalog.ColumnAlreadyExistException | Catalog.ColumnNotExistException e) { + throw new InvalidAlterTableException(e.getMessage(), e); + } + } + @Override public void close() { IOUtils.closeQuietly(paimonCatalog, "paimon catalog"); diff --git a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/lookup/PaimonLakeTableLookuper.java b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/lookup/PaimonLakeTableLookuper.java index caa296ae8a6..76c9f82a372 100644 --- a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/lookup/PaimonLakeTableLookuper.java +++ b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/lookup/PaimonLakeTableLookuper.java @@ -17,6 +17,7 @@ package org.apache.fluss.lake.paimon.lookup; +import org.apache.fluss.bucketing.BucketingFunction; import org.apache.fluss.config.Configuration; import org.apache.fluss.config.MemorySize; import org.apache.fluss.config.TableConfig; @@ -25,6 +26,7 @@ import org.apache.fluss.lake.lakestorage.LakeTableLookuper; import org.apache.fluss.lake.paimon.utils.PaimonPartitionBucket; import org.apache.fluss.lake.paimon.utils.PaimonRowAsFlussRow; +import org.apache.fluss.metadata.DataLakeFormat; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.row.BinaryRow; import org.apache.fluss.row.InternalRow; @@ -104,6 +106,10 @@ */ public class PaimonLakeTableLookuper implements LakeTableLookuper { + /** Bucketing function the lake data was written with, used to recompute a bucket. */ + private static final BucketingFunction BUCKETING_FUNCTION = + BucketingFunction.of(DataLakeFormat.PAIMON); + private final Configuration paimonConfig; private final TablePath tablePath; private final String ioTmpDir; @@ -119,6 +125,9 @@ public class PaimonLakeTableLookuper implements LakeTableLookuper { // Remains non-zero until a refresh completes without observing another request. private final AtomicLong pendingRefreshRequests; + /** Bucket count each partition was written with, resolved from the lake metadata. */ + private final Map totalBucketsByPartition; + private @Nullable Catalog catalog; private @Nullable FileStoreTable fileStoreTable; private @Nullable IOManager ioManager; @@ -153,6 +162,7 @@ public PaimonLakeTableLookuper( this.lookupStateLock = new Object(); this.registeredFiles = new ConcurrentHashMap<>(); this.pendingRefreshRequests = new AtomicLong(); + this.totalBucketsByPartition = new ConcurrentHashMap<>(); } @Override @@ -191,6 +201,7 @@ public void close() { IOUtils.closeQuietly(ioManager, "Paimon lookup IO manager"); IOUtils.closeQuietly(catalog, "Paimon catalog"); registeredFiles.clear(); + totalBucketsByPartition.clear(); localTableQuery = null; compactedKeyDecoder = null; trimmedPrimaryKeys = null; @@ -353,10 +364,14 @@ private org.apache.paimon.data.BinaryRow getKey(byte[] key, LookupContext contex } private @Nullable byte[] lookupInternal(byte[] key, LookupContext context) { + org.apache.paimon.data.BinaryRow partition = getPartition(context); + Integer bucket = resolveLakeBucket(key, partition, context); + if (bucket == null) { + return null; + } org.apache.paimon.data.InternalRow paimonRow; try { - paimonRow = - lookupPaimon(getPartition(context), context.bucketId(), getKey(key, context)); + paimonRow = lookupPaimon(partition, bucket, getKey(key, context)); } catch (IOException e) { // Historical Paimon point lookup is part of the Fluss KV lookup path. Expose a // persistent I/O failure as a retriable KV error so the existing KV RPC retry @@ -373,6 +388,81 @@ private org.apache.paimon.data.BinaryRow getKey(byte[] key, LookupContext contex return encodeValue(paimonRow, context.schemaId(), context.valueRowType()); } + /** + * Resolves the bucket the key lives in: the caller's bucket id when it matches the lake layout, + * otherwise recomputed from the bucket count the partition was written with. Returns null when + * the partition holds no data in the lake. + */ + private @Nullable Integer resolveLakeBucket( + byte[] key, org.apache.paimon.data.BinaryRow partition, LookupContext context) { + if (context.bucketId() != null) { + return context.bucketId(); + } + Integer totalBuckets = resolveTotalBuckets(partition); + if (totalBuckets == null) { + return null; + } + return BUCKETING_FUNCTION.bucketing(deriveBucketKey(key, context), totalBuckets); + } + + /** + * Reads the bucket count the given partition was written with from the lake metadata. Fluss + * writes each partition with a single bucket count, so more than one value means the lake data + * cannot be routed reliably. + */ + private @Nullable Integer resolveTotalBuckets(org.apache.paimon.data.BinaryRow partition) { + return totalBucketsByPartition.computeIfAbsent( + partition.copy(), + p -> { + Set totalBuckets = new HashSet<>(); + InnerTableScan tableScan = + fileStoreTable + .newScan() + .withPartitionFilter(Collections.singletonList(p)); + for (Split split : tableScan.plan().splits()) { + if (split instanceof DataSplit) { + totalBuckets.add(((DataSplit) split).totalBuckets()); + } + } + if (totalBuckets.isEmpty()) { + return null; + } + if (totalBuckets.size() > 1) { + throw new KvStorageException( + "Cannot look up historical data of table " + + tablePath + + " because its lake data reports multiple bucket counts " + + totalBuckets + + " for one partition, so the bucket a key was written to " + + "cannot be determined."); + } + return totalBuckets.iterator().next(); + }); + } + + /** + * Returns the bucket key bytes the lake bucketing function expects. The lookup key already is + * the bucket key when the table uses the default bucket key; otherwise the primary key is + * decoded and the bucket key fields are re-encoded with the lake encoder. + */ + private byte[] deriveBucketKey(byte[] key, LookupContext context) { + List bucketKeys = fileStoreTable.schema().bucketKeys(); + if (bucketKeys.equals(trimmedPrimaryKeys)) { + return key; + } + RowType primaryKeyRowType = context.valueRowType().project(trimmedPrimaryKeys); + InternalRow primaryKeyRow; + if (compactedKeyDecoder != null) { + primaryKeyRow = compactedKeyDecoder.decodeKey(key); + } else { + org.apache.paimon.data.BinaryRow paimonKeyRow = + new org.apache.paimon.data.BinaryRow(trimmedPrimaryKeys.size()); + paimonKeyRow.pointTo(MemorySegment.wrap(key), 0, key.length); + primaryKeyRow = new PaimonRowAsFlussRow(paimonKeyRow); + } + return new PaimonKeyEncoder(primaryKeyRowType, bucketKeys).encodeKey(primaryKeyRow); + } + private @Nullable org.apache.paimon.data.InternalRow lookupPaimon( org.apache.paimon.data.BinaryRow partition, int bucket, diff --git a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/PaimonLakeWriter.java b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/PaimonLakeWriter.java index a93d61b2e01..a8169518b67 100644 --- a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/PaimonLakeWriter.java +++ b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/PaimonLakeWriter.java @@ -33,8 +33,10 @@ import org.apache.paimon.catalog.Catalog; import org.apache.paimon.table.FileStoreTable; +import javax.annotation.Nullable; + import java.io.IOException; -import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -51,11 +53,19 @@ public PaimonLakeWriter( PaimonCatalogProvider paimonCatalogProvider, WriterInitContext writerInitContext) throws IOException { this.paimonCatalog = paimonCatalogProvider.get(); + // Only Fixed Bucket tables (bucket keys non-empty) carry a positive BUCKET in Paimon. + // Overriding on an Unaware Bucket table (BUCKET = -1) would change its bucket mode. + // The context always resolves the actual bucket count. + Integer bucketOverride = + !writerInitContext.tableInfo().getBucketKeys().isEmpty() + ? writerInitContext.bucketCountActual() + : null; TablePath lakeTablePath = writerInitContext.tableInfo().getLakeTablePath(); FileStoreTable fileStoreTable = getTable( lakeTablePath, - writerInitContext.tableInfo().getTableConfig().isDataLakeAutoCompaction()); + writerInitContext.tableInfo().getTableConfig().isDataLakeAutoCompaction(), + bucketOverride); List partitionKeys = fileStoreTable.partitionKeys(); RowType flussRowType = writerInitContext.tableInfo().getRowType(); @@ -139,15 +149,23 @@ public void close() throws IOException { } } - private FileStoreTable getTable(TablePath tablePath, boolean isAutoCompaction) + private FileStoreTable getTable( + TablePath tablePath, boolean isAutoCompaction, @Nullable Integer bucketOverride) throws IOException { try { FileStoreTable table = (FileStoreTable) paimonCatalog.getTable(toPaimon(tablePath)); - Map compactionOptions = - Collections.singletonMap( - CoreOptions.WRITE_ONLY.key(), - isAutoCompaction ? Boolean.FALSE.toString() : Boolean.TRUE.toString()); - return table.copy(compactionOptions); + if (bucketOverride != null) { + // copy(Map) rejects BUCKET as immutable, so swap it in via a schema copy, + // which only rebuilds the in-memory table view. + Map schemaOptions = new HashMap<>(table.schema().options()); + schemaOptions.put(CoreOptions.BUCKET.key(), String.valueOf(bucketOverride)); + table = table.copy(table.schema().copy(schemaOptions)); + } + Map dynamicOptions = new HashMap<>(); + dynamicOptions.put( + CoreOptions.WRITE_ONLY.key(), + isAutoCompaction ? Boolean.FALSE.toString() : Boolean.TRUE.toString()); + return table.copy(dynamicOptions); } catch (Exception e) { throw new IOException("Failed to get table " + tablePath + " in Paimon.", e); } diff --git a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/append/AppendOnlyWriter.java b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/append/AppendOnlyWriter.java index a6d4f4292fd..f6431cb124e 100644 --- a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/append/AppendOnlyWriter.java +++ b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/append/AppendOnlyWriter.java @@ -59,9 +59,7 @@ public AppendOnlyWriter( boolean historicalPartition) { //noinspection unchecked super( - (TableWriteImpl) - // todo: set ioManager to support write-buffer-spillable - fileStoreTable.newWrite(FLUSS_LAKE_TIERING_COMMIT_USER), + buildTableWrite(fileStoreTable), fileStoreTable.rowType(), tableBucket, partition, @@ -73,6 +71,15 @@ public AppendOnlyWriter( this.paimonIncludingSystemColumns = paimonIncludingSystemColumns; } + @SuppressWarnings("unchecked") + private static TableWriteImpl buildTableWrite(FileStoreTable fileStoreTable) { + TableWriteImpl tableWrite = + (TableWriteImpl) + // todo: set ioManager to support write-buffer-spillable + fileStoreTable.newWrite(FLUSS_LAKE_TIERING_COMMIT_USER); + return tableWrite; + } + @Override public void write(LogRecord record) throws Exception { BinaryRow targetPartition = prepareRecordAndGetPartition(record); diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/PaimonLakeCatalogTest.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/PaimonLakeCatalogTest.java index c704bef954d..16bfd82dd25 100644 --- a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/PaimonLakeCatalogTest.java +++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/PaimonLakeCatalogTest.java @@ -580,6 +580,56 @@ private org.apache.paimon.schema.Schema.Builder createPaimonSchemaBuilder( .option(CoreOptions.BUCKET_KEY.key(), bucketKey); } + @Test + void testUserFacingAlterTableStillRejectsBucketChange() throws Exception { + // The "bucket.num" SetOption is Fluss's trusted bucket-count propagation and is applied, + // while any attempt to set Paimon's own bucket option directly through property change + // keeps being rejected. + String database = "test_user_bucket_reject_db"; + String tableName = "test_user_bucket_reject_table"; + TablePath tablePath = TablePath.of(database, tableName); + createFixedBucketTable(database, tableName, 4); + + TableDescriptor fixedBucketDescriptor = fixedBucketTableDescriptor(4); + TestingLakeCatalogContext matchingContext = + new TestingLakeCatalogContext(fixedBucketDescriptor, fixedBucketDescriptor); + + List userChanges = + Collections.singletonList( + TableChange.set( + "paimon." + org.apache.paimon.CoreOptions.BUCKET.key(), "8")); + assertThatThrownBy( + () -> + flussPaimonCatalog.alterTable( + tablePath, userChanges, matchingContext)) + .hasMessageContaining("bucket") + .hasMessageContaining("cannot be changed"); + + flussPaimonCatalog.alterTable( + tablePath, + Collections.singletonList(TableChange.set("bucket.num", "8")), + matchingContext); + Identifier identifier = Identifier.create(database, tableName); + Table after = flussPaimonCatalog.getPaimonCatalog().getTable(identifier); + assertThat(after.options().get(org.apache.paimon.CoreOptions.BUCKET.key())).isEqualTo("8"); + } + + private TableDescriptor fixedBucketTableDescriptor(int bucketCount) { + return TableDescriptor.builder() + .schema(FLUSS_SCHEMA) + .property(TABLE_DATALAKE_ENABLED.key(), "true") + .property(TABLE_DATALAKE_FORMAT.key(), "paimon") + .property("table.datalake.paimon.warehouse", tempWarehouseDir.toURI().toString()) + .distributedBy(bucketCount, "id") + .build(); + } + + private void createFixedBucketTable(String database, String tableName, int initialBucketCount) { + TableDescriptor td = fixedBucketTableDescriptor(initialBucketCount); + flussPaimonCatalog.createTable( + TablePath.of(database, tableName), td, new TestingLakeCatalogContext(td, td)); + } + private void createTable(String database, String tableName) { TableDescriptor td = getTableDescriptor(FLUSS_SCHEMA); TablePath tablePath = TablePath.of(database, tableName); diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/flink/FlinkUnionReadRescaleBucketITCase.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/flink/FlinkUnionReadRescaleBucketITCase.java new file mode 100644 index 00000000000..c92e9385fec --- /dev/null +++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/flink/FlinkUnionReadRescaleBucketITCase.java @@ -0,0 +1,506 @@ +/* + * 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.lake.paimon.flink; + +import org.apache.fluss.client.initializer.BucketOffsetsRetrieverImpl; +import org.apache.fluss.config.ConfigOptions; +import org.apache.fluss.metadata.PartitionInfo; +import org.apache.fluss.metadata.PartitionSpec; +import org.apache.fluss.metadata.Schema; +import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TableChange; +import org.apache.fluss.metadata.TableDescriptor; +import org.apache.fluss.metadata.TablePath; +import org.apache.fluss.row.InternalRow; +import org.apache.fluss.types.DataTypes; + +import org.apache.flink.core.execution.JobClient; +import org.apache.flink.types.Row; +import org.apache.flink.types.RowKind; +import org.apache.flink.util.CloseableIterator; +import org.apache.flink.util.CollectionUtil; +import org.apache.paimon.CoreOptions; +import org.apache.paimon.table.FileStoreTable; +import org.apache.paimon.table.source.DataSplit; +import org.apache.paimon.table.source.Split; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.apache.fluss.flink.source.testutils.FlinkRowAssertionsUtils.collectRowsWithTimeout; +import static org.apache.fluss.lake.paimon.utils.PaimonConversions.toPaimon; +import static org.apache.fluss.testutils.DataTestUtils.row; +import static org.apache.fluss.testutils.common.CommonTestUtils.retry; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * The IT case for union read (lake + fluss log) on a partitioned table whose partitions carry + * different bucket counts after an ALTER TABLE ... SET ('bucket.num' = N): the partition created + * before the ALTER keeps its original bucket count while the partition created afterwards uses the + * new one. Verifies that tiering stamps each partition's files with the partition's actual bucket + * count and that union read enumerates buckets per partition. + */ +class FlinkUnionReadRescaleBucketITCase extends FlinkUnionReadTestBase { + + private static final int OLD_BUCKET_NUM = 2; + private static final int NEW_BUCKET_NUM = 4; + private static final int RECORDS_PER_ROUND = 16; + + @BeforeAll + protected static void beforeAll() { + FlinkUnionReadTestBase.beforeAll(); + } + + @Test + void testUnionReadAcrossPartitionsWithDifferentBucketCountActuals() throws Exception { + String tableName = "rescale_bucket_log_table"; + TablePath tablePath = TablePath.of(DEFAULT_DB, tableName); + createPartitionedLogTable(tablePath, OLD_BUCKET_NUM); + + // "old" partition is created before the ALTER and keeps OLD_BUCKET_NUM buckets + createPartition(tablePath, "old"); + List expectedRows = new ArrayList<>(writeRows(tablePath, "old", 0)); + + // ALTER bucket.num, which also propagates the new BUCKET to the Paimon table + alterBucketNum(tablePath); + + // "new" partition is created after the ALTER and uses NEW_BUCKET_NUM buckets + createPartition(tablePath, "new"); + expectedRows.addAll(writeRows(tablePath, "new", 0)); + + Map bucketCountActualByPartition = + bucketCountActualByPartitionName(tablePath); + assertThat(bucketCountActualByPartition) + .containsEntry("old", OLD_BUCKET_NUM) + .containsEntry("new", NEW_BUCKET_NUM); + + // the ALTER must have propagated the new BUCKET to the Paimon schema + FileStoreTable paimonTable = (FileStoreTable) paimonCatalog.getTable(toPaimon(tablePath)); + assertThat(paimonTable.options().get(CoreOptions.BUCKET.key())) + .isEqualTo(String.valueOf(NEW_BUCKET_NUM)); + + // start tiering and wait until both partitions are fully synced by their own bucket range + JobClient jobClient = buildTieringJob(execEnv); + try { + long tableId = admin.getTableInfo(tablePath).get().getTableId(); + waitUntilPartitionBucketsSynced(tablePath, tableId); + + // files of each partition must be stamped with the partition's actual bucket count + assertThat(totalBucketsOfPartition(tablePath, "old")).containsExactly(OLD_BUCKET_NUM); + assertThat(totalBucketsOfPartition(tablePath, "new")).containsExactly(NEW_BUCKET_NUM); + + // union read with all data in lake + List actual = + CollectionUtil.iteratorToList( + batchTEnv.executeSql("select * from " + tableName).collect()); + assertThat(actual).containsExactlyInAnyOrderElementsOf(expectedRows); + } finally { + jobClient.cancel().get(); + } + + // write more rows after tiering stopped so union read mixes lake splits and fluss log + expectedRows.addAll(writeRows(tablePath, "old", RECORDS_PER_ROUND)); + expectedRows.addAll(writeRows(tablePath, "new", RECORDS_PER_ROUND)); + + List actual = + CollectionUtil.iteratorToList( + batchTEnv.executeSql("select * from " + tableName).collect()); + assertThat(actual).containsExactlyInAnyOrderElementsOf(expectedRows); + + // partition filter on the partition with the original bucket count + List actualOldPartition = + CollectionUtil.iteratorToList( + batchTEnv + .executeSql("select * from " + tableName + " where c = 'old'") + .collect()); + List expectedOldPartition = new ArrayList<>(); + for (Row r : expectedRows) { + if ("old".equals(r.getField(2))) { + expectedOldPartition.add(r); + } + } + assertThat(actualOldPartition).containsExactlyInAnyOrderElementsOf(expectedOldPartition); + } + + @Test + void testUnionReadPkTableAcrossPartitionsWithDifferentBucketCountActuals() throws Exception { + String tableName = "rescale_bucket_pk_table"; + TablePath tablePath = TablePath.of(DEFAULT_DB, tableName); + createPartitionedPkTable(tablePath, OLD_BUCKET_NUM); + + // "old" partition keeps OLD_BUCKET_NUM; "new" partition (post-ALTER) uses NEW_BUCKET_NUM + createPartition(tablePath, "old"); + writeUpsertRows(tablePath, "old", 0); + alterBucketNum(tablePath); + createPartition(tablePath, "new"); + writeUpsertRows(tablePath, "new", 0); + + assertThat(bucketCountActualByPartitionName(tablePath)) + .containsEntry("old", OLD_BUCKET_NUM) + .containsEntry("new", NEW_BUCKET_NUM); + + JobClient jobClient = buildTieringJob(execEnv); + try { + long tableId = admin.getTableInfo(tablePath).get().getTableId(); + waitUntilPartitionBucketsSynced(tablePath, tableId); + + assertThat(totalBucketsOfPartition(tablePath, "old")).containsExactly(OLD_BUCKET_NUM); + assertThat(totalBucketsOfPartition(tablePath, "new")).containsExactly(NEW_BUCKET_NUM); + + // lake-only read: latest value per key across both partitions, verified by content + List expectedSnapshot = new ArrayList<>(); + for (String partition : new String[] {"old", "new"}) { + for (int i = 0; i < RECORDS_PER_ROUND; i++) { + expectedSnapshot.add(Row.of(i, "v" + i, partition)); + } + } + List lakeOnly = + CollectionUtil.iteratorToList( + batchTEnv.executeSql("select * from " + tableName).collect()); + assertThat(lakeOnly).containsExactlyInAnyOrderElementsOf(expectedSnapshot); + } finally { + jobClient.cancel().get(); + } + + // update existing keys after tiering so the read must merge lake snapshot with the fluss + // log tail (dedup by primary key), on both the old and new bucket-count partitions + List updates = new ArrayList<>(); + updates.add(row(0, "old-updated", "old")); + updates.add(row(0, "new-updated", "new")); + writeRows(tablePath, updates, false); + + // full expected state after the updates: key 0 of each partition carries the new value, + // every other key keeps its tiered value + List expectedMerged = new ArrayList<>(); + expectedMerged.add(Row.of(0, "old-updated", "old")); + expectedMerged.add(Row.of(0, "new-updated", "new")); + for (String partition : new String[] {"old", "new"}) { + for (int i = 1; i < RECORDS_PER_ROUND; i++) { + expectedMerged.add(Row.of(i, "v" + i, partition)); + } + } + List merged = + CollectionUtil.iteratorToList( + batchTEnv.executeSql("select * from " + tableName).collect()); + assertThat(merged).containsExactlyInAnyOrderElementsOf(expectedMerged); + + // partition filter on the partition that kept the original bucket count + List expectedOldOnly = new ArrayList<>(); + for (Row r : expectedMerged) { + if ("old".equals(r.getField(2))) { + expectedOldOnly.add(r); + } + } + List oldOnly = + CollectionUtil.iteratorToList( + batchTEnv + .executeSql("select * from " + tableName + " where c = 'old'") + .collect()); + assertThat(oldOnly).containsExactlyInAnyOrderElementsOf(expectedOldOnly); + } + + @Test + void testStreamUnionReadAcrossPartitionsWithDifferentBucketCountActuals() throws Exception { + String tableName = "rescale_bucket_stream_log_table"; + TablePath tablePath = TablePath.of(DEFAULT_DB, tableName); + createPartitionedLogTable(tablePath, OLD_BUCKET_NUM); + + createPartition(tablePath, "old"); + List expectedRows = new ArrayList<>(writeRows(tablePath, "old", 0)); + alterBucketNum(tablePath); + createPartition(tablePath, "new"); + expectedRows.addAll(writeRows(tablePath, "new", 0)); + + assertThat(bucketCountActualByPartitionName(tablePath)) + .containsEntry("old", OLD_BUCKET_NUM) + .containsEntry("new", NEW_BUCKET_NUM); + + JobClient jobClient = buildTieringJob(execEnv); + try { + long tableId = admin.getTableInfo(tablePath).get().getTableId(); + waitUntilPartitionBucketsSynced(tablePath, tableId); + + // streaming union read: read lake snapshot then keep streaming the fluss log tail + CloseableIterator iterator = + streamTEnv + .executeSql( + "select * from " + + tableName + + " /*+ OPTIONS('scan.partition.discovery.interval'='100ms') */") + .collect(); + // append more rows to both partitions after starting the stream + expectedRows.addAll(writeRows(tablePath, "old", RECORDS_PER_ROUND)); + expectedRows.addAll(writeRows(tablePath, "new", RECORDS_PER_ROUND)); + + List actual = collectRowsWithTimeout(iterator, expectedRows.size(), true); + assertThat(actual) + .containsExactlyInAnyOrderElementsOf( + expectedRows.stream().map(Row::toString).collect(Collectors.toList())); + } finally { + jobClient.cancel().get(); + } + } + + @Test + void testStreamUnionReadPkTableAcrossPartitionsWithDifferentBucketCountActuals() + throws Exception { + String tableName = "rescale_bucket_stream_pk_table"; + TablePath tablePath = TablePath.of(DEFAULT_DB, tableName); + createPartitionedPkTable(tablePath, OLD_BUCKET_NUM); + + createPartition(tablePath, "old"); + writeUpsertRows(tablePath, "old", 0); + alterBucketNum(tablePath); + createPartition(tablePath, "new"); + writeUpsertRows(tablePath, "new", 0); + + assertThat(bucketCountActualByPartitionName(tablePath)) + .containsEntry("old", OLD_BUCKET_NUM) + .containsEntry("new", NEW_BUCKET_NUM); + + JobClient jobClient = buildTieringJob(execEnv); + try { + long tableId = admin.getTableInfo(tablePath).get().getTableId(); + waitUntilPartitionBucketsSynced(tablePath, tableId); + assertThat(totalBucketsOfPartition(tablePath, "old")).containsExactly(OLD_BUCKET_NUM); + assertThat(totalBucketsOfPartition(tablePath, "new")).containsExactly(NEW_BUCKET_NUM); + + // streaming union read on the PK table: the snapshot phase emits +I for the latest + // value of every key across both partitions, then keeps consuming the changelog tail + CloseableIterator iterator = + streamTEnv + .executeSql( + "select * from " + + tableName + + " /*+ OPTIONS('scan.partition.discovery.interval'='100ms') */") + .collect(); + + List expectedEvents = new ArrayList<>(); + for (int i = 0; i < RECORDS_PER_ROUND; i++) { + expectedEvents.add(Row.ofKind(RowKind.INSERT, i, "v" + i, "old").toString()); + expectedEvents.add(Row.ofKind(RowKind.INSERT, i, "v" + i, "new").toString()); + } + + // update one key in each partition after the stream started: the changelog must + // arrive as -U/+U from the fluss log tail on both the old (2-bucket) and the new + // (4-bucket) partition, proving the tail is subscribed by per-partition bucket range + List updates = new ArrayList<>(); + updates.add(row(0, "old-updated", "old")); + updates.add(row(0, "new-updated", "new")); + writeRows(tablePath, updates, false); + expectedEvents.add(Row.ofKind(RowKind.UPDATE_BEFORE, 0, "v0", "old").toString()); + expectedEvents.add( + Row.ofKind(RowKind.UPDATE_AFTER, 0, "old-updated", "old").toString()); + expectedEvents.add(Row.ofKind(RowKind.UPDATE_BEFORE, 0, "v0", "new").toString()); + expectedEvents.add( + Row.ofKind(RowKind.UPDATE_AFTER, 0, "new-updated", "new").toString()); + + List actual = collectRowsWithTimeout(iterator, expectedEvents.size(), true); + assertThat(actual).containsExactlyInAnyOrderElementsOf(expectedEvents); + } finally { + jobClient.cancel().get(); + } + } + + @Test + void testUnionReadLakeOnlyExpiredPartitionAfterRescale() throws Exception { + String tableName = "rescale_bucket_expired_log_table"; + TablePath tablePath = TablePath.of(DEFAULT_DB, tableName); + createPartitionedLogTable(tablePath, OLD_BUCKET_NUM); + + createPartition(tablePath, "old"); + List oldRows = writeRows(tablePath, "old", 0); + alterBucketNum(tablePath); + createPartition(tablePath, "new"); + List newRows = writeRows(tablePath, "new", 0); + + assertThat(bucketCountActualByPartitionName(tablePath)) + .containsEntry("old", OLD_BUCKET_NUM) + .containsEntry("new", NEW_BUCKET_NUM); + + // tier everything to the lake, then drop the rescaled "old" partition in Fluss so that it + // survives only in the lake (its files are stamped with OLD_BUCKET_NUM) + JobClient jobClient = buildTieringJob(execEnv); + try { + long tableId = admin.getTableInfo(tablePath).get().getTableId(); + waitUntilPartitionBucketsSynced(tablePath, tableId); + assertThat(totalBucketsOfPartition(tablePath, "old")).containsExactly(OLD_BUCKET_NUM); + } finally { + jobClient.cancel().get(); + } + + admin.dropPartition( + tablePath, new PartitionSpec(Collections.singletonMap("c", "old")), false) + .get(); + retry( + Duration.ofSeconds(60), + () -> assertThat(admin.listPartitionInfos(tablePath).get()).hasSize(1)); + + // union read: the expired "old" partition is served entirely from the lake (with its + // original bucket count), the "new" partition from Fluss+lake + List expected = new ArrayList<>(oldRows); + expected.addAll(newRows); + List actual = + CollectionUtil.iteratorToList( + batchTEnv.executeSql("select * from " + tableName).collect()); + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + + // partition filter on the lake-only expired partition still returns its data + List oldActual = + CollectionUtil.iteratorToList( + batchTEnv + .executeSql("select * from " + tableName + " where c = 'old'") + .collect()); + assertThat(oldActual).containsExactlyInAnyOrderElementsOf(oldRows); + } + + private void createPartitionedLogTable(TablePath tablePath, int bucketNum) throws Exception { + TableDescriptor descriptor = + TableDescriptor.builder() + .schema( + Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("b", DataTypes.STRING()) + .column("c", DataTypes.STRING()) + .build()) + .distributedBy(bucketNum, "a") + .partitionedBy("c") + .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(), "true") + .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS, Duration.ofMillis(500)) + .build(); + createTable(tablePath, descriptor); + } + + private void createPartitionedPkTable(TablePath tablePath, int bucketNum) throws Exception { + TableDescriptor descriptor = + TableDescriptor.builder() + .schema( + Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("b", DataTypes.STRING()) + .column("c", DataTypes.STRING()) + .primaryKey("a", "c") + .build()) + .distributedBy(bucketNum, "a") + .partitionedBy("c") + .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(), "true") + .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS, Duration.ofMillis(500)) + .build(); + createTable(tablePath, descriptor); + } + + private void createPartition(TablePath tablePath, String value) throws Exception { + admin.createPartition( + tablePath, new PartitionSpec(Collections.singletonMap("c", value)), false) + .get(); + } + + private void alterBucketNum(TablePath tablePath) throws Exception { + admin.alterTable( + tablePath, + Collections.singletonList( + TableChange.set("bucket.num", String.valueOf(NEW_BUCKET_NUM))), + false) + .get(); + } + + private void writeUpsertRows(TablePath tablePath, String partition, int keyOffset) + throws Exception { + List rows = new ArrayList<>(); + for (int i = keyOffset; i < keyOffset + RECORDS_PER_ROUND; i++) { + rows.add(row(i, "v" + i, partition)); + } + writeRows(tablePath, rows, false); + } + + private List writeRows(TablePath tablePath, String partition, int keyOffset) + throws Exception { + List rows = new ArrayList<>(); + List flinkRows = new ArrayList<>(); + for (int i = keyOffset; i < keyOffset + RECORDS_PER_ROUND; i++) { + rows.add(row(i, "v" + i, partition)); + flinkRows.add(Row.of(i, "v" + i, partition)); + } + writeRows(tablePath, rows, true); + return flinkRows; + } + + private Map bucketCountActualByPartitionName(TablePath tablePath) + throws Exception { + List partitionInfos = admin.listPartitionInfos(tablePath).get(); + Map bucketCountActualByName = new java.util.HashMap<>(); + for (PartitionInfo partitionInfo : partitionInfos) { + bucketCountActualByName.put( + partitionInfo.getPartitionName(), partitionInfo.getBucketCountActual()); + } + return bucketCountActualByName; + } + + private void waitUntilPartitionBucketsSynced(TablePath tablePath, long tableId) + throws Exception { + // empty buckets never get a tiering split (nothing to tier), so only wait for the lake + // sync marker on buckets that actually contain data + BucketOffsetsRetrieverImpl bucketOffsetsRetriever = + new BucketOffsetsRetrieverImpl(admin, tablePath); + Set tableBuckets = new HashSet<>(); + for (PartitionInfo partitionInfo : admin.listPartitionInfos(tablePath).get()) { + int bucketCountActual = partitionInfo.getBucketCountActual(); + List buckets = new ArrayList<>(); + for (int bucket = 0; bucket < bucketCountActual; bucket++) { + buckets.add(bucket); + } + Map latestOffsets = + bucketOffsetsRetriever.latestOffsets(partitionInfo.getPartitionName(), buckets); + for (int bucket = 0; bucket < bucketCountActual; bucket++) { + Long latestOffset = latestOffsets.get(bucket); + if (latestOffset != null && latestOffset > 0) { + tableBuckets.add( + new TableBucket(tableId, partitionInfo.getPartitionId(), bucket)); + } + } + } + waitUntilBucketsSynced(tableBuckets); + } + + private Set totalBucketsOfPartition(TablePath tablePath, String partition) + throws Exception { + FileStoreTable fileStoreTable = + (FileStoreTable) paimonCatalog.getTable(toPaimon(tablePath)); + List splits = + fileStoreTable + .newReadBuilder() + .withPartitionFilter(Collections.singletonMap("c", partition)) + .newScan() + .plan() + .splits(); + assertThat(splits).isNotEmpty(); + Set totalBuckets = new HashSet<>(); + for (Split split : splits) { + totalBuckets.add(((DataSplit) split).totalBuckets()); + } + return totalBuckets; + } +} diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java index 20b05d55f28..4d63b29f413 100644 --- a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java +++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java @@ -17,6 +17,7 @@ package org.apache.fluss.lake.paimon.lookup; +import org.apache.fluss.bucketing.BucketingFunction; import org.apache.fluss.client.Connection; import org.apache.fluss.client.ConnectionFactory; import org.apache.fluss.client.lookup.LookupResult; @@ -25,6 +26,7 @@ import org.apache.fluss.config.AutoPartitionTimeUnit; import org.apache.fluss.config.ConfigOptions; import org.apache.fluss.lake.paimon.testutils.FlinkPaimonTieringTestBase; +import org.apache.fluss.metadata.DataLakeFormat; import org.apache.fluss.metadata.PartitionInfo; import org.apache.fluss.metadata.PartitionSpec; import org.apache.fluss.metadata.Schema; @@ -33,11 +35,16 @@ import org.apache.fluss.metadata.TableDescriptor; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.row.InternalRow; +import org.apache.fluss.row.encode.KeyEncoder; import org.apache.fluss.server.testutils.FlussClusterExtension; import org.apache.fluss.server.zk.data.PartitionRegistration; import org.apache.fluss.types.DataTypes; +import org.apache.fluss.types.RowType; import org.apache.flink.core.execution.JobClient; +import org.apache.paimon.table.FileStoreTable; +import org.apache.paimon.table.source.DataSplit; +import org.apache.paimon.table.source.Split; import org.apache.paimon.utils.CloseableIterator; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -49,10 +56,13 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; +import static org.apache.fluss.lake.paimon.utils.PaimonConversions.toPaimon; import static org.apache.fluss.testutils.DataTestUtils.row; import static org.apache.fluss.testutils.InternalRowAssert.assertThatRow; import static org.apache.fluss.testutils.common.CommonTestUtils.retry; @@ -66,6 +76,9 @@ class HistoricalPartitionITCase extends FlinkPaimonTieringTestBase { private static final String SECOND_EXPIRED_PARTITION_NAME = "20240102"; private static final int INITIAL_PARTITION_RETENTION = 100000; private static final int EXPIRED_PARTITION_RETENTION = 1; + private static final int PRE_RESCALE_BUCKET_NUM = 2; + private static final int POST_RESCALE_BUCKET_NUM = 4; + private static final int MAX_CANDIDATE_ID = 64; @RegisterExtension public static final FlussClusterExtension FLUSS_CLUSTER_EXTENSION = @@ -315,6 +328,200 @@ void testLookupExpiredPartitionFromPaimon(boolean defaultBucketKey) throws Excep dropTable(tablePath); } + /** + * Changing bucket.num must not disable historical point lookup. The old partition keeps the + * bucket layout it was tiered with while later partitions use the new count, so the lookup has + * to be served from the bucket the lake data actually lives in. + */ + @ParameterizedTest(name = "defaultBucketKey={0}") + @ValueSource(booleans = {true, false}) + void testLookupExpiredPartitionAfterBucketNumRescale(boolean defaultBucketKey) + throws Exception { + TablePath tablePath = + TablePath.of( + DEFAULT_DB, + defaultBucketKey + ? "historical_rescale_default_bucket" + : "historical_rescale_bucket_subset"); + Schema schema = partitionedPkSchema(defaultBucketKey); + long tableId = + createTable(tablePath, partitionedPkDescriptor(schema, PRE_RESCALE_BUCKET_NUM)); + + // A key that lands in different buckets under the two layouts, so routing with the wrong + // bucket count cannot accidentally hit the right lake bucket. + int lookupId = idRoutedDifferentlyAcrossLayouts(defaultBucketKey, schema); + + admin.alterTable( + tablePath, + Collections.singletonList( + TableChange.set( + ConfigOptions.TABLE_DATALAKE_HISTORICAL_PARTITION_ENABLED + .key(), + "true")), + false) + .get(); + Optional historicalPartition = + FLUSS_CLUSTER_EXTENSION + .getZooKeeperClient() + .getPartition(tablePath, HISTORICAL_PARTITION_VALUE); + assertThat(historicalPartition).isPresent(); + FLUSS_CLUSTER_EXTENSION.waitUntilTablePartitionReady( + tableId, historicalPartition.get().getPartitionId()); + + // The old partition is created and written before the rescale, so it is laid out with + // PRE_RESCALE_BUCKET_NUM buckets. + admin.createPartition(tablePath, partitionSpec(EXPIRED_PARTITION_NAME), false).get(); + long oldPartitionId = getPartitionId(tablePath, EXPIRED_PARTITION_NAME); + FLUSS_CLUSTER_EXTENSION.waitUntilTablePartitionReady(tableId, oldPartitionId); + assertThat(bucketCountActualOf(tablePath, EXPIRED_PARTITION_NAME)) + .isEqualTo(PRE_RESCALE_BUCKET_NUM); + + InternalRow expectedOldRow = + dataRow(defaultBucketKey, lookupId, "sub-" + lookupId, "Alice"); + writeRows(tablePath, Collections.singletonList(expectedOldRow), false); + + admin.alterTable( + tablePath, + Collections.singletonList( + TableChange.set( + "bucket.num", String.valueOf(POST_RESCALE_BUCKET_NUM))), + false) + .get(); + + // A partition created after the rescale uses the new count, establishing mixed layouts. + admin.createPartition(tablePath, partitionSpec(SECOND_EXPIRED_PARTITION_NAME), false).get(); + long newPartitionId = getPartitionId(tablePath, SECOND_EXPIRED_PARTITION_NAME); + FLUSS_CLUSTER_EXTENSION.waitUntilTablePartitionReady(tableId, newPartitionId); + assertThat(bucketCountActualOf(tablePath, SECOND_EXPIRED_PARTITION_NAME)) + .isEqualTo(POST_RESCALE_BUCKET_NUM); + writeRows( + tablePath, + Collections.singletonList( + dataRow( + defaultBucketKey, + lookupId, + "sub-" + lookupId, + "Carol", + SECOND_EXPIRED_PARTITION_NAME)), + false); + + // Snapshot only the buckets the rows were actually written to; a primary-key table is + // tiered from its KV snapshots. + Set tableBuckets = new HashSet<>(); + tableBuckets.add( + new TableBucket( + tableId, + oldPartitionId, + lakeBucketOf(defaultBucketKey, schema, lookupId, PRE_RESCALE_BUCKET_NUM))); + tableBuckets.add( + new TableBucket( + tableId, + newPartitionId, + lakeBucketOf(defaultBucketKey, schema, lookupId, POST_RESCALE_BUCKET_NUM))); + FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshots(tableBuckets); + + JobClient jobClient = buildTieringJob(execEnv); + try { + // The tiered lake data of the old partition keeps the bucket count it was written with, + // not the new table-level one. + retry( + Duration.ofMinutes(2), + () -> + assertThat(totalBucketsOfPartition(tablePath, EXPIRED_PARTITION_NAME)) + .containsExactly(PRE_RESCALE_BUCKET_NUM)); + } finally { + jobClient.cancel().get(); + } + + try (Connection lookupConn = ConnectionFactory.createConnection(clientConf); + Table table = lookupConn.getTable(tablePath)) { + Lookuper lookuper = table.newLookup().createLookuper(); + + admin.alterTable( + tablePath, + Collections.singletonList( + TableChange.set( + ConfigOptions.TABLE_AUTO_PARTITION_NUM_RETENTION.key(), + String.valueOf(EXPIRED_PARTITION_RETENTION))), + false) + .get(); + waitUntilPartitionDropped(tablePath, EXPIRED_PARTITION_NAME); + + InternalRow lookupRow = + lookuper.lookup(lookupKey(defaultBucketKey, lookupId, "sub-" + lookupId)) + .get() + .getSingletonRow(); + assertThatRow(lookupRow).withSchema(schema.getRowType()).isEqualTo(expectedOldRow); + } + dropTable(tablePath); + } + + /** + * Returns an id whose bucket differs between the pre-rescale and post-rescale layouts, so a + * lookup routed with the wrong bucket count cannot accidentally read the right lake bucket. + */ + private static int idRoutedDifferentlyAcrossLayouts(boolean defaultBucketKey, Schema schema) { + for (int id = 1; id <= MAX_CANDIDATE_ID; id++) { + if (lakeBucketOf(defaultBucketKey, schema, id, PRE_RESCALE_BUCKET_NUM) + != lakeBucketOf(defaultBucketKey, schema, id, POST_RESCALE_BUCKET_NUM)) { + return id; + } + } + throw new AssertionError( + "No id within " + + MAX_CANDIDATE_ID + + " candidates is routed to different buckets by the " + + PRE_RESCALE_BUCKET_NUM + + "- and " + + POST_RESCALE_BUCKET_NUM + + "-bucket layouts, so this test could not detect routing with the wrong " + + "bucket count."); + } + + /** Computes the lake bucket of a lookup key the same way the write and lookup paths do. */ + private static int lakeBucketOf( + boolean defaultBucketKey, Schema schema, int id, int bucketNum) { + RowType lookupRowType = schema.getRowType().project(schema.getPrimaryKeyColumnNames()); + KeyEncoder bucketKeyEncoder = + KeyEncoder.ofBucketKeyEncoder( + lookupRowType, Collections.singletonList("id"), DataLakeFormat.PAIMON); + byte[] bucketKey = bucketKeyEncoder.encodeKey(lookupKey(defaultBucketKey, id, "sub-" + id)); + return BucketingFunction.of(DataLakeFormat.PAIMON).bucketing(bucketKey, bucketNum); + } + + /** Reads the bucket count a Fluss partition was created with. */ + private static int bucketCountActualOf(TablePath tablePath, String partitionName) + throws Exception { + Optional partition = + FLUSS_CLUSTER_EXTENSION.getZooKeeperClient().getPartition(tablePath, partitionName); + assertThat(partition).isPresent(); + return partition.get().getBucketCountActual(); + } + + /** Reads the bucket counts the tiered lake data of a partition was written with. */ + private static Set totalBucketsOfPartition(TablePath tablePath, String partitionName) + throws Exception { + FileStoreTable fileStoreTable = + (FileStoreTable) paimonCatalog.getTable(toPaimon(tablePath)); + List splits = + fileStoreTable + .newReadBuilder() + .withPartitionFilter(Collections.singletonMap("dt", partitionName)) + .newScan() + .plan() + .splits(); + assertThat(splits) + .withFailMessage( + "No lake splits for partition %s; all lake splits: %s", + partitionName, fileStoreTable.newReadBuilder().newScan().plan().splits()) + .isNotEmpty(); + Set totalBuckets = new HashSet<>(); + for (Split split : splits) { + totalBuckets.add(((DataSplit) split).totalBuckets()); + } + return totalBuckets; + } + @Override protected FlussClusterExtension getFlussClusterExtension() { return FLUSS_CLUSTER_EXTENSION; @@ -468,6 +675,26 @@ private static TableDescriptor partitionedDescriptor( return builder.build(); } + /** Same as {@link #partitionedDescriptor} but with an explicit table-level bucket count. */ + private static TableDescriptor partitionedPkDescriptor(Schema schema, int bucketNum) { + return TableDescriptor.builder() + .schema(schema) + // This is the default bucket key for (id, dt), and a strict subset of the physical + // primary key for (id, sub_id, dt). + .distributedBy(bucketNum, "id") + .partitionedBy("dt") + .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, true) + .property(ConfigOptions.TABLE_AUTO_PARTITION_KEY, "dt") + .property(ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT, AutoPartitionTimeUnit.DAY) + .property( + ConfigOptions.TABLE_AUTO_PARTITION_NUM_RETENTION, + INITIAL_PARTITION_RETENTION) + .property(ConfigOptions.TABLE_AUTO_PARTITION_TIMEZONE, "UTC") + .property(ConfigOptions.TABLE_DATALAKE_ENABLED, true) + .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS, Duration.ofMillis(500)) + .build(); + } + private static InternalRow dataRow( boolean defaultBucketKey, int id, String subId, String name) { return dataRow(defaultBucketKey, id, subId, name, EXPIRED_PARTITION_NAME); diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/tiering/PaimonTieringTest.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/tiering/PaimonTieringTest.java index 19c59600f92..a03c1783a0c 100644 --- a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/tiering/PaimonTieringTest.java +++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/tiering/PaimonTieringTest.java @@ -75,9 +75,11 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; import static org.apache.fluss.lake.committer.LakeCommitter.FLUSS_LAKE_SNAP_BUCKET_OFFSET_PROPERTY; @@ -478,6 +480,125 @@ void testThreePartitionTiering() throws Exception { } } + @Test + void testTieringStampsPartitionBucketCountActualAcrossRounds() throws Exception { + // After ALTER bucket.num=8: files tiered for the "old" partition are stamped with its + // actual count 4 (writer override) while the "new" partition inherits the schema value 8; + // a second tiering round passes Paimon's native bucket-count check (historical 4 == + // writer 4), and all rows of both partitions stay readable via bucket-aware reads. + int schemaBucketCount = 8; + int oldPartitionBucketCount = 4; + int recordsPerBucketPerRound = 2; + TablePath tablePath = TablePath.of("paimon", "test_partition_bucket_count_stamp"); + createTable( + tablePath, + false, + true, + schemaBucketCount, + Collections.singletonMap(CoreOptions.BUCKET_KEY.key(), "c1")); + + TableDescriptor descriptor = + TableDescriptor.builder() + .schema( + org.apache.fluss.metadata.Schema.newBuilder() + .column("c1", org.apache.fluss.types.DataTypes.INT()) + .column("c2", org.apache.fluss.types.DataTypes.STRING()) + .column("c3", org.apache.fluss.types.DataTypes.STRING()) + .build()) + .partitionedBy("c3") + .distributedBy(schemaBucketCount, "c1") + .property(ConfigOptions.TABLE_DATALAKE_ENABLED, true) + .build(); + TableInfo tableInfo = + TableInfo.of(tablePath, 0, 1, descriptor, DEFAULT_REMOTE_DATA_DIR, 1L, 1L); + + // two independent tiering rounds against the SAME partitions; each round creates fresh + // writers and its own committer, exactly as TieringCommitOperator does + for (int round = 0; round < 2; round++) { + List paimonWriteResults = new ArrayList<>(); + // "old" partition: created before the ALTER, still routes by its original bucket count + for (int bucket = 0; bucket < oldPartitionBucketCount; bucket++) { + try (LakeWriter lakeWriter = + createLakeWriter( + tablePath, bucket, "old", 1L, tableInfo, oldPartitionBucketCount)) { + for (LogRecord logRecord : + genLogTableRecords("old", bucket, recordsPerBucketPerRound).f0) { + lakeWriter.write(logRecord); + } + paimonWriteResults.add(lakeWriter.complete()); + } + } + // "new" partition: created after the ALTER, routes by the schema bucket count + for (int bucket = 0; bucket < schemaBucketCount; bucket++) { + try (LakeWriter lakeWriter = + createLakeWriter(tablePath, bucket, "new", 2L, tableInfo, null)) { + for (LogRecord logRecord : + genLogTableRecords("new", bucket, recordsPerBucketPerRound).f0) { + lakeWriter.write(logRecord); + } + paimonWriteResults.add(lakeWriter.complete()); + } + } + try (LakeCommitter lakeCommitter = + createLakeCommitter(tablePath, tableInfo, new Configuration())) { + PaimonCommittable committable = lakeCommitter.toCommittable(paimonWriteResults); + lakeCommitter.commit(committable, Collections.emptyMap()); + } + } + + // files of BOTH rounds carry each partition's actual bucket count + assertThat(totalBucketsOfPartition(tablePath, "old")) + .containsExactly(oldPartitionBucketCount); + assertThat(totalBucketsOfPartition(tablePath, "new")).containsExactly(schemaBucketCount); + + // both partitions must stay fully readable through the bucket-aware read path, each + // holding exactly its own (rounds * records * bucketCount) rows + assertThat(rowCountOfPartition(tablePath, "old")) + .isEqualTo(2 * recordsPerBucketPerRound * oldPartitionBucketCount); + assertThat(rowCountOfPartition(tablePath, "new")) + .isEqualTo(2 * recordsPerBucketPerRound * schemaBucketCount); + } + + private int rowCountOfPartition(TablePath tablePath, String partition) throws Exception { + FileStoreTable fileStoreTable = + (FileStoreTable) paimonCatalog.getTable(toPaimon(tablePath)); + ReadBuilder readBuilder = + fileStoreTable + .newReadBuilder() + .withPartitionFilter(Collections.singletonMap("c3", partition)); + int rowCount = 0; + try (CloseableIterator iterator = + readBuilder + .newRead() + .createReader(readBuilder.newScan().plan().splits()) + .toCloseableIterator()) { + while (iterator.hasNext()) { + iterator.next(); + rowCount++; + } + } + return rowCount; + } + + private Set totalBucketsOfPartition(TablePath tablePath, String partition) + throws Exception { + FileStoreTable fileStoreTable = + (FileStoreTable) paimonCatalog.getTable(toPaimon(tablePath)); + List splits = + fileStoreTable + .newReadBuilder() + .withPartitionFilter(Collections.singletonMap("c3", partition)) + .newScan() + .plan() + .splits(); + assertThat(splits).isNotEmpty(); + Set totalBuckets = new HashSet<>(); + for (Split split : splits) { + totalBuckets.add(((DataSplit) split).totalBuckets()); + } + return totalBuckets; + } + @ParameterizedTest @MethodSource("snapshotExpireArgs") void testSnapshotExpiration( @@ -929,6 +1050,17 @@ private LakeWriter createLakeWriter( @Nullable Long partitionId, TableInfo tableInfo) throws IOException { + return createLakeWriter(tablePath, bucket, partition, partitionId, tableInfo, null); + } + + private LakeWriter createLakeWriter( + TablePath tablePath, + int bucket, + @Nullable String partition, + @Nullable Long partitionId, + TableInfo tableInfo, + @Nullable Integer partitionBucketCount) + throws IOException { return paimonLakeTieringFactory.createLakeWriter( new WriterInitContext() { @Override @@ -952,6 +1084,13 @@ public String partition() { public TableInfo tableInfo() { return tableInfo; } + + @Override + public int bucketCountActual() { + return partitionBucketCount != null + ? partitionBucketCount + : tableInfo.getNumBuckets(); + } }); } diff --git a/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/Errors.java b/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/Errors.java index c7d074ed113..45fe5301baa 100644 --- a/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/Errors.java +++ b/fluss-rpc/src/main/java/org/apache/fluss/rpc/protocol/Errors.java @@ -78,6 +78,7 @@ import org.apache.fluss.exception.ServerNotExistException; import org.apache.fluss.exception.ServerTagAlreadyExistException; import org.apache.fluss.exception.ServerTagNotExistException; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.exception.StorageBackpressureException; import org.apache.fluss.exception.StorageException; import org.apache.fluss.exception.TableAlreadyExistException; @@ -285,7 +286,12 @@ public enum Errors { HISTORICAL_PARTITION_THROTTLED( 73, "Historical partition request is throttled because too many historical requests are in flight.", - HistoricalPartitionThrottledException::new); + HistoricalPartitionThrottledException::new), + STALE_METADATA( + 74, + "The bucket count in the request does not match the server's actual bucket count. The " + + "client should refresh metadata and retry.", + StaleMetadataException::new); private static final Logger LOG = LoggerFactory.getLogger(Errors.class); diff --git a/fluss-rpc/src/main/proto/FlussApi.proto b/fluss-rpc/src/main/proto/FlussApi.proto index e0fd955d568..9254885f653 100644 --- a/fluss-rpc/src/main/proto/FlussApi.proto +++ b/fluss-rpc/src/main/proto/FlussApi.proto @@ -157,6 +157,7 @@ message GetTableInfoResponse { required int64 created_time = 4; required int64 modified_time = 5; optional string remote_data_dir = 6; + optional int64 bucket_layout_epoch = 7; } // list tables request and response @@ -295,6 +296,7 @@ message LimitScanRequest { optional int64 partition_id = 3; required int32 bucket_id = 4; required int32 limit = 5; + optional int32 routing_bucket_count = 6; } message LimitScanResponse{ @@ -315,6 +317,7 @@ message PbScanReqForBucket { required int32 bucket_id = 3; // If set, stops returning rows after this many records. optional int64 limit = 4; + optional int32 routing_bucket_count = 5; } message ScanKvRequest { @@ -399,6 +402,7 @@ message ListOffsetsRequest { optional int64 partition_id = 4; repeated int32 bucket_id = 5 [packed = true]; // it is recommended to use packed for repeated numerics to get more efficient encoding optional int64 startTimestamp = 6; + optional int32 routing_bucket_count = 7; } message ListOffsetsResponse { repeated PbListOffsetsRespForBucket buckets_resp = 1; @@ -860,6 +864,8 @@ message PbTableMetadata { required int64 created_time = 6; required int64 modified_time = 7; optional string remote_data_dir = 8; + // A table-level, monotonically increasing version for bucket.num changes. + optional int64 bucket_layout_epoch = 9; // TODO add a new filed 'deleted_table' to indicate this table is deleted in UpdateMetadataRequest. // trace by: https://github.com/apache/fluss/issues/981 @@ -871,6 +877,8 @@ message PbPartitionMetadata { required string partition_name = 2; required int64 partition_id = 3; repeated PbBucketMetadata bucket_metadata = 4; + // the actual bucket count for this partition, used for per-partition bucket rescale + optional int32 bucket_count_actual = 5; } message PbBucketMetadata { @@ -891,6 +899,7 @@ message PbProduceLogReqForBucket { required bytes records = 3; // The original partition name for a historical write; unset for a normal write. optional string original_partition_name = 4; + optional int32 routing_bucket_count = 5; } message PbProduceLogRespForBucket { @@ -921,6 +930,7 @@ message PbFetchLogReqForBucket { // TODO leader epoch required int64 fetch_offset = 3; required int32 max_fetch_bytes = 4; + optional int32 routing_bucket_count = 5; } message PbFetchLogRespForTable { @@ -951,6 +961,9 @@ message PbPutKvReqForBucket { required bytes records = 3; // The original partition name for historical PK writes. It is unset for normal writes. optional string original_partition_name = 4; + // the bucket count the sender used to form bucket_id; the server compares it against the + // actual count to detect stale routing. Not authoritative metadata. + optional int32 routing_bucket_count = 5; } message PbPutKvRespForBucket { @@ -975,6 +988,7 @@ message PbLookupReqForBucket { repeated bytes keys = 3; // The original partition name for historical lookup. It is unset for normal lookup. optional string original_partition_name = 4; + optional int32 routing_bucket_count = 5; } message PbLookupRespForBucket { @@ -1000,6 +1014,7 @@ message PbPrefixLookupReqForBucket { optional int64 partition_id = 1; required int32 bucket_id = 2; repeated bytes keys = 3; + optional int32 routing_bucket_count = 4; } message PbPrefixLookupRespForBucket { @@ -1069,6 +1084,8 @@ message PbNotifyLeaderAndIsrReqForBucket { repeated int32 isr = 6 [packed = true]; required int32 bucket_epoch = 7; repeated int32 standby_replicas = 8 [packed = true]; + optional int32 bucket_count_actual = 9; + optional int64 bucket_layout_epoch = 10; } message PbNotifyLeaderAndIsrRespForBucket { @@ -1139,6 +1156,8 @@ message PbPartitionInfo { required int64 partition_id = 1; required PbPartitionSpec partition_spec = 2; optional string remote_data_dir = 3; + // the actual bucket count for this partition, used for per-partition bucket rescale + optional int32 bucket_count_actual = 4; } message PbPartitionSpec { @@ -1349,6 +1368,7 @@ message PbKvSnapshotLeaseForBucket { message PbTableStatsReqForBucket { optional int64 partition_id = 1; required int32 bucket_id = 2; + optional int32 routing_bucket_count = 3; } message PbTableStatsRespForBucket { diff --git a/fluss-rust/bindings/python/test/conftest.py b/fluss-rust/bindings/python/test/conftest.py index 22de4d43e35..8ad2b649933 100644 --- a/fluss-rust/bindings/python/test/conftest.py +++ b/fluss-rust/bindings/python/test/conftest.py @@ -175,18 +175,10 @@ async def _wait(table_path, timeout=15, partition_name=None): table_path, [0], fluss.OffsetSpec.earliest() ) return - except (fluss.FlussError, Exception) as e: - # Catch "No leader found" or other errors that indicate the table/partition is still initializing - err_msg = str(e) - if any( - msg in err_msg - for msg in [ - "No leader found", - "Table not ready", - "Metadata not ready", - "not leader or follower", - ] - ): + except fluss.FlussError as e: + # Retriable means the table or partition is still initializing. A missing leader + # is a client-side error, which is never flagged retriable, so match it too. + if e.is_retriable or "No leader found" in str(e): await asyncio.sleep(1) continue raise diff --git a/fluss-rust/crates/fluss/proto/FlussApi.proto b/fluss-rust/crates/fluss/proto/FlussApi.proto index e0fd955d568..9254885f653 100644 --- a/fluss-rust/crates/fluss/proto/FlussApi.proto +++ b/fluss-rust/crates/fluss/proto/FlussApi.proto @@ -157,6 +157,7 @@ message GetTableInfoResponse { required int64 created_time = 4; required int64 modified_time = 5; optional string remote_data_dir = 6; + optional int64 bucket_layout_epoch = 7; } // list tables request and response @@ -295,6 +296,7 @@ message LimitScanRequest { optional int64 partition_id = 3; required int32 bucket_id = 4; required int32 limit = 5; + optional int32 routing_bucket_count = 6; } message LimitScanResponse{ @@ -315,6 +317,7 @@ message PbScanReqForBucket { required int32 bucket_id = 3; // If set, stops returning rows after this many records. optional int64 limit = 4; + optional int32 routing_bucket_count = 5; } message ScanKvRequest { @@ -399,6 +402,7 @@ message ListOffsetsRequest { optional int64 partition_id = 4; repeated int32 bucket_id = 5 [packed = true]; // it is recommended to use packed for repeated numerics to get more efficient encoding optional int64 startTimestamp = 6; + optional int32 routing_bucket_count = 7; } message ListOffsetsResponse { repeated PbListOffsetsRespForBucket buckets_resp = 1; @@ -860,6 +864,8 @@ message PbTableMetadata { required int64 created_time = 6; required int64 modified_time = 7; optional string remote_data_dir = 8; + // A table-level, monotonically increasing version for bucket.num changes. + optional int64 bucket_layout_epoch = 9; // TODO add a new filed 'deleted_table' to indicate this table is deleted in UpdateMetadataRequest. // trace by: https://github.com/apache/fluss/issues/981 @@ -871,6 +877,8 @@ message PbPartitionMetadata { required string partition_name = 2; required int64 partition_id = 3; repeated PbBucketMetadata bucket_metadata = 4; + // the actual bucket count for this partition, used for per-partition bucket rescale + optional int32 bucket_count_actual = 5; } message PbBucketMetadata { @@ -891,6 +899,7 @@ message PbProduceLogReqForBucket { required bytes records = 3; // The original partition name for a historical write; unset for a normal write. optional string original_partition_name = 4; + optional int32 routing_bucket_count = 5; } message PbProduceLogRespForBucket { @@ -921,6 +930,7 @@ message PbFetchLogReqForBucket { // TODO leader epoch required int64 fetch_offset = 3; required int32 max_fetch_bytes = 4; + optional int32 routing_bucket_count = 5; } message PbFetchLogRespForTable { @@ -951,6 +961,9 @@ message PbPutKvReqForBucket { required bytes records = 3; // The original partition name for historical PK writes. It is unset for normal writes. optional string original_partition_name = 4; + // the bucket count the sender used to form bucket_id; the server compares it against the + // actual count to detect stale routing. Not authoritative metadata. + optional int32 routing_bucket_count = 5; } message PbPutKvRespForBucket { @@ -975,6 +988,7 @@ message PbLookupReqForBucket { repeated bytes keys = 3; // The original partition name for historical lookup. It is unset for normal lookup. optional string original_partition_name = 4; + optional int32 routing_bucket_count = 5; } message PbLookupRespForBucket { @@ -1000,6 +1014,7 @@ message PbPrefixLookupReqForBucket { optional int64 partition_id = 1; required int32 bucket_id = 2; repeated bytes keys = 3; + optional int32 routing_bucket_count = 4; } message PbPrefixLookupRespForBucket { @@ -1069,6 +1084,8 @@ message PbNotifyLeaderAndIsrReqForBucket { repeated int32 isr = 6 [packed = true]; required int32 bucket_epoch = 7; repeated int32 standby_replicas = 8 [packed = true]; + optional int32 bucket_count_actual = 9; + optional int64 bucket_layout_epoch = 10; } message PbNotifyLeaderAndIsrRespForBucket { @@ -1139,6 +1156,8 @@ message PbPartitionInfo { required int64 partition_id = 1; required PbPartitionSpec partition_spec = 2; optional string remote_data_dir = 3; + // the actual bucket count for this partition, used for per-partition bucket rescale + optional int32 bucket_count_actual = 4; } message PbPartitionSpec { @@ -1349,6 +1368,7 @@ message PbKvSnapshotLeaseForBucket { message PbTableStatsReqForBucket { optional int64 partition_id = 1; required int32 bucket_id = 2; + optional int32 routing_bucket_count = 3; } message PbTableStatsRespForBucket { diff --git a/fluss-rust/crates/fluss/src/client/table/scanner.rs b/fluss-rust/crates/fluss/src/client/table/scanner.rs index 14c587e57d9..a8720b5ef79 100644 --- a/fluss-rust/crates/fluss/src/client/table/scanner.rs +++ b/fluss-rust/crates/fluss/src/client/table/scanner.rs @@ -2357,6 +2357,7 @@ impl LogFetcher { bucket_id: bucket.bucket_id(), fetch_offset: offset, max_fetch_bytes: self.fetch_max_bytes_for_bucket, + routing_bucket_count: None, }; fetch_log_req_for_buckets diff --git a/fluss-rust/crates/fluss/src/client/write/sender.rs b/fluss-rust/crates/fluss/src/client/write/sender.rs index 25233df7088..4210dd915af 100644 --- a/fluss-rust/crates/fluss/src/client/write/sender.rs +++ b/fluss-rust/crates/fluss/src/client/write/sender.rs @@ -2105,6 +2105,7 @@ mod tests { created_time: 0, modified_time: 0, remote_data_dir: None, + bucket_layout_epoch: None, } .encode(&mut body) .expect("encode GetTableInfoResponse"); diff --git a/fluss-rust/crates/fluss/src/metadata/partition.rs b/fluss-rust/crates/fluss/src/metadata/partition.rs index c63fe296c5c..7626305def2 100644 --- a/fluss-rust/crates/fluss/src/metadata/partition.rs +++ b/fluss-rust/crates/fluss/src/metadata/partition.rs @@ -301,6 +301,7 @@ impl PartitionInfo { partition_id: self.partition_id, partition_spec: self.partition_spec.to_pb(), remote_data_dir: None, + bucket_count_actual: None, } } diff --git a/fluss-rust/crates/fluss/src/metadata/table_stats.rs b/fluss-rust/crates/fluss/src/metadata/table_stats.rs index 53c6f72f35f..f459f7c2221 100644 --- a/fluss-rust/crates/fluss/src/metadata/table_stats.rs +++ b/fluss-rust/crates/fluss/src/metadata/table_stats.rs @@ -38,6 +38,7 @@ impl BucketStatsRequest { PbTableStatsReqForBucket { partition_id: self.partition_id, bucket_id: self.bucket_id, + routing_bucket_count: None, } } diff --git a/fluss-rust/crates/fluss/src/proto/fluss.rs b/fluss-rust/crates/fluss/src/proto/fluss.rs index 12295277351..7c546cf409c 100644 --- a/fluss-rust/crates/fluss/src/proto/fluss.rs +++ b/fluss-rust/crates/fluss/src/proto/fluss.rs @@ -185,6 +185,8 @@ pub struct GetTableInfoResponse { pub modified_time: i64, #[prost(string, optional, tag = "6")] pub remote_data_dir: ::core::option::Option<::prost::alloc::string::String>, + #[prost(int64, optional, tag = "7")] + pub bucket_layout_epoch: ::core::option::Option, } /// list tables request and response #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -365,6 +367,8 @@ pub struct LimitScanRequest { pub bucket_id: i32, #[prost(int32, required, tag = "5")] pub limit: i32, + #[prost(int32, optional, tag = "6")] + pub routing_bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct LimitScanResponse { @@ -392,6 +396,8 @@ pub struct PbScanReqForBucket { /// If set, stops returning rows after this many records. #[prost(int64, optional, tag = "4")] pub limit: ::core::option::Option, + #[prost(int32, optional, tag = "5")] + pub routing_bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct ScanKvRequest { @@ -512,6 +518,8 @@ pub struct ListOffsetsRequest { pub bucket_id: ::prost::alloc::vec::Vec, #[prost(int64, optional, tag = "6")] pub start_timestamp: ::core::option::Option, + #[prost(int32, optional, tag = "7")] + pub routing_bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct ListOffsetsResponse { @@ -1131,6 +1139,9 @@ pub struct PbTableMetadata { pub modified_time: i64, #[prost(string, optional, tag = "8")] pub remote_data_dir: ::core::option::Option<::prost::alloc::string::String>, + /// A table-level, monotonically increasing version for bucket.num changes. + #[prost(int64, optional, tag = "9")] + pub bucket_layout_epoch: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PbPartitionMetadata { @@ -1143,6 +1154,9 @@ pub struct PbPartitionMetadata { pub partition_id: i64, #[prost(message, repeated, tag = "4")] pub bucket_metadata: ::prost::alloc::vec::Vec, + /// the actual bucket count for this partition, used for per-partition bucket rescale + #[prost(int32, optional, tag = "5")] + pub bucket_count_actual: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct PbBucketMetadata { @@ -1173,6 +1187,8 @@ pub struct PbProduceLogReqForBucket { /// The original partition name for a historical write; unset for a normal write. #[prost(string, optional, tag = "4")] pub original_partition_name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(int32, optional, tag = "5")] + pub routing_bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct PbProduceLogRespForBucket { @@ -1219,6 +1235,8 @@ pub struct PbFetchLogReqForBucket { pub fetch_offset: i64, #[prost(int32, required, tag = "4")] pub max_fetch_bytes: i32, + #[prost(int32, optional, tag = "5")] + pub routing_bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PbFetchLogRespForTable { @@ -1267,6 +1285,10 @@ pub struct PbPutKvReqForBucket { /// The original partition name for historical PK writes. It is unset for normal writes. #[prost(string, optional, tag = "4")] pub original_partition_name: ::core::option::Option<::prost::alloc::string::String>, + /// the bucket count the sender used to form bucket_id; the server compares it against the + /// actual count to detect stale routing. Not authoritative metadata. + #[prost(int32, optional, tag = "5")] + pub routing_bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PbPutKvRespForBucket { @@ -1302,6 +1324,8 @@ pub struct PbLookupReqForBucket { /// The original partition name for historical lookup. It is unset for normal lookup. #[prost(string, optional, tag = "4")] pub original_partition_name: ::core::option::Option<::prost::alloc::string::String>, + #[prost(int32, optional, tag = "5")] + pub routing_bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PbLookupRespForBucket { @@ -1338,6 +1362,8 @@ pub struct PbPrefixLookupReqForBucket { pub bucket_id: i32, #[prost(bytes = "bytes", repeated, tag = "3")] pub keys: ::prost::alloc::vec::Vec<::prost::bytes::Bytes>, + #[prost(int32, optional, tag = "4")] + pub routing_bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PbPrefixLookupRespForBucket { @@ -1447,6 +1473,10 @@ pub struct PbNotifyLeaderAndIsrReqForBucket { pub bucket_epoch: i32, #[prost(int32, repeated, tag = "8")] pub standby_replicas: ::prost::alloc::vec::Vec, + #[prost(int32, optional, tag = "9")] + pub bucket_count_actual: ::core::option::Option, + #[prost(int64, optional, tag = "10")] + pub bucket_layout_epoch: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct PbNotifyLeaderAndIsrRespForBucket { @@ -1550,6 +1580,9 @@ pub struct PbPartitionInfo { pub partition_spec: PbPartitionSpec, #[prost(string, optional, tag = "3")] pub remote_data_dir: ::core::option::Option<::prost::alloc::string::String>, + /// the actual bucket count for this partition, used for per-partition bucket rescale + #[prost(int32, optional, tag = "4")] + pub bucket_count_actual: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PbPartitionSpec { @@ -1865,6 +1898,8 @@ pub struct PbTableStatsReqForBucket { pub partition_id: ::core::option::Option, #[prost(int32, required, tag = "2")] pub bucket_id: i32, + #[prost(int32, optional, tag = "3")] + pub routing_bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct PbTableStatsRespForBucket { diff --git a/fluss-rust/crates/fluss/src/rpc/message/limit_scan.rs b/fluss-rust/crates/fluss/src/rpc/message/limit_scan.rs index 9dfa408eef7..9528eff31c2 100644 --- a/fluss-rust/crates/fluss/src/rpc/message/limit_scan.rs +++ b/fluss-rust/crates/fluss/src/rpc/message/limit_scan.rs @@ -42,6 +42,7 @@ impl LimitScanRequest { partition_id, bucket_id, limit, + routing_bucket_count: None, }; Self { diff --git a/fluss-rust/crates/fluss/src/rpc/message/list_offsets.rs b/fluss-rust/crates/fluss/src/rpc/message/list_offsets.rs index a690b82efe9..9d71d180d24 100644 --- a/fluss-rust/crates/fluss/src/rpc/message/list_offsets.rs +++ b/fluss-rust/crates/fluss/src/rpc/message/list_offsets.rs @@ -86,6 +86,7 @@ impl ListOffsetsRequest { partition_id, bucket_id: bucket_ids, start_timestamp: offset_spec.start_timestamp(), + routing_bucket_count: None, }, } } diff --git a/fluss-rust/crates/fluss/src/rpc/message/lookup.rs b/fluss-rust/crates/fluss/src/rpc/message/lookup.rs index 6a31d3c6fca..26f9e0370ce 100644 --- a/fluss-rust/crates/fluss/src/rpc/message/lookup.rs +++ b/fluss-rust/crates/fluss/src/rpc/message/lookup.rs @@ -44,6 +44,7 @@ impl LookupRequest { bucket_id, keys, original_partition_name: None, + routing_bucket_count: None, }, ) .collect(); diff --git a/fluss-rust/crates/fluss/src/rpc/message/prefix_lookup.rs b/fluss-rust/crates/fluss/src/rpc/message/prefix_lookup.rs index afafbbf71e9..befe9bbcac2 100644 --- a/fluss-rust/crates/fluss/src/rpc/message/prefix_lookup.rs +++ b/fluss-rust/crates/fluss/src/rpc/message/prefix_lookup.rs @@ -43,6 +43,7 @@ impl PrefixLookupRequest { partition_id, bucket_id, keys, + routing_bucket_count: None, }, ) .collect(); diff --git a/fluss-rust/crates/fluss/src/rpc/message/produce_log.rs b/fluss-rust/crates/fluss/src/rpc/message/produce_log.rs index 041e0adde01..eb31682c504 100644 --- a/fluss-rust/crates/fluss/src/rpc/message/produce_log.rs +++ b/fluss-rust/crates/fluss/src/rpc/message/produce_log.rs @@ -50,6 +50,7 @@ impl ProduceLogRequest { bucket_id: ready_batch.table_bucket.bucket_id(), records: ready_batch.write_batch.build()?, original_partition_name: None, + routing_bucket_count: None, }) } diff --git a/fluss-rust/crates/fluss/src/rpc/message/put_kv.rs b/fluss-rust/crates/fluss/src/rpc/message/put_kv.rs index c6ce9eb5cb4..5dcdaa7b7b3 100644 --- a/fluss-rust/crates/fluss/src/rpc/message/put_kv.rs +++ b/fluss-rust/crates/fluss/src/rpc/message/put_kv.rs @@ -52,6 +52,7 @@ impl PutKvRequest { bucket_id: ready_batch.table_bucket.bucket_id(), records: ready_batch.write_batch.build()?, original_partition_name: None, + routing_bucket_count: None, }) } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java b/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java index e2ef1094ee1..bbce2eb550b 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java @@ -319,7 +319,8 @@ public CompletableFuture getTableInfo(GetTableInfoRequest .setTableId(tableInfo.getTableId()) .setRemoteDataDir(tableInfo.getRemoteDataDir()) .setCreatedTime(tableInfo.getCreatedTime()) - .setModifiedTime(tableInfo.getModifiedTime()); + .setModifiedTime(tableInfo.getModifiedTime()) + .setBucketLayoutEpoch(tableInfo.getBucketLayoutEpoch()); return CompletableFuture.completedFuture(response); } @@ -391,8 +392,15 @@ public CompletableFuture getLatestKvSnapshots( // get table id long tableId = tableInfo.getTableId(); int numBuckets = tableInfo.getNumBuckets(); - Long partitionId = - hasPartitionName ? getPartitionId(tablePath, request.getPartitionName()) : null; + Long partitionId = null; + if (hasPartitionName) { + PartitionRegistration partition = + getPartition(tablePath, request.getPartitionName()); + partitionId = partition.getPartitionId(); + numBuckets = + partition.getBucketCountActualOrDefault( + numBuckets, tableInfo.getBucketLayoutEpoch()); + } Map> snapshots; if (partitionId != null) { snapshots = zkClient.getPartitionLatestBucketSnapshot(partitionId); @@ -406,7 +414,7 @@ public CompletableFuture getLatestKvSnapshots( } } - private long getPartitionId(TablePath tablePath, String partitionName) { + private PartitionRegistration getPartition(TablePath tablePath, String partitionName) { Optional optPartitionRegistration; try { optPartitionRegistration = zkClient.getPartition(tablePath, partitionName); @@ -421,7 +429,7 @@ private long getPartitionId(TablePath tablePath, String partitionName) { "The partition '%s' of table '%s' does not exist.", partitionName, tablePath)); } - return optPartitionRegistration.get().getPartitionId(); + return optPartitionRegistration.get(); } @Override @@ -495,6 +503,12 @@ public CompletableFuture listPartitionInfos( TablePath tablePath = toTablePath(request.getTablePath()); authorizeTable(OperationType.DESCRIBE, tablePath); + // Read table metadata before reading partitions. This prevents a read spanning ALTER from + // combining a pre-ALTER PartitionRegistration (without bucketCountActual) with a post-ALTER + // TableInfo. + TableInfo tableInfo = metadataManager.getTable(tablePath); + List partitionKeys = tableInfo.getPartitionKeys(); + Map partitionRegistrations; if (request.hasPartialPartitionSpec()) { ResolvedPartitionSpec partitionSpecFromRequest = @@ -506,10 +520,12 @@ public CompletableFuture listPartitionInfos( } // TODO: Return the actual lake partitions instead of the internal historical partition. partitionRegistrations.remove(HISTORICAL_PARTITION_VALUE); - TableInfo tableInfo = metadataManager.getTable(tablePath); - List partitionKeys = tableInfo.getPartitionKeys(); return CompletableFuture.completedFuture( - toListPartitionInfosResponse(partitionKeys, partitionRegistrations)); + toListPartitionInfosResponse( + partitionKeys, + partitionRegistrations, + tableInfo.getNumBuckets(), + tableInfo.getBucketLayoutEpoch())); } @Override diff --git a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/AutoPartitionManager.java b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/AutoPartitionManager.java index c2f3ce32c45..456fd83b9dc 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/AutoPartitionManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/AutoPartitionManager.java @@ -256,12 +256,14 @@ void createHistoricalPartition(TableInfo tableInfo) { partitionsByTable.get(tableId), "Auto partition state does not exist for table " + tableId); if (!currentPartitions.containsKey(HISTORICAL_PARTITION_VALUE)) { + TablePath tablePath = tableInfo.getTablePath(); createPartition( tableInfo, new ResolvedPartitionSpec( tableInfo.getPartitionKeys(), Collections.singletonList(HISTORICAL_PARTITION_VALUE)), - currentPartitions); + currentPartitions, + metadataManager.getTableRegistration(tablePath).bucketCount); } }); } @@ -278,9 +280,10 @@ void dropHistoricalPartition(TableInfo tableInfo) { && !currentPartitions.containsKey(HISTORICAL_PARTITION_VALUE)) { return; } + TablePath tablePath = tableInfo.getTablePath(); try { metadataManager.dropPartition( - tableInfo.getTablePath(), + tablePath, new ResolvedPartitionSpec( tableInfo.getPartitionKeys(), Collections.singletonList(HISTORICAL_PARTITION_VALUE)), @@ -288,13 +291,11 @@ void dropHistoricalPartition(TableInfo tableInfo) { if (currentPartitions != null) { currentPartitions.remove(HISTORICAL_PARTITION_VALUE); } - LOG.info( - "Deleted historical partition for table [{}].", - tableInfo.getTablePath()); + LOG.info("Deleted historical partition for table [{}].", tablePath); } catch (Exception e) { LOG.warn( - "Failed to delete historical partition for table [{}].", - tableInfo.getTablePath(), + "Failed to delete historical partition for table [{}] .", + tablePath, e); } }); @@ -482,32 +483,52 @@ private void createPartitions( return; } + TablePath tablePath = tableInfo.getTablePath(); + + // Read the table-level bucket count fresh from ZK, not from the possibly-stale TableInfo + int bucketCount; + try { + bucketCount = metadataManager.getTableRegistration(tablePath).bucketCount; + } catch (Exception e) { + LOG.warn( + "Skipping auto partitioning for table [{}] as failed to read the " + + "table-level bucket count.", + tablePath, + e); + return; + } for (ResolvedPartitionSpec partition : partitionsToPreCreate) { - createPartition(tableInfo, partition, currentPartitions); + createPartition(tableInfo, partition, currentPartitions, bucketCount); } } private void createPartition( TableInfo tableInfo, ResolvedPartitionSpec partition, - TreeMap> currentPartitions) { + TreeMap> currentPartitions, + int bucketCount) { TablePath tablePath = tableInfo.getTablePath(); long tableId = tableInfo.getTableId(); int replicaFactor = tableInfo.getTableConfig().getReplicationFactor(); TabletServerInfo[] servers = metadataCache.getLiveServers(); - long newKvLeaderReplicaCount = tableInfo.hasPrimaryKey() ? tableInfo.getNumBuckets() : 0; + long newKvLeaderReplicaCount = tableInfo.hasPrimaryKey() ? bucketCount : 0; try { replicaCapacityController.checkCanCreateKvLeaderReplicas(newKvLeaderReplicaCount); Map bucketAssignments = - generateAssignment(tableInfo.getNumBuckets(), replicaFactor, servers) - .getBucketAssignments(); + generateAssignment(bucketCount, replicaFactor, servers).getBucketAssignments(); PartitionAssignment partitionAssignment = new PartitionAssignment(tableInfo.getTableId(), bucketAssignments); String remoteDataDir = remoteDirDynamicLoader.getRemoteDirSelector().nextDataDir(); metadataManager.createPartition( - tablePath, tableId, remoteDataDir, partitionAssignment, partition, false); + tablePath, + tableId, + remoteDataDir, + partitionAssignment, + partition, + false, + bucketCount); currentPartitions.put(partition.getPartitionName(), null); LOG.info( "Auto partitioning created partition {} for table [{}].", partition, tablePath); @@ -612,8 +633,8 @@ private void dropPartitions( currentPartitions.headMap(lastRetainPartitionTime).entrySet().iterator(); while (iterator.hasNext()) { Map.Entry> entry = iterator.next(); - // Historical system partitions are managed explicitly by table configuration changes - // and Coordinator recovery, never by normal retention cleanup. + // Historical system partitions are managed explicitly by table configuration + // changes and Coordinator recovery, never by normal retention cleanup. if (HISTORICAL_PARTITION_VALUE.equals(entry.getKey())) { continue; } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java index cb57a20abb6..598a708fda9 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java @@ -373,6 +373,11 @@ public int getCoordinatorEpoch() { return coordinatorContext.getCoordinatorEpoch(); } + /** The ZK version of the coordinator epoch znode, used for epoch fencing of ZK mutations. */ + public int getCoordinatorZkVersion() { + return coordinatorContext.getCoordinatorZkVersion(); + } + private void initCoordinatorContext() throws Exception { long start = System.currentTimeMillis(); // get all coordinator servers @@ -1005,6 +1010,19 @@ private void postAlterTableProperties(TableInfo oldTableInfo, TableInfo newTable newAutoPartitionStrategy); autoPartitionManager.handleAutoPartitionStrategyChange( newTableInfo, oldAutoPartitionStrategy, newAutoPartitionStrategy); + } else if (newAutoPartitionStrategy.isAutoPartitionEnabled() + && oldTableInfo.getNumBuckets() != newTableInfo.getNumBuckets()) { + // bucket.num changed (e.g. via ALTER TABLE) without any auto-partition strategy + // change. Refresh the cached TableInfo so that newly auto-created partitions use + // the updated table-level bucket count as their per-partition bucket count. + LOG.info( + "Updating auto-partition metadata for table {} (tableId={}) after " + + "bucket.num changed from {} to {}.", + newTableInfo.getTablePath(), + newTableInfo.getTableId(), + oldTableInfo.getNumBuckets(), + newTableInfo.getNumBuckets()); + autoPartitionManager.updateAutoPartitionTables(newTableInfo); } // If standby replica config changed, trigger re-election for all online buckets diff --git a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java index 6b416957858..758532c2598 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java @@ -212,6 +212,27 @@ public void addNotifyLeaderRequestForTabletServers( TableBucket tableBucket, List bucketReplicas, LeaderAndIsr leaderAndIsr) { + // Leader activation requires the routing bucket count; skip the bucket when the + // coordinator context has no assignment for it (e.g. raced by a drop) instead of + // sending a notification without the count, which the TabletServer would reject. + Integer bucketCountActual = getBucketCountActual(tableBucket); + if (bucketCountActual == null) { + coordinatorContext.addPendingLeaderActivation(tableBucket); + LOG.error( + "Skip notifying leader and isr for {}: no bucket assignment in coordinator " + + "context.", + tableBucket); + return; + } + Long bucketLayoutEpoch = getBucketLayoutEpoch(tableBucket.getTableId()); + if (bucketLayoutEpoch == null) { + coordinatorContext.addPendingLeaderActivation(tableBucket); + LOG.error( + "Skip notifying leader and isr for {}: no table info in coordinator context.", + tableBucket); + return; + } + tabletServers.stream() .filter(s -> s >= 0 && !coordinatorContext.shuttingDownTabletServers().contains(s)) .forEach( @@ -226,7 +247,9 @@ public void addNotifyLeaderRequestForTabletServers( tablePath, tableBucket, bucketReplicas, - leaderAndIsr)); + leaderAndIsr, + bucketCountActual, + bucketLayoutEpoch)); notifyBucketLeaderAndIsr.put(tableBucket, notifyLeaderAndIsrForBucket); }); @@ -239,6 +262,29 @@ public void addNotifyLeaderRequestForTabletServers( Collections.singleton(tableBucket)); } + /** + * The actual bucket count of the bucket's owning table/partition, or null when no assignment is + * in the coordinator context. The count is immutable per bucket, so it is carried with the + * activation instead of waiting for the metadata push. + */ + private @Nullable Integer getBucketCountActual(TableBucket tableBucket) { + Map> assignment; + if (tableBucket.getPartitionId() != null) { + assignment = + coordinatorContext.getPartitionAssignment( + new TablePartition( + tableBucket.getTableId(), tableBucket.getPartitionId())); + } else { + assignment = coordinatorContext.getTableAssignment(tableBucket.getTableId()); + } + return assignment.isEmpty() ? null : assignment.size(); + } + + private @Nullable Long getBucketLayoutEpoch(long tableId) { + TableInfo tableInfo = coordinatorContext.getTableInfoById(tableId); + return tableInfo == null ? null : tableInfo.getBucketLayoutEpoch(); + } + public void addStopReplicaRequestForTabletServers( Set tabletServers, TableBucket tableBucket, @@ -685,6 +731,13 @@ private UpdateMetadataRequest buildUpdateMetadataRequest() { coordinatorContext.isPartitionQueuedForDeletion( new TablePartition(tableId, partitionId)); String partitionName = coordinatorContext.getPartitionName(partitionId); + // the partition assignment size is the partition's actual bucket count; + // null when the assignment is not in context + Map> partitionAssignment = + coordinatorContext.getPartitionAssignment( + new TablePartition(tableId, partitionId)); + Integer bucketCountActual = + partitionAssignment.isEmpty() ? null : partitionAssignment.size(); PartitionMetadata partitionMetadata; if (partitionName == null) { if (partitionQueuedForDeletion) { @@ -693,7 +746,8 @@ private UpdateMetadataRequest buildUpdateMetadataRequest() { tableId, DELETED_PARTITION_NAME, partitionId, - kvEntry.getValue()); + kvEntry.getValue(), + bucketCountActual); } else { throw new IllegalStateException( "Partition name is null for partition " + partitionId); @@ -706,7 +760,8 @@ private UpdateMetadataRequest buildUpdateMetadataRequest() { partitionQueuedForDeletion ? DELETED_PARTITION_ID : partitionId, - kvEntry.getValue()); + kvEntry.getValue(), + bucketCountActual); } // table partitionMetadataList.add(partitionMetadata); diff --git a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java index 6e0c6f3c99d..e3eddcf33a5 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorService.java @@ -58,6 +58,7 @@ import org.apache.fluss.metadata.TableChange; import org.apache.fluss.metadata.TableDescriptor; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.row.encode.KvValueLayout; import org.apache.fluss.rpc.gateway.CoordinatorGateway; @@ -251,6 +252,7 @@ public final class CoordinatorService extends RpcServiceBase implements Coordina private final boolean kvTableAllowCreation; private final Supplier eventManagerSupplier; private final Supplier coordinatorEpochSupplier; + private final Supplier coordinatorEpochZkVersionSupplier; private final CoordinatorMetadataCache metadataCache; private final Supplier snapshotStoreManagerSupplier; @@ -296,6 +298,8 @@ public CoordinatorService( () -> coordinatorEventProcessorSupplier.get().getCoordinatorEventManager(); this.coordinatorEpochSupplier = () -> coordinatorEventProcessorSupplier.get().getCoordinatorEpoch(); + this.coordinatorEpochZkVersionSupplier = + () -> coordinatorEventProcessorSupplier.get().getCoordinatorZkVersion(); this.snapshotStoreManagerSupplier = () -> coordinatorEventProcessorSupplier.get().completedSnapshotStoreManager(); this.lakeTableTieringManager = lakeTableTieringManager; @@ -609,7 +613,8 @@ public CompletableFuture alterTable(AlterTableRequest reques request.isIgnoreIfNotExists(), currentSession().getPrincipal(), this::beforeTablePropertiesUpdate, - this::afterTablePropertiesUpdate); + this::afterTablePropertiesUpdate, + coordinatorEpochZkVersionSupplier.get()); } return CompletableFuture.completedFuture(new AlterTableResponse()); @@ -618,13 +623,13 @@ public CompletableFuture alterTable(AlterTableRequest reques private void beforeTablePropertiesUpdate(TableInfo currentTable, TableDescriptor updatedTable) { if (!currentTable.getTableConfig().isHistoricalPartitionEnabled() && isHistoricalPartitionEnabled(updatedTable)) { + TablePath tablePath = currentTable.getTablePath(); try { replicaCapacityController.checkCanCreateKvLeaderReplicas( getBucketCount(updatedTable)); - createHistoricalPartition( - currentTable.getTablePath(), currentTable.getTableId(), updatedTable); + createHistoricalPartition(tablePath, currentTable.getTableId(), updatedTable); } catch (Exception e) { - throw historicalPartitionEnableException(currentTable.getTablePath(), e); + throw historicalPartitionEnableException(tablePath, e); } } } @@ -636,11 +641,11 @@ private void afterTablePropertiesUpdate(TableInfo currentTable, TableDescriptor return; } + TablePath tablePath = currentTable.getTablePath(); try { - metadataManager.dropPartition( - currentTable.getTablePath(), historicalPartitionSpec(updatedTable), true); + metadataManager.dropPartition(tablePath, historicalPartitionSpec(updatedTable), true); } catch (Exception e) { - throw historicalPartitionDisableException(currentTable.getTablePath(), e); + throw historicalPartitionDisableException(tablePath, e); } } @@ -648,9 +653,9 @@ private void createHistoricalPartition( TablePath tablePath, long tableId, TableDescriptor tableDescriptor) { int replicaFactor = tableDescriptor.getReplicationFactor(); TabletServerInfo[] servers = metadataCache.getLiveServers(); + int bucketCount = getBucketCount(tableDescriptor); Map bucketAssignments = - generateAssignment(getBucketCount(tableDescriptor), replicaFactor, servers) - .getBucketAssignments(); + generateAssignment(bucketCount, replicaFactor, servers).getBucketAssignments(); PartitionAssignment partitionAssignment = new PartitionAssignment(tableId, bucketAssignments); String remoteDataDir = remoteDirDynamicLoader.getRemoteDirSelector().nextDataDir(); @@ -661,7 +666,8 @@ private void createHistoricalPartition( remoteDataDir, partitionAssignment, historicalPartitionSpec(tableDescriptor), - true); + true, + bucketCount); } private static ResolvedPartitionSpec historicalPartitionSpec(TableDescriptor tableDescriptor) { @@ -871,6 +877,8 @@ public CompletableFuture createPartition( authorizeTable(OperationType.WRITE, tablePath); CreatePartitionResponse response = new CreatePartitionResponse(); + // The table metadata (including bucket.num) is read fresh here, and the partition's + // registration persists its assignment and bucket count atomically in one ZK transaction TableInfo tableInfo = metadataManager.getTable(tablePath); if (!tableInfo.isPartitioned()) { throw new TableNotPartitionedException( @@ -919,7 +927,8 @@ public CompletableFuture createPartition( remoteDataDir, partitionAssignment, partitionToCreate, - request.isIgnoreIfNotExists()); + request.isIgnoreIfNotExists(), + tableInfo.getNumBuckets()); return CompletableFuture.completedFuture(response); } @@ -1055,6 +1064,17 @@ private CompletableFuture resolveNumBuckets(long tableId, @Nullable Lon AccessContextEvent event = new AccessContextEvent<>( ctx -> { + if (partitionId != null) { + // for partitions, the table-level bucket count may differ from the + // partition's actual bucket count after ALTER bucket.num; use the + // partition assignment size instead + Map> partitionAssignment = + ctx.getPartitionAssignment( + new TablePartition(tableId, partitionId)); + return partitionAssignment.isEmpty() + ? null + : partitionAssignment.size(); + } TablePath tablePath = ctx.getTablePathById(tableId); if (tablePath != null) { TableInfo tableInfo = ctx.getTableInfoById(tableId); diff --git a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java index eb3947f270c..082c05833f3 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java @@ -68,6 +68,7 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; @@ -77,6 +78,7 @@ import java.util.Set; import java.util.concurrent.Callable; import java.util.function.BiConsumer; +import java.util.stream.Collectors; import static org.apache.fluss.server.utils.TableDescriptorValidation.validateAlterTableProperties; import static org.apache.fluss.server.utils.TableDescriptorValidation.validateAlterTableSchema; @@ -86,6 +88,15 @@ public class MetadataManager { private static final Logger LOG = LoggerFactory.getLogger(MetadataManager.class); + /** + * Max internal retries for the side-effect-free ALTER read-modify-write when a CAS/epoch + * conflict (BadVersionException) indicates a concurrent metadata change. + */ + private static final int MAX_ALTER_TABLE_RETRIES = 3; + + /** The Fluss table property carrying a bucket count rescale. */ + private static final String BUCKET_NUM_PROPERTY = "bucket.num"; + private final ZooKeeperClient zookeeperClient; private final int maxPartitionNum; private final int maxBucketNum; @@ -508,6 +519,93 @@ private void syncSchemaChangesToLake( } } + private void propagateBucketCountToLake( + TablePath tablePath, + TableInfo tableInfo, + int newBucketCount, + FlussPrincipal flussPrincipal) { + if (!isDataLakeEnabled(tableInfo.toTableDescriptor())) { + return; + } + // Paimon only tracks a bucket count for Fixed Bucket tables (bucket-key non-empty). + if (tableInfo.getBucketKeys().isEmpty()) { + return; + } + LakeCatalog lakeCatalog = + lakeCatalogDynamicLoader.getLakeCatalogContainer().getLakeCatalog(); + if (lakeCatalog == null) { + throw new FlussRuntimeException( + "Cannot propagate ALTER bucket.num to the lake side for table " + + tablePath + + " because the Fluss cluster does not have a lake catalog configured."); + } + // The propagation travels through the unified alterTable channel as a "bucket.num" + TableDescriptor currentDescriptor = tableInfo.toTableDescriptor(); + List bucketCountChange = + Collections.singletonList( + TableChange.set(BUCKET_NUM_PROPERTY, String.valueOf(newBucketCount))); + LakeCatalog.Context lakeCatalogContext = + new CoordinatorService.DefaultLakeCatalogContext( + false, + tableInfo.getLakeTablePath(), + flussPrincipal, + currentDescriptor, + currentDescriptor); + // Lake First: this runs BEFORE the Fluss ZK commit, so a lake failure aborts the ALTER + // with the Fluss side unchanged. + try { + lakeCatalog.alterTable( + tableInfo.getLakeTablePath(), bucketCountChange, lakeCatalogContext); + } catch (TableNotExistException e) { + throw new FlussRuntimeException( + "Lake table doesn't exist for lake-enabled table " + + tablePath + + ", which shouldn't happen. Please check if the lake table was deleted manually.", + e); + } catch (Exception e) { + throw new FlussRuntimeException( + String.format( + "ALTER bucket.num for table %s was aborted: propagating the new " + + "bucket count (%d) to the lake schema failed. The Fluss " + + "side was NOT changed. Re-run the same ALTER once the " + + "lake is reachable.", + tablePath, newBucketCount), + e); + } + } + + /** + * Validates an ALTER bucket.num request: only partitioned tables are supported and the new + * value must fall within [1, maxBucketNum]. Runs before the lake-side propagation so an invalid + * ALTER never mutates lake metadata. + */ + private void validateBucketNumRescale( + TablePath tablePath, TableInfo tableInfo, int newBucketNum) { + // Non-partitioned tables require creating new bucket assignments and initializing + // LogTablets on TabletServers, which is not yet implemented. + if (tableInfo.getPartitionKeys().isEmpty()) { + throw new InvalidAlterTableException( + String.format( + "Cannot alter 'bucket.num' on non-partitioned table %s. " + + "Non-partitioned table rescale is not yet supported.", + tablePath)); + } + if (newBucketNum < 1) { + throw new InvalidAlterTableException( + String.format( + "Cannot alter 'bucket.num' to %d on table %s. " + + "The bucket count must be at least 1.", + newBucketNum, tablePath)); + } + if (newBucketNum > maxBucketNum) { + throw new TooManyBucketsException( + String.format( + "Cannot alter 'bucket.num' to %d on table %s, " + + "exceeding the maximum of %d buckets per partition.", + newBucketNum, tablePath, maxBucketNum)); + } + } + /** Alters table properties and invokes the callbacks around the metadata update. */ public void alterTableProperties( TablePath tablePath, @@ -516,74 +614,323 @@ public void alterTableProperties( boolean ignoreIfNotExists, FlussPrincipal flussPrincipal, BiConsumer beforeUpdate, - BiConsumer afterUpdate) { - try { - // it throws TableNotExistException if the table or database not exists - TableRegistration tableReg = getTableRegistration(tablePath); - SchemaInfo schemaInfo = getLatestSchema(tablePath); - // we can't use MetadataManager#getTable here, because it will add the default - // lake options to the table properties, which may cause the validation failure - TableInfo tableInfo = tableReg.toTableInfo(tablePath, schemaInfo); + BiConsumer afterUpdate, + int coordinatorEpochZkVersion) { + // 'bucket.num' is a structural field of the table distribution, so a RESET has no default + // to restore and no target count to rescale to. + if (tablePropertyChanges.customPropertiesToReset.contains(BUCKET_NUM_PROPERTY)) { + throw new InvalidAlterTableException( + "Cannot reset 'bucket.num' on table " + + tablePath + + "; use ALTER TABLE ... SET ('bucket.num' = '') to " + + "change the bucket count."); + } + String newBucketNumStr = + tablePropertyChanges.customPropertiesToSet.remove(BUCKET_NUM_PROPERTY); + Integer newBucketNum; + if (newBucketNumStr == null) { + newBucketNum = null; + } else { + try { + newBucketNum = Integer.parseInt(newBucketNumStr); + } catch (NumberFormatException e) { + throw new InvalidAlterTableException( + "Invalid value for 'bucket.num': " + newBucketNumStr, e); + } + } + boolean bucketNumRescale = newBucketNum != null; + // bucket.num travels to the lake through the dedicated lake-first propagation below; + // exclude it from the changes handed to the regular lake sync to avoid a second delivery. + List remainingTableChanges = tableChanges; + if (bucketNumRescale) { + remainingTableChanges = + tableChanges.stream() + .filter( + change -> + !(change instanceof TableChange.SetOption + && BUCKET_NUM_PROPERTY.equals( + ((TableChange.SetOption) change) + .getKey()))) + .collect(Collectors.toList()); + // Reject mixed ALTERs (bucket.num + other property changes) before any lake-side + // mutation. A mixed ALTER could leave Fluss and the lake permanently diverged if the + // non-bucket.num changes fail after the bucket count has already been propagated. + if (!remainingTableChanges.isEmpty()) { + throw new InvalidAlterTableException( + "Cannot alter 'bucket.num' together with other property changes " + + "on table " + + tablePath + + "; please issue separate ALTER TABLE statements."); + } + } + int attempt = 0; + while (true) { + try { + // Lake First: a lake failure aborts the ALTER with Fluss unchanged. + // The propagation is idempotent, so it re-runs after each conflict. + if (bucketNumRescale) { + TableInfo preAlterTableInfo = getTable(tablePath); + validateBucketNumRescale(tablePath, preAlterTableInfo, newBucketNum); + // A same-value ALTER leaves the bucket layout unchanged. + if (newBucketNum == preAlterTableInfo.getNumBuckets()) { + return; + } + propagateBucketCountToLake( + tablePath, preAlterTableInfo, newBucketNum, flussPrincipal); + } + doAlterTablePropertiesOnce( + tablePath, + remainingTableChanges, + tablePropertyChanges, + newBucketNum, + flussPrincipal, + beforeUpdate, + afterUpdate, + coordinatorEpochZkVersion); + return; + } catch (TableNotExistException e) { + if (ignoreIfNotExists) { + return; + } + throw e; + } catch (KeeperException.NoNodeException e) { + // A partition was dropped concurrently, or the table itself was dropped. + if (!isTablePresent(tablePath)) { + if (ignoreIfNotExists) { + return; + } + throw new TableNotExistException("Table " + tablePath + " does not exist.", e); + } + retryAlterOrThrow(tablePath, ++attempt, e); + } catch (KeeperException.BadVersionException e) { + // A CAS/epoch conflict means our snapshot was stale. + retryAlterOrThrow(tablePath, ++attempt, e); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new FlussRuntimeException( + "Failed to alter table properties: " + tablePath, e); + } + } + } - // validate the changes - validateAlterTableProperties(tableInfo, tablePropertyChanges.tableKeysToChange()); + private void retryAlterOrThrow(TablePath tablePath, int attempt, Exception cause) { + if (attempt >= MAX_ALTER_TABLE_RETRIES) { + throw new FlussRuntimeException( + String.format( + "Failed to alter table properties for %s after %d retries " + + "due to concurrent metadata changes; please retry.", + tablePath, attempt), + cause); + } + LOG.info( + "Retrying ALTER on table {} due to a concurrent metadata change (attempt {}).", + tablePath, + attempt); + } - TableDescriptor tableDescriptor = tableInfo.toTableDescriptor(); - TableDescriptor newDescriptor = - getUpdatedTableDescriptor(tableDescriptor, tablePropertyChanges); + /** Returns whether the table registration node still exists in ZooKeeper. */ + private boolean isTablePresent(TablePath tablePath) { + try { + return zookeeperClient.tableExist(tablePath); + } catch (Exception e) { + return false; + } + } - if (newDescriptor != null) { - // is to enable datalake for the table - if (isDataLakeEnabled(newDescriptor) && !isDataLakeEnabled(tableDescriptor)) { - // The table was created before cluster-level datalake was enabled. - // Backfill `table.datalake.format` before enabling datalake on the table - // so the updated table metadata stays consistent with the cluster setting. - if (!tableInfo.getTableConfig().getDataLakeFormat().isPresent()) { - DataLakeFormat dataLakeFormat = - lakeCatalogDynamicLoader - .getLakeCatalogContainer() - .getDataLakeFormat(); - if (dataLakeFormat == null) { - throw new InvalidAlterTableException( - "Cannot alter table " - + tablePath - + " in data lake, because the Fluss cluster doesn't enable datalake tables."); - } - newDescriptor = newDescriptor.withDataLakeFormat(dataLakeFormat); + /** + * One attempt of the ALTER read-modify-write. All ZK writes are CAS-guarded by the table ZK + * version read here plus the coordinator epoch version, so a stale snapshot or a deposed + * coordinator fails with {@link KeeperException.BadVersionException} instead of committing. + * + *

Only the side-effect-free pure bucket.num path lets {@code BadVersionException} propagate + * for the caller to retry; paths running {@link #preAlterTableProperties} (external lake side + * effects) surface the conflict as a non-retried {@link FlussRuntimeException}. + */ + private void doAlterTablePropertiesOnce( + TablePath tablePath, + List tableChanges, + TablePropertyChanges tablePropertyChanges, + @Nullable Integer newBucketNum, + FlussPrincipal flussPrincipal, + BiConsumer beforeUpdate, + BiConsumer afterUpdate, + int coordinatorEpochZkVersion) + throws Exception { + // it throws TableNotExistException if the table or database not exists + ZooKeeperClient.VersionedData versionedTableReg = + getTableRegistrationWithVersion(tablePath); + TableRegistration tableReg = versionedTableReg.data(); + int tableZkVersion = versionedTableReg.zkVersion(); + SchemaInfo schemaInfo = getLatestSchema(tablePath); + // we can't use MetadataManager#getTable here, because it will add the default + // lake options to the table properties, which may cause the validation failure + TableInfo tableInfo = tableReg.toTableInfo(tablePath, schemaInfo); + + // Old-partition bucket count backfill to be committed atomically with the + // table-level bucket.num update; stays empty unless bucket.num is being changed. + Map> + partitionBucketCountBackfills = Collections.emptyMap(); + + if (newBucketNum != null) { + // TODO: bucket-layout ALTERs should be rejected during a rolling server + // upgrade. Until that is enforced by the server, the supported upgrade procedure + // is: upgrade clients first, prohibit ALTER bucket.num while the servers are being + // rolled, and enable ALTER only after every server is upgraded. + // If bucket.num is being changed on a partitioned table, compute the backfill + // of old partitions' current actual bucket count. + partitionBucketCountBackfills = computePartitionBucketCountBackfill(tablePath); + + // Update the structural bucketCount field and increment bucketLayoutEpoch + tableReg = tableReg.withBucketCount(newBucketNum); + } + + // validate the changes + validateAlterTableProperties(tableInfo, tablePropertyChanges.tableKeysToChange()); + + TableDescriptor tableDescriptor = tableInfo.toTableDescriptor(); + TableDescriptor newDescriptor = + getUpdatedTableDescriptor(tableDescriptor, tablePropertyChanges); + + if (newDescriptor != null) { + // is to enable datalake for the table + if (isDataLakeEnabled(newDescriptor) && !isDataLakeEnabled(tableDescriptor)) { + // The table was created before cluster-level datalake was enabled. + // Backfill `table.datalake.format` before enabling datalake on the table + // so the updated table metadata stays consistent with the cluster setting. + if (!tableInfo.getTableConfig().getDataLakeFormat().isPresent()) { + DataLakeFormat dataLakeFormat = + lakeCatalogDynamicLoader.getLakeCatalogContainer().getDataLakeFormat(); + if (dataLakeFormat == null) { + throw new InvalidAlterTableException( + "Cannot alter table " + + tablePath + + " in data lake, because the Fluss cluster doesn't enable datalake tables."); } + newDescriptor = newDescriptor.withDataLakeFormat(dataLakeFormat); } + } - // reuse the same validate logic with the createTable() method - validateTableDescriptor(newDescriptor); - - beforeUpdate.accept(tableInfo, newDescriptor); - - // pre alter table properties, e.g. create lake table in lake storage if it's to - // enable datalake for the table - preAlterTableProperties( - tablePath, tableDescriptor, newDescriptor, tableChanges, flussPrincipal); - // update the table to zk - TableRegistration updatedTableRegistration = - tableReg.newProperties( - newDescriptor.getProperties(), newDescriptor.getCustomProperties()); - zookeeperClient.updateTable(tablePath, updatedTableRegistration); - afterUpdate.accept(tableInfo, newDescriptor); - } else { - LOG.info( - "No properties changed when alter table {}, skip update table.", tablePath); + if (newBucketNum != null) { + // Lake-First propagation is a no-op while the table is not yet lake-enabled, so + // enabling datalake here must create the lake table with the new bucket count. + newDescriptor = newDescriptor.withBucketCount(newBucketNum); } - } catch (Exception e) { - if (e instanceof TableNotExistException) { - if (ignoreIfNotExists) { - return; - } - throw (TableNotExistException) e; - } else if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } else { + + // reuse the same validate logic with the createTable() method + validateTableDescriptor(newDescriptor); + + beforeUpdate.accept(tableInfo, newDescriptor); + + // pre alter table properties, e.g. create lake table in lake storage if it's to + // enable datalake for the table. NOTE: this may have external (lake catalog) side + // effects and is therefore NOT safe to auto-retry. + preAlterTableProperties( + tablePath, tableDescriptor, newDescriptor, tableChanges, flussPrincipal); + + // update the table to zk, together with the (possibly empty) partition backfill in + // one atomic transaction + TableRegistration updatedTableRegistration = + tableReg.newProperties( + newDescriptor.getProperties(), newDescriptor.getCustomProperties()); + try { + zookeeperClient.updateTableWithPartitionBucketCountBackfill( + tablePath, + updatedTableRegistration, + tableZkVersion, + partitionBucketCountBackfills, + coordinatorEpochZkVersion); + } catch (KeeperException.BadVersionException e) { + // preAlterTableProperties above may have applied external lake side effects, so we + // must NOT auto-retry. Surface as a retriable failure for the operator/client. throw new FlussRuntimeException( - "Failed to alter table properties: " + tablePath, e); + String.format( + "Concurrent metadata change while altering table %s; the change was " + + "not committed, please retry the ALTER.", + tablePath), + e); } + afterUpdate.accept(tableInfo, newDescriptor); + } else if (newBucketNum != null) { + // Pure bucket.num change (side-effect-free): commit backfill + table-level update in + // one atomic transaction; BadVersionException propagates for the caller to retry. + zookeeperClient.updateTableWithPartitionBucketCountBackfill( + tablePath, + tableReg, + tableZkVersion, + partitionBucketCountBackfills, + coordinatorEpochZkVersion); + } else { + LOG.info("No properties changed when alter table {}, skip update table.", tablePath); + } + } + + /** + * Compute the bucket-count backfill (derived from assignment size) for existing partitions + * lacking one. Nothing is written here: the caller commits the returned registrations together + * with the table-level bucket.num update in a single ZK transaction, CAS-guarded by the + * versions captured here, so old partitions never observe the new table-level value without + * their own bucket count. + * + *

Idempotent: partitions that already have a persisted bucket count are skipped. + */ + private Map> + computePartitionBucketCountBackfill(TablePath tablePath) { + try { + Map> backfills = + new HashMap<>(); + Set partitionNames = zookeeperClient.getPartitions(tablePath); + for (String partitionName : partitionNames) { + Optional> optReg = + zookeeperClient.getPartitionWithVersion(tablePath, partitionName); + if (!optReg.isPresent()) { + // A partial backfill would leave this partition routed by the NEW table-level + // value; fail the whole ALTER instead. + throw new InvalidAlterTableException( + String.format( + "Cannot alter 'bucket.num' on table %s: partition '%s' is " + + "listed but its registration is missing. Please " + + "resolve the metadata inconsistency and retry the " + + "ALTER.", + tablePath, partitionName)); + } + PartitionRegistration reg = optReg.get().data(); + int partitionZkVersion = optReg.get().zkVersion(); + if (reg.getBucketCountActual() != null) { + // Already has bucket count persisted, skip. Idempotent so retries are safe. + continue; + } + // Derive bucket count from assignment size + long partitionId = reg.getPartitionId(); + Optional optAssignment = + zookeeperClient.getPartitionAssignment(partitionId); + if (!optAssignment.isPresent()) { + // Registration exists but assignment does not — same risk as above. + throw new InvalidAlterTableException( + String.format( + "Cannot alter 'bucket.num' on table %s: partition '%s' " + + "(id=%d) has no readable bucket assignment. Please " + + "resolve the metadata inconsistency and retry the " + + "ALTER.", + tablePath, partitionName, partitionId)); + } + int bucketCountActual = optAssignment.get().getBucketAssignments().size(); + PartitionRegistration updatedReg = + new PartitionRegistration( + reg.getTableId(), + reg.getPartitionId(), + reg.getRemoteDataDir(), + bucketCountActual); + backfills.put( + partitionName, + new ZooKeeperClient.VersionedData<>(updatedReg, partitionZkVersion)); + } + return backfills; + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new FlussRuntimeException( + "Failed to compute partition bucket count backfill for table: " + tablePath, e); } } @@ -792,6 +1139,24 @@ public TableRegistration getTableRegistration(TablePath tablePath) { return optionalTable.get(); } + /** + * Reads the table registration together with the ZK version of its znode, for a subsequent + * compare-and-set write. Throws {@link TableNotExistException} when the table does not exist. + */ + private ZooKeeperClient.VersionedData getTableRegistrationWithVersion( + TablePath tablePath) { + Optional> optionalTable; + try { + optionalTable = zookeeperClient.getTableWithVersion(tablePath); + } catch (Exception e) { + throw new RuntimeException(e); + } + if (!optionalTable.isPresent()) { + throw new TableNotExistException("Table '" + tablePath + "' does not exist."); + } + return optionalTable.get(); + } + public SchemaInfo getLatestSchema(TablePath tablePath) throws SchemaNotExistException { final int currentSchemaId; try { @@ -848,7 +1213,8 @@ public void createPartition( String remoteDataDir, PartitionAssignment partitionAssignment, ResolvedPartitionSpec partition, - boolean ignoreIfExists) { + boolean ignoreIfExists, + int bucketCountActual) { String partitionName = partition.getPartitionName(); Optional optionalPartitionRegistration = getOptionalPartitionRegistration(tablePath, partitionName); @@ -880,12 +1246,15 @@ public void createPartition( e); } - int bucketCount = partitionAssignment.getBucketAssignments().size(); - if (bucketCount > maxBucketNum) { + int assignmentBucketCount = partitionAssignment.getBucketAssignments().size(); + if (assignmentBucketCount > maxBucketNum) { throw new TooManyBucketsException( String.format( "Partition '%s' has %d buckets for table %s, exceeding the maximum of %d buckets per partition.", - partition.getPartitionName(), bucketCount, tablePath, maxBucketNum)); + partition.getPartitionName(), + assignmentBucketCount, + tablePath, + maxBucketNum)); } try { @@ -897,7 +1266,8 @@ public void createPartition( partitionAssignment, remoteDataDir, tablePath, - tableId); + tableId, + bucketCountActual); LOG.info( "Register partition {} to zookeeper for table [{}].", partitionName, tablePath); } catch (KeeperException.NodeExistsException nodeExistsException) { diff --git a/fluss-server/src/main/java/org/apache/fluss/server/entity/NotifyLeaderAndIsrData.java b/fluss-server/src/main/java/org/apache/fluss/server/entity/NotifyLeaderAndIsrData.java index e6702cdcaf6..1351a76ef58 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/entity/NotifyLeaderAndIsrData.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/entity/NotifyLeaderAndIsrData.java @@ -22,6 +22,8 @@ import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrRequest; import org.apache.fluss.server.zk.data.LeaderAndIsr; +import javax.annotation.Nullable; + import java.util.List; /** The table bucket data of {@link NotifyLeaderAndIsrRequest}. */ @@ -30,16 +32,31 @@ public final class NotifyLeaderAndIsrData { private final TableBucket tableBucket; private final List replicas; private final LeaderAndIsr leaderAndIsr; + // null when a legacy coordinator omits the fields + private final @Nullable Integer bucketCountActual; + private final @Nullable Long bucketLayoutEpoch; public NotifyLeaderAndIsrData( PhysicalTablePath physicalTablePath, TableBucket tableBucket, List replicas, LeaderAndIsr leaderAndIsr) { + this(physicalTablePath, tableBucket, replicas, leaderAndIsr, null, null); + } + + public NotifyLeaderAndIsrData( + PhysicalTablePath physicalTablePath, + TableBucket tableBucket, + List replicas, + LeaderAndIsr leaderAndIsr, + @Nullable Integer bucketCountActual, + @Nullable Long bucketLayoutEpoch) { this.physicalTablePath = physicalTablePath; this.tableBucket = tableBucket; this.replicas = replicas; this.leaderAndIsr = leaderAndIsr; + this.bucketCountActual = bucketCountActual; + this.bucketLayoutEpoch = bucketLayoutEpoch; } public PhysicalTablePath getPhysicalTablePath() { @@ -93,4 +110,14 @@ public List getStandbyReplicas() { public int[] getStandbyReplicasArray() { return leaderAndIsr.standbyReplicas().stream().mapToInt(Integer::intValue).toArray(); } + + /** The actual bucket count of the owning table/partition, or null if not carried. */ + public @Nullable Integer getBucketCountActual() { + return bucketCountActual; + } + + /** The bucket layout epoch of the owning table, or null if not carried. */ + public @Nullable Long getBucketLayoutEpoch() { + return bucketLayoutEpoch; + } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/metadata/CoordinatorMetadataProvider.java b/fluss-server/src/main/java/org/apache/fluss/server/metadata/CoordinatorMetadataProvider.java index 9cae3977f7b..6f04908667b 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/metadata/CoordinatorMetadataProvider.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/metadata/CoordinatorMetadataProvider.java @@ -105,7 +105,12 @@ public Optional getPartitionMetadataFromCache( partitionId, ctx.getPartitionAssignment(new TablePartition(tableId, partitionId))); return Optional.of( - new PartitionMetadata(tableId, partitionName, partitionId, bucketMetadataList)); + new PartitionMetadata( + tableId, + partitionName, + partitionId, + bucketMetadataList, + bucketMetadataList.size())); } @Override diff --git a/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionMetadata.java b/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionMetadata.java index 74c17b3606c..1fd58810190 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionMetadata.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionMetadata.java @@ -17,6 +17,8 @@ package org.apache.fluss.server.metadata; +import javax.annotation.Nullable; + import java.util.List; /** This entity used to describe the table's partition metadata. */ @@ -40,16 +42,27 @@ public class PartitionMetadata { private final String partitionName; private final long partitionId; private final List bucketMetadataList; + @Nullable private final Integer bucketCountActual; public PartitionMetadata( long tableId, String partitionName, long partitionId, List bucketMetadataList) { + this(tableId, partitionName, partitionId, bucketMetadataList, null); + } + + public PartitionMetadata( + long tableId, + String partitionName, + long partitionId, + List bucketMetadataList, + @Nullable Integer bucketCountActual) { this.tableId = tableId; this.partitionName = partitionName; this.partitionId = partitionId; this.bucketMetadataList = bucketMetadataList; + this.bucketCountActual = bucketCountActual; } public long getTableId() { @@ -67,4 +80,10 @@ public long getPartitionId() { public List getBucketMetadataList() { return bucketMetadataList; } + + /** Returns the actual bucket count for this partition, or null if not set (old data). */ + @Nullable + public Integer getBucketCountActual() { + return bucketCountActual; + } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/metadata/ServerMetadataSnapshot.java b/fluss-server/src/main/java/org/apache/fluss/server/metadata/ServerMetadataSnapshot.java index a3faeb90e9d..752f308e9a4 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/metadata/ServerMetadataSnapshot.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/metadata/ServerMetadataSnapshot.java @@ -23,6 +23,7 @@ import org.apache.fluss.cluster.TabletServerInfo; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import javax.annotation.Nullable; @@ -60,6 +61,14 @@ public class ServerMetadataSnapshot { // bucketMetadata> private final Map> bucketMetadataMapForPartitions; + // TablePartition -> bucket count; absent only when a legacy Coordinator omits it; a new + // Coordinator always sends the field. + private final Map partitionBucketCountActuals; + + // tableId -> bucketLayoutEpoch; a TabletServer keeps the latest value and ignores a lower + // epoch to prevent an older bucket layout (ALTER bucket.num) from replacing a newer one. + private final Map bucketLayoutEpochByTableId; + public ServerMetadataSnapshot( @Nullable ServerInfo coordinatorServer, Map aliveTabletServers, @@ -67,7 +76,9 @@ public ServerMetadataSnapshot( Map pathByTableId, Map partitionIdByPath, Map> bucketMetadataMapForTables, - Map> bucketMetadataMapForPartitions) { + Map> bucketMetadataMapForPartitions, + Map partitionBucketCountActuals, + Map bucketLayoutEpochByTableId) { this.coordinatorServer = coordinatorServer; this.aliveTabletServers = Collections.unmodifiableMap(aliveTabletServers); @@ -84,6 +95,8 @@ public ServerMetadataSnapshot( this.bucketMetadataMapForTables = Collections.unmodifiableMap(bucketMetadataMapForTables); this.bucketMetadataMapForPartitions = Collections.unmodifiableMap(bucketMetadataMapForPartitions); + this.partitionBucketCountActuals = Collections.unmodifiableMap(partitionBucketCountActuals); + this.bucketLayoutEpochByTableId = Collections.unmodifiableMap(bucketLayoutEpochByTableId); } /** Create an empty cluster instance with no nodes and no table-buckets. */ @@ -95,6 +108,8 @@ public static ServerMetadataSnapshot empty() { Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), Collections.emptyMap()); } @@ -159,6 +174,31 @@ public Map getBucketMetadataForPartition(long partition return bucketMetadataMapForPartitions.getOrDefault(partitionId, Collections.emptyMap()); } + /** + * Returns the actual bucket count for the given table partition, or null when the coordinator + * didn't send an explicit bucket count for it. + */ + public @Nullable Integer getPartitionBucketCountActual(TablePartition tablePartition) { + return partitionBucketCountActuals.get(tablePartition); + } + + public Map getPartitionBucketCountActuals() { + return partitionBucketCountActuals; + } + + /** + * Returns the bucket layout epoch for the given tableId, or empty if not known (legacy table + * without the field, read as 0). + */ + public OptionalLong getBucketLayoutEpoch(long tableId) { + Long epoch = bucketLayoutEpochByTableId.get(tableId); + return epoch == null ? OptionalLong.empty() : OptionalLong.of(epoch); + } + + public Map getBucketLayoutEpochByTableId() { + return bucketLayoutEpochByTableId; + } + public Map getPartitionIdByPath() { return partitionIdByPath; } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/metadata/TabletServerMetadataCache.java b/fluss-server/src/main/java/org/apache/fluss/server/metadata/TabletServerMetadataCache.java index 8f307ae1437..f837839bc74 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/metadata/TabletServerMetadataCache.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/metadata/TabletServerMetadataCache.java @@ -26,6 +26,7 @@ import org.apache.fluss.metadata.SchemaInfo; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.server.coordinator.MetadataManager; import org.apache.fluss.server.tablet.TabletServer; @@ -151,6 +152,14 @@ public void updateLatestSchema(long tableId, SchemaInfo schemaInfo) { tableId, (short) schemaInfo.getSchemaId(), schemaInfo.getSchema()); } + /** + * Returns the bucket layout epoch for the table, or empty if not known (legacy table without + * the field, read as 0). + */ + public OptionalLong getBucketLayoutEpoch(long tableId) { + return serverMetadataSnapshot.getBucketLayoutEpoch(tableId); + } + public Optional getPartitionMetadata(PhysicalTablePath partitionPath) { TablePath tablePath = partitionPath.getTablePath(); String partitionName = partitionPath.getPartitionName(); @@ -161,13 +170,22 @@ public Optional getPartitionMetadata(PhysicalTablePath partit if (tableIdOpt.isPresent() && partitionIdOpt.isPresent()) { long tableId = tableIdOpt.getAsLong(); long partitionId = partitionIdOpt.get(); + List bucketMetadataList = + new ArrayList<>(snapshot.getBucketMetadataForPartition(partitionId).values()); + // prefer the explicit bucket count sent by the coordinator; the merged bucket + // metadata list may be transiently partial during incremental updates + Integer bucketCountActual = + snapshot.getPartitionBucketCountActual( + new TablePartition(tableId, partitionId)); return Optional.of( new PartitionMetadata( tableId, partitionName, partitionId, - new ArrayList<>( - snapshot.getBucketMetadataForPartition(partitionId).values()))); + bucketMetadataList, + bucketCountActual != null + ? bucketCountActual + : bucketMetadataList.size())); } else { return Optional.empty(); @@ -207,22 +225,20 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { new HashMap<>(serverMetadataSnapshot.getTableIdByPath()); Map> bucketMetadataMapForTables = new HashMap<>(serverMetadataSnapshot.getBucketMetadataMapForTables()); + Map bucketLayoutEpochByTableId = + new HashMap<>(serverMetadataSnapshot.getBucketLayoutEpochByTableId()); for (TableMetadata tableMetadata : clusterMetadata.getTableMetadataList()) { TableInfo tableInfo = tableMetadata.getTableInfo(); TablePath tablePath = tableInfo.getTablePath(); long tableId = tableInfo.getTableId(); - // Update schema metadata. - // todo: apply schema id and schema info if needs - int schemaId = tableInfo.getSchemaId(); - Schema schema = tableInfo.getSchema(); - serverSchemaCache.updateLatestSchema(tableId, (short) schemaId, schema); if (tableId == DELETED_TABLE_ID) { Long removedTableId = tableIdByPath.remove(tablePath); if (removedTableId != null) { bucketMetadataMapForTables.remove(removedTableId); deletedTableIds.add(removedTableId); + bucketLayoutEpochByTableId.remove(removedTableId); } } else if (tablePath == DELETED_TABLE_PATH) { serverMetadataSnapshot @@ -230,7 +246,24 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { .ifPresent(tableIdByPath::remove); bucketMetadataMapForTables.remove(tableId); deletedTableIds.add(tableId); + bucketLayoutEpochByTableId.remove(tableId); } else { + // Ignore an older UpdateMetadata to prevent an older bucket + // layout (ALTER bucket.num) from replacing a newer one. + long newEpoch = tableInfo.getBucketLayoutEpoch(); + long currentEpoch = + bucketLayoutEpochByTableId.getOrDefault(tableId, 0L); + if (newEpoch < currentEpoch) { + continue; + } + bucketLayoutEpochByTableId.put(tableId, newEpoch); + + // Update schema metadata. + // todo: apply schema id and schema info if needs + int schemaId = tableInfo.getSchemaId(); + Schema schema = tableInfo.getSchema(); + serverSchemaCache.updateLatestSchema(tableId, (short) schemaId, schema); + tableIdByPath.put(tablePath, tableId); tableMetadata .getBucketMetadataList() @@ -255,6 +288,8 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { Map> bucketMetadataMapForPartitions = new HashMap<>( serverMetadataSnapshot.getBucketMetadataMapForPartitions()); + Map partitionBucketCountActuals = + new HashMap<>(serverMetadataSnapshot.getPartitionBucketCountActuals()); for (PartitionMetadata partitionMetadata : clusterMetadata.getPartitionMetadataList()) { @@ -268,14 +303,25 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { Long removedPartitionId = partitionIdByPath.remove(physicalTablePath); if (removedPartitionId != null) { bucketMetadataMapForPartitions.remove(removedPartitionId); + partitionBucketCountActuals + .keySet() + .removeIf(k -> k.getPartitionId() == removedPartitionId); } } else if (partitionName.equals(DELETED_PARTITION_NAME)) { serverMetadataSnapshot .getPhysicalTablePath(partitionId) .ifPresent(partitionIdByPath::remove); bucketMetadataMapForPartitions.remove(partitionId); + partitionBucketCountActuals + .keySet() + .removeIf(k -> k.getPartitionId() == partitionId); } else { partitionIdByPath.put(physicalTablePath, partitionId); + mergePartitionBucketCountActual( + partitionBucketCountActuals, + tableId, + partitionId, + partitionMetadata); partitionMetadata .getBucketMetadataList() .forEach( @@ -298,7 +344,9 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { newPathByTableId, partitionIdByPath, bucketMetadataMapForTables, - bucketMetadataMapForPartitions); + bucketMetadataMapForPartitions, + partitionBucketCountActuals, + bucketLayoutEpochByTableId); return deletedTableIds; }); } @@ -319,6 +367,8 @@ public void clearTableMetadata() { Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap(), + Collections.emptyMap(), + Collections.emptyMap(), Collections.emptyMap()); }); } @@ -340,6 +390,17 @@ public void updateTableMetadata(TableMetadata tableMetadata) { // Get current snapshot ServerMetadataSnapshot currentSnapshot = serverMetadataSnapshot; + // Ignore an older UpdateMetadata for this table to prevent it from + // overwriting newer state when messages arrive out of order. + long newEpoch = tableInfo.getBucketLayoutEpoch(); + long currentEpoch = + currentSnapshot + .getBucketLayoutEpochByTableId() + .getOrDefault(tableId, 0L); + if (newEpoch < currentEpoch) { + return; + } + // Create new maps based on current state Map tableIdByPath = new HashMap<>(currentSnapshot.getTableIdByPath()); @@ -367,6 +428,11 @@ public void updateTableMetadata(TableMetadata tableMetadata) { // Build pathByTableId from tableIdByPath tableIdByPath.forEach((path, id) -> pathByTableId.put(id, path)); + // Update epoch for this table + Map bucketLayoutEpochByTableId = + new HashMap<>(currentSnapshot.getBucketLayoutEpochByTableId()); + bucketLayoutEpochByTableId.put(tableId, tableInfo.getBucketLayoutEpoch()); + // Create new snapshot serverMetadataSnapshot = new ServerMetadataSnapshot( @@ -376,7 +442,9 @@ public void updateTableMetadata(TableMetadata tableMetadata) { pathByTableId, partitionIdByPath, bucketMetadataMapForTables, - bucketMetadataMapForPartitions); + bucketMetadataMapForPartitions, + currentSnapshot.getPartitionBucketCountActuals(), + bucketLayoutEpochByTableId); }); } @@ -427,6 +495,11 @@ public void updatePartitionMetadata(PartitionMetadata partitionMetadata) { } bucketMetadataMapForPartitions.put(partitionId, partitionBucketMetadata); + Map partitionBucketCountActuals = + new HashMap<>(currentSnapshot.getPartitionBucketCountActuals()); + mergePartitionBucketCountActual( + partitionBucketCountActuals, tableId, partitionId, partitionMetadata); + // Copy other existing data Map> bucketMetadataMapForTables = new HashMap<>(currentSnapshot.getBucketMetadataMapForTables()); @@ -443,10 +516,29 @@ public void updatePartitionMetadata(PartitionMetadata partitionMetadata) { pathByTableId, partitionIdByPath, bucketMetadataMapForTables, - bucketMetadataMapForPartitions); + bucketMetadataMapForPartitions, + partitionBucketCountActuals, + currentSnapshot.getBucketLayoutEpochByTableId()); }); } + /** + * Merges the coordinator-sent per-partition bucket count into the cache map. Coordinators of + * older versions do not send it; in that case the cache keeps no entry and readers fall back to + * the merged bucket metadata size (see {@link #getPartitionMetadata}). + */ + private static void mergePartitionBucketCountActual( + Map partitionBucketCountActuals, + long tableId, + long partitionId, + PartitionMetadata partitionMetadata) { + if (partitionMetadata.getBucketCountActual() != null) { + partitionBucketCountActuals.put( + new TablePartition(tableId, partitionId), + partitionMetadata.getBucketCountActual()); + } + } + @VisibleForTesting public ServerSchemaCache getServerSchemaCache() { return serverSchemaCache; diff --git a/fluss-server/src/main/java/org/apache/fluss/server/metadata/ZkBasedMetadataProvider.java b/fluss-server/src/main/java/org/apache/fluss/server/metadata/ZkBasedMetadataProvider.java index e83241b6596..88e0da30877 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/metadata/ZkBasedMetadataProvider.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/metadata/ZkBasedMetadataProvider.java @@ -110,7 +110,8 @@ public List getPartitionsMetadataFromZK( tableId, partitionName, partitionId, - bucketMetadataList); + bucketMetadataList, + bucketMetadataList.size()); result.add(partitionMetadata); }); return result; diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java index 079e1f96502..c3dcaba7d71 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java @@ -34,6 +34,7 @@ import org.apache.fluss.exception.NotEnoughReplicasException; import org.apache.fluss.exception.NotLeaderOrFollowerException; import org.apache.fluss.exception.TooManyScannersException; +import org.apache.fluss.exception.UnsupportedVersionException; import org.apache.fluss.fs.FsPath; import org.apache.fluss.metadata.ChangelogImage; import org.apache.fluss.metadata.LogFormat; @@ -193,6 +194,12 @@ public final class Replica { private final SchemaGetter schemaGetter; private volatile TableInfo tableInfo; + + // Routing state carried with activation: both values are immutable per bucket, so they are + // set once and never change. Null until the coordinator notifies them. + private volatile @Nullable Integer routingBucketCount; + private volatile @Nullable Long bucketLayoutEpoch; + private final boolean historicalPartition; // logFormat and arrowCompressionInfo are immutable and used in hot-path, so cache them here. private final LogFormat logFormat; @@ -449,6 +456,47 @@ public LogFormat getLogFormat() { return logFormat; } + /** + * Adopts the routing state carried by the notification. Both values are immutable per bucket, + * so unset fields never overwrite known ones. + */ + public void updateRoutingState(NotifyLeaderAndIsrData data) { + if (data.getBucketCountActual() != null) { + this.routingBucketCount = data.getBucketCountActual(); + } + if (data.getBucketLayoutEpoch() != null) { + this.bucketLayoutEpoch = data.getBucketLayoutEpoch(); + } + } + + /** + * Fails leader activation when the notification carries no routing state, which means the + * coordinator is older than this server (upgrade contract: coordinator first). + */ + private void requireRoutingState(NotifyLeaderAndIsrData data) { + if (data.getBucketCountActual() == null) { + throw new UnsupportedVersionException( + "Leader activation for bucket " + + tableBucket + + " requires the routing bucket count, but the notification from " + + "coordinator epoch " + + data.getCoordinatorEpoch() + + " carries none. The CoordinatorServer is older than this " + + "TabletServer; upgrade the CoordinatorServer first (upgrade " + + "contract: coordinator before tablet servers)."); + } + } + + /** The actual bucket count of the owning table/partition, or null if not yet notified. */ + public @Nullable Integer getRoutingBucketCount() { + return routingBucketCount; + } + + /** The bucket layout epoch of the owning table, or null if not yet notified. */ + public @Nullable Long getBucketLayoutEpoch() { + return bucketLayoutEpoch; + } + public void makeLeader(NotifyLeaderAndIsrData data) throws IOException { boolean leaderHWIncremented = inWriteLock( @@ -457,7 +505,11 @@ public void makeLeader(NotifyLeaderAndIsrData data) throws IOException { int requestBucketEpoch = data.getBucketEpoch(); validateBucketEpoch(requestBucketEpoch); + // Leader activation requires the routing state + requireRoutingState(data); + coordinatorEpoch = data.getCoordinatorEpoch(); + updateRoutingState(data); long currentTimeMs = clock.milliseconds(); // Updating the assignment and ISR state is safe if the bucket epoch is @@ -530,6 +582,7 @@ public boolean makeFollower(NotifyLeaderAndIsrData data) { validateBucketEpoch(requestBucketEpoch); coordinatorEpoch = data.getCoordinatorEpoch(); + updateRoutingState(data); updateAssignmentAndIsr( Collections.emptyList(), @@ -1853,6 +1906,13 @@ public long getOffset(RemoteLogManager remoteLogManager, ListOffsetsParam listOf return inReadLock( leaderIsrUpdateLock, () -> { + if (!isLeader()) { + throw new NotLeaderOrFollowerException( + String.format( + "Leader not local for bucket %s on tabletServer %d", + tableBucket, localTabletServerId)); + } + int offsetType = listOffsetsParam.getOffsetType(); if (offsetType == ListOffsetsParam.TIMESTAMP_OFFSET_TYPE) { return getOffsetByTimestamp(remoteLogManager, listOffsetsParam); diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java index 142facb80f5..53311cf2fe6 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java @@ -34,6 +34,7 @@ import org.apache.fluss.exception.LogStorageException; import org.apache.fluss.exception.NonPrimaryKeyTableException; import org.apache.fluss.exception.NotLeaderOrFollowerException; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.exception.StorageBackpressureException; import org.apache.fluss.exception.StorageException; import org.apache.fluss.exception.UnknownTableOrBucketException; @@ -2500,6 +2501,9 @@ protected Optional maybeCreateReplica(NotifyLeaderAndIsrData data) { clock, remoteLogManager, scannerManager); + // Initialize the routing state before the replica becomes visible, so a + // ready leader always has its routing bucket count ready. + replica.updateRoutingState(data); if (!existingLogTabletOpt.isPresent()) { localDiskManager.recordReplicaLoad(dataDir, isKvTable); } @@ -2531,6 +2535,67 @@ public Replica getReplicaOrException(TableBucket tableBucket) { } } + /** + * Validates the routing bucket count of a client request against the replica-local routing + * state. A mismatch (or a legacy client on a rescaled table) fails with STALE_METADATA; a + * replica not resolvable here keeps the existing per-bucket not-exist/unknown error semantics + * of the downstream replica lookup. + * + *

Only client requests may be validated; follower-initiated requests carry bucket ids + * assigned authoritatively by NotifyLeaderAndIsr and must skip this check. + */ + public void validateRoutingBucketCount(TableBucket tableBucket, int routingBucketCount) { + HostedReplica hostedReplica = getReplica(tableBucket); + if (!(hostedReplica instanceof OnlineReplica)) { + return; + } + Replica replica = ((OnlineReplica) hostedReplica).getReplica(); + if (!replica.isLeader()) { + return; + } + + if (routingBucketCount <= 0) { + // Legacy client (no bucket count in request): reject only when a rescale is known, + // because then the bucketId may come from an outdated count. + if (resolveBucketLayoutEpoch(replica) > 0) { + throw new StaleMetadataException( + "STALE_METADATA for " + + tableBucket + + ": the table's 'bucket.num' has been altered and requests without" + + " a routing bucket count are no longer accepted."); + } + return; + } + + Integer actual = replica.getRoutingBucketCount(); + if (actual == null) { + return; + } + if (routingBucketCount != actual) { + throw new StaleMetadataException( + "STALE_METADATA for " + + tableBucket + + ": the request's routing bucket count " + + routingBucketCount + + " does not match the actual bucket count " + + actual + + ". The client should refresh metadata and retry."); + } + } + + /** + * Resolves the effective bucket layout epoch as the maximum of the replica-local value and the + * metadata cache: ALTER bucket.num advances only the cache, and the epoch is monotonic. + */ + private long resolveBucketLayoutEpoch(Replica replica) { + Long replicaEpoch = replica.getBucketLayoutEpoch(); + long cachedEpoch = + metadataCache + .getBucketLayoutEpoch(replica.getTableBucket().getTableId()) + .orElse(0L); + return Math.max(replicaEpoch == null ? 0L : replicaEpoch, cachedEpoch); + } + public HostedReplica getReplica(TableBucket tableBucket) { return allReplicas.getOrDefault(tableBucket, new NoneReplica()); } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java index 5e309481431..c6321a9eca1 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java @@ -361,10 +361,16 @@ private LookupContext createLookupContext( LakeTableLookuper.LookupMetricRecorder lookupMetricRecorder) { TableBucket tableBucket = lookupData.tableBucket(); TablePath tablePath = tableInfo.getLakeTablePath(); + + // The request's bucket id only routes the request. It matches the lake layout only while + // the table was never rescaled; otherwise the lake lookuper resolves the bucket itself. + Integer lakeBucketId = + tableInfo.getBucketLayoutEpoch() == 0 ? tableBucket.getBucket() : null; + LakeTableLookuper.LookupContext lookupContext = new LakeTableLookuper.LookupContext( originalPartitionSpec, - tableBucket.getBucket(), + lakeBucketId, (short) schemaInfo.getSchemaId(), schemaInfo.getSchema().getRowType(), lookupMetricRecorder); diff --git a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java index c091913acd2..12198e1c2de 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java @@ -64,7 +64,14 @@ import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrResponse; import org.apache.fluss.rpc.messages.NotifyRemoteLogOffsetsRequest; import org.apache.fluss.rpc.messages.NotifyRemoteLogOffsetsResponse; +import org.apache.fluss.rpc.messages.PbFetchLogReqForBucket; +import org.apache.fluss.rpc.messages.PbFetchLogReqForTable; +import org.apache.fluss.rpc.messages.PbLookupReqForBucket; +import org.apache.fluss.rpc.messages.PbPrefixLookupReqForBucket; +import org.apache.fluss.rpc.messages.PbProduceLogReqForBucket; +import org.apache.fluss.rpc.messages.PbPutKvReqForBucket; import org.apache.fluss.rpc.messages.PbScanReqForBucket; +import org.apache.fluss.rpc.messages.PbTableStatsReqForBucket; import org.apache.fluss.rpc.messages.PrefixLookupRequest; import org.apache.fluss.rpc.messages.PrefixLookupResponse; import org.apache.fluss.rpc.messages.ProduceLogRequest; @@ -218,6 +225,14 @@ public void shutdown() {} @Override public CompletableFuture produceLog(ProduceLogRequest request) { authorizeTable(WRITE, request.getTableId()); + for (PbProduceLogReqForBucket pbBucket : request.getBucketsReqsList()) { + validateRoutingBucketCountOrThrow( + new TableBucket( + request.getTableId(), + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); + } CompletableFuture response = new CompletableFuture<>(); List produceLogData = toProduceLogDataForBuckets(request); UserContext userContext = new UserContext(currentSession().getPrincipal()); @@ -245,8 +260,42 @@ public CompletableFuture produceLog(ProduceLogRequest reques return response; } + /** + * Validates the routing bucket count of a client request against the replica-local routing + * state (see {@link ReplicaManager#validateRoutingBucketCount}). + */ + private void validateRoutingBucketCountOrThrow( + TableBucket tableBucket, int routingBucketCount) { + replicaManager.validateRoutingBucketCount(tableBucket, routingBucketCount); + } + + /** + * Bucket-count validation applies to client requests only ({@code followerServerId < 0}). + * Server-internal replication traffic (follower fetch and follower listOffsets) never carries a + * bucket count, and a follower's bucket ids come from {@code NotifyLeaderAndIsr}, which is + * authoritative. Validating them against the leader's metadata cache would stall replication + * whenever that cache lags behind or the table has been rescaled. + */ + private static boolean isFromClient(int followerServerId) { + return followerServerId < 0; + } + @Override public CompletableFuture fetchLog(FetchLogRequest request) { + if (isFromClient(request.getFollowerServerId())) { + for (PbFetchLogReqForTable pbTable : request.getTablesReqsList()) { + for (PbFetchLogReqForBucket pbBucket : pbTable.getBucketsReqsList()) { + validateRoutingBucketCountOrThrow( + new TableBucket( + pbTable.getTableId(), + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket.hasRoutingBucketCount() + ? pbBucket.getRoutingBucketCount() + : 0); + } + } + } Map fetchLogData = getFetchLogData(request); Map errorResponseMap = new HashMap<>(); Map interesting = @@ -307,6 +356,14 @@ private static FetchParams getFetchParams(FetchLogRequest request) { @Override public CompletableFuture putKv(PutKvRequest request) { authorizeTable(WRITE, request.getTableId()); + for (PbPutKvReqForBucket pbBucket : request.getBucketsReqsList()) { + validateRoutingBucketCountOrThrow( + new TableBucket( + request.getTableId(), + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); + } List putKvData = toPutKvDataForBuckets(request); // Get mergeMode from request, default to DEFAULT if not set @@ -342,6 +399,14 @@ public CompletableFuture putKv(PutKvRequest request) { @Override public CompletableFuture lookup(LookupRequest request) { + for (PbLookupReqForBucket pbBucket : request.getBucketsReqsList()) { + validateRoutingBucketCountOrThrow( + new TableBucket( + request.getTableId(), + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); + } Map errorResponseMap = new HashMap<>(); CompletableFuture response = new CompletableFuture<>(); @@ -390,6 +455,14 @@ public CompletableFuture lookup(LookupRequest request) { @Override public CompletableFuture prefixLookup(PrefixLookupRequest request) { + for (PbPrefixLookupReqForBucket pbBucket : request.getBucketsReqsList()) { + validateRoutingBucketCountOrThrow( + new TableBucket( + request.getTableId(), + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); + } Map> prefixLookupData = toPrefixLookupData(request); Map errorResponseMap = new HashMap<>(); Map> interesting = @@ -410,6 +483,12 @@ public CompletableFuture prefixLookup(PrefixLookupRequest @Override public CompletableFuture limitScan(LimitScanRequest request) { authorizeTable(READ, request.getTableId()); + validateRoutingBucketCountOrThrow( + new TableBucket( + request.getTableId(), + request.hasPartitionId() ? request.getPartitionId() : null, + request.getBucketId()), + request.hasRoutingBucketCount() ? request.getRoutingBucketCount() : 0); CompletableFuture response = new CompletableFuture<>(); replicaManager.limitScan( @@ -425,6 +504,14 @@ public CompletableFuture limitScan(LimitScanRequest request) @Override public CompletableFuture getTableStats(GetTableStatsRequest request) { authorizeTable(READ, request.getTableId()); + for (PbTableStatsReqForBucket pbBucket : request.getBucketsReqsList()) { + validateRoutingBucketCountOrThrow( + new TableBucket( + request.getTableId(), + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); + } CompletableFuture response = new CompletableFuture<>(); replicaManager.getTableStats( @@ -496,6 +583,16 @@ public CompletableFuture stopReplica( @Override public CompletableFuture listOffsets(ListOffsetsRequest request) { authorizeTable(DESCRIBE, request.getTableId()); + if (isFromClient(request.getFollowerServerId())) { + Long partitionId = request.hasPartitionId() ? request.getPartitionId() : null; + int routingBucketCount = + request.hasRoutingBucketCount() ? request.getRoutingBucketCount() : 0; + for (int bucketId : request.getBucketIds()) { + validateRoutingBucketCountOrThrow( + new TableBucket(request.getTableId(), partitionId, bucketId), + routingBucketCount); + } + } CompletableFuture response = new CompletableFuture<>(); Set tableBuckets = getListOffsetsData(request); replicaManager.listOffsets( @@ -628,6 +725,12 @@ public CompletableFuture scanKv(ScanKvRequest request) { if (request.hasBucketScanReq()) { PbScanReqForBucket bucketReq = request.getBucketScanReq(); + validateRoutingBucketCountOrThrow( + new TableBucket( + bucketReq.getTableId(), + bucketReq.hasPartitionId() ? bucketReq.getPartitionId() : null, + bucketReq.getBucketId()), + bucketReq.hasRoutingBucketCount() ? bucketReq.getRoutingBucketCount() : 0); long tableId = bucketReq.getTableId(); authorizeTable(READ, tableId); diff --git a/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java b/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java index 079bb4c9947..317b2adebbb 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java @@ -609,7 +609,8 @@ private static PbTableMetadata toPbTableMetadata(TableMetadata tableMetadata) { .setTableJson(tableInfo.toTableDescriptor().toJsonBytes()) .setRemoteDataDir(tableInfo.getRemoteDataDir()) .setCreatedTime(tableInfo.getCreatedTime()) - .setModifiedTime(tableInfo.getModifiedTime()); + .setModifiedTime(tableInfo.getModifiedTime()) + .setBucketLayoutEpoch(tableInfo.getBucketLayoutEpoch()); TablePath tablePath = tableInfo.getTablePath(); pbTableMetadata .setTablePath() @@ -628,6 +629,16 @@ private static PbPartitionMetadata toPbPartitionMetadata(PartitionMetadata parti .setPartitionName(partitionMetadata.getPartitionName()); pbPartitionMetadata.addAllBucketMetadatas( toPbBucketMetadata(partitionMetadata.getBucketMetadataList())); + Integer bucketCountActual = partitionMetadata.getBucketCountActual(); + int effectiveBucketCount = + bucketCountActual != null + ? bucketCountActual + : partitionMetadata.getBucketMetadataList().size(); + // 0 means the partition assignment is not known yet, not a zero-bucket layout; + // omitting the field keeps the client on its table-level fallback instead of 0. + if (effectiveBucketCount > 0) { + pbPartitionMetadata.setBucketCountActual(effectiveBucketCount); + } return pbPartitionMetadata; } @@ -684,7 +695,11 @@ private static TableMetadata toTableMetaData(PbTableMetadata pbTableMetadata) { ? pbTableMetadata.getRemoteDataDir() : null, pbTableMetadata.getCreatedTime(), - pbTableMetadata.getModifiedTime()); + pbTableMetadata.getModifiedTime(), + // legacy Coordinators predate bucketLayoutEpoch; read as 0 (never ALTERed) + pbTableMetadata.hasBucketLayoutEpoch() + ? pbTableMetadata.getBucketLayoutEpoch() + : 0L); List bucketMetadata = new ArrayList<>(); for (PbBucketMetadata pbBucketMetadata : pbTableMetadata.getBucketMetadatasList()) { @@ -713,7 +728,10 @@ private static PartitionMetadata toPartitionMetadata(PbPartitionMetadata pbParti pbPartitionMetadata.getPartitionId(), pbPartitionMetadata.getBucketMetadatasList().stream() .map(ServerRpcMessageUtils::toBucketMetadata) - .collect(Collectors.toList())); + .collect(Collectors.toList()), + pbPartitionMetadata.hasBucketCountActual() + ? pbPartitionMetadata.getBucketCountActual() + : null); } public static NotifyLeaderAndIsrRequest makeNotifyLeaderAndIsrRequest( @@ -747,6 +765,12 @@ public static PbNotifyLeaderAndIsrReqForBucket makeNotifyBucketLeaderAndIsr( .setPhysicalTablePath(fromPhysicalTablePath(physicalTablePath)) .setReplicas(notifyLeaderAndIsrData.getReplicasArray()) .setIsrs(notifyLeaderAndIsrData.getIsrArray()); + if (notifyLeaderAndIsrData.getBucketCountActual() != null) { + reqForBucket.setBucketCountActual(notifyLeaderAndIsrData.getBucketCountActual()); + } + if (notifyLeaderAndIsrData.getBucketLayoutEpoch() != null) { + reqForBucket.setBucketLayoutEpoch(notifyLeaderAndIsrData.getBucketLayoutEpoch()); + } return reqForBucket; } @@ -783,7 +807,13 @@ public static List getNotifyLeaderAndIsrRequestData( isr, standbyReplicas, request.getCoordinatorEpoch(), - reqForBucket.getBucketEpoch()))); + reqForBucket.getBucketEpoch()), + reqForBucket.hasBucketCountActual() + ? reqForBucket.getBucketCountActual() + : null, + reqForBucket.hasBucketLayoutEpoch() + ? reqForBucket.getBucketLayoutEpoch() + : null)); } return notifyLeaderAndIsrDataList; } @@ -1852,18 +1882,25 @@ public static NotifyKvSnapshotOffsetRequest makeNotifyKvSnapshotOffsetRequest( } public static ListPartitionInfosResponse toListPartitionInfosResponse( - List partitionKeys, Map partitionRegistrations) { + List partitionKeys, + Map partitionRegistrations, + int tableBucketCount, + long bucketLayoutEpoch) { ListPartitionInfosResponse listPartitionsResponse = new ListPartitionInfosResponse(); for (Map.Entry partitionRegistration : partitionRegistrations.entrySet()) { ResolvedPartitionSpec spec = ResolvedPartitionSpec.fromPartitionName( partitionKeys, partitionRegistration.getKey()); + PartitionRegistration partition = partitionRegistration.getValue(); listPartitionsResponse .addPartitionsInfo() - .setPartitionId(partitionRegistration.getValue().getPartitionId()) + .setPartitionId(partition.getPartitionId()) .setPartitionSpec(makePbPartitionSpec(spec)) - .setRemoteDataDir(partitionRegistration.getValue().getRemoteDataDir()); + .setRemoteDataDir(partition.getRemoteDataDir()) + .setBucketCountActual( + partition.getBucketCountActualOrDefault( + tableBucketCount, bucketLayoutEpoch)); } return listPartitionsResponse; } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java index 1911196095f..1bcfe4124e2 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java @@ -222,6 +222,18 @@ public Optional getOrEmpty(String path) throws Exception { } } + /** + * Reads the znode data and captures its {@link Stat} (hence the ZK version) atomically. Used by + * compare-and-set callers that must write back with the exact version they read. + */ + private Optional getDataWithStat(String path, Stat stat) throws Exception { + try { + return Optional.of(zkClient.getData().storingStatIn(stat).forPath(path)); + } catch (KeeperException.NoNodeException e) { + return Optional.empty(); + } + } + public String getDefaultRemoteDataDir() { return defaultRemoteDataDir; } @@ -801,6 +813,19 @@ public Optional getTable(TablePath tablePath) throws Exceptio t -> t.remoteDataDir == null ? t.newRemoteDataDir(defaultRemoteDataDir) : t); } + /** + * Get the table registration together with the ZK version of its znode, so callers can perform + * a compare-and-set write (see {@link #updateTableWithPartitionBucketCountBackfill}). + */ + public Optional> getTableWithVersion(TablePath tablePath) + throws Exception { + Stat stat = new Stat(); + Optional bytes = getDataWithStat(TableZNode.path(tablePath), stat); + return bytes.map(TableZNode::decode) + .map(t -> t.remoteDataDir == null ? t.newRemoteDataDir(defaultRemoteDataDir) : t) + .map(t -> new VersionedData<>(t, stat.getVersion())); + } + /** Get the tables in ZK. */ public Map getTables(Collection tablePaths) throws Exception { @@ -1033,6 +1058,91 @@ public Optional getPartition(TablePath tablePath, String p -> p.getRemoteDataDir() == null ? p.newRemoteDataDir(defaultRemoteDataDir) : p); } + /** + * Get a partition registration together with the ZK version of its znode, so callers can + * perform a compare-and-set backfill (see {@link + * #updateTableWithPartitionBucketCountBackfill}). + */ + public Optional> getPartitionWithVersion( + TablePath tablePath, String partitionName) throws Exception { + String path = PartitionZNode.path(tablePath, partitionName); + Stat stat = new Stat(); + return getDataWithStat(path, stat) + .map(PartitionZNode::decode) + .map( + p -> + p.getRemoteDataDir() == null + ? p.newRemoteDataDir(defaultRemoteDataDir) + : p) + .map(p -> new VersionedData<>(p, stat.getVersion())); + } + + /** + * Overwrites a partition's registration znode without a version check. NOT used in production + * (the ALTER bucket.num backfill goes through {@link + * #updateTableWithPartitionBucketCountBackfill}); this is a test-only backdoor for constructing + * legacy partition znodes, e.g. one with a null per-partition bucket count (v1 data) or a stale + * znode version. + */ + @VisibleForTesting + public void updatePartitionRegistration( + TablePath tablePath, String partitionName, PartitionRegistration registration) + throws Exception { + String path = PartitionZNode.path(tablePath, partitionName); + byte[] data = PartitionZNode.encode(registration); + zkClient.setData().forPath(path, data); + } + + /** + * Updates the table registration and the given partition registrations in one atomic ZooKeeper + * transaction. Every {@code setData} is CAS-guarded by its expected ZK version and the whole + * transaction is fenced on the coordinator epoch znode ({@link ZkVersion#MATCH_ANY_VERSION} + * skips the fence), so a stale snapshot or a deposed coordinator fails with {@link + * KeeperException.BadVersionException} instead of committing. + * + * @param tablePath the table to update + * @param tableRegistration the new table-level registration + * @param expectedTableZkVersion the expected ZK version of the table znode + * @param partitionBackfills partition name -> (updated registration + expected ZK version) + * @param expectedCoordinatorEpochZkVersion the coordinator epoch znode version to fence on + */ + public void updateTableWithPartitionBucketCountBackfill( + TablePath tablePath, + TableRegistration tableRegistration, + int expectedTableZkVersion, + Map> partitionBackfills, + int expectedCoordinatorEpochZkVersion) + throws Exception { + List ops = new ArrayList<>(partitionBackfills.size() + 1); + for (Map.Entry> entry : + partitionBackfills.entrySet()) { + String partitionPath = PartitionZNode.path(tablePath, entry.getKey()); + byte[] partitionData = PartitionZNode.encode(entry.getValue().data()); + ops.add( + zkClient.transactionOp() + .setData() + .withVersion(entry.getValue().zkVersion()) + .forPath(partitionPath, partitionData)); + } + String tablePathStr = TableZNode.path(tablePath); + byte[] tableData = TableZNode.encode(tableRegistration); + ops.add( + zkClient.transactionOp() + .setData() + .withVersion(expectedTableZkVersion) + .forPath(tablePathStr, tableData)); + + List fencedOps = + wrapRequestsWithEpochCheck(ops, expectedCoordinatorEpochZkVersion); + zkClient.transaction().forOperations(fencedOps); + LOG.info( + "Atomically backfilled bucket count for {} partition(s) and updated table {} in one " + + "transaction (CAS + epoch fence {}).", + partitionBackfills.size(), + tablePath, + expectedCoordinatorEpochZkVersion); + } + /** Get partition id and table id for each partition in a batch async way. */ public Map getPartitionIds( Collection partitionPaths) throws Exception { @@ -1077,7 +1187,8 @@ public void registerPartitionAssignmentAndMetadata( PartitionAssignment partitionAssignment, String remoteDataDir, TablePath tablePath, - long tableId) + long tableId, + int bucketCountActual) throws Exception { // Merge "registerPartitionAssignment()" and "registerPartition()" // into one transaction. This is to avoid the case that the partition assignment is @@ -1123,7 +1234,10 @@ public void registerPartitionAssignmentAndMetadata( metadataPath, PartitionZNode.encode( new PartitionRegistration( - tableId, partitionId, remoteDataDir))); + tableId, + partitionId, + remoteDataDir, + bucketCountActual))); ops.add(tabletServerPartitionNode); ops.add(metadataPartitionNode); @@ -1400,6 +1514,28 @@ public List listRemoteLogManifestHandles( return result; } + /** + * A decoded znode value together with the ZK version of its znode. Used to carry the version + * captured at read time so a later write can compare-and-set against it. + */ + public static final class VersionedData { + private final T data; + private final int zkVersion; + + public VersionedData(T data, int zkVersion) { + this.data = data; + this.zkVersion = zkVersion; + } + + public T data() { + return data; + } + + public int zkVersion() { + return zkVersion; + } + } + /** Tuple of a table bucket and its current remote log manifest handle. */ public static final class TableBucketAndManifest { private final TableBucket tableBucket; diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistration.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistration.java index 2aef5858f82..d1b27959a19 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistration.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistration.java @@ -18,6 +18,7 @@ package org.apache.fluss.server.zk.data; import org.apache.fluss.config.ConfigOptions; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.TablePartition; import javax.annotation.Nullable; @@ -45,10 +46,26 @@ public class PartitionRegistration { */ private final @Nullable String remoteDataDir; + /** + * The bucket count of this partition. It is null when deserialized from an older version that + * does not persist per-partition bucket count. In that case, callers should fall back to the + * table-level bucket count. + */ + private final @Nullable Integer bucketCountActual; + public PartitionRegistration(long tableId, long partitionId, @Nullable String remoteDataDir) { + this(tableId, partitionId, remoteDataDir, null); + } + + public PartitionRegistration( + long tableId, + long partitionId, + @Nullable String remoteDataDir, + @Nullable Integer bucketCountActual) { this.tableId = tableId; this.partitionId = partitionId; this.remoteDataDir = remoteDataDir; + this.bucketCountActual = bucketCountActual; } public long getTableId() { @@ -64,6 +81,36 @@ public String getRemoteDataDir() { return remoteDataDir; } + /** Returns the bucket count of this partition, or null if not persisted (old data). */ + @Nullable + public Integer getBucketCountActual() { + return bucketCountActual; + } + + /** + * Returns the bucket count of this partition, falling back to the given table-level bucket + * count when this partition was persisted by an older version that does not store the + * per-partition count. + * + *

The fallback is only valid at {@code bucketLayoutEpoch == 0} (legacy table or old server). + * At {@code bucketLayoutEpoch > 0}, the first ALTER must have backfilled the per-partition + * count; a missing count indicates an incomplete backfill and throws {@link + * StaleMetadataException} so the caller can refresh metadata and retry. + */ + public int getBucketCountActualOrDefault(int tableBucketCount, long bucketLayoutEpoch) { + if (bucketCountActual != null) { + return bucketCountActual; + } + if (bucketLayoutEpoch == 0) { + return tableBucketCount; + } + throw new StaleMetadataException( + "Partition " + + partitionId + + " is missing a per-partition bucket count at bucketLayoutEpoch " + + bucketLayoutEpoch); + } + public TablePartition toTablePartition() { return new TablePartition(tableId, partitionId); } @@ -77,7 +124,7 @@ public TablePartition toTablePartition() { * @return a new registration with the given remote data directory */ public PartitionRegistration newRemoteDataDir(String remoteDataDir) { - return new PartitionRegistration(tableId, partitionId, remoteDataDir); + return new PartitionRegistration(tableId, partitionId, remoteDataDir, bucketCountActual); } @Override @@ -88,12 +135,13 @@ public boolean equals(Object o) { PartitionRegistration that = (PartitionRegistration) o; return tableId == that.tableId && partitionId == that.partitionId - && Objects.equals(remoteDataDir, that.remoteDataDir); + && Objects.equals(remoteDataDir, that.remoteDataDir) + && Objects.equals(bucketCountActual, that.bucketCountActual); } @Override public int hashCode() { - return Objects.hash(tableId, partitionId, remoteDataDir); + return Objects.hash(tableId, partitionId, remoteDataDir, bucketCountActual); } @Override @@ -106,6 +154,8 @@ public String toString() { + ", remoteDataDir='" + remoteDataDir + '\'' + + ", bucketCountActual=" + + bucketCountActual + '}'; } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerde.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerde.java index ad83b18a079..4687e28c8eb 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerde.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerde.java @@ -37,7 +37,8 @@ public class PartitionRegistrationJsonSerde private static final String TABLE_ID_KEY = "table_id"; private static final String PARTITION_ID_KEY = "partition_id"; private static final String REMOTE_DATA_DIR_KEY = "remote_data_dir"; - private static final int VERSION = 1; + private static final String BUCKET_COUNT_ACTUAL_KEY = "bucket_count_actual"; + private static final int VERSION = 2; @Override public void serialize(PartitionRegistration registration, JsonGenerator generator) @@ -49,6 +50,10 @@ public void serialize(PartitionRegistration registration, JsonGenerator generato if (registration.getRemoteDataDir() != null) { generator.writeStringField(REMOTE_DATA_DIR_KEY, registration.getRemoteDataDir()); } + if (registration.getBucketCountActual() != null) { + generator.writeNumberField( + BUCKET_COUNT_ACTUAL_KEY, registration.getBucketCountActual()); + } generator.writeEndObject(); } @@ -62,6 +67,12 @@ public PartitionRegistration deserialize(JsonNode node) { if (node.has(REMOTE_DATA_DIR_KEY)) { remoteDataDir = node.get(REMOTE_DATA_DIR_KEY).asText(); } - return new PartitionRegistration(tableId, partitionId, remoteDataDir); + // When deserialize from an old version (v1), bucket_count_actual may not exist. + // Callers should fall back to table-level bucket count when this is null. + Integer bucketCountActual = null; + if (node.has(BUCKET_COUNT_ACTUAL_KEY)) { + bucketCountActual = node.get(BUCKET_COUNT_ACTUAL_KEY).asInt(); + } + return new PartitionRegistration(tableId, partitionId, remoteDataDir, bucketCountActual); } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistration.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistration.java index 89e7716d01a..51b28010414 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistration.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistration.java @@ -66,6 +66,14 @@ public class TableRegistration { public final long createdTime; public final long modifiedTime; + /** + * A table-level, monotonically increasing version for bucket.num changes. New tables start at + * 0; a legacy JSON without the field is read as 0; every committed bucket.num change increments + * it. It is used to decide whether legacy clients without bucket count are still allowed (epoch + * 0) and to let TabletServers ignore older UpdateMetadata messages. + */ + public final long bucketLayoutEpoch; + public TableRegistration( long tableId, @Nullable String comment, @@ -76,6 +84,30 @@ public TableRegistration( @Nullable String remoteDataDir, long createdTime, long modifiedTime) { + this( + tableId, + comment, + partitionKeys, + tableDistribution, + properties, + customProperties, + remoteDataDir, + createdTime, + modifiedTime, + 0L); + } + + public TableRegistration( + long tableId, + @Nullable String comment, + List partitionKeys, + TableDistribution tableDistribution, + Map properties, + Map customProperties, + @Nullable String remoteDataDir, + long createdTime, + long modifiedTime, + long bucketLayoutEpoch) { checkArgument( tableDistribution.getBucketCount().isPresent(), "Bucket count is required for table registration."); @@ -89,6 +121,7 @@ public TableRegistration( this.remoteDataDir = remoteDataDir; this.createdTime = createdTime; this.modifiedTime = modifiedTime; + this.bucketLayoutEpoch = bucketLayoutEpoch; } public boolean isPartitioned() { @@ -127,7 +160,8 @@ public TableInfo toTableInfo( this.remoteDataDir, this.comment, this.createdTime, - this.modifiedTime); + this.modifiedTime, + this.bucketLayoutEpoch); } public static TableRegistration newTable( @@ -160,7 +194,28 @@ public TableRegistration newProperties( newCustomProperties, remoteDataDir, createdTime, - currentMillis); + currentMillis, + bucketLayoutEpoch); + } + + /** + * Replaces the table-level bucket count and increments {@code bucketLayoutEpoch} atomically. + * For a partitioned table, the new count applies to partitions created after this ALTER; + * existing partitions retain their actual bucket counts in their partition registrations. + */ + public TableRegistration withBucketCount(int newBucketCount) { + final long currentMillis = System.currentTimeMillis(); + return new TableRegistration( + tableId, + comment, + partitionKeys, + new TableDistribution(newBucketCount, bucketKeys), + properties, + customProperties, + remoteDataDir, + createdTime, + currentMillis, + bucketLayoutEpoch + 1); } /** @@ -181,7 +236,8 @@ public TableRegistration newRemoteDataDir(String remoteDataDir) { customProperties, remoteDataDir, createdTime, - modifiedTime); + modifiedTime, + bucketLayoutEpoch); } @Override @@ -197,6 +253,7 @@ public boolean equals(Object o) { return tableId == that.tableId && createdTime == that.createdTime && modifiedTime == that.modifiedTime + && bucketLayoutEpoch == that.bucketLayoutEpoch && Objects.equals(comment, that.comment) && Objects.equals(partitionKeys, that.partitionKeys) && Objects.equals(bucketCount, that.bucketCount) @@ -218,7 +275,8 @@ public int hashCode() { customProperties, remoteDataDir, createdTime, - modifiedTime); + modifiedTime, + bucketLayoutEpoch); } @Override @@ -245,6 +303,8 @@ public String toString() { + createdTime + ", modifiedTime=" + modifiedTime + + ", bucketLayoutEpoch=" + + bucketLayoutEpoch + '}'; } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerde.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerde.java index 6a9f93380ec..49d967152ac 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerde.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerde.java @@ -48,8 +48,9 @@ public class TableRegistrationJsonSerde static final String REMOTE_DATA_DIR = "remote_data_dir"; static final String CREATED_TIME = "created_time"; static final String MODIFIED_TIME = "modified_time"; + static final String BUCKET_LAYOUT_EPOCH = "bucket_layout_epoch"; private static final String VERSION_KEY = "version"; - private static final int VERSION = 1; + private static final int VERSION = 2; @Override public void serialize(TableRegistration tableReg, JsonGenerator generator) throws IOException { @@ -112,6 +113,9 @@ public void serialize(TableRegistration tableReg, JsonGenerator generator) throw // serialize modifiedTime generator.writeNumberField(MODIFIED_TIME, tableReg.modifiedTime); + // serialize bucketLayoutEpoch + generator.writeNumberField(BUCKET_LAYOUT_EPOCH, tableReg.bucketLayoutEpoch); + generator.writeEndObject(); } @@ -157,6 +161,11 @@ public TableRegistration deserialize(JsonNode node) { long createdTime = node.get(CREATED_TIME).asLong(); long modifiedTime = node.get(MODIFIED_TIME).asLong(); + // When deserializing from a legacy version, the bucket layout epoch may not exist; + // read it as 0 (the table has never been ALTERed). + long bucketLayoutEpoch = + node.has(BUCKET_LAYOUT_EPOCH) ? node.get(BUCKET_LAYOUT_EPOCH).asLong() : 0L; + return new TableRegistration( tableId, comment, @@ -166,7 +175,8 @@ public TableRegistration deserialize(JsonNode node) { customProperties, remoteDataDir, createdTime, - modifiedTime); + modifiedTime, + bucketLayoutEpoch); } private Map deserializeProperties(JsonNode node) { diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java new file mode 100644 index 00000000000..7cfa03e7810 --- /dev/null +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java @@ -0,0 +1,1046 @@ +/* + * 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.server.coordinator; + +import org.apache.fluss.cluster.Endpoint; +import org.apache.fluss.cluster.TabletServerInfo; +import org.apache.fluss.config.ConfigOptions; +import org.apache.fluss.config.Configuration; +import org.apache.fluss.exception.FlussRuntimeException; +import org.apache.fluss.exception.InvalidAlterTableException; +import org.apache.fluss.exception.TableNotExistException; +import org.apache.fluss.exception.TooManyBucketsException; +import org.apache.fluss.lake.lakestorage.LakeCatalog; +import org.apache.fluss.metadata.DatabaseDescriptor; +import org.apache.fluss.metadata.ResolvedPartitionSpec; +import org.apache.fluss.metadata.Schema; +import org.apache.fluss.metadata.TableChange; +import org.apache.fluss.metadata.TableDescriptor; +import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePath; +import org.apache.fluss.server.entity.TablePropertyChanges; +import org.apache.fluss.server.zk.CuratorFrameworkWithUnhandledErrorListener; +import org.apache.fluss.server.zk.NOPErrorHandler; +import org.apache.fluss.server.zk.ZooKeeperClient; +import org.apache.fluss.server.zk.ZooKeeperExtension; +import org.apache.fluss.server.zk.data.CoordinatorAddress; +import org.apache.fluss.server.zk.data.PartitionAssignment; +import org.apache.fluss.server.zk.data.PartitionRegistration; +import org.apache.fluss.server.zk.data.TableAssignment; +import org.apache.fluss.server.zk.data.TableRegistration; +import org.apache.fluss.server.zk.data.TabletServerRegistration; +import org.apache.fluss.server.zk.data.ZkData; +import org.apache.fluss.server.zk.data.ZkVersion; +import org.apache.fluss.shaded.zookeeper3.org.apache.zookeeper.KeeperException; +import org.apache.fluss.testutils.common.AllCallbackWrapper; +import org.apache.fluss.types.DataTypes; +import org.apache.fluss.utils.function.RunnableWithException; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +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.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import static org.apache.fluss.config.ConfigOptions.DEFAULT_LISTENER_NAME; +import static org.apache.fluss.metadata.ResolvedPartitionSpec.fromPartitionName; +import static org.apache.fluss.server.utils.TableAssignmentUtils.generateAssignment; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** Tests for ALTER TABLE SET ('bucket.num' = 'N') per-partition bucket count rescale. */ +class AlterBucketNumTest { + + private static final String DEFAULT_DB = "db"; + + @RegisterExtension + public static final AllCallbackWrapper ZOO_KEEPER_EXTENSION_WRAPPER = + new AllCallbackWrapper<>(new ZooKeeperExtension()); + + private static ZooKeeperClient zookeeperClient; + private static MetadataManager metadataManager; + private static String remoteDataDir; + + @BeforeAll + static void beforeAll() throws Exception { + zookeeperClient = + ZOO_KEEPER_EXTENSION_WRAPPER + .getCustomExtension() + .getZooKeeperClient(NOPErrorHandler.INSTANCE); + metadataManager = + new MetadataManager( + zookeeperClient, + new Configuration(), + new LakeCatalogDynamicLoader(new Configuration(), null, true)); + + // register coordinator server + zookeeperClient.registerCoordinatorLeader( + new CoordinatorAddress( + "1", Endpoint.fromListenersString("CLIENT://localhost:10012"))); + zookeeperClient.fenceBecomeCoordinatorLeader("1"); + + // register 3 tablet servers + for (int i = 0; i < 3; i++) { + zookeeperClient.registerTabletServer( + i, + new TabletServerRegistration( + "rack" + i, + Collections.singletonList( + new Endpoint("host" + i, 1000, DEFAULT_LISTENER_NAME)), + System.currentTimeMillis())); + } + + // create database + metadataManager.createDatabase(DEFAULT_DB, DatabaseDescriptor.builder().build(), false); + remoteDataDir = zookeeperClient.getDefaultRemoteDataDir(); + } + + // ====================== Lake Propagation Tests ====================== + + @Test + void testAlterBucketNumOnLakeTablePassesValidationButAbortsWithoutLakeCatalog() + throws Exception { + // A lake table is no longer rejected by validation; the ALTER proceeds to the lake + // propagation, which aborts here because this harness wires no lake catalog. Covers the + // lakeCatalog == null branch (distinct from a propagation call that fails, tested below). + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_lake_table_alter_allowed"); + int originalBucketCount = 4; + createSeededLakePartitionedTable(metadataManager, tablePath, originalBucketCount); + + // Lake First: the ALTER validates and then attempts to propagate the new bucket count to + // the lake side BEFORE the Fluss ZK commit. This unit-test harness has no real lake + // catalog wired in, so the propagation step fails with a FlussRuntimeException whose + // message clearly points at the propagation stage. + assertThatThrownBy(() -> alterBucketNum(metadataManager, tablePath, "8")) + .isInstanceOf(FlussRuntimeException.class) + .hasMessageContaining("propagate ALTER bucket.num to the lake side"); + + // The propagation failure aborts the ALTER BEFORE the Fluss ZK commit, so table-level and + // pre-existing partition state must both be unchanged. + assertThat(metadataManager.getTable(tablePath).getNumBuckets()) + .isEqualTo(originalBucketCount); + Optional pre = zookeeperClient.getPartition(tablePath, "2024-01"); + assertThat(pre).isPresent(); + assertThat(pre.get().getBucketCountActual()).isEqualTo(originalBucketCount); + } + + @Test + void testAlterBucketNumLakePropagationFailureAbortsAlter() throws Exception { + CountingLakeCatalog stub = new CountingLakeCatalog(true); + MetadataManager mm = buildMetadataManagerWithLakeCatalog(stub); + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_lake_alter_persistent_fail"); + int originalBucketCount = 4; + createSeededLakePartitionedTable(mm, tablePath, originalBucketCount); + + // A lake failure fails loud: the ALTER aborts BEFORE the Fluss ZK commit with a clear + // error telling the operator nothing was changed on the Fluss side and to re-run the + // ALTER once the lake is reachable. + assertThatThrownBy(() -> alterBucketNum(mm, tablePath, "8")) + .isInstanceOf(FlussRuntimeException.class) + .hasMessageContaining("to the lake schema failed") + .hasMessageContaining("The Fluss side was NOT changed") + .hasMessageContaining("Re-run the same ALTER"); + + // Propagation is attempted exactly once. + assertThat(stub.attempts.get()).isEqualTo(1); + + // Lake First: the Fluss ZK commit never ran, so table-level bucket count and the + // pre-existing partition are both unchanged. + assertThat(mm.getTable(tablePath).getNumBuckets()).isEqualTo(originalBucketCount); + Optional pre = zookeeperClient.getPartition(tablePath, "2024-01"); + assertThat(pre).isPresent(); + assertThat(pre.get().getBucketCountActual()).isEqualTo(originalBucketCount); + } + + @Test + void testAlterBucketNumLakePropagationSucceedsFirstTry() throws Exception { + CountingLakeCatalog stub = new CountingLakeCatalog(false); + MetadataManager mm = buildMetadataManagerWithLakeCatalog(stub); + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_lake_alter_success"); + int newBucketCount = 8; + createSeededLakePartitionedTable(mm, tablePath, 4); + + alterBucketNum(mm, tablePath, String.valueOf(newBucketCount)); + + // Propagation succeeded on the first attempt. + assertThat(stub.attempts.get()).isEqualTo(1); + assertThat(stub.lastBucketCount).isEqualTo(newBucketCount); + assertThat(mm.getTable(tablePath).getNumBuckets()).isEqualTo(newBucketCount); + } + + @Test + void testAlterBucketNumSkipsLakePropagationForUnawareBucketTable() throws Exception { + // A lake table WITHOUT bucket keys is an Unaware Bucket table in Paimon (BUCKET = -1 + // encodes the bucket MODE); propagating a positive BUCKET would flip its mode. The + // propagation must be skipped entirely while the Fluss-side rescale still succeeds. + CountingLakeCatalog stub = new CountingLakeCatalog(false); + MetadataManager mm = buildMetadataManagerWithLakeCatalog(stub); + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_lake_alter_unaware_skip"); + int originalBucketCount = 4; + int newBucketCount = 8; + TableDescriptor unawareLakeTable = + TableDescriptor.builder() + .schema( + Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("b", DataTypes.STRING()) + .build()) + .distributedBy(originalBucketCount) + .partitionedBy("b") + .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(), "true") + .property(ConfigOptions.TABLE_DATALAKE_FORMAT.key(), "paimon") + .build() + .withReplicationFactor(3); + mm.createTable( + tablePath, + remoteDataDir, + unawareLakeTable, + generateAssignment(originalBucketCount, 3, getTabletServers()), + false); + + alterBucketNum(mm, tablePath, String.valueOf(newBucketCount)); + + // no call ever reached the lake catalog, and the Fluss side still rescaled + assertThat(stub.attempts.get()).isEqualTo(0); + assertThat(mm.getTable(tablePath).getNumBuckets()).isEqualTo(newBucketCount); + } + + /** + * Builds a coordinator-side MetadataManager and reflectively injects the given stub as the + * cluster lake catalog, so lake propagation can be exercised without a real Paimon catalog. + */ + private static MetadataManager buildMetadataManagerWithLakeCatalog(LakeCatalog stub) + throws Exception { + LakeCatalogDynamicLoader loader = + new LakeCatalogDynamicLoader(new Configuration(), null, true); + Field containerField = + LakeCatalogDynamicLoader.class.getDeclaredField("lakeCatalogContainer"); + containerField.setAccessible(true); + Object container = containerField.get(loader); + Field catalogField = container.getClass().getDeclaredField("lakeCatalog"); + catalogField.setAccessible(true); + catalogField.set(container, stub); + return new MetadataManager(zookeeperClient, new Configuration(), loader); + } + + /** + * Creates a lake-enabled, partitioned Fixed Bucket table with the given original bucket count + * and seeds one pre-existing partition "2024-01" carrying that bucket count. + */ + private static void createSeededLakePartitionedTable( + MetadataManager mm, TablePath tablePath, int originalBucketCount) throws Exception { + TableDescriptor lakeTable = + TableDescriptor.builder() + .schema( + Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("b", DataTypes.STRING()) + .build()) + .distributedBy(originalBucketCount, "a") + .partitionedBy("b") + .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(), "true") + .property(ConfigOptions.TABLE_DATALAKE_FORMAT.key(), "paimon") + .build() + .withReplicationFactor(3); + TableAssignment tableAssignment = + generateAssignment(originalBucketCount, 3, getTabletServers()); + mm.createTable(tablePath, remoteDataDir, lakeTable, tableAssignment, false); + TableInfo tableInfo = mm.getTable(tablePath); + mm.createPartition( + tablePath, + tableInfo.getTableId(), + remoteDataDir, + new PartitionAssignment( + tableInfo.getTableId(), tableAssignment.getBucketAssignments()), + fromPartitionName(tableInfo.getPartitionKeys(), "2024-01"), + false, + originalBucketCount); + } + + /** + * A stub lake catalog that counts bucket-count propagations (a "bucket.num" SetOption through + * alterTable) and can simulate transient faults. + */ + private static final class CountingLakeCatalog implements LakeCatalog { + private final AtomicInteger attempts = new AtomicInteger(); + private volatile boolean failing; + private volatile Integer lastBucketCount; + + CountingLakeCatalog(boolean failing) { + this.failing = failing; + } + + @Override + public void createTable( + TablePath tablePath, TableDescriptor tableDescriptor, Context context) { + // not used by these tests + } + + @Override + public void alterTable( + TablePath tablePath, java.util.List tableChanges, Context context) { + for (TableChange change : tableChanges) { + if (change instanceof TableChange.SetOption + && "bucket.num".equals(((TableChange.SetOption) change).getKey())) { + attempts.incrementAndGet(); + if (failing) { + throw new RuntimeException("simulated transient lake failure"); + } + lastBucketCount = Integer.parseInt(((TableChange.SetOption) change).getValue()); + } + } + } + } + + @Test + void testAlterBucketNumRejectedOnNonPartitionedTable() throws Exception { + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_non_partitioned_reject"); + TableDescriptor logTable = + TableDescriptor.builder() + .schema( + Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("b", DataTypes.STRING()) + .build()) + .distributedBy(4) + .build() + .withReplicationFactor(3); + + TableAssignment tableAssignment = generateAssignment(4, 3, getTabletServers()); + metadataManager.createTable(tablePath, remoteDataDir, logTable, tableAssignment, false); + + // ALTER bucket.num on non-partitioned table should be rejected + assertThatThrownBy(() -> alterBucketNum(metadataManager, tablePath, "8")) + .isInstanceOf(InvalidAlterTableException.class) + .hasMessageContaining("Cannot alter 'bucket.num' on non-partitioned table"); + } + + @Test + void testResetBucketNumRejected() throws Exception { + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_reset_bucket_num_reject"); + int originalBucketCount = 4; + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + generateAssignment(originalBucketCount, 3, getTabletServers()), + false); + long originalEpoch = metadataManager.getTable(tablePath).getBucketLayoutEpoch(); + + // RESET carries no target count, so it must be rejected instead of silently succeeding. + assertThatThrownBy(() -> resetBucketNum(metadataManager, tablePath)) + .isInstanceOf(InvalidAlterTableException.class) + .hasMessageContaining("Cannot reset 'bucket.num'") + .hasMessageContaining("SET ('bucket.num' = '')"); + + // The bucket layout is untouched: neither the count nor the epoch moved. + TableInfo afterTableInfo = metadataManager.getTable(tablePath); + assertThat(afterTableInfo.getNumBuckets()).isEqualTo(originalBucketCount); + assertThat(afterTableInfo.getBucketLayoutEpoch()).isEqualTo(originalEpoch); + } + + // ========================== Success Tests ========================== + + @ParameterizedTest(name = "bucketNum {0} -> {1}") + @CsvSource({"3, 6", "6, 3"}) + void testBackfillOnlyAffectsPartitionsWithoutBucketCountActual( + int originalBucketCount, int newBucketCount) throws Exception { + TablePath tablePath = + TablePath.of( + DEFAULT_DB, + "test_backfill_idempotent_" + originalBucketCount + "_" + newBucketCount); + TableAssignment tableAssignment = + generateAssignment(originalBucketCount, 3, getTabletServers()); + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + tableAssignment, + false); + TableInfo tableInfo = metadataManager.getTable(tablePath); + long tableId = tableInfo.getTableId(); + + // Create two partitions with bucketCountActual = originalBucketCount + PartitionAssignment partitionAssignment = + new PartitionAssignment(tableId, tableAssignment.getBucketAssignments()); + metadataManager.createPartition( + tablePath, + tableId, + remoteDataDir, + partitionAssignment, + fromPartitionName(tableInfo.getPartitionKeys(), "legacy"), + false, + originalBucketCount); + metadataManager.createPartition( + tablePath, + tableId, + remoteDataDir, + partitionAssignment, + fromPartitionName(tableInfo.getPartitionKeys(), "new"), + false, + originalBucketCount); + + // Simulate a legacy partition by overwriting its registration with null bucketCountActual. + // This models a partition created before per-partition bucket count was introduced. + Optional legacyReg = + zookeeperClient.getPartition(tablePath, "legacy"); + assertThat(legacyReg).isPresent(); + PartitionRegistration nullBucketCountReg = + new PartitionRegistration( + legacyReg.get().getTableId(), + legacyReg.get().getPartitionId(), + legacyReg.get().getRemoteDataDir()); + zookeeperClient.updatePartitionRegistration(tablePath, "legacy", nullBucketCountReg); + + // Verify: "legacy" has null bucketCountActual, "new" has originalBucketCount + Optional beforeLegacy = + zookeeperClient.getPartition(tablePath, "legacy"); + assertThat(beforeLegacy).isPresent(); + assertThat(beforeLegacy.get().getBucketCountActual()).isNull(); + + Optional beforeNew = zookeeperClient.getPartition(tablePath, "new"); + assertThat(beforeNew).isPresent(); + assertThat(beforeNew.get().getBucketCountActual()).isEqualTo(originalBucketCount); + + // ALTER bucket.num in both directions (scale-up 3->6 and scale-down 6->3) + alterBucketNum(metadataManager, tablePath, String.valueOf(newBucketCount)); + + // Verify: table-level bucket count was updated and persisted in ZK + assertThat(metadataManager.getTable(tablePath).getNumBuckets()).isEqualTo(newBucketCount); + + // Verify: "legacy" was backfilled with actual bucket count (from assignment size) + Optional afterLegacy = + zookeeperClient.getPartition(tablePath, "legacy"); + assertThat(afterLegacy).isPresent(); + assertThat(afterLegacy.get().getBucketCountActual()).isEqualTo(originalBucketCount); + + // Verify: "new" still has the original bucketCountActual (not overwritten to the new value) + Optional afterNew = zookeeperClient.getPartition(tablePath, "new"); + assertThat(afterNew).isPresent(); + assertThat(afterNew.get().getBucketCountActual()).isEqualTo(originalBucketCount); + } + + @Test + void testPartitionCreatedInsideAlterWindowKeepsItsOwnBucketCount() throws Exception { + // A partition created between the backfill enumeration and the commit is not part of the + // backfill, so it must keep the bucket count of the assignment it was created with. + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_alter_vs_create_occ"); + int originalBucketCount = 4; + TableAssignment tableAssignment = + generateAssignment(originalBucketCount, 3, getTabletServers()); + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + tableAssignment, + false); + TableInfo tableInfo = metadataManager.getTable(tablePath); + long tableId = tableInfo.getTableId(); + + // an existing partition created with the original bucket count (4) + PartitionAssignment partitionAssignment = + new PartitionAssignment(tableId, tableAssignment.getBucketAssignments()); + metadataManager.createPartition( + tablePath, + tableId, + remoteDataDir, + partitionAssignment, + fromPartitionName(tableInfo.getPartitionKeys(), "2024-01"), + false, + originalBucketCount); + + // The concurrent partition is created with the original count, mirroring a creation that + // read the table registration before the ALTER committed. + MetadataManager alterManager = + metadataManagerOver( + zkClientRunningBeforeFirstCommit( + () -> + metadataManager.createPartition( + tablePath, + tableId, + remoteDataDir, + new PartitionAssignment( + tableId, + tableAssignment.getBucketAssignments()), + fromPartitionName( + tableInfo.getPartitionKeys(), "2024-02"), + false, + originalBucketCount))); + + alterBucketNum(alterManager, tablePath, "8"); + + // The ALTER committed the new table-level count. + assertThat(metadataManager.getTable(tablePath).getNumBuckets()).isEqualTo(8); + + // Both partitions keep a count consistent with the assignment they were created with. + assertPartitionCountMatchesAssignment(tablePath, "2024-01", originalBucketCount); + assertPartitionCountMatchesAssignment(tablePath, "2024-02", originalBucketCount); + } + + /** + * Asserts the partition's persisted bucket count equals both its assignment size and {@code + * expected}. + */ + private static void assertPartitionCountMatchesAssignment( + TablePath tablePath, String partitionName, int expected) throws Exception { + Optional partition = + zookeeperClient.getPartition(tablePath, partitionName); + assertThat(partition).isPresent(); + Optional assignment = + zookeeperClient.getPartitionAssignment(partition.get().getPartitionId()); + assertThat(assignment).isPresent(); + assertThat(partition.get().getBucketCountActual()) + .isEqualTo(assignment.get().getBucketAssignments().size()) + .isEqualTo(expected); + } + + private enum StaleFence { + TABLE_VERSION, + PARTITION_VERSION, + COORDINATOR_EPOCH + } + + @ParameterizedTest + @EnumSource(StaleFence.class) + void testBackfillCommitRejectsStaleFence(StaleFence fence) throws Exception { + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_fence_" + fence.name().toLowerCase()); + int originalBucketCount = 4; + TableAssignment tableAssignment = + generateAssignment(originalBucketCount, 3, getTabletServers()); + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + tableAssignment, + false); + + String partitionName = "2024-01"; + if (fence == StaleFence.PARTITION_VERSION) { + TableInfo tableInfo = metadataManager.getTable(tablePath); + metadataManager.createPartition( + tablePath, + tableInfo.getTableId(), + remoteDataDir, + new PartitionAssignment( + tableInfo.getTableId(), tableAssignment.getBucketAssignments()), + fromPartitionName(tableInfo.getPartitionKeys(), partitionName), + false, + originalBucketCount); + } + + // Capture fresh versions, then make exactly the fenced dimension stale (simulating a + // concurrent read-modify-write or a deposed coordinator). + ZooKeeperClient.VersionedData table = + zookeeperClient.getTableWithVersion(tablePath).get(); + int tableVersion = table.zkVersion(); + Map> backfills = + new HashMap<>(); + int epochVersion = ZkVersion.MATCH_ANY_VERSION.getVersion(); + switch (fence) { + case TABLE_VERSION: + zookeeperClient.updateTable(tablePath, table.data()); + break; + case PARTITION_VERSION: + ZooKeeperClient.VersionedData partition = + zookeeperClient.getPartitionWithVersion(tablePath, partitionName).get(); + zookeeperClient.updatePartitionRegistration( + tablePath, partitionName, partition.data()); + backfills.put(partitionName, partition); + break; + case COORDINATOR_EPOCH: + epochVersion = zookeeperClient.getCurrentEpoch().getCoordinatorEpochZkVersion() + 1; + break; + } + + int staleEpochVersion = epochVersion; + assertThatThrownBy( + () -> + zookeeperClient.updateTableWithPartitionBucketCountBackfill( + tablePath, + table.data().withBucketCount(8), + tableVersion, + backfills, + staleEpochVersion)) + .isInstanceOf(KeeperException.BadVersionException.class); + // The atomic transaction rejected everything: table-level unchanged. + assertThat(metadataManager.getTable(tablePath).getNumBuckets()) + .isEqualTo(originalBucketCount); + + // With every dimension fresh the same commit succeeds. + ZooKeeperClient.VersionedData freshTable = + zookeeperClient.getTableWithVersion(tablePath).get(); + Map> freshBackfills = + new HashMap<>(); + if (fence == StaleFence.PARTITION_VERSION) { + freshBackfills.put( + partitionName, + zookeeperClient.getPartitionWithVersion(tablePath, partitionName).get()); + } + zookeeperClient.updateTableWithPartitionBucketCountBackfill( + tablePath, + freshTable.data().withBucketCount(8), + freshTable.zkVersion(), + freshBackfills, + zookeeperClient.getCurrentEpoch().getCoordinatorEpochZkVersion()); + assertThat(metadataManager.getTable(tablePath).getNumBuckets()).isEqualTo(8); + } + + @ParameterizedTest(name = "newBucketNum={0}") + @MethodSource("outOfRangeBucketNums") + void testAlterBucketNumRejectedOutOfRange( + String newBucketNum, Class expectedException, String message) + throws Exception { + TablePath tablePath = + TablePath.of(DEFAULT_DB, "test_alter_bucket_num_out_of_range_" + newBucketNum); + int originalBucketCount = 4; + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + generateAssignment(originalBucketCount, 3, getTabletServers()), + false); + + assertThatThrownBy(() -> alterBucketNum(metadataManager, tablePath, newBucketNum)) + .isInstanceOf(expectedException) + .hasMessageContaining(message); + // table-level bucket count unchanged + assertThat(metadataManager.getTable(tablePath).getNumBuckets()) + .isEqualTo(originalBucketCount); + } + + private static Stream outOfRangeBucketNums() { + return Stream.of( + Arguments.of("0", InvalidAlterTableException.class, "at least 1"), + Arguments.of( + String.valueOf(ConfigOptions.MAX_BUCKET_NUM.defaultValue() + 1), + TooManyBucketsException.class, + "exceeding the maximum")); + } + + @Test + void testAlterBucketNumRetriesOnceThenSucceeds() throws Exception { + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_alter_bucket_num_retry_success"); + int originalBucketCount = 4; + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + generateAssignment(originalBucketCount, 3, getTabletServers()), + false); + + // A ZK client that throws BadVersion on the first bucket-count commit, then delegates. + // This deterministically exercises the retry loop in alterTableProperties: attempt 1 + // hits BadVersion, attempt 2 re-reads a fresh version and commits successfully. + AtomicInteger commitCalls = new AtomicInteger(); + MetadataManager retryMetadataManager = + metadataManagerOver(zkClientFailingCommits(1, commitCalls)); + + alterBucketNum(retryMetadataManager, tablePath, "8"); + + // The retry loop must have re-invoked the commit exactly once after the injected failure. + assertThat(commitCalls.get()).isEqualTo(2); + // Table-level bucket count is now the new value, confirming the retried attempt succeeded. + assertThat(metadataManager.getTable(tablePath).getNumBuckets()).isEqualTo(8); + } + + @Test + void testAlterBucketNumFailsAfterMaxRetries() throws Exception { + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_alter_bucket_num_retry_exhaust"); + int originalBucketCount = 4; + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + generateAssignment(originalBucketCount, 3, getTabletServers()), + false); + + // Every commit throws BadVersion so the retry loop exhausts its budget and wraps the + // failure into FlussRuntimeException with the "after 3 retries" message. + AtomicInteger commitCalls = new AtomicInteger(); + MetadataManager exhaustRetryManager = + metadataManagerOver(zkClientFailingCommits(Integer.MAX_VALUE, commitCalls)); + + assertThatThrownBy(() -> alterBucketNum(exhaustRetryManager, tablePath, "8")) + .isInstanceOf(FlussRuntimeException.class) + .hasMessageContaining("after 3 retries") + .hasCauseInstanceOf(KeeperException.BadVersionException.class); + // Commit was attempted exactly MAX_ALTER_TABLE_RETRIES=3 times. + assertThat(commitCalls.get()).isEqualTo(3); + // Nothing was persisted. + assertThat(metadataManager.getTable(tablePath).getNumBuckets()) + .isEqualTo(originalBucketCount); + } + + @Test + void testAlterBackfillRejectsPartitionMissingRegistration() throws Exception { + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_alter_bucket_num_missing_reg"); + int originalBucketCount = 4; + String victimPartition = "2024-01"; + createTableWithLegacyPartition(tablePath, victimPartition); + + // Wrap the ZK client so getPartitionWithVersion returns empty for the victim, simulating a + // race where getPartitions() listed the partition but the individual znode has vanished. + Configuration wrapperConfig = new Configuration(); + wrapperConfig.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir); + ZooKeeperClient missingRegClient = + new ZooKeeperClient(sharedZkWrapper(), wrapperConfig) { + @Override + public Optional> + getPartitionWithVersion(TablePath tp, String partitionName) + throws Exception { + if (victimPartition.equals(partitionName)) { + return Optional.empty(); + } + return super.getPartitionWithVersion(tp, partitionName); + } + }; + MetadataManager missingRegManager = metadataManagerOver(missingRegClient); + + assertThatThrownBy(() -> alterBucketNum(missingRegManager, tablePath, "8")) + .isInstanceOf(InvalidAlterTableException.class) + .hasMessageContaining("is listed but its registration is missing"); + // Table-level bucket count MUST remain unchanged: partial commit is unacceptable. + assertThat(metadataManager.getTable(tablePath).getNumBuckets()) + .isEqualTo(originalBucketCount); + } + + @Test + void testAlterBackfillRejectsPartitionMissingAssignment() throws Exception { + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_alter_bucket_num_missing_assign"); + int originalBucketCount = 4; + String victimPartition = "2024-02"; + long victimPartitionId = createTableWithLegacyPartition(tablePath, victimPartition); + + Configuration wrapperConfig = new Configuration(); + wrapperConfig.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir); + ZooKeeperClient missingAssignClient = + new ZooKeeperClient(sharedZkWrapper(), wrapperConfig) { + @Override + public Optional getPartitionAssignment(long partitionId) + throws Exception { + if (partitionId == victimPartitionId) { + return Optional.empty(); + } + return super.getPartitionAssignment(partitionId); + } + }; + MetadataManager missingAssignManager = metadataManagerOver(missingAssignClient); + + assertThatThrownBy(() -> alterBucketNum(missingAssignManager, tablePath, "8")) + .isInstanceOf(InvalidAlterTableException.class) + .hasMessageContaining("has no readable bucket assignment"); + assertThat(metadataManager.getTable(tablePath).getNumBuckets()) + .isEqualTo(originalBucketCount); + } + + @Test + void testAlterRetriesOnConcurrentPartitionDeleteNoNode() throws Exception { + // A partition deleted after the backfill enumeration but before the transaction commit + // makes the commit fail with NoNode. The ALTER must re-read the metadata, skip the + // vanished partition on retry, and succeed — not fail permanently. + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_alter_no_node_retry"); + int originalBucketCount = 4; + TableAssignment tableAssignment = + generateAssignment(originalBucketCount, 3, getTabletServers()); + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + tableAssignment, + false); + TableInfo tableInfo = metadataManager.getTable(tablePath); + long tableId = tableInfo.getTableId(); + + String victimPartition = "2024-01"; + metadataManager.createPartition( + tablePath, + tableId, + remoteDataDir, + new PartitionAssignment(tableId, tableAssignment.getBucketAssignments()), + fromPartitionName(tableInfo.getPartitionKeys(), victimPartition), + false, + originalBucketCount); + + MetadataManager noNodeManager = + metadataManagerOver( + zkClientNoNodeOnce( + () -> { + try { + // The concurrent delete wins the race just before commit. + metadataManager.dropPartition( + tablePath, + ResolvedPartitionSpec.fromPartitionName( + tableInfo.getPartitionKeys(), + victimPartition), + true); + } catch (Exception e) { + throw new RuntimeException(e); + } + })); + + alterBucketNum(noNodeManager, tablePath, "8"); + + // The retry re-enumerated without the deleted partition and committed successfully. + assertThat(metadataManager.getTable(tablePath).getNumBuckets()).isEqualTo(8); + assertThat(zookeeperClient.getPartition(tablePath, victimPartition)).isEmpty(); + } + + @Test + void testAlterFailsCleanlyWhenTableDeletedMidAlter() throws Exception { + // When the table itself is dropped while an ALTER is in flight, the next retry must + // surface a clear TableNotExistException instead of an opaque ZK error. + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_alter_table_gone"); + int originalBucketCount = 4; + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + generateAssignment(originalBucketCount, 3, getTabletServers()), + false); + + MetadataManager tableGoneManager = + metadataManagerOver( + zkClientNoNodeOnce( + () -> { + try { + metadataManager.dropTable(tablePath, true); + } catch (Exception e) { + throw new RuntimeException(e); + } + })); + + assertThatThrownBy(() -> alterBucketNum(tableGoneManager, tablePath, "8")) + .isInstanceOf(TableNotExistException.class); + } + + // ========================== Helpers ========================== + + private static TabletServerInfo[] getTabletServers() { + return new TabletServerInfo[] { + new TabletServerInfo(0, "rack0"), + new TabletServerInfo(1, "rack1"), + new TabletServerInfo(2, "rack2") + }; + } + + /** A partitioned log table: INT column "a", STRING partition key "dt". */ + private static TableDescriptor partitionedLogTable(int bucketCount) { + return TableDescriptor.builder() + .schema( + Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("dt", DataTypes.STRING()) + .build()) + .distributedBy(bucketCount) + .partitionedBy("dt") + .build() + .withReplicationFactor(3); + } + + private static void alterBucketNum( + MetadataManager manager, TablePath tablePath, String newBucketNum) { + TablePropertyChanges.Builder builder = TablePropertyChanges.builder(); + builder.setCustomProperty("bucket.num", newBucketNum); + manager.alterTableProperties( + tablePath, + Collections.singletonList(TableChange.set("bucket.num", newBucketNum)), + builder.build(), + false, + null, + (currentTable, updatedTable) -> {}, + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion()); + } + + private static void resetBucketNum(MetadataManager manager, TablePath tablePath) { + TablePropertyChanges.Builder builder = TablePropertyChanges.builder(); + builder.resetCustomProperty("bucket.num"); + manager.alterTableProperties( + tablePath, + Collections.singletonList(TableChange.reset("bucket.num")), + builder.build(), + false, + null, + (currentTable, updatedTable) -> {}, + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion()); + } + + /** + * Creates a partitioned log table with one partition whose persisted bucket count is then + * cleared, so an ALTER bucket.num must enter the backfill path for it. Returns the partition + * id. + */ + private static long createTableWithLegacyPartition(TablePath tablePath, String partitionName) + throws Exception { + int originalBucketCount = 4; + TableAssignment tableAssignment = + generateAssignment(originalBucketCount, 3, getTabletServers()); + metadataManager.createTable( + tablePath, + remoteDataDir, + partitionedLogTable(originalBucketCount), + tableAssignment, + false); + TableInfo tableInfo = metadataManager.getTable(tablePath); + metadataManager.createPartition( + tablePath, + tableInfo.getTableId(), + remoteDataDir, + new PartitionAssignment( + tableInfo.getTableId(), tableAssignment.getBucketAssignments()), + fromPartitionName(tableInfo.getPartitionKeys(), partitionName), + false, + originalBucketCount); + ZooKeeperClient.VersionedData versioned = + zookeeperClient.getPartitionWithVersion(tablePath, partitionName).get(); + zookeeperClient.updatePartitionRegistration( + tablePath, + partitionName, + new PartitionRegistration( + versioned.data().getTableId(), + versioned.data().getPartitionId(), + versioned.data().getRemoteDataDir(), + null)); + return versioned.data().getPartitionId(); + } + + /** Builds a MetadataManager over a decorated ZK client sharing the test cluster. */ + private static MetadataManager metadataManagerOver(ZooKeeperClient decoratedClient) { + return new MetadataManager( + decoratedClient, + new Configuration(), + new LakeCatalogDynamicLoader(new Configuration(), null, true)); + } + + /** Shares the test ZK connection so decorating subclasses can override single methods. */ + private static CuratorFrameworkWithUnhandledErrorListener sharedZkWrapper() throws Exception { + Field wrapperField = ZooKeeperClient.class.getDeclaredField("curatorFrameworkWrapper"); + wrapperField.setAccessible(true); + return (CuratorFrameworkWithUnhandledErrorListener) wrapperField.get(zookeeperClient); + } + + /** + * A ZK client sharing the test connection whose bucket-count commit throws BadVersion for the + * first {@code failures} calls (counted in {@code commitCalls}) and delegates afterwards. + */ + private static ZooKeeperClient zkClientFailingCommits(int failures, AtomicInteger commitCalls) + throws Exception { + Configuration wrapperConfig = new Configuration(); + wrapperConfig.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir); + return new ZooKeeperClient(sharedZkWrapper(), wrapperConfig) { + @Override + public void updateTableWithPartitionBucketCountBackfill( + TablePath tp, + TableRegistration reg, + int expectedTableZkVersion, + Map> backfills, + int expectedCoordinatorEpochZkVersion) + throws Exception { + if (commitCalls.getAndIncrement() < failures) { + throw new KeeperException.BadVersionException(); + } + super.updateTableWithPartitionBucketCountBackfill( + tp, + reg, + expectedTableZkVersion, + backfills, + expectedCoordinatorEpochZkVersion); + } + }; + } + + /** + * A ZK client sharing the test connection whose first bucket-count commit performs {@code + * beforeFirstCommit} and then throws NoNode, simulating a concurrent delete winning the race + * just before the commit; later calls delegate. + */ + private static ZooKeeperClient zkClientNoNodeOnce(Runnable beforeFirstCommit) throws Exception { + Configuration wrapperConfig = new Configuration(); + wrapperConfig.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir); + return new ZooKeeperClient(sharedZkWrapper(), wrapperConfig) { + private boolean failed; + + @Override + public void updateTableWithPartitionBucketCountBackfill( + TablePath tp, + TableRegistration reg, + int expectedTableZkVersion, + Map> backfills, + int expectedCoordinatorEpochZkVersion) + throws Exception { + if (!failed) { + failed = true; + beforeFirstCommit.run(); + throw new KeeperException.NoNodeException(ZkData.TableZNode.path(tp)); + } + super.updateTableWithPartitionBucketCountBackfill( + tp, + reg, + expectedTableZkVersion, + backfills, + expectedCoordinatorEpochZkVersion); + } + }; + } + + /** + * A ZK client sharing the test connection that runs {@code beforeFirstCommit} right before the + * first bucket-count commit and then delegates, giving a deterministic interleaving without + * injecting a failure. + */ + private static ZooKeeperClient zkClientRunningBeforeFirstCommit(RunnableWithException action) + throws Exception { + Configuration wrapperConfig = new Configuration(); + wrapperConfig.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir); + return new ZooKeeperClient(sharedZkWrapper(), wrapperConfig) { + private boolean ran; + + @Override + public void updateTableWithPartitionBucketCountBackfill( + TablePath tp, + TableRegistration reg, + int expectedTableZkVersion, + Map> backfills, + int expectedCoordinatorEpochZkVersion) + throws Exception { + if (!ran) { + ran = true; + action.run(); + } + super.updateTableWithPartitionBucketCountBackfill( + tp, + reg, + expectedTableZkVersion, + backfills, + expectedCoordinatorEpochZkVersion); + } + }; + } +} diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java index 72a71d6373a..91d98274acf 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java @@ -370,7 +370,8 @@ void testAddPartitionedTable(TestParams params) throws Exception { remoteDataDir, partitionAssignment, fromPartitionName(table.getPartitionKeys(), partitionName), - false); + false, + table.getNumBuckets()); // mock the partition is created in zk. autoPartitionManager.addPartition(tableId, partitionName); } @@ -454,7 +455,8 @@ void testDayFormatWithDashes() throws Exception { remoteDataDir, partitionAssignment, fromPartitionName(table.getPartitionKeys(), "2024-09-15"), - false); + false, + table.getNumBuckets()); autoPartitionManager.addPartition(table.getTableId(), "2024-09-15"); metadataManager.dropPartition( @@ -551,7 +553,8 @@ void testMaxPartitions() throws Exception { remoteDataDir, partitionAssignment, fromPartitionName(table.getPartitionKeys(), i + ""), - false); + false, + table.getNumBuckets()); // mock the partition is created in zk. autoPartitionManager.addPartition(tableId, i + ""); } @@ -734,6 +737,70 @@ void testMaxBucketNumPerPartition() throws Exception { assertThat(partitionsNum).isEqualTo(5); } + /** + * Verifies that after {@code ALTER TABLE ... SET ('bucket.num' = ...)} refreshes the cached + * {@link TableInfo} (via {@link AutoPartitionManager#updateAutoPartitionTables}), auto-created + * new partitions use the new bucket count while already-existing partitions keep their original + * bucket count. This guards the {@code CoordinatorEventProcessor.postAlterTableProperties} + * refresh path for bucket.num-only changes. + */ + @Test + void testAutoCreatedPartitionUsesUpdatedBucketCountActual() throws Exception { + ZonedDateTime startTime = + LocalDateTime.parse("2024-09-10T00:00:00").atZone(ZoneId.systemDefault()); + long startMs = startTime.toInstant().toEpochMilli(); + ManualClock clock = new ManualClock(startMs); + ManuallyTriggeredScheduledExecutorService periodicExecutor = + new ManuallyTriggeredScheduledExecutorService(); + + AutoPartitionManager autoPartitionManager = + new AutoPartitionManager( + new TestingServerMetadataCache(3), + metadataManager, + remoteDirDynamicLoader, + new Configuration(), + disabledCapacityController(), + clock, + periodicExecutor); + autoPartitionManager.start(); + + // DAY-partitioned table with 4 buckets per partition, never auto-drop, pre-create 4 + TableInfo table = createPartitionedTableWithBuckets(-1, 4, AutoPartitionTimeUnit.DAY, 4); + TablePath tablePath = table.getTablePath(); + autoPartitionManager.addAutoPartitionTable(table, true); + periodicExecutor.triggerNonPeriodicScheduledTask(); + + Map partitions = + zookeeperClient.getPartitionRegistrations(tablePath); + assertThat(partitions.keySet()) + .containsExactlyInAnyOrder("20240910", "20240911", "20240912", "20240913"); + // all pre-created partitions carry the original bucket count 4 + for (PartitionRegistration reg : partitions.values()) { + assertThat(reg.getBucketCountActual()).isEqualTo(4); + } + + // simulate ALTER bucket.num 4 -> 8: a real ALTER first persists the new table-level bucket + // count to ZK (the authoritative source auto-partition reads), then the coordinator + // refreshes the cached TableInfo. + TableRegistration reg = zookeeperClient.getTable(tablePath).get(); + zookeeperClient.updateTable(tablePath, reg.withBucketCount(8)); + TableInfo updatedTable = createUpdatedBucketCountTableInfo(table, 8); + autoPartitionManager.updateAutoPartitionTables(updatedTable); + // drain the immediate task scheduled by updateAutoPartitionTables (no new partition yet, + // current partition 20240910 and its 4 pre-created partitions already exist) + periodicExecutor.triggerNonPeriodicScheduledTask(); + + // advance one day to trigger creation of a new partition under the new bucket count + clock.advanceTime(Duration.ofDays(1).plusHours(23)); + periodicExecutor.triggerPeriodicScheduledTasks(); + + partitions = zookeeperClient.getPartitionRegistrations(tablePath); + assertThat(partitions.keySet()).contains("20240914"); + // old partition keeps its original bucket count, new partition uses the updated one + assertThat(partitions.get("20240910").getBucketCountActual()).isEqualTo(4); + assertThat(partitions.get("20240914").getBucketCountActual()).isEqualTo(8); + } + @Test void testAutoCreatePartitionChecksCapacityWithoutReservation() throws Exception { ZonedDateTime startTime = @@ -795,14 +862,16 @@ void testAutoDropPartitionDoesNotMutateObservedKvLeaderReplicaCount() throws Exc remoteDataDir, partitionAssignment, fromPartitionName(table.getPartitionKeys(), "2025042600"), - false); + false, + table.getNumBuckets()); metadataManager.createPartition( tablePath, table.getTableId(), remoteDataDir, partitionAssignment, fromPartitionName(table.getPartitionKeys(), "2025042601"), - false); + false, + table.getNumBuckets()); autoPartitionManager.addPartition(table.getTableId(), "2025042600"); autoPartitionManager.addPartition(table.getTableId(), "2025042601"); capacityController.updateObservedKvLeaderReplicaCount((long) table.getNumBuckets() * 2); @@ -1218,7 +1287,8 @@ private void createPartition( remoteDataDir, partitionAssignment, fromPartitionName(tableInfo.getPartitionKeys(), partitionName), - false); + false, + bucketAssignments.size()); autoPartitionManager.addPartition(tableInfo.getTableId(), partitionName); } @@ -1420,6 +1490,24 @@ private TableInfo createUpdatedHistoricalPartitionEnabledTableInfo( return createUpdatedTableInfo(original, newProperties); } + /** Creates a new TableInfo with an updated table-level bucket count, reusing the original. */ + private TableInfo createUpdatedBucketCountTableInfo(TableInfo original, int newNumBuckets) { + return new TableInfo( + original.getTablePath(), + original.getTableId(), + original.getSchemaId(), + original.getSchema(), + original.getBucketKeys(), + original.getPartitionKeys(), + newNumBuckets, + original.getProperties(), + original.getCustomProperties(), + original.getRemoteDataDir(), + original.getComment().orElse(null), + original.getCreatedTime(), + System.currentTimeMillis()); + } + private TableInfo createUpdatedTableInfo(TableInfo original, Configuration newProperties) { return new TableInfo( original.getTablePath(), diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java index a39b3c935db..248bf484810 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java @@ -91,6 +91,7 @@ import org.apache.fluss.server.zk.data.ZkData; import org.apache.fluss.server.zk.data.ZkData.PartitionIdsZNode; import org.apache.fluss.server.zk.data.ZkData.TableIdsZNode; +import org.apache.fluss.server.zk.data.ZkVersion; import org.apache.fluss.testutils.common.AllCallbackWrapper; import org.apache.fluss.testutils.common.ManuallyTriggeredScheduledExecutorService; import org.apache.fluss.types.DataTypes; @@ -1822,7 +1823,8 @@ void testTableRegistrationChange() throws Exception { false, null, (currentTable, updatedTable) -> {}, - (currentTable, updatedTable) -> {}); + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion()); // get updated table info and verify metadata update request is sent TableInfo updatedTableInfo = metadataManager.getTable(t1); @@ -1889,7 +1891,8 @@ void testAlterStandbyReplicaEnabled() throws Exception { false, null, (currentTable, updatedTable) -> {}, - (currentTable, updatedTable) -> {}); + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion()); // verify standby replicas are removed after re-election retryVerifyContext( @@ -1963,7 +1966,8 @@ void testAlterEnableStandbyReplicaForExistingTable() throws Exception { false, null, (currentTable, updatedTable) -> {}, - (currentTable, updatedTable) -> {}); + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion()); // Verify re-election happened: standby assigned and leaderEpoch incremented retryVerifyContext( @@ -2026,7 +2030,8 @@ void testAlterStandbyReplicaEnabledForLogTable() throws Exception { false, null, (currentTable, updatedTable) -> {}, - (currentTable, updatedTable) -> {})) + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion())) .isInstanceOf(InvalidAlterTableException.class) .hasMessageContaining("can only be altered on primary key tables"); @@ -2043,7 +2048,8 @@ void testAlterStandbyReplicaEnabledForLogTable() throws Exception { false, null, (currentTable, updatedTable) -> {}, - (currentTable, updatedTable) -> {})) + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion())) .isInstanceOf(InvalidAlterTableException.class) .hasMessageContaining("can only be altered on primary key tables"); } @@ -2468,14 +2474,16 @@ private Tuple2 preparePartitionAssignment( partitionAssignment, remoteDataDir, tablePath, - tableId); + tableId, + partitionAssignment.getBucketAssignments().size()); zookeeperClient.registerPartitionAssignmentAndMetadata( partition2Id, partition2Name, partitionAssignment, remoteDataDir, tablePath, - tableId); + tableId, + partitionAssignment.getBucketAssignments().size()); return Tuple2.of( new PartitionIdName(partition1Id, partition1Name), diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatchTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatchTest.java index 0b3892a1cc2..dffb1265abb 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatchTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatchTest.java @@ -20,6 +20,7 @@ import org.apache.fluss.exception.NetworkException; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TableInfo; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrRequest; import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrResponse; @@ -35,6 +36,8 @@ import java.util.Collections; import java.util.function.BiConsumer; +import static org.apache.fluss.record.TestData.DATA1_TABLE_DESCRIPTOR; +import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR; import static org.assertj.core.api.Assertions.assertThat; /** Test for the {@link CoordinatorRequestBatch}. */ @@ -59,6 +62,7 @@ void testNotifyLeaderAndIsrSendFailureClearsLeaderPending() { TablePath tablePath = TablePath.of("db1", "t1"); coordinatorContext.putTablePath(tableId, tablePath); + putTableInfo(tableId, tablePath); coordinatorContext.setLiveTabletServers( CoordinatorTestUtils.createServers(Collections.singletonList(0))); coordinatorContext.updateBucketReplicaAssignment(tb, Collections.singletonList(0)); @@ -98,6 +102,7 @@ void testNotifyLeaderAndIsrSendFailureToFollowerDoesNotClearOtherPending() { TablePath tablePath = TablePath.of("db1", "t2"); coordinatorContext.putTablePath(tableId, tablePath); + putTableInfo(tableId, tablePath); coordinatorContext.setLiveTabletServers( CoordinatorTestUtils.createServers(Arrays.asList(0, 1))); coordinatorContext.updateBucketReplicaAssignment(followerTb, Arrays.asList(0, 1)); @@ -132,6 +137,22 @@ void testNotifyLeaderAndIsrSendFailureToFollowerDoesNotClearOtherPending() { .containsExactly(otherLeaderTb); } + /** + * Registers the table metadata that {@code addNotifyLeaderRequestForTabletServers} needs to + * resolve the bucket layout epoch; without it the bucket is skipped before the send path. + */ + private void putTableInfo(long tableId, TablePath tablePath) { + coordinatorContext.putTableInfo( + TableInfo.of( + tablePath, + tableId, + 0, + DATA1_TABLE_DESCRIPTOR, + DEFAULT_REMOTE_DATA_DIR, + System.currentTimeMillis(), + System.currentTimeMillis())); + } + private static TestCoordinatorChannelManager newAlwaysFailingChannelManager() { return new TestCoordinatorChannelManager() { @Override diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerTest.java index 8116cfcb35b..235d09af841 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/TableManagerTest.java @@ -318,7 +318,8 @@ void testCreateAndDropPartition() throws Exception { partitionAssignment, DEFAULT_REMOTE_DATA_DIR, DATA1_TABLE_PATH, - tableId); + tableId, + partitionAssignment.getBucketAssignments().size()); // create partition tableManager.onCreateNewPartition( diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcherTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcherTest.java index 0b0a1b1daa5..28a4131ecd8 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcherTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/event/watcher/TableChangeWatcherTest.java @@ -46,6 +46,7 @@ import org.apache.fluss.server.zk.data.PartitionAssignment; import org.apache.fluss.server.zk.data.TableAssignment; import org.apache.fluss.server.zk.data.TableRegistration; +import org.apache.fluss.server.zk.data.ZkVersion; import org.apache.fluss.testutils.common.AllCallbackWrapper; import org.apache.fluss.types.DataTypes; import org.apache.fluss.utils.clock.SystemClock; @@ -252,9 +253,21 @@ void testPartitionedTable() throws Exception { .getBucketAssignments()); // register assignment and metadata zookeeperClient.registerPartitionAssignmentAndMetadata( - 1L, "2011", partitionAssignment, remoteDataDir, tablePath, tableId); + 1L, + "2011", + partitionAssignment, + remoteDataDir, + tablePath, + tableId, + partitionAssignment.getBucketAssignments().size()); zookeeperClient.registerPartitionAssignmentAndMetadata( - 2L, "2022", partitionAssignment, remoteDataDir, tablePath, tableId); + 2L, + "2022", + partitionAssignment, + remoteDataDir, + tablePath, + tableId, + partitionAssignment.getBucketAssignments().size()); // create partitions events expectedEvents.add( @@ -415,7 +428,8 @@ void testTableRegistrationChange() { false, null, (currentTable, updatedTable) -> {}, - (currentTable, updatedTable) -> {}); + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion()); // get the updated table registration TableRegistration updatedTableRegistration = diff --git a/fluss-server/src/test/java/org/apache/fluss/server/log/remote/RemoteLogManagerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/log/remote/RemoteLogManagerTest.java index ba876c2e7b5..17079e042ad 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/log/remote/RemoteLogManagerTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/log/remote/RemoteLogManagerTest.java @@ -443,7 +443,9 @@ void testRemoteFirstFetchRejectsNonLeader(boolean partitionTable) throws Excepti Arrays.asList(TABLET_SERVER_ID, newLeaderId), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH + 1))); + INITIAL_BUCKET_EPOCH + 1), + 3, + 0L)); CompletableFuture> fetchFuture = new CompletableFuture<>(); diff --git a/fluss-server/src/test/java/org/apache/fluss/server/log/remote/RemoteLogTestBase.java b/fluss-server/src/test/java/org/apache/fluss/server/log/remote/RemoteLogTestBase.java index a533c3ecda7..d0ed729eb8b 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/log/remote/RemoteLogTestBase.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/log/remote/RemoteLogTestBase.java @@ -88,12 +88,9 @@ private Replica makeReplicaAndAddSegments( tb, Collections.singletonList(0), new LeaderAndIsr( - 0, - 0, - Collections.singletonList(0), - Collections.emptyList(), - 0, - 0))); + 0, 0, Collections.singletonList(0), Collections.emptyList(), 0, 0), + 3, + 0L)); addMultiSegmentsToLogTablet(replica.getLogTablet(), segmentSize); return replica; } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/metadata/TabletServerMetadataCacheTest.java b/fluss-server/src/test/java/org/apache/fluss/server/metadata/TabletServerMetadataCacheTest.java index 213d60b0ebe..e4ae9ad4f0c 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/metadata/TabletServerMetadataCacheTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/metadata/TabletServerMetadataCacheTest.java @@ -47,12 +47,14 @@ import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH; import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR; import static org.apache.fluss.server.metadata.PartitionMetadata.DELETED_PARTITION_ID; +import static org.apache.fluss.server.metadata.PartitionMetadata.DELETED_PARTITION_NAME; import static org.apache.fluss.server.metadata.TableMetadata.DELETED_TABLE_ID; import static org.apache.fluss.server.zk.data.LeaderAndIsr.NO_LEADER; import static org.assertj.core.api.Assertions.assertThat; /** Test for {@link TabletServerMetadataCache}. */ public class TabletServerMetadataCacheTest { + private TabletServerMetadataCache serverMetadataCache; private ServerInfo coordinatorServer; private Set aliveTableServers; @@ -319,6 +321,145 @@ private void assertTableMetadataEquals( .hasSameElementsAs(expectedBucketMetadataList); } + @Test + void testPartitionBucketCountActualRemovedOnDelete() { + // Seed both partitions with explicit per-partition bucket counts. + int explicitBucketCount = 8; + serverMetadataCache.updateClusterMetadata( + new ClusterMetadata( + coordinatorServer, + aliveTableServers, + tableMetadataList, + Arrays.asList( + new PartitionMetadata( + partitionTableId, + partitionName1, + partitionId1, + initialBucketMetadata, + explicitBucketCount), + new PartitionMetadata( + partitionTableId, + partitionName2, + partitionId2, + initialBucketMetadata, + explicitBucketCount)))); + // The explicit count must be exposed via the updateClusterMetadata path (not the + // merged bucket-metadata-list size); the delete/re-add asserts below build on this. + assertThat( + serverMetadataCache + .getPartitionMetadata( + PhysicalTablePath.of(partitionedTablePath, partitionName1)) + .get() + .getBucketCountActual()) + .isEqualTo(explicitBucketCount); + + // Delete partition1 via DELETED_PARTITION_ID (partitionId marks deletion). + serverMetadataCache.updateClusterMetadata( + new ClusterMetadata( + coordinatorServer, + aliveTableServers, + Collections.emptyList(), + Collections.singletonList( + new PartitionMetadata( + partitionTableId, + partitionName1, + DELETED_PARTITION_ID, + Collections.emptyList())))); + assertThat( + serverMetadataCache.getPartitionMetadata( + PhysicalTablePath.of(partitionedTablePath, partitionName1))) + .isEmpty(); + + // Re-add partition1 WITHOUT an explicit bucketCountActual. The cache must NOT return the + // stale 8; the DELETED_PARTITION_ID path must have removed the prior entry from the + // partitionBucketCountActuals map so the fallback (bucketMetadataList.size()) applies. + serverMetadataCache.updateClusterMetadata( + new ClusterMetadata( + coordinatorServer, + aliveTableServers, + Collections.emptyList(), + Collections.singletonList( + new PartitionMetadata( + partitionTableId, + partitionName1, + partitionId1, + initialBucketMetadata)))); + assertThat( + serverMetadataCache + .getPartitionMetadata( + PhysicalTablePath.of(partitionedTablePath, partitionName1)) + .get() + .getBucketCountActual()) + .isEqualTo(initialBucketMetadata.size()); + + // Delete partition2 via DELETED_PARTITION_NAME (partitionName marks deletion). + serverMetadataCache.updateClusterMetadata( + new ClusterMetadata( + coordinatorServer, + aliveTableServers, + Collections.emptyList(), + Collections.singletonList( + new PartitionMetadata( + partitionTableId, + DELETED_PARTITION_NAME, + partitionId2, + Collections.emptyList())))); + assertThat( + serverMetadataCache.getPartitionMetadata( + PhysicalTablePath.of(partitionedTablePath, partitionName2))) + .isEmpty(); + + // Re-add partition2 WITHOUT explicit; the DELETED_PARTITION_NAME path must have also + // cleared partitionBucketCountActuals, so fallback (list size) applies rather than stale 8. + serverMetadataCache.updateClusterMetadata( + new ClusterMetadata( + coordinatorServer, + aliveTableServers, + Collections.emptyList(), + Collections.singletonList( + new PartitionMetadata( + partitionTableId, + partitionName2, + partitionId2, + initialBucketMetadata)))); + assertThat( + serverMetadataCache + .getPartitionMetadata( + PhysicalTablePath.of(partitionedTablePath, partitionName2)) + .get() + .getBucketCountActual()) + .isEqualTo(initialBucketMetadata.size()); + } + + @Test + void testUpdatePartitionMetadataPropagatesExplicitBucketCountActual() { + // Seed table metadata: updatePartitionMetadata bails out if the tableId is unknown. + serverMetadataCache.updateClusterMetadata( + new ClusterMetadata( + coordinatorServer, + aliveTableServers, + tableMetadataList, + Collections.emptyList())); + + // Route via the single-partition updatePartitionMetadata path (distinct from + // updateClusterMetadata). The explicit bucketCountActual must be applied to the cache. + int explicitBucketCount = 8; + serverMetadataCache.updatePartitionMetadata( + new PartitionMetadata( + partitionTableId, + partitionName1, + partitionId1, + initialBucketMetadata, + explicitBucketCount)); + assertThat( + serverMetadataCache + .getPartitionMetadata( + PhysicalTablePath.of(partitionedTablePath, partitionName1)) + .get() + .getBucketCountActual()) + .isEqualTo(explicitBucketCount); + } + private void assertPartitionMetadataEquals( long partitionId, long expectedTableId, diff --git a/fluss-server/src/test/java/org/apache/fluss/server/metadata/ZkBasedMetadataProviderTest.java b/fluss-server/src/test/java/org/apache/fluss/server/metadata/ZkBasedMetadataProviderTest.java index 5e74623c740..6200cf886d7 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/metadata/ZkBasedMetadataProviderTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/metadata/ZkBasedMetadataProviderTest.java @@ -187,7 +187,8 @@ void testGetPartitionMetadataFromZk() throws Exception { partitionAssignment, DEFAULT_REMOTE_DATA_DIR, tablePath, - tableId); + tableId, + partitionAssignment.getBucketAssignments().size()); // Create leader and isr for partition buckets TableBucket partitionBucket0 = new TableBucket(tableId, partitionId, 0); @@ -272,14 +273,16 @@ void testBatchGetPartitionMetadataFromZkAsync() throws Exception { partitionAssignment1, DEFAULT_REMOTE_DATA_DIR, tablePath1, - tableId1); + tableId1, + partitionAssignment1.getBucketAssignments().size()); zookeeperClient.registerPartitionAssignmentAndMetadata( partitionId2, partitionName2, partitionAssignment2, DEFAULT_REMOTE_DATA_DIR, tablePath1, - tableId1); + tableId1, + partitionAssignment2.getBucketAssignments().size()); // Create partition for table2 long partitionId3 = 21L; @@ -295,7 +298,8 @@ void testBatchGetPartitionMetadataFromZkAsync() throws Exception { partitionAssignment3, DEFAULT_REMOTE_DATA_DIR, tablePath2, - tableId2); + tableId2, + partitionAssignment3.getBucketAssignments().size()); // Create leader and isr for all partition buckets TableBucket bucket1 = new TableBucket(tableId1, partitionId1, 0); diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/AdjustIsrTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/AdjustIsrTest.java index 77e425bbcc8..b481b2f855e 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/AdjustIsrTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/AdjustIsrTest.java @@ -140,7 +140,9 @@ void testShrinkIsrUsesLatestStateAfterLeaderChange() throws Exception { DATA1_PHYSICAL_TABLE_PATH, tb, Arrays.asList(1, 2), - new LeaderAndIsr(1, 0, Arrays.asList(1, 2), Collections.emptyList(), 0, 0)); + new LeaderAndIsr(1, 0, Arrays.asList(1, 2), Collections.emptyList(), 0, 0), + 3, + 0L); ReentrantReadWriteLock leaderIsrUpdateLock = replica.getLeaderIsrUpdateLock(); @@ -349,6 +351,8 @@ private void notifyLeaderAndIsr( replicas, Collections.emptyList(), replica.getCoordinatorEpoch(), - bucketEpoch)))); + bucketEpoch), + 3, + 0L))); } } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java index 3364aa61b08..5d95f19c9a5 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaManagerTest.java @@ -617,7 +617,9 @@ void testNewKvLeaderRejectedWhenDiskLocked() throws Exception { Collections.singletonList(TABLET_SERVER_ID), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), future::complete); List results = future.get(); @@ -643,7 +645,9 @@ void testNewKvLeaderRejectedWhenDiskLocked() throws Exception { Collections.singletonList(TABLET_SERVER_ID), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), future::complete); assertThat(future.get()).containsOnly(new NotifyLeaderAndIsrResultForBucket(logTb)); @@ -1787,7 +1791,9 @@ void becomeLeaderOrFollower() throws Exception { Arrays.asList(1, 2, 3), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), future::complete); assertThat(future.get()).containsOnly(new NotifyLeaderAndIsrResultForBucket(tb)); assertReplicaEpochEquals( @@ -1808,7 +1814,9 @@ void becomeLeaderOrFollower() throws Exception { Arrays.asList(1, 2, 3), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), future::complete); assertThat(future.get()) .containsOnly( @@ -1857,7 +1865,9 @@ void testLakeSnapshotReadFailureDoesNotFailLeaderTransition() throws Exception { Collections.singletonList(TABLET_SERVER_ID), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), future::complete); assertThat(future.get()).containsOnly(new NotifyLeaderAndIsrResultForBucket(tableBucket)); @@ -1884,7 +1894,9 @@ void testStopReplica() throws Exception { Arrays.asList(1, 2, 3), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), future::complete); assertThat(future.get()).containsOnly(new NotifyLeaderAndIsrResultForBucket(tb)); assertReplicaEpochEquals( @@ -1916,7 +1928,9 @@ void testStopReplica() throws Exception { Arrays.asList(1, 2, 3), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), future::complete); assertThat(future.get()).containsOnly(new NotifyLeaderAndIsrResultForBucket(tb)); assertReplicaEpochEquals( diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java new file mode 100644 index 00000000000..fb234e75344 --- /dev/null +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java @@ -0,0 +1,143 @@ +/* + * 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.server.replica; + +import org.apache.fluss.exception.StaleMetadataException; +import org.apache.fluss.metadata.PhysicalTablePath; +import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.rpc.protocol.Errors; +import org.apache.fluss.server.entity.NotifyLeaderAndIsrData; +import org.apache.fluss.server.entity.NotifyLeaderAndIsrResultForBucket; +import org.apache.fluss.server.metadata.ClusterMetadata; +import org.apache.fluss.server.metadata.TableMetadata; +import org.apache.fluss.server.zk.data.LeaderAndIsr; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.apache.fluss.record.TestData.DATA1_TABLE_DESCRIPTOR; +import static org.apache.fluss.record.TestData.DATA1_TABLE_ID; +import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH; +import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR; +import static org.apache.fluss.server.coordinator.CoordinatorContext.INITIAL_COORDINATOR_EPOCH; +import static org.apache.fluss.server.zk.data.LeaderAndIsr.INITIAL_BUCKET_EPOCH; +import static org.apache.fluss.server.zk.data.LeaderAndIsr.INITIAL_LEADER_EPOCH; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Test for the routing state a {@link Replica} is armed with on leader activation and the request + * validation driven by it. + */ +final class ReplicaRoutingStateTest extends ReplicaTestBase { + + /** + * A bucket no other test class in this package uses. {@code TestingMetricGroups} caches bucket + * metric groups per (table, bucket) in a static registry, so sharing a bucket coordinate hands + * stale gauges across test classes. + */ + private static final int TEST_BUCKET = 5; + + @Test + void testLeaderActivationRequiresRoutingState() throws Exception { + TableBucket tb = new TableBucket(DATA1_TABLE_ID, TEST_BUCKET); + + // A legacy coordinator's notification (no routing fields) fails leader activation loudly: + // the upgrade contract requires the CoordinatorServer to be upgraded first. + CompletableFuture> legacyFuture = + new CompletableFuture<>(); + replicaManager.becomeLeaderOrFollower( + INITIAL_COORDINATOR_EPOCH, + Collections.singletonList(notifyDataWithRoutingState(tb, null, null)), + legacyFuture::complete); + assertThat(legacyFuture.get().get(0).getError().error()) + .isEqualTo(Errors.UNSUPPORTED_VERSION); + assertThat(legacyFuture.get().get(0).getError().messageWithFallback()) + .contains("upgrade the CoordinatorServer first"); + assertThat(replicaManager.getReplicaOrException(tb).isLeader()).isFalse(); + + // A new coordinator's notification activates the leader and arms the routing state. + makeLeaderWithRoutingState(tb, 3, 0L); + assertThat(replicaManager.getReplicaOrException(tb).isLeader()).isTrue(); + replicaManager.validateRoutingBucketCount(tb, 3); + assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(tb, 4)) + .isInstanceOf(StaleMetadataException.class); + + // A legacy client (no count) passes on a non-rescaled table... + replicaManager.validateRoutingBucketCount(tb, 0); + // ...but is rejected once an ALTER advances the metadata cache to epoch 1 through + // UpdateMetadata, which does not re-notify the already active replica. + replicaManager.maybeUpdateMetadataCache( + INITIAL_COORDINATOR_EPOCH, + new ClusterMetadata( + null, + Collections.emptySet(), + Collections.singletonList( + new TableMetadata( + TableInfo.of( + DATA1_TABLE_PATH, + DATA1_TABLE_ID, + 1, + DATA1_TABLE_DESCRIPTOR, + DEFAULT_REMOTE_DATA_DIR, + 1L, + 1L, + 1L), + Collections.emptyList())), + Collections.emptyList())); + assertThat(replicaManager.getReplicaOrException(tb).getBucketLayoutEpoch()).isEqualTo(0L); + assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(tb, 0)) + .isInstanceOf(StaleMetadataException.class); + + // An unknown bucket keeps the downstream per-bucket error semantics: validation passes. + replicaManager.validateRoutingBucketCount(new TableBucket(DATA1_TABLE_ID, 99), 3); + } + + private void makeLeaderWithRoutingState( + TableBucket tb, Integer bucketCountActual, Long bucketLayoutEpoch) throws Exception { + CompletableFuture> future = + new CompletableFuture<>(); + replicaManager.becomeLeaderOrFollower( + INITIAL_COORDINATOR_EPOCH, + Collections.singletonList( + notifyDataWithRoutingState(tb, bucketCountActual, bucketLayoutEpoch)), + future::complete); + assertThat(future.get()).containsOnly(new NotifyLeaderAndIsrResultForBucket(tb)); + } + + private static NotifyLeaderAndIsrData notifyDataWithRoutingState( + TableBucket tb, Integer bucketCountActual, Long bucketLayoutEpoch) { + return new NotifyLeaderAndIsrData( + PhysicalTablePath.of(DATA1_TABLE_PATH), + tb, + Collections.singletonList(TABLET_SERVER_ID), + new LeaderAndIsr( + TABLET_SERVER_ID, + INITIAL_LEADER_EPOCH, + Collections.singletonList(TABLET_SERVER_ID), + Collections.emptyList(), + INITIAL_COORDINATOR_EPOCH, + INITIAL_BUCKET_EPOCH), + bucketCountActual, + bucketLayoutEpoch); + } +} diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java index b1e98b9d30f..b9e8cb7ce4b 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTest.java @@ -19,6 +19,7 @@ import org.apache.fluss.config.ConfigOptions; import org.apache.fluss.config.Configuration; +import org.apache.fluss.exception.NotLeaderOrFollowerException; import org.apache.fluss.exception.OutOfOrderSequenceException; import org.apache.fluss.fs.FsPath; import org.apache.fluss.metadata.LogFormat; @@ -48,6 +49,7 @@ import org.apache.fluss.server.kv.snapshot.KvSnapshotDownloadSpec; import org.apache.fluss.server.kv.snapshot.TestingCompletedKvSnapshotCommitter; import org.apache.fluss.server.log.FetchParams; +import org.apache.fluss.server.log.ListOffsetsParam; import org.apache.fluss.server.log.LogAppendInfo; import org.apache.fluss.server.log.LogReadInfo; import org.apache.fluss.server.testutils.KvTestUtils; @@ -149,6 +151,21 @@ void testMakeLeader() throws Exception { assertThat(kvReplica.getKvTablet()).isNotNull(); } + @Test + void testGetOffsetRequiresLeader() throws Exception { + Replica replica = + makeLogReplica(DATA1_PHYSICAL_TABLE_PATH, new TableBucket(DATA1_TABLE_ID, 1)); + + assertThat(replica.isLeader()).isFalse(); + assertThatThrownBy( + () -> + replica.getOffset( + remoteLogManager, + new ListOffsetsParam( + -1, ListOffsetsParam.LATEST_OFFSET_TYPE, null))) + .isInstanceOf(NotLeaderOrFollowerException.class); + } + @Test void testAppendRecordsToLeader() throws Exception { Replica logReplica = @@ -237,7 +254,9 @@ void testBucketPhysicalStorageLocalLogSizeIncludesFollower() throws Exception { replicas, Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - followerLeaderEpoch))); + followerLeaderEpoch), + 3, + 0L)); assertThat(logReplica.isLeader()).isFalse(); assertThat(localLogSizeGauge.getValue()).isEqualTo(localLogSize); @@ -1147,7 +1166,9 @@ private void makeKvReplicaAsFollower(Replica replica, int leaderEpoch) { Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, // we also use the leader epoch as bucket epoch - leaderEpoch))); + leaderEpoch), + 3, + 0L)); } private void makeLeaderReplica( @@ -1165,7 +1186,9 @@ private void makeLeaderReplica( Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, // we also use the leader epoch as bucket epoch - leaderEpoch))); + leaderEpoch), + 3, + 0L)); } private static LogRecords fetchRecords(Replica replica) throws IOException { diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTestBase.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTestBase.java index 98d714edc1e..fbc9b25c512 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTestBase.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaTestBase.java @@ -457,7 +457,10 @@ protected void makeLogTableAsLeader( isr, Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH)))); + INITIAL_BUCKET_EPOCH), + // all test tables are created distributedBy(3) + TEST_ROUTING_BUCKET_COUNT, + 0L))); } // TODO this is only for single tablet server unit test. @@ -500,9 +503,15 @@ protected void makeKvTableAsLeader( Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, // use leader epoch as bucket epoch - leaderEpoch)))); + leaderEpoch), + // all test tables are created distributedBy(3) + TEST_ROUTING_BUCKET_COUNT, + 0L))); } + /** The routing bucket count carried by test notifications; test tables are distributedBy(3). */ + protected static final Integer TEST_ROUTING_BUCKET_COUNT = 3; + protected void makeLeaderAndFollower(List notifyLeaderAndIsrDataList) { replicaManager.becomeLeaderOrFollower(0, notifyLeaderAndIsrDataList, result -> {}); } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherManagerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherManagerTest.java index d61b6319c10..5c059dea1d2 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherManagerTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherManagerTest.java @@ -96,7 +96,9 @@ void testAddAndRemoveBucket() { Arrays.asList(leader.id(), TABLET_SERVER_ID), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - LeaderAndIsr.INITIAL_BUCKET_EPOCH))), + LeaderAndIsr.INITIAL_BUCKET_EPOCH), + 3, + 0L)), result -> {}); InitialFetchStatus initialFetchStatus = @@ -143,7 +145,9 @@ void testDoesNotAddFetcherWhenFollowerHasNoLeader() { Collections.emptyList(), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - LeaderAndIsr.INITIAL_BUCKET_EPOCH))), + LeaderAndIsr.INITIAL_BUCKET_EPOCH), + 3, + 0L)), result::set); assertThat(result.get()).hasSize(1); diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherThreadTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherThreadTest.java index 37b764c0bf5..8e8103ec8e3 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherThreadTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/fetcher/ReplicaFetcherThreadTest.java @@ -570,7 +570,9 @@ private void makeLeaderAndFollower( Arrays.asList(leaderServerId, followerServerId), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), result -> {}); followerRM.becomeLeaderOrFollower( INITIAL_COORDINATOR_EPOCH, @@ -585,7 +587,9 @@ private void makeLeaderAndFollower( Arrays.asList(leaderServerId, followerServerId), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), result -> {}); } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManagerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManagerTest.java index b18f96b3a7a..ff7d174358a 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManagerTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/historical/HistoricalPartitionManagerTest.java @@ -889,7 +889,9 @@ private TableInfo registerHistoricalTableAndBecomeLeader( Collections.singletonList(TABLET_SERVER_ID), Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH))), + INITIAL_BUCKET_EPOCH), + 3, + 0L)), leaderFuture::complete); assertThat(leaderFuture.get(10, TimeUnit.SECONDS)) .containsOnly(new NotifyLeaderAndIsrResultForBucket(TABLE_BUCKET)); @@ -909,7 +911,9 @@ private static NotifyLeaderAndIsrData followerState() { replicas, Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH + 1)); + INITIAL_BUCKET_EPOCH + 1), + 3, + 0L); } private static NotifyLeaderAndIsrData leaderStateAfterFollower() { @@ -924,7 +928,9 @@ private static NotifyLeaderAndIsrData leaderStateAfterFollower() { replicas, Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, - INITIAL_BUCKET_EPOCH + 2)); + INITIAL_BUCKET_EPOCH + 2), + 3, + 0L); } private static void await(CountDownLatch latch) { diff --git a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java index 84708d0b6c1..b0848e4e299 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java @@ -20,6 +20,7 @@ import org.apache.fluss.config.ConfigOptions; import org.apache.fluss.exception.FlussRuntimeException; import org.apache.fluss.exception.InvalidRequiredAcksException; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.KvFormat; import org.apache.fluss.metadata.LogFormat; import org.apache.fluss.metadata.PhysicalTablePath; @@ -39,6 +40,7 @@ import org.apache.fluss.rpc.messages.FetchLogResponse; import org.apache.fluss.rpc.messages.InitWriterRequest; import org.apache.fluss.rpc.messages.InitWriterResponse; +import org.apache.fluss.rpc.messages.ListOffsetsRequest; import org.apache.fluss.rpc.messages.ListOffsetsResponse; import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrRequest; import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrResponse; @@ -766,6 +768,70 @@ void testLimitScanLogTable() throws Exception { expected2); } + @Test + void testRoutingBucketCountValidationAppliesToClientRequestsOnly() throws Exception { + long tableId = + createTable(FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH, DATA1_TABLE_DESCRIPTOR); + TableBucket tb = new TableBucket(tableId, 0); + + FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb); + + int leader = FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb); + TabletServerGateway leaderGateWay = + FLUSS_CLUSTER_EXTENSION.newTabletServerClientForNode(leader); + + // a client whose bucket count doesn't match the actual one computed its bucketId from a + // count the server has confirmed to be stale. + assertThatThrownBy( + () -> + leaderGateWay + .listOffsets( + newListOffsetsRequestWithRoutingBucketCount( + -1, + ListOffsetsParam.LATEST_OFFSET_TYPE, + tableId, + 0, + 999)) + .get()) + .cause() + .isInstanceOf(StaleMetadataException.class); + + // the very same count coming from a follower is not validated: a follower's bucket ids come + // from NotifyLeaderAndIsr, so replication must not depend on the leader's metadata cache. + assertListOffsetsResponse( + leaderGateWay + .listOffsets( + newListOffsetsRequestWithRoutingBucketCount( + 1, ListOffsetsParam.LATEST_OFFSET_TYPE, tableId, 0, 999)) + .get(), + 0L, + Errors.NONE.code(), + null); + + // a client request for a table this server doesn't host a replica for is not validated + // here: it keeps the existing per-bucket unknown-object error of the replica lookup, + // which triggers the client's metadata refresh. + assertListOffsetsResponse( + leaderGateWay + .listOffsets( + newListOffsetsRequestWithRoutingBucketCount( + -1, ListOffsetsParam.LATEST_OFFSET_TYPE, 10005L, 0, 3)) + .get(), + null, + Errors.UNKNOWN_TABLE_OR_BUCKET_EXCEPTION.code(), + "Unknown table or bucket"); + } + + private static ListOffsetsRequest newListOffsetsRequestWithRoutingBucketCount( + int followerServerId, + int offsetType, + long tableId, + int bucketId, + int routingBucketCount) { + return newListOffsetsRequest(followerServerId, offsetType, tableId, bucketId) + .setRoutingBucketCount(routingBucketCount); + } + @Test void testListOffsets() throws Exception { long tableId = @@ -988,7 +1054,12 @@ private NotifyLeaderAndIsrRequest makeNotifyLeaderAndIsrRequest( PbNotifyLeaderAndIsrReqForBucket reqForBucket = makeNotifyBucketLeaderAndIsr( new NotifyLeaderAndIsrData( - physicalTablePath, tableBucket, leaderAndIsr.isr(), leaderAndIsr)); + physicalTablePath, + tableBucket, + leaderAndIsr.isr(), + leaderAndIsr, + 3, + 0L)); return ServerRpcMessageUtils.makeNotifyLeaderAndIsrRequest( 0, Collections.singletonList(reqForBucket)); } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java b/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java index 0cc615482ab..1511a0d4bad 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java @@ -738,7 +738,11 @@ public void triggerAndWaitSnapshot(TablePath tablePath) throws Exception { Map partitions = zooKeeperClient.getPartitionRegistrations(tablePath); for (PartitionRegistration partition : partitions.values()) { - for (int bucketId = 0; bucketId < bucketCount; bucketId++) { + // partitions diverge from the table-level count after ALTER bucket.num + int partitionBucketCountActual = + partition.getBucketCountActualOrDefault( + bucketCount, tableRegistration.bucketLayoutEpoch); + for (int bucketId = 0; bucketId < partitionBucketCountActual; bucketId++) { tableBuckets.add( new TableBucket(tableId, partition.getPartitionId(), bucketId)); } @@ -866,7 +870,9 @@ public void notifyLeaderAndIsr( PhysicalTablePath.of(tablePath), tableBucket, replicas, - leaderAndIsr)); + leaderAndIsr, + 3, + 0L)); NotifyLeaderAndIsrRequest notifyLeaderAndIsrRequest = ServerRpcMessageUtils.makeNotifyLeaderAndIsrRequest( 0, Collections.singletonList(reqForBucket)); diff --git a/fluss-server/src/test/java/org/apache/fluss/server/testutils/PartitionMetadataAssert.java b/fluss-server/src/test/java/org/apache/fluss/server/testutils/PartitionMetadataAssert.java index 349fe9a6fd6..6eb971a910a 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/testutils/PartitionMetadataAssert.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/testutils/PartitionMetadataAssert.java @@ -41,6 +41,12 @@ private PartitionMetadataAssert(PartitionMetadata actual) { public PartitionMetadataAssert isEqualTo(PartitionMetadata expected) { assertThat(expected.getPartitionName()).isEqualTo(actual.getPartitionName()); + // actual bucketCountActual is always non-null (falls back to bucketMetadataList size), so + // only compare when expected sets it — otherwise legacy callers passing null fail + // spuriously. + if (expected.getBucketCountActual() != null) { + assertThat(actual.getBucketCountActual()).isEqualTo(expected.getBucketCountActual()); + } List bucketMetadataList = expected.getBucketMetadataList(); List actualBucketMetadataList = actual.getBucketMetadataList(); assertThat(bucketMetadataList) diff --git a/fluss-server/src/test/java/org/apache/fluss/server/zk/ZooKeeperClientTest.java b/fluss-server/src/test/java/org/apache/fluss/server/zk/ZooKeeperClientTest.java index cbc0b85c6a2..d5f2160a6c4 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/zk/ZooKeeperClientTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/zk/ZooKeeperClientTest.java @@ -594,7 +594,8 @@ void testGetLatestBucketSnapshotsInBatch() throws Exception { new PartitionAssignment(tableId, partitionBucketAssignments), remoteDataDir, TablePath.of("db", "partitioned_table"), - tableId); + tableId, + partitionBucketAssignments.size()); TableBucket partitionBucket = new TableBucket(tableId, partitionId, 0); BucketSnapshot partitionSnapshot = new BucketSnapshot(5L, 50L, "oss://test/partition-cp5"); zookeeperClient.registerTableBucketSnapshot(partitionBucket, partitionSnapshot); @@ -734,9 +735,21 @@ void testPartition() throws Exception { }) .getBucketAssignments()); zookeeperClient.registerPartitionAssignmentAndMetadata( - 1L, "p1", partitionAssignment, remoteDataDir, tablePath, tableId); + 1L, + "p1", + partitionAssignment, + remoteDataDir, + tablePath, + tableId, + partitionAssignment.getBucketAssignments().size()); zookeeperClient.registerPartitionAssignmentAndMetadata( - 2L, "p2", partitionAssignment, remoteDataDir, tablePath, tableId); + 2L, + "p2", + partitionAssignment, + remoteDataDir, + tablePath, + tableId, + partitionAssignment.getBucketAssignments().size()); // check created partitions partitions = zookeeperClient.getPartitions(tablePath); diff --git a/fluss-server/src/test/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerdeTest.java b/fluss-server/src/test/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerdeTest.java index 3676688f5c0..573df6fce07 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerdeTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerdeTest.java @@ -36,10 +36,13 @@ class PartitionRegistrationJsonSerdeTest extends JsonSerdeTestBase + if (info.getBucketCountActual != tableBucketCount) { + throw new UnsupportedOperationException( + s"Spark does not yet support per-partition bucket count rescale. " + + s"Table $tablePath partition ${info.getPartitionName} has bucket count " + + s"${info.getBucketCountActual} but the table-level count is $tableBucketCount.") + } + } + infos + } private var allDataForTriggerAvailableNow: Option[TableBucketOffsets] = None diff --git a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/SplitPlanner.scala b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/SplitPlanner.scala index e8386c94ffa..3a314048fdf 100644 --- a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/SplitPlanner.scala +++ b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/SplitPlanner.scala @@ -130,8 +130,22 @@ abstract class AbstractSplitPlanner( admin0 } - protected lazy val partitionInfos: util.List[PartitionInfo] = - admin.listPartitionInfos(tablePath).get() + protected lazy val partitionInfos: util.List[PartitionInfo] = { + val infos = admin.listPartitionInfos(tablePath).get() + // Fail fast if any partition's bucket count differs from the table-level count. + // Per-partition bucket count rescale (ALTER bucket.num) is not yet supported in Spark. + val tableBucketCount = tableInfo.getNumBuckets + infos.asScala.foreach { + info => + if (info.getBucketCountActual != tableBucketCount) { + throw new UnsupportedOperationException( + s"Spark does not yet support per-partition bucket count rescale. " + + s"Table $tablePath partition ${info.getPartitionName} has bucket count " + + s"${info.getBucketCountActual} but the table-level count is $tableBucketCount.") + } + } + infos + } protected def stoppingOffsetsInitializer: OffsetsInitializer diff --git a/website/docs/engine-flink/ddl.md b/website/docs/engine-flink/ddl.md index 37ca0090a9a..63b000675be 100644 --- a/website/docs/engine-flink/ddl.md +++ b/website/docs/engine-flink/ddl.md @@ -290,6 +290,7 @@ When using SET to modify [Storage Options](engine-flink/options.md#storage-optio **Supported Options to modify** - All [Read Options](engine-flink/options.md#read-options), [Write Options](engine-flink/options.md#write-options), [Lookup Options](engine-flink/options.md#lookup-options) and [Other Options](engine-flink/options.md#other-options) except `bootstrap.servers`. +- `bucket.num`: Set the target number of buckets. For partitioned tables, the new value applies to newly created partitions; existing partitions retain their original bucket count. Not supported on non-partitioned tables, and among lake-enabled tables only Paimon is supported. - The following [Storage Options](engine-flink/options.md#storage-options): - `table.datalake.enabled`: Enable or disable lakehouse storage for the table. - `table.datalake.historical-partition.enabled`: Enable or disable historical partition lookup. @@ -299,6 +300,9 @@ When using SET to modify [Storage Options](engine-flink/options.md#storage-optio - `table.auto-partition.num-precreate`: Set the number of future partitions to pre-create for auto partitioning. ```sql title="Flink SQL" +-- Change the bucket count for a partitioned table (applies to new partitions only) +ALTER TABLE my_table SET ('bucket.num' = '8'); + -- Enable lakehouse storage for the table ALTER TABLE my_table SET ('table.datalake.enabled' = 'true'); diff --git a/website/docs/engine-flink/options.md b/website/docs/engine-flink/options.md index 895051e7d9e..a30f93dcb85 100644 --- a/website/docs/engine-flink/options.md +++ b/website/docs/engine-flink/options.md @@ -64,7 +64,7 @@ See more details about [ALTER TABLE ... SET](engine-flink/ddl.md#set-properties) | Option | Type | Default | Description | |-----------------------------------------|----------|-------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | auto-increment.fields | String | (None) | Defines the auto increment columns. The auto increment column can only be used in primary-key table. With an auto increment column in the table, whenever a new row is inserted into the table, the new row will be assigned with the next available value from the auto-increment sequence. The data type of the auto increment column must be INT or BIGINT. Currently a table can have only one auto-increment column. Adding an auto increment column to an existing table is not supported. | -| bucket.num | int | The bucket number of Fluss cluster. | The number of buckets of a Fluss table. | +| bucket.num | int | The bucket number of Fluss cluster. | The target number of buckets for a Fluss table. For partitioned tables, this value applies to newly created partitions; existing partitions retain their original bucket count. | | bucket.key | String | (None) | Specific the distribution policy of the Fluss table. Data will be distributed to each bucket according to the hash value of bucket-key (It must be a subset of the primary keys excluding partition keys of the primary key table). If you specify multiple fields, delimiter is `,`. If the table has a primary key and a bucket key is not specified, the bucket key will be used as primary key(excluding the partition key). If the table has no primary key and the bucket key is not specified, the data will be distributed to each bucket randomly. | | table.log.ttl | Duration | 7 days | The time to live for log segments. The configuration controls the maximum time log segments are retained before they become eligible for deletion. When remote log tiering is enabled, this value controls the retention of remote log segments. Setting the value to '0ms' disables TTL-based deletion. The default value is 7 days. | | table.log.local-ttl | Duration | (None) | The time to live for local log segments. The configuration controls the maximum time local log segments are retained before they become eligible for deletion. When remote log tiering is enabled, an expired local segment is deleted only after it has been copied to remote storage. Setting the value to '0ms' disables TTL-based deletion. If not configured, the value inherits `table.log.ttl`. When both values are positive, it must be less than or equal to `table.log.ttl`. | diff --git a/website/docs/table-design/data-distribution/bucketing.md b/website/docs/table-design/data-distribution/bucketing.md index 615c1cb0d0b..89f6829c800 100644 --- a/website/docs/table-design/data-distribution/bucketing.md +++ b/website/docs/table-design/data-distribution/bucketing.md @@ -8,7 +8,7 @@ sidebar_position: 1 A bucketing strategy is a data distribution technique that divides table data into small pieces and distributes the data to multiple hosts and services. -When creating a Fluss table, you can specify the number of buckets by setting `'bucket.num' = ''` property for the table, see more details in [DDL](engine-flink/ddl.md). +When creating a Fluss table, you can specify the number of buckets by setting `'bucket.num' = ''` property for the table, see more details in [DDL](engine-flink/ddl.md). For partitioned tables, `bucket.num` can be altered via `ALTER TABLE SET ('bucket.num' = '')` — the new value applies to newly created partitions while existing partitions retain their original bucket count. Currently, Fluss supports 3 bucketing strategies: **Hash Bucketing**, **Sticky Bucketing** and **Round-Robin Bucketing**. Primary-Key Tables only allow to use **Hash Bucketing**. Log Tables use **Sticky Bucketing** by default but can use other two bucketing strategies. From bf61c558c9a6851b76f1c3c3259e444ea5446a6a Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Fri, 4 Sep 2026 02:48:34 +0800 Subject: [PATCH 02/16] Guard the historical reroute against mismatched bucket counts --- .../fluss/client/write/RecordAccumulator.java | 18 ++++- .../org/apache/fluss/client/write/Sender.java | 41 ++++++++++-- .../apache/fluss/client/write/SenderTest.java | 65 ++++++++++++++++++- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java b/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java index 9c86f54ea7d..8d01fcf7d60 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java @@ -453,10 +453,11 @@ boolean isHistoricalPartitionEnabled(TablePath tablePath) { } /** Reroutes queued batches for {@code originalPath} to the historical target. */ - void rerouteQueuedWritesToHistorical( + boolean rerouteQueuedWritesToHistorical( PhysicalTablePath originalPath, PhysicalTablePath historicalPath, - long historicalPartitionId) { + long historicalPartitionId, + @Nullable Integer historicalBucketCount) { BucketAndWriteBatches writeTarget = checkNotNull( writeBatches.get(originalPath), @@ -466,7 +467,17 @@ void rerouteQueuedWritesToHistorical( synchronized (writeTarget) { if (writeTarget.isHistoricalWriteTarget()) { writeTarget.partitionId = historicalPartitionId; - return; + return true; + } + if (historicalBucketCount != null) { + for (Deque deque : writeTarget.batches.values()) { + for (WriteBatch batch : deque) { + if (batch.getBucketCountActual() > 0 + && batch.getBucketCountActual() != historicalBucketCount) { + return false; + } + } + } } // New appends observe the historical route and are marked as historical. Existing // queued batches are converted below before the Sender can drain again. @@ -494,6 +505,7 @@ void rerouteQueuedWritesToHistorical( } } } + return true; } /** Aborts incomplete batches whose current RPC target is {@code targetPath}. */ diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java index 41012ee06ba..55754ebb380 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java @@ -31,6 +31,7 @@ import org.apache.fluss.exception.UnknownTableOrBucketException; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.rpc.gateway.TabletServerGateway; import org.apache.fluss.rpc.messages.PbProduceLogRespForBucket; import org.apache.fluss.rpc.messages.PbPutKvRespForBucket; @@ -815,10 +816,42 @@ private void handleMissingPartition(PhysicalTablePath targetPath, Throwable caus @Nullable Throwable historicalTargetCause = null; try { if (metadataUpdater.checkAndUpdatePartitionMetadata(historicalPath)) { - accumulator.rerouteQueuedWritesToHistorical( - targetPath, - historicalPath, - metadataUpdater.getPartitionIdOrElseThrow(historicalPath)); + // The queued batches were routed by the original partition's bucket count; they can + // only land in the right buckets of the historical partition if its own count + // matches. Otherwise the bucket ids would be hashes against the wrong layout. + TablePartition historicalPartition = + metadataUpdater + .getCluster() + .getTablePartition(historicalPath) + .orElseThrow( + () -> + new PartitionNotExistException( + "Historical partition " + + historicalPath + + " does not exist.")); + Integer historicalBucketCount = + metadataUpdater + .getCluster() + .getBucketCountActual(historicalPartition) + .orElse(null); + boolean rerouted = + accumulator.rerouteQueuedWritesToHistorical( + targetPath, + historicalPath, + historicalPartition.getPartitionId(), + historicalBucketCount); + if (!rerouted) { + abortBatches( + targetPath, + newPartitionNotExistException( + "Cannot reroute writes from " + + targetPath + + " to the historical partition because their " + + "bucket ids were routed by a different bucket " + + "count than the historical partition's.", + historicalTargetCause)); + return; + } LOG.info( "Rerouted writes from partition {} to historical partition {}.", targetPath, diff --git a/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java b/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java index f5dcaf053fe..62a06936684 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java @@ -39,6 +39,7 @@ import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableDescriptor; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.record.MemoryLogRecords; import org.apache.fluss.row.BinaryRow; @@ -100,6 +101,7 @@ import static org.apache.fluss.testutils.common.CommonTestUtils.retry; import static org.apache.fluss.utils.PartitionUtils.HISTORICAL_PARTITION_VALUE; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** ITCase for {@link Sender}. */ final class SenderTest { @@ -207,6 +209,47 @@ void testReroutesWriteAfterExplicitMissingPartitionResponse() throws Exception { assertThat(future.get()).isNull(); } + @Test + void testAbortsRerouteWhenQueuedBatchBucketCountDiffers() throws Exception { + sender.destroyResources(); + TableInfo tableInfo = createHistoricalTableInfo(); + PhysicalTablePath originalPath = PhysicalTablePath.of(tableInfo.getTablePath(), "20990101"); + PhysicalTablePath historicalPath = + PhysicalTablePath.of(tableInfo.getTablePath(), HISTORICAL_PARTITION_VALUE); + TableBucket originalBucket = new TableBucket(tableInfo.getTableId(), 21L, 0); + TableBucket historicalBucket = new TableBucket(tableInfo.getTableId(), 22L, 0); + metadataUpdater = missingPartitionMetadataUpdater(tableInfo, originalPath); + Map tableBucketsByPath = new HashMap<>(); + tableBucketsByPath.put(originalPath, originalBucket); + tableBucketsByPath.put(historicalPath, historicalBucket); + // The historical partition keeps one bucket while the queued batch was routed by a + // rescaled partition's count of four: its bucket id cannot be moved to the historical + // layout, so the reroute must abort instead of silently misrouting the records. + Map bucketCountActualByPartition = new HashMap<>(); + bucketCountActualByPartition.put( + new TablePartition(tableInfo.getTableId(), historicalBucket.getPartitionId()), 1); + metadataUpdater.updateCluster( + partitionedCluster(tableInfo, tableBucketsByPath, bucketCountActualByPartition)); + sender = setupWithIdempotenceState(); + + CompletableFuture future = + appendKvRecord(tableInfo, originalPath, 1, metadataUpdater.getCluster(), 4); + sender.runOnce(); + + TestTabletServerGateway gateway = node1Gateway(); + gateway.response( + 0, createPutKvResponse(originalBucket, Errors.UNKNOWN_TABLE_OR_BUCKET_EXCEPTION)); + sender.runOnce(); + + assertThat(future.get()) + .isInstanceOf(PartitionNotExistException.class) + .hasMessageContaining("different bucket count"); + // Nothing was sent to the historical partition: aborting is the whole point. + assertThatThrownBy(() -> gateway.getRequest(0)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No requests pending"); + } + @Test void testAbortsOnlyMissingPartitionWhenRerouteIsUnsafe() throws Exception { sender.destroyResources(); @@ -1593,6 +1636,13 @@ public boolean checkAndUpdatePartitionMetadata(PhysicalTablePath physicalTablePa private static Cluster partitionedCluster( TableInfo tableInfo, Map tableBucketsByPath) { + return partitionedCluster(tableInfo, tableBucketsByPath, Collections.emptyMap()); + } + + private static Cluster partitionedCluster( + TableInfo tableInfo, + Map tableBucketsByPath, + Map bucketCountActualByPartition) { int[] replicas = new int[] {TestingMetadataUpdater.NODE1.id()}; Map> bucketLocationsByPath = new HashMap<>(); Map partitionIdsByPath = new HashMap<>(); @@ -1614,12 +1664,24 @@ private static Cluster partitionedCluster( TestingMetadataUpdater.COORDINATOR, bucketLocationsByPath, Collections.singletonMap(tableInfo.getTablePath(), tableInfo.getTableId()), - partitionIdsByPath); + partitionIdsByPath, + bucketCountActualByPartition, + Collections.emptyMap()); } private CompletableFuture appendKvRecord( TableInfo tableInfo, PhysicalTablePath physicalTablePath, int id, Cluster cluster) throws Exception { + return appendKvRecord(tableInfo, physicalTablePath, id, cluster, 0); + } + + private CompletableFuture appendKvRecord( + TableInfo tableInfo, + PhysicalTablePath physicalTablePath, + int id, + Cluster cluster, + int bucketCountActual) + throws Exception { accumulator.checkAndCacheHistoricalPartitionEnabled(tableInfo); BinaryRow row = compactedRow( @@ -1643,6 +1705,7 @@ private CompletableFuture appendKvRecord( (tableBucket, logEndOffset, error) -> future.complete(error), cluster, 0, + bucketCountActual, false); return future; } From c223a7ee5e771a6be4428b644e6a329a99809c99 Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Fri, 4 Sep 2026 03:59:11 +0800 Subject: [PATCH 03/16] Rename bucket_count_actual to bucket_count and bucket_layout_epoch to bucket_count_epoch --- .../apache/fluss/client/admin/FlussAdmin.java | 18 ++-- .../client/lookup/AbstractLookupQuery.java | 10 +-- .../fluss/client/lookup/AbstractLookuper.java | 4 +- .../fluss/client/lookup/LookupBatch.java | 10 +-- .../fluss/client/lookup/LookupClient.java | 8 +- .../fluss/client/lookup/LookupQuery.java | 4 +- .../fluss/client/lookup/LookupSender.java | 6 +- .../client/lookup/PrefixKeyLookuper.java | 11 ++- .../client/lookup/PrefixLookupBatch.java | 10 +-- .../client/lookup/PrefixLookupQuery.java | 4 +- .../client/lookup/PrimaryKeyLookuper.java | 18 ++-- .../fluss/client/table/scanner/TableScan.java | 2 +- .../table/scanner/batch/KvBatchScanner.java | 3 +- .../scanner/batch/LimitBatchScanner.java | 2 +- .../client/table/scanner/log/LogFetcher.java | 2 +- .../client/utils/ClientRpcMessageUtils.java | 23 +++-- .../fluss/client/utils/MetadataUtils.java | 34 ++++---- .../client/write/DynamicPartitionCreator.java | 2 +- .../fluss/client/write/RecordAccumulator.java | 12 +-- .../org/apache/fluss/client/write/Sender.java | 2 +- .../apache/fluss/client/write/WriteBatch.java | 10 +-- .../fluss/client/write/WriterClient.java | 28 +++---- .../fluss/client/lookup/LookupSenderTest.java | 12 +-- ...rtitionBucketCountActualRescaleITCase.java | 35 ++++---- .../utils/ClientRpcMessageUtilsTest.java | 32 +++---- .../apache/fluss/client/write/SenderTest.java | 14 ++-- .../org/apache/fluss/cluster/Cluster.java | 27 +++--- .../fluss/lake/writer/WriterInitContext.java | 2 +- .../apache/fluss/metadata/PartitionInfo.java | 22 ++--- .../org/apache/fluss/metadata/TableInfo.java | 22 ++--- .../lake/writer/WriterInitContextTest.java | 2 +- .../flink/action/orphan/OrphanCleanUtils.java | 2 +- .../fluss/flink/lake/LakeSplitGenerator.java | 4 +- .../sink/undo/RecoveryOffsetManager.java | 4 +- .../enumerator/FlinkSourceEnumerator.java | 16 ++-- .../FlussOnlyBatchSplitGenerator.java | 2 +- .../tiering/source/TieringSplitReader.java | 3 +- .../source/TieringWriterInitContext.java | 14 ++-- .../source/split/TieringSplitGenerator.java | 4 +- .../fluss/flink/utils/PushdownUtils.java | 3 +- .../flink/lake/LakeSplitGeneratorTest.java | 26 +++--- .../sink/undo/RecoveryOffsetManagerTest.java | 28 +++---- .../enumerator/FlinkSourceEnumeratorTest.java | 4 +- .../source/TieringWriterInitContextTest.java | 4 +- .../lake/hudi/tiering/HudiTieringTest.java | 2 +- .../lake/iceberg/IcebergLakeCatalogTest.java | 2 +- .../iceberg/tiering/IcebergTieringTest.java | 2 +- .../lake/lance/tiering/LanceTieringTest.java | 2 +- .../lake/paimon/tiering/PaimonLakeWriter.java | 2 +- .../FlinkUnionReadRescaleBucketITCase.java | 38 ++++----- .../lookup/HistoricalPartitionITCase.java | 9 +- .../paimon/tiering/PaimonTieringTest.java | 4 +- fluss-rpc/src/main/proto/FlussApi.proto | 12 +-- fluss-rust/crates/fluss/proto/FlussApi.proto | 12 +-- .../crates/fluss/src/client/write/sender.rs | 2 +- .../crates/fluss/src/metadata/partition.rs | 2 +- fluss-rust/crates/fluss/src/proto/fluss.rs | 12 +-- .../apache/fluss/server/RpcServiceBase.java | 10 +-- .../coordinator/CoordinatorRequestBatch.java | 24 +++--- .../server/coordinator/MetadataManager.java | 16 ++-- .../server/entity/NotifyLeaderAndIsrData.java | 20 ++--- .../server/metadata/PartitionMetadata.java | 10 +-- .../metadata/ServerMetadataSnapshot.java | 30 +++---- .../metadata/TabletServerMetadataCache.java | 84 +++++++++---------- .../apache/fluss/server/replica/Replica.java | 16 ++-- .../fluss/server/replica/ReplicaManager.java | 10 +-- .../HistoricalLakeLookupManager.java | 2 +- .../server/utils/ServerRpcMessageUtils.java | 43 +++++----- .../fluss/server/zk/ZooKeeperClient.java | 7 +- .../server/zk/data/PartitionRegistration.java | 36 ++++---- .../data/PartitionRegistrationJsonSerde.java | 15 ++-- .../server/zk/data/TableRegistration.java | 28 +++---- .../zk/data/TableRegistrationJsonSerde.java | 10 +-- .../coordinator/AlterBucketNumTest.java | 28 +++---- .../coordinator/AutoPartitionManagerTest.java | 8 +- .../TabletServerMetadataCacheTest.java | 20 ++--- .../replica/ReplicaRoutingStateTest.java | 12 +-- .../testutils/FlussClusterExtension.java | 8 +- .../testutils/PartitionMetadataAssert.java | 6 +- .../PartitionRegistrationJsonSerdeTest.java | 8 +- .../data/TableRegistrationJsonSerdeTest.java | 4 +- .../spark/read/FlussMicroBatchStream.scala | 4 +- .../fluss/spark/read/SplitPlanner.scala | 4 +- 83 files changed, 514 insertions(+), 555 deletions(-) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java index 36d46add514..ce5618f579c 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java @@ -347,7 +347,7 @@ public CompletableFuture getTableInfo(TablePath tablePath) { r.hasRemoteDataDir() ? r.getRemoteDataDir() : null, r.getCreatedTime(), r.getModifiedTime(), - r.hasBucketLayoutEpoch() ? r.getBucketLayoutEpoch() : 0L)); + r.hasBucketCountEpoch() ? r.getBucketCountEpoch() : 0L)); } @Override @@ -400,7 +400,7 @@ public CompletableFuture> listPartitionInfos( response -> { boolean allHaveBucketCount = response.getPartitionsInfosList().stream() - .allMatch(PbPartitionInfo::hasBucketCountActual); + .allMatch(PbPartitionInfo::hasBucketCount); if (allHaveBucketCount) { // Every partition already carries its own bucket count, so skip // the extra getTableInfo RPC (the -1 default is never used). @@ -414,9 +414,9 @@ public CompletableFuture> listPartitionInfos( // fails leader activation with UnsupportedVersionException; // 3) prohibit ALTER bucket.num during the server rolling upgrade; // 4) a fully old cluster omits the partition count, while the - // table-level count is still safe (bucketLayoutEpoch == 0); + // table-level count is still safe (bucketCountEpoch == 0); // 5) after every server is upgraded, ListPartitionInfosResponse - // must return an explicit partitionId and bucketCountActual; + // must return an explicit partitionId and bucketCount; // 6) if a new server still returns a missing count, fail loud // instead of calling getTableInfo to guess it. // TODO: a future FIP should enforce server-side rejection of @@ -425,7 +425,7 @@ public CompletableFuture> listPartitionInfos( return getTableInfo(tablePath) .thenApply( tableInfo -> { - long epoch = tableInfo.getBucketLayoutEpoch(); + long epoch = tableInfo.getBucketCountEpoch(); // Post-ALTER server must return the count; // missing means inconsistency, so fail loud. if (epoch > 0) { @@ -433,7 +433,7 @@ public CompletableFuture> listPartitionInfos( "Server omitted the per-partition " + "bucket count for table " + tablePath - + " at bucketLayoutEpoch " + + " at bucketCountEpoch " + epoch + " > 0; refusing to fall " + "back to the table-level " @@ -608,9 +608,9 @@ public CompletableFuture getTableStats(TablePath tablePath) { } List tableBuckets = new ArrayList<>(); for (PartitionInfo partitionInfo : partitionInfos) { - int bucketCountActual = - PartitionInfo.bucketCountActualOrDefault(partitionInfo, tableBucketCount); - for (int bucket = 0; bucket < bucketCountActual; bucket++) { + int bucketCount = + PartitionInfo.bucketCountOrDefault(partitionInfo, tableBucketCount); + for (int bucket = 0; bucket < bucketCount; bucket++) { tableBuckets.add( new TableBucket( tableInfo.getTableId(), diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookupQuery.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookupQuery.java index 0da292e6823..a99b0e23c11 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookupQuery.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookupQuery.java @@ -39,7 +39,7 @@ public abstract class AbstractLookupQuery { */ private final @Nullable String originalPartitionName; - private final int bucketCountActual; + private final int bucketCount; private int retries; private long nextRetryTimeMs; @@ -60,12 +60,12 @@ public AbstractLookupQuery( TableBucket tableBucket, byte[] key, @Nullable String originalPartitionName, - int bucketCountActual) { + int bucketCount) { this.tablePath = tablePath; this.tableBucket = tableBucket; this.key = key; this.originalPartitionName = originalPartitionName; - this.bucketCountActual = bucketCountActual; + this.bucketCount = bucketCount; this.retries = 0; this.nextRetryTimeMs = 0; } @@ -87,8 +87,8 @@ public TableBucket tableBucket() { } /** The bucket count used to calculate this lookup's bucketId, or 0 if unknown (legacy). */ - public int bucketCountActual() { - return bucketCountActual; + public int bucketCount() { + return bucketCount; } public int retries() { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java index 094784cdaa8..86a555f4711 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java @@ -82,11 +82,11 @@ abstract class AbstractLookuper implements Lookuper { * when the cluster metadata has it, falling back to the table-level bucket count otherwise * (non-partitioned tables or partitions created by older versions). */ - protected int resolvePartitionBucketCountActual( + protected int resolvePartitionBucketCount( TablePartition tablePartition, int tableLevelNumBuckets) { return metadataUpdater .getCluster() - .getBucketCountActual(tablePartition) + .getBucketCount(tablePartition) .orElse(tableLevelNumBuckets); } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupBatch.java index b2752514645..a535b967b67 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupBatch.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupBatch.java @@ -34,12 +34,12 @@ public class LookupBatch { private final List lookups; - private final int bucketCountActual; + private final int bucketCount; - LookupBatch(LookupBatchKey lookupBatchKey, int bucketCountActual) { + LookupBatch(LookupBatchKey lookupBatchKey, int bucketCount) { this.lookupBatchKey = lookupBatchKey; this.lookups = new ArrayList<>(); - this.bucketCountActual = bucketCountActual; + this.bucketCount = bucketCount; } public void addLookup(LookupQuery lookup) { @@ -59,8 +59,8 @@ public TableBucket tableBucket() { } /** The bucket count the bucketId was calculated with, or 0 if unknown (legacy). */ - public int getBucketCountActual() { - return bucketCountActual; + public int getBucketCount() { + return bucketCount; } LookupBatchKey lookupBatchKey() { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupClient.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupClient.java index 1fb418f5a1b..21fda7fe5a5 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupClient.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupClient.java @@ -114,7 +114,7 @@ public CompletableFuture lookup( byte[] keyBytes, boolean insertIfNotExists, @Nullable String originalPartitionName, - int bucketCountActual) { + int bucketCount) { LookupQuery lookup = new LookupQuery( tablePath, @@ -122,15 +122,15 @@ public CompletableFuture lookup( keyBytes, insertIfNotExists, originalPartitionName, - bucketCountActual); + bucketCount); lookupQueue.appendLookup(lookup); return lookup.future(); } public CompletableFuture> prefixLookup( - TablePath tablePath, TableBucket tableBucket, byte[] keyBytes, int bucketCountActual) { + TablePath tablePath, TableBucket tableBucket, byte[] keyBytes, int bucketCount) { PrefixLookupQuery prefixLookup = - new PrefixLookupQuery(tablePath, tableBucket, keyBytes, bucketCountActual); + new PrefixLookupQuery(tablePath, tableBucket, keyBytes, bucketCount); lookupQueue.appendLookup(prefixLookup); return prefixLookup.future(); } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupQuery.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupQuery.java index 3dce5e32757..88e1ad4f0a6 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupQuery.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupQuery.java @@ -42,8 +42,8 @@ public class LookupQuery extends AbstractLookupQuery { byte[] key, boolean insertIfNotExists, @Nullable String originalPartitionName, - int bucketCountActual) { - super(tablePath, tableBucket, key, originalPartitionName, bucketCountActual); + int bucketCount) { + super(tablePath, tableBucket, key, originalPartitionName, bucketCount); this.future = new CompletableFuture<>(); this.insertIfNotExists = insertIfNotExists; } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupSender.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupSender.java index 3c87a6bad44..cda60d85f79 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupSender.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/LookupSender.java @@ -222,8 +222,7 @@ private void sendLookupRequest( LookupBatchKey batchKey = new LookupBatchKey(tb, lookup.originalPartitionName()); lookupByTableId .computeIfAbsent(tableId, k -> new LinkedHashMap<>()) - .computeIfAbsent( - batchKey, k -> new LookupBatch(batchKey, lookup.bucketCountActual())) + .computeIfAbsent(batchKey, k -> new LookupBatch(batchKey, lookup.bucketCount())) .addLookup(lookup); } @@ -301,8 +300,7 @@ private void sendPrefixLookupRequest( long tableId = tb.getTableId(); lookupByTableId .computeIfAbsent(tableId, k -> new HashMap<>()) - .computeIfAbsent( - tb, k -> new PrefixLookupBatch(tb, prefixLookup.bucketCountActual())) + .computeIfAbsent(tb, k -> new PrefixLookupBatch(tb, prefixLookup.bucketCount())) .addLookup(prefixLookup); } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java index 11dc7ba0eaf..fb1fe47a4cd 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java @@ -168,7 +168,7 @@ public CompletableFuture lookup(InternalRow prefixKey) { : bucketKeyEncoder.encodeKey(prefixKey); Long partitionId = null; - int bucketCountActual = numBuckets; + int bucketCount = numBuckets; if (partitionGetter != null) { try { partitionId = @@ -177,8 +177,8 @@ public CompletableFuture lookup(InternalRow prefixKey) { partitionGetter, tableInfo.getTablePath(), metadataUpdater); - bucketCountActual = - resolvePartitionBucketCountActual( + bucketCount = + resolvePartitionBucketCount( new TablePartition(tableInfo.getTableId(), partitionId), numBuckets); } catch (PartitionNotExistException e) { @@ -187,13 +187,12 @@ public CompletableFuture lookup(InternalRow prefixKey) { } // Compute bucket ID after partition resolution — needs per-partition bucket count - int bucketId = bucketingFunction.bucketing(bucketKeyBytes, bucketCountActual); + int bucketId = bucketingFunction.bucketing(bucketKeyBytes, bucketCount); CompletableFuture lookupFuture = new CompletableFuture<>(); TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucketId); lookupClient - .prefixLookup( - tableInfo.getTablePath(), tableBucket, prefixKeyBytes, bucketCountActual) + .prefixLookup(tableInfo.getTablePath(), tableBucket, prefixKeyBytes, bucketCount) .whenComplete( (result, error) -> { if (error != null) { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupBatch.java index 711ad7eb02a..902dd9aa10f 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupBatch.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupBatch.java @@ -34,13 +34,13 @@ public class PrefixLookupBatch { /** The table bucket that the lookup operations should fall into. */ private final TableBucket tableBucket; - private final int bucketCountActual; + private final int bucketCount; private final List prefixLookups; - public PrefixLookupBatch(TableBucket tableBucket, int bucketCountActual) { + public PrefixLookupBatch(TableBucket tableBucket, int bucketCount) { this.tableBucket = tableBucket; - this.bucketCountActual = bucketCountActual; + this.bucketCount = bucketCount; this.prefixLookups = new ArrayList<>(); } @@ -57,8 +57,8 @@ public TableBucket tableBucket() { } /** The bucket count the bucketId was calculated with, or 0 if unknown (legacy). */ - public int getBucketCountActual() { - return bucketCountActual; + public int getBucketCount() { + return bucketCount; } public void complete(List> values) { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupQuery.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupQuery.java index fc1191efee1..9d3c2d2ca99 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupQuery.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixLookupQuery.java @@ -33,8 +33,8 @@ public class PrefixLookupQuery extends AbstractLookupQuery> { private final CompletableFuture> future; PrefixLookupQuery( - TablePath tablePath, TableBucket tableBucket, byte[] prefixKey, int bucketCountActual) { - super(tablePath, tableBucket, prefixKey, null, bucketCountActual); + TablePath tablePath, TableBucket tableBucket, byte[] prefixKey, int bucketCount) { + super(tablePath, tableBucket, prefixKey, null, bucketCount); this.future = new CompletableFuture<>(); } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java index 865c9cff4c9..05b5eafba20 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java @@ -128,7 +128,7 @@ public CompletableFuture lookup(InternalRow lookupKey) { int bucketId = bucketingFunction.bucketing(bkBytes, numBuckets); Long partitionId = null; String originalPartitionName = null; - int bucketCountActual = numBuckets; + int bucketCount = numBuckets; if (partitionGetter != null) { originalPartitionName = partitionGetter.getPartition(lookupKey); if (confirmedHistoricalPartitions.contains(originalPartitionName)) { @@ -141,8 +141,8 @@ public CompletableFuture lookup(InternalRow lookupKey) { partitionGetter, tableInfo.getTablePath(), metadataUpdater); - bucketCountActual = - resolvePartitionBucketCountActual( + bucketCount = + resolvePartitionBucketCount( new TablePartition(tableInfo.getTableId(), partitionId), numBuckets); } catch (PartitionNotExistException e) { @@ -153,8 +153,8 @@ public CompletableFuture lookup(InternalRow lookupKey) { // A partition created before ALTER bucket.num keeps its own layout, so re-route by the // partition's actual count. The historical lookups above are routed by the historical // partition's own count on their own path. - if (bucketCountActual != numBuckets) { - bucketId = bucketingFunction.bucketing(bkBytes, bucketCountActual); + if (bucketCount != numBuckets) { + bucketId = bucketingFunction.bucketing(bkBytes, bucketCount); } TableBucket tableBucket = new TableBucket(tableInfo.getTableId(), partitionId, bucketId); return lookupBucket( @@ -164,7 +164,7 @@ public CompletableFuture lookup(InternalRow lookupKey) { insertIfNotExists, false, originalPartitionName, - bucketCountActual); + bucketCount); } /** @@ -212,7 +212,7 @@ private CompletableFuture historicalLookup( // Route by the historical partition's own count, which an ALTER bucket.num does not // change. The bucket the lake data lives in is resolved on the server. int historicalBucketCount = - resolvePartitionBucketCountActual( + resolvePartitionBucketCount( new TablePartition(tableInfo.getTableId(), historicalPartitionId), numBuckets); int routingBucketId = @@ -239,7 +239,7 @@ private CompletableFuture lookupBucket( boolean insertIfNotExists, boolean historicalLookup, @Nullable String originalPartitionName, - int bucketCountActual) { + int bucketCount) { CompletableFuture lookupFuture = new CompletableFuture<>(); lookupClient .lookup( @@ -248,7 +248,7 @@ private CompletableFuture lookupBucket( keyBytes, insertIfNotExists, historicalLookup ? originalPartitionName : null, - bucketCountActual) + bucketCount) .whenComplete( (result, error) -> { if (error != null) { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/TableScan.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/TableScan.java index 0790c6fdacb..3410ddc240e 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/TableScan.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/TableScan.java @@ -257,7 +257,7 @@ public BatchScanner createBatchScanner() throws IOException { partitionInfos.stream() .flatMap( partitionInfo -> - IntStream.range(0, partitionInfo.getBucketCountActual()) + IntStream.range(0, partitionInfo.getBucketCount()) .mapToObj( bucketId -> new TableBucket( diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/KvBatchScanner.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/KvBatchScanner.java index 9681bb55e5e..18d6b733901 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/KvBatchScanner.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/KvBatchScanner.java @@ -186,8 +186,7 @@ private void openScanner() { .setBucketId(bucket.getBucket()); if (bucket.getPartitionId() != null) { bucketReq.setPartitionId(bucket.getPartitionId()); - cluster.getBucketCountActual( - new TablePartition(bucket.getTableId(), bucket.getPartitionId())) + cluster.getBucketCount(new TablePartition(bucket.getTableId(), bucket.getPartitionId())) .ifPresent(bucketReq::setRoutingBucketCount); } else { cluster.getBucketCountForTable(bucket.getTableId()) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java index 1b0a50fc315..bf2ebf3b01e 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/batch/LimitBatchScanner.java @@ -108,7 +108,7 @@ public LimitBatchScanner( if (tableBucket.getPartitionId() != null) { limitScanRequest.setPartitionId(tableBucket.getPartitionId()); - cluster.getBucketCountActual( + cluster.getBucketCount( new TablePartition( tableBucket.getTableId(), tableBucket.getPartitionId())) .ifPresent(limitScanRequest::setRoutingBucketCount); diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java index a7d825b4ce4..eab76924a83 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/table/scanner/log/LogFetcher.java @@ -600,7 +600,7 @@ Map prepareFetchLogRequests(List fetchabl fetchLogReqForBucket.setPartitionId(tb.getPartitionId()); metadataUpdater .getCluster() - .getBucketCountActual( + .getBucketCount( new TablePartition(tb.getTableId(), tb.getPartitionId())) .ifPresent(fetchLogReqForBucket::setRoutingBucketCount); } else { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java index 29df7e29e41..86fc81793fb 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java @@ -145,8 +145,7 @@ public static ProduceLogRequest makeProduceLogRequest( PbProduceLogReqForBucket pbProduceLogReqForBucket = request.addBucketsReq() .setBucketId(tableBucket.getBucket()) - .setRoutingBucketCount( - readyBatch.writeBatch().getBucketCountActual()) + .setRoutingBucketCount(readyBatch.writeBatch().getBucketCount()) .setRecordsBytesView(readyBatch.writeBatch().build()); if (tableBucket.getPartitionId() != null) { pbProduceLogReqForBucket.setPartitionId(tableBucket.getPartitionId()); @@ -206,8 +205,7 @@ public static PutKvRequest makePutKvRequest( PbPutKvReqForBucket pbPutKvReqForBucket = request.addBucketsReq() .setBucketId(tableBucket.getBucket()) - .setRoutingBucketCount( - readyBatch.writeBatch().getBucketCountActual()) + .setRoutingBucketCount(readyBatch.writeBatch().getBucketCount()) .setRecordsBytesView(readyBatch.writeBatch().build()); if (tableBucket.getPartitionId() != null) { pbPutKvReqForBucket.setPartitionId(tableBucket.getPartitionId()); @@ -243,8 +241,8 @@ public static LookupRequest makeLookupRequest( } // Carry the bucket count the bucketId was calculated with so the server can // validate it; 0 means unknown (legacy) and leaves the field unset. - if (batch.getBucketCountActual() > 0) { - pbLookupReqForBucket.setRoutingBucketCount(batch.getBucketCountActual()); + if (batch.getBucketCount() > 0) { + pbLookupReqForBucket.setRoutingBucketCount(batch.getBucketCount()); } if (batch.originalPartitionName() != null) { pbLookupReqForBucket.setOriginalPartitionName( @@ -268,9 +266,8 @@ public static PrefixLookupRequest makePrefixLookupRequest( } // Carry the bucket count the bucketId was calculated with so the server can // validate it; 0 means unknown (legacy) and leaves the field unset. - if (batch.getBucketCountActual() > 0) { - pbPrefixLookupReqForBucket.setRoutingBucketCount( - batch.getBucketCountActual()); + if (batch.getBucketCount() > 0) { + pbPrefixLookupReqForBucket.setRoutingBucketCount(batch.getBucketCount()); } batch.lookups().forEach(get -> pbPrefixLookupReqForBucket.addKey(get.key())); }); @@ -383,7 +380,7 @@ public static ListOffsetsRequest makeListOffsetsRequest( .setBucketIds(bucketIdList.stream().mapToInt(Integer::intValue).toArray()); if (partitionId != null) { listOffsetsRequest.setPartitionId(partitionId); - cluster.getBucketCountActual(new TablePartition(tableId, partitionId)) + cluster.getBucketCount(new TablePartition(tableId, partitionId)) .ifPresent(listOffsetsRequest::setRoutingBucketCount); } else { cluster.getBucketCountForTable(tableId) @@ -681,8 +678,8 @@ public static List toPartitionInfos( : null, // old clusters do not send the per-partition bucket count; // resolve to the table-level count here - pbPartitionInfo.hasBucketCountActual() - ? pbPartitionInfo.getBucketCountActual() + pbPartitionInfo.hasBucketCount() + ? pbPartitionInfo.getBucketCount() : defaultBucketCount)) .collect(Collectors.toList()); } @@ -913,7 +910,7 @@ public static GetTableStatsRequest makeGetTableStatsRequest( .setBucketId(bucket.getBucket()); if (bucket.getPartitionId() != null) { pbBucket.setPartitionId(bucket.getPartitionId()); - cluster.getBucketCountActual( + cluster.getBucketCount( new TablePartition( bucket.getTableId(), bucket.getPartitionId())) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/utils/MetadataUtils.java b/fluss-client/src/main/java/org/apache/fluss/client/utils/MetadataUtils.java index 15f3b7efee3..b541769b656 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/utils/MetadataUtils.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/utils/MetadataUtils.java @@ -122,7 +122,7 @@ public static Cluster sendMetadataRequestAndRebuildCluster( Map newTablePathToTableId; Map> newBucketLocations; Map newPartitionIdByPath; - Map newBucketCountActualByPartition; + Map newBucketCountByPartition; Map newBucketCountByTable; NewTableMetadata newTableMetadata = @@ -137,17 +137,16 @@ public static Cluster sendMetadataRequestAndRebuildCluster( new HashMap<>(originCluster.getBucketLocationsByPath()); newPartitionIdByPath = new HashMap<>(originCluster.getPartitionIdByPath()); - newBucketCountActualByPartition = - new HashMap<>( - originCluster.getBucketCountActualByPartition()); + newBucketCountByPartition = + new HashMap<>(originCluster.getBucketCountByPartition()); newBucketCountByTable = new HashMap<>(originCluster.getBucketCountByTable()); newTablePathToTableId.putAll(newTableMetadata.tablePathToTableId); newBucketLocations.putAll(newTableMetadata.bucketLocations); newPartitionIdByPath.putAll(newTableMetadata.partitionIdByPath); - newBucketCountActualByPartition.putAll( - newTableMetadata.bucketCountActualByPartition); + newBucketCountByPartition.putAll( + newTableMetadata.bucketCountByPartition); newBucketCountByTable.putAll(newTableMetadata.bucketCountByTable); } else { @@ -156,8 +155,7 @@ public static Cluster sendMetadataRequestAndRebuildCluster( newTablePathToTableId = newTableMetadata.tablePathToTableId; newBucketLocations = newTableMetadata.bucketLocations; newPartitionIdByPath = newTableMetadata.partitionIdByPath; - newBucketCountActualByPartition = - newTableMetadata.bucketCountActualByPartition; + newBucketCountByPartition = newTableMetadata.bucketCountByPartition; newBucketCountByTable = newTableMetadata.bucketCountByTable; } @@ -167,7 +165,7 @@ public static Cluster sendMetadataRequestAndRebuildCluster( newBucketLocations, newTablePathToTableId, newPartitionIdByPath, - newBucketCountActualByPartition, + newBucketCountByPartition, newBucketCountByTable); }) .get(30, TimeUnit.SECONDS); // TODO currently, we don't have timeout logic in @@ -180,7 +178,7 @@ private static NewTableMetadata getTableMetadataToUpdate( Map newTablePathToTableId = new HashMap<>(); Map> newBucketLocations = new HashMap<>(); Map newPartitionIdByPath = new HashMap<>(); - Map newBucketCountActualByPartition = new HashMap<>(); + Map newBucketCountByPartition = new HashMap<>(); Map newBucketCountByTable = new HashMap<>(); // iterate all table metadata @@ -234,11 +232,11 @@ private static NewTableMetadata getTableMetadataToUpdate( // a non-positive count is not a valid bucket layout (an old server omits the // field, a new one may still report 0 before the assignment exists), so keep // the entry out and let callers fall back to the table-level count - if (pbPartitionMetadata.hasBucketCountActual() - && pbPartitionMetadata.getBucketCountActual() > 0) { - newBucketCountActualByPartition.put( + if (pbPartitionMetadata.hasBucketCount() + && pbPartitionMetadata.getBucketCount() > 0) { + newBucketCountByPartition.put( new TablePartition(tableId, pbPartitionMetadata.getPartitionId()), - pbPartitionMetadata.getBucketCountActual()); + pbPartitionMetadata.getBucketCount()); } }); @@ -246,7 +244,7 @@ private static NewTableMetadata getTableMetadataToUpdate( newTablePathToTableId, newBucketLocations, newPartitionIdByPath, - newBucketCountActualByPartition, + newBucketCountByPartition, newBucketCountByTable); } @@ -254,19 +252,19 @@ private static final class NewTableMetadata { private final Map tablePathToTableId; private final Map> bucketLocations; private final Map partitionIdByPath; - private final Map bucketCountActualByPartition; + private final Map bucketCountByPartition; private final Map bucketCountByTable; public NewTableMetadata( Map tablePathToTableId, Map> bucketLocations, Map partitionIdByPath, - Map bucketCountActualByPartition, + Map bucketCountByPartition, Map bucketCountByTable) { this.tablePathToTableId = tablePathToTableId; this.bucketLocations = bucketLocations; this.partitionIdByPath = partitionIdByPath; - this.bucketCountActualByPartition = bucketCountActualByPartition; + this.bucketCountByPartition = bucketCountByPartition; this.bucketCountByTable = bucketCountByTable; } } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java b/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java index 1e51ba9e511..25d190dd7ac 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java @@ -188,7 +188,7 @@ private boolean isPartitionMetadataAvailable( Cluster cluster, PhysicalTablePath physicalTablePath) { Optional tablePartition = cluster.getTablePartition(physicalTablePath); return tablePartition.isPresent() - && cluster.getBucketCountActual(tablePartition.get()).isPresent(); + && cluster.getBucketCount(tablePartition.get()).isPresent(); } private boolean forceCheckPartitionExist(PhysicalTablePath physicalTablePath) { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java b/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java index 8d01fcf7d60..ffca6a3210e 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/RecordAccumulator.java @@ -210,7 +210,7 @@ public RecordAppendResult append( WriteCallback callback, Cluster cluster, int bucketId, - int bucketCountActual, + int bucketCount, boolean abortIfBatchFull) throws Exception { PhysicalTablePath physicalTablePath = writeRecord.getPhysicalTablePath(); @@ -256,7 +256,7 @@ public RecordAppendResult append( writeRecord, callback, bucketId, - bucketCountActual, + bucketCount, tableInfo, dq, memorySegments); @@ -472,8 +472,8 @@ boolean rerouteQueuedWritesToHistorical( if (historicalBucketCount != null) { for (Deque deque : writeTarget.batches.values()) { for (WriteBatch batch : deque) { - if (batch.getBucketCountActual() > 0 - && batch.getBucketCountActual() != historicalBucketCount) { + if (batch.getBucketCount() > 0 + && batch.getBucketCount() != historicalBucketCount) { return false; } } @@ -799,7 +799,7 @@ private RecordAppendResult appendNewBatch( WriteRecord writeRecord, WriteCallback callback, int bucketId, - int bucketCountActual, + int bucketCount, TableInfo tableInfo, Deque deque, List segments) @@ -839,7 +839,7 @@ private RecordAppendResult appendNewBatch( schemaId, isHistoricalPartition); - batch.setBucketCountActual(bucketCountActual); + batch.setBucketCount(bucketCount); batch.tryAppend(writeRecord, callback); deque.addLast(batch); incomplete.add(batch); diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java index 55754ebb380..1edc7f2d8c8 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java @@ -832,7 +832,7 @@ private void handleMissingPartition(PhysicalTablePath targetPath, Throwable caus Integer historicalBucketCount = metadataUpdater .getCluster() - .getBucketCountActual(historicalPartition) + .getBucketCount(historicalPartition) .orElse(null); boolean rerouted = accumulator.rerouteQueuedWritesToHistorical( diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java b/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java index 2200057c4c5..eca3e6ed59f 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/WriteBatch.java @@ -54,7 +54,7 @@ public abstract class WriteBatch { // The bucket count used to calculate this batch's bucketId; carried into the request // so the TabletServer can validate it against the actual count (STALE_METADATA on mismatch). - private int bucketCountActual; + private int bucketCount; protected final List callbacks = new ArrayList<>(); private final AtomicReference finalState = new AtomicReference<>(null); @@ -200,12 +200,12 @@ public int bucketId() { return bucketId; } - public int getBucketCountActual() { - return bucketCountActual; + public int getBucketCount() { + return bucketCount; } - public void setBucketCountActual(int bucketCountActual) { - this.bucketCountActual = bucketCountActual; + public void setBucketCount(int bucketCount) { + this.bucketCount = bucketCount; } public long tableId() { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java index c226ec83623..4f8b7f0d1af 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java @@ -230,7 +230,7 @@ && mayBeExpiredHistoricalPartition( // maybe create bucket assigner. long tableId = tableInfo.getTableId(); BucketAssigner bucketAssigner; - int bucketCountActual; + int bucketCount; if (tableInfo.isPartitioned()) { PhysicalTablePath assignerPath = routingPath; TablePartition tablePartition = @@ -240,8 +240,8 @@ && mayBeExpiredHistoricalPartition( new FlussRuntimeException( "Partition metadata not available for " + assignerPath)); - bucketCountActual = - cluster.getBucketCountActual(tablePartition) + bucketCount = + cluster.getBucketCount(tablePartition) .orElseThrow( () -> new FlussRuntimeException( @@ -252,19 +252,16 @@ && mayBeExpiredHistoricalPartition( tablePartition, k -> createBucketAssigner( - tableInfo, assignerPath, bucketCountActual, conf)); + tableInfo, assignerPath, bucketCount, conf)); } else { - bucketCountActual = + bucketCount = cluster.getBucketCountForTable(tableId).orElse(tableInfo.getNumBuckets()); bucketAssigner = tableBucketAssigners.computeIfAbsent( tableId, k -> createBucketAssigner( - tableInfo, - physicalTablePath, - bucketCountActual, - conf)); + tableInfo, physicalTablePath, bucketCount, conf)); } // Append the record to the accumulator. @@ -276,7 +273,7 @@ && mayBeExpiredHistoricalPartition( callback, cluster, bucketId, - bucketCountActual, + bucketCount, bucketAssigner.abortIfBatchFull()); if (result.abortRecordForNewBatch) { @@ -289,8 +286,7 @@ && mayBeExpiredHistoricalPartition( bucketId, prevBucketId); result = - accumulator.append( - record, callback, cluster, bucketId, bucketCountActual, false); + accumulator.append(record, callback, cluster, bucketId, bucketCount, false); } if (result.batchIsFull || result.newBatchCreated) { @@ -536,21 +532,21 @@ private void invalidateBucketAssigner(TableBucket tableBucket) { private BucketAssigner createBucketAssigner( TableInfo tableInfo, PhysicalTablePath physicalTablePath, - int bucketCountActual, + int bucketCount, Configuration conf) { List bucketKeys = tableInfo.getBucketKeys(); if (!bucketKeys.isEmpty()) { BucketingFunction function = BucketingFunction.of( tableInfo.getTableConfig().getDataLakeFormat().orElse(null)); - return new HashBucketAssigner(bucketCountActual, function); + return new HashBucketAssigner(bucketCount, function); } else { ConfigOptions.NoKeyAssigner noKeyAssigner = conf.get(ConfigOptions.CLIENT_WRITER_BUCKET_NO_KEY_ASSIGNER); if (noKeyAssigner == ROUND_ROBIN) { - return new RoundRobinBucketAssigner(physicalTablePath, bucketCountActual); + return new RoundRobinBucketAssigner(physicalTablePath, bucketCount); } else if (noKeyAssigner == STICKY) { - return new StickyBucketAssigner(physicalTablePath, bucketCountActual); + return new StickyBucketAssigner(physicalTablePath, bucketCount); } else { throw new IllegalArgumentException( "Unsupported append only row bucket assigner: " + noKeyAssigner); diff --git a/fluss-client/src/test/java/org/apache/fluss/client/lookup/LookupSenderTest.java b/fluss-client/src/test/java/org/apache/fluss/client/lookup/LookupSenderTest.java index be6138d1198..bcb64378da6 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/lookup/LookupSenderTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/lookup/LookupSenderTest.java @@ -610,11 +610,11 @@ void testLookupRequestCarriesPinnedRoutingBucketCount() throws Exception { return createSuccessResponse(request, "value".getBytes()); }); - // T1: create query with bucketCountActual=4 (the partition's actual count at lookup time) + // T1: create query with bucketCount=4 (the partition's actual count at lookup time) LookupQuery query = new LookupQuery(DATA1_TABLE_PATH_PK, TABLE_BUCKET, bytes("key"), false, null, 4); // The pinned value is visible on the query object - assertThat(query.bucketCountActual()).isEqualTo(4); + assertThat(query.bucketCount()).isEqualTo(4); lookupSender.sendLookups(1, LookupType.LOOKUP, Collections.singletonList(query)); @@ -624,11 +624,11 @@ void testLookupRequestCarriesPinnedRoutingBucketCount() throws Exception { assertThat(request.getBucketsReqAt(0).hasRoutingBucketCount()).isTrue(); assertThat(request.getBucketsReqAt(0).getRoutingBucketCount()).isEqualTo(4); - // A legacy query (bucketCountActual=0) must not set routing_bucket_count at all, letting + // A legacy query (bucketCount=0) must not set routing_bucket_count at all, letting // the server's epoch check decide. receivedRequests.clear(); LookupQuery legacyQuery = new LookupQuery(DATA1_TABLE_PATH_PK, TABLE_BUCKET, bytes("key")); - assertThat(legacyQuery.bucketCountActual()).isEqualTo(0); + assertThat(legacyQuery.bucketCount()).isEqualTo(0); lookupSender.sendLookups(1, LookupType.LOOKUP, Collections.singletonList(legacyQuery)); @@ -647,10 +647,10 @@ void testPrefixLookupRequestCarriesPinnedRoutingBucketCount() throws Exception { return createSuccessPrefixLookupResponse(request); }); - // T1: create prefix query with bucketCountActual=4 + // T1: create prefix query with bucketCount=4 PrefixLookupQuery query = new PrefixLookupQuery(DATA1_TABLE_PATH_PK, TABLE_BUCKET, bytes("prefix"), 4); - assertThat(query.bucketCountActual()).isEqualTo(4); + assertThat(query.bucketCount()).isEqualTo(4); lookupSender.sendLookups(1, LookupType.PREFIX_LOOKUP, Collections.singletonList(query)); diff --git a/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionBucketCountActualRescaleITCase.java b/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionBucketCountActualRescaleITCase.java index 857728dc3dd..61d266abf89 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionBucketCountActualRescaleITCase.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/table/PartitionBucketCountActualRescaleITCase.java @@ -59,7 +59,7 @@ * partitions created after the ALTER use the new count, and data written to each partition is read * back correctly through its own bucket range. */ -class PartitionBucketCountActualRescaleITCase extends ClientToServerITCaseBase { +class PartitionBucketCountRescaleITCase extends ClientToServerITCaseBase { private static final int OLD_BUCKET_NUM = 2; private static final int NEW_BUCKET_NUM = 4; @@ -92,7 +92,7 @@ void testLogTableReadWriteAcrossRescale() throws Exception { } appendWriter.flush(); - // read back by subscribing EACH partition's own bucket range [0, bucketCountActual) + // read back by subscribing EACH partition's own bucket range [0, bucketCount) Map> actualByPartitionId = scanAllBucketsPerPartition(table, partitionInfos); @@ -106,7 +106,7 @@ void testPkTableReadPathsAcrossRescale() throws Exception { createPartitionedTable(tablePath, schema); List partitionInfos = setupOldNewPartitions(tablePath); Map idByName = partitionIdByName(partitionInfos); - Map bucketCountActualByName = bucketCountActualByName(partitionInfos); + Map bucketCountByName = bucketCountByName(partitionInfos); Table table = conn.getTable(tablePath); long tableId = table.getTableInfo().getTableId(); @@ -141,9 +141,9 @@ void testPkTableReadPathsAcrossRescale() throws Exception { // per (partition, bucket) with BatchScanner using the partition's own bucket count. for (String partitionName : OLD_NEW_PARTITIONS) { long partitionId = idByName.get(partitionName); - int bucketCountActual = bucketCountActualByName.get(partitionName); + int bucketCount = bucketCountByName.get(partitionName); int partitionSum = 0; - for (int bucketId = 0; bucketId < bucketCountActual; bucketId++) { + for (int bucketId = 0; bucketId < bucketCount; bucketId++) { TableBucket tb = new TableBucket(tableId, partitionId, bucketId); long snapshotId = FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshot(tb).getSnapshotID(); @@ -180,7 +180,7 @@ void testPkTableReadPathsAcrossRescale() throws Exception { @Test void testSameValueBucketNumAlterIsNoOp() throws Exception { // SET ('bucket.num' = currentValue) does not change the bucket layout, so it must not - // advance bucketLayoutEpoch: legacy-client routing and historical lookup both read + // advance bucketCountEpoch: legacy-client routing and historical lookup both read // epoch > 0 as evidence that mixed bucket layouts may exist. TablePath tablePath = TablePath.of("test_db_1", "test_same_value_bucket_num_alter"); createPartitionedTable(tablePath, logSchema()); @@ -192,7 +192,7 @@ void testSameValueBucketNumAlterIsNoOp() throws Exception { TableInfo sameValued = admin.getTableInfo(tablePath).get(); assertThat(sameValued.getNumBuckets()).isEqualTo(OLD_BUCKET_NUM); - assertThat(sameValued.getBucketLayoutEpoch()).isEqualTo(before.getBucketLayoutEpoch()); + assertThat(sameValued.getBucketCountEpoch()).isEqualTo(before.getBucketCountEpoch()); // A real rescale does advance the epoch, which also proves the assertions above observe a // value that actually moves. @@ -200,7 +200,7 @@ void testSameValueBucketNumAlterIsNoOp() throws Exception { TableInfo rescaled = admin.getTableInfo(tablePath).get(); assertThat(rescaled.getNumBuckets()).isEqualTo(NEW_BUCKET_NUM); - assertThat(rescaled.getBucketLayoutEpoch()).isGreaterThan(before.getBucketLayoutEpoch()); + assertThat(rescaled.getBucketCountEpoch()).isGreaterThan(before.getBucketCountEpoch()); // Repeating the ALTER at the new count is a no-op too, so the comparison is against the // current bucket count rather than the one the table was created with. @@ -208,11 +208,11 @@ void testSameValueBucketNumAlterIsNoOp() throws Exception { TableInfo after = admin.getTableInfo(tablePath).get(); assertThat(after.getNumBuckets()).isEqualTo(NEW_BUCKET_NUM); - assertThat(after.getBucketLayoutEpoch()).isEqualTo(rescaled.getBucketLayoutEpoch()); + assertThat(after.getBucketCountEpoch()).isEqualTo(rescaled.getBucketCountEpoch()); } @Test - void testDynamicallyCreatedPartitionUsesPostAlterBucketCountActual() throws Exception { + void testDynamicallyCreatedPartitionUsesPostAlterBucketCount() throws Exception { // A partition created dynamically by the WRITER after an ALTER must use the new bucket // count and be readable through that range. clientConf.set(ConfigOptions.CLIENT_WRITER_DYNAMIC_CREATE_PARTITION_ENABLED, true); @@ -244,7 +244,7 @@ void testDynamicallyCreatedPartitionUsesPostAlterBucketCountActual() throws Exce .filter(p -> "auto".equals(p.getPartitionName())) .findFirst() .orElseThrow(() -> new AssertionError("dynamic partition was not created")); - assertThat(autoPartition.getBucketCountActual()).isEqualTo(NEW_BUCKET_NUM); + assertThat(autoPartition.getBucketCount()).isEqualTo(NEW_BUCKET_NUM); expectedByPartitionId.put(autoPartition.getPartitionId(), expectedRows); // all rows are readable through the partition's own bucket range @@ -279,7 +279,7 @@ void testStaleTableHandleWritesToDynamicallyCreatedPartitionAfterAlter() throws // the dynamically created partition carries the post-ALTER bucket count List partitionInfos = admin.listPartitionInfos(tablePath).get(); - assertThat(bucketCountActualByName(partitionInfos)).containsEntry("auto", NEW_BUCKET_NUM); + assertThat(bucketCountByName(partitionInfos)).containsEntry("auto", NEW_BUCKET_NUM); // every key must be found: write routing and lookup routing must agree on the // partition's actual bucket count @@ -292,7 +292,7 @@ void testStaleTableHandleWritesToDynamicallyCreatedPartitionAfterAlter() throws } @Test - void testPrefixLookupAcrossPartitionsWithDifferentBucketCountActuals() throws Exception { + void testPrefixLookupAcrossPartitionsWithDifferentBucketCounts() throws Exception { // Prefix lookup must resolve the bucket with the correct per-partition count; a mismatch // would query the wrong bucket and miss rows. TablePath tablePath = TablePath.of("test_db_1", "test_rescale_prefix_lookup"); @@ -370,7 +370,7 @@ private List setupOldNewPartitions(TablePath tablePath) throws Ex admin.createPartition(tablePath, newPartitionSpec("c", "new"), false).get(); List partitionInfos = admin.listPartitionInfos(tablePath).get(); - assertThat(bucketCountActualByName(partitionInfos)) + assertThat(bucketCountByName(partitionInfos)) .containsEntry("old", OLD_BUCKET_NUM) .containsEntry("new", NEW_BUCKET_NUM); return partitionInfos; @@ -396,11 +396,10 @@ private void alterBucketNum(TablePath tablePath, int newBucketNum) throws Except .get(); } - private static Map bucketCountActualByName( - List partitionInfos) { + private static Map bucketCountByName(List partitionInfos) { Map map = new HashMap<>(); for (PartitionInfo p : partitionInfos) { - map.put(p.getPartitionName(), p.getBucketCountActual()); + map.put(p.getPartitionName(), p.getBucketCount()); } return map; } @@ -416,7 +415,7 @@ private static Map partitionIdByName(List partition private static void subscribeAllBuckets( LogScanner logScanner, List partitionInfos) { for (PartitionInfo partitionInfo : partitionInfos) { - for (int bucketId = 0; bucketId < partitionInfo.getBucketCountActual(); bucketId++) { + for (int bucketId = 0; bucketId < partitionInfo.getBucketCount(); bucketId++) { logScanner.subscribeFromBeginning(partitionInfo.getPartitionId(), bucketId); } } diff --git a/fluss-client/src/test/java/org/apache/fluss/client/utils/ClientRpcMessageUtilsTest.java b/fluss-client/src/test/java/org/apache/fluss/client/utils/ClientRpcMessageUtilsTest.java index 687f09e4d28..218b0da5219 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/utils/ClientRpcMessageUtilsTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/utils/ClientRpcMessageUtilsTest.java @@ -134,8 +134,8 @@ void testMakePutKvRequestWithSingleBatch() throws Exception { } @Test - void testToPartitionInfosParsesBucketCountActual() { - // one partition with bucket_count_actual set, one without (simulating an old cluster / old + void testToPartitionInfosParsesBucketCount() { + // one partition with bucket_count set, one without (simulating an old cluster / old // partition that did not persist per-partition bucket count) ListPartitionInfosResponse response = new ListPartitionInfosResponse() @@ -148,26 +148,26 @@ void testToPartitionInfosParsesBucketCountActual() { assertThat(partitionInfos).hasSize(2); - PartitionInfo withBucketCountActual = partitionInfos.get(0); - assertThat(withBucketCountActual.getPartitionId()).isEqualTo(1L); - assertThat(withBucketCountActual.getPartitionName()).isEqualTo("20240101"); - assertThat(withBucketCountActual.getRemoteDataDir()).isEqualTo("file://dir1"); - assertThat(withBucketCountActual.getBucketCountActual()).isEqualTo(8); + PartitionInfo withBucketCount = partitionInfos.get(0); + assertThat(withBucketCount.getPartitionId()).isEqualTo(1L); + assertThat(withBucketCount.getPartitionName()).isEqualTo("20240101"); + assertThat(withBucketCount.getRemoteDataDir()).isEqualTo("file://dir1"); + assertThat(withBucketCount.getBucketCount()).isEqualTo(8); - // backward compatibility: missing bucket_count_actual must resolve to the given table-level + // backward compatibility: missing bucket_count must resolve to the given table-level // default, not the proto default 0 - PartitionInfo withoutBucketCountActual = partitionInfos.get(1); - assertThat(withoutBucketCountActual.getPartitionId()).isEqualTo(2L); - assertThat(withoutBucketCountActual.getPartitionName()).isEqualTo("20240102"); - assertThat(withoutBucketCountActual.getRemoteDataDir()).isNull(); - assertThat(withoutBucketCountActual.getBucketCountActual()).isEqualTo(4); + PartitionInfo withoutBucketCount = partitionInfos.get(1); + assertThat(withoutBucketCount.getPartitionId()).isEqualTo(2L); + assertThat(withoutBucketCount.getPartitionName()).isEqualTo("20240102"); + assertThat(withoutBucketCount.getRemoteDataDir()).isNull(); + assertThat(withoutBucketCount.getBucketCount()).isEqualTo(4); } private static PbPartitionInfo makePbPartitionInfo( long partitionId, String partitionValue, @Nullable String remoteDataDir, - @Nullable Integer bucketCountActual) { + @Nullable Integer bucketCount) { PbPartitionSpec partitionSpec = new PbPartitionSpec(); PbKeyValue keyValue = new PbKeyValue().setKey("dt").setValue(partitionValue); partitionSpec.addAllPartitionKeyValues(Collections.singletonList(keyValue)); @@ -177,8 +177,8 @@ private static PbPartitionInfo makePbPartitionInfo( if (remoteDataDir != null) { pbPartitionInfo.setRemoteDataDir(remoteDataDir); } - if (bucketCountActual != null) { - pbPartitionInfo.setBucketCountActual(bucketCountActual); + if (bucketCount != null) { + pbPartitionInfo.setBucketCount(bucketCount); } return pbPartitionInfo; } diff --git a/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java b/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java index 62a06936684..1f9bd03c42a 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java @@ -225,11 +225,11 @@ void testAbortsRerouteWhenQueuedBatchBucketCountDiffers() throws Exception { // The historical partition keeps one bucket while the queued batch was routed by a // rescaled partition's count of four: its bucket id cannot be moved to the historical // layout, so the reroute must abort instead of silently misrouting the records. - Map bucketCountActualByPartition = new HashMap<>(); - bucketCountActualByPartition.put( + Map bucketCountByPartition = new HashMap<>(); + bucketCountByPartition.put( new TablePartition(tableInfo.getTableId(), historicalBucket.getPartitionId()), 1); metadataUpdater.updateCluster( - partitionedCluster(tableInfo, tableBucketsByPath, bucketCountActualByPartition)); + partitionedCluster(tableInfo, tableBucketsByPath, bucketCountByPartition)); sender = setupWithIdempotenceState(); CompletableFuture future = @@ -1642,7 +1642,7 @@ private static Cluster partitionedCluster( private static Cluster partitionedCluster( TableInfo tableInfo, Map tableBucketsByPath, - Map bucketCountActualByPartition) { + Map bucketCountByPartition) { int[] replicas = new int[] {TestingMetadataUpdater.NODE1.id()}; Map> bucketLocationsByPath = new HashMap<>(); Map partitionIdsByPath = new HashMap<>(); @@ -1665,7 +1665,7 @@ private static Cluster partitionedCluster( bucketLocationsByPath, Collections.singletonMap(tableInfo.getTablePath(), tableInfo.getTableId()), partitionIdsByPath, - bucketCountActualByPartition, + bucketCountByPartition, Collections.emptyMap()); } @@ -1680,7 +1680,7 @@ private CompletableFuture appendKvRecord( PhysicalTablePath physicalTablePath, int id, Cluster cluster, - int bucketCountActual) + int bucketCount) throws Exception { accumulator.checkAndCacheHistoricalPartitionEnabled(tableInfo); BinaryRow row = @@ -1705,7 +1705,7 @@ private CompletableFuture appendKvRecord( (tableBucket, logEndOffset, error) -> future.complete(error), cluster, 0, - bucketCountActual, + bucketCount, false); return future; } diff --git a/fluss-common/src/main/java/org/apache/fluss/cluster/Cluster.java b/fluss-common/src/main/java/org/apache/fluss/cluster/Cluster.java index 4deea5df045..ddc10d5ed99 100644 --- a/fluss-common/src/main/java/org/apache/fluss/cluster/Cluster.java +++ b/fluss-common/src/main/java/org/apache/fluss/cluster/Cluster.java @@ -54,7 +54,7 @@ public final class Cluster { private final Map pathByTableId; private final Map partitionsIdByPath; private final Map partitionNameById; - private final Map bucketCountActualByPartition; + private final Map bucketCountByPartition; private final Map bucketCountByTable; public Cluster( @@ -79,7 +79,7 @@ public Cluster( Map> bucketLocationsByPath, Map tableIdByPath, Map partitionsIdByPath, - Map bucketCountActualByPartition, + Map bucketCountByPartition, Map bucketCountByTable) { this.coordinatorServer = coordinatorServer; this.aliveTabletServersById = Collections.unmodifiableMap(aliveTabletServersById); @@ -87,8 +87,7 @@ public Cluster( Collections.unmodifiableList(new ArrayList<>(aliveTabletServersById.values())); this.tableIdByPath = Collections.unmodifiableMap(tableIdByPath); this.partitionsIdByPath = Collections.unmodifiableMap(partitionsIdByPath); - this.bucketCountActualByPartition = - Collections.unmodifiableMap(bucketCountActualByPartition); + this.bucketCountByPartition = Collections.unmodifiableMap(bucketCountByPartition); this.bucketCountByTable = Collections.unmodifiableMap(bucketCountByTable); // Index the bucket locations by table path, and index bucket location by bucket. @@ -160,10 +159,10 @@ public Cluster invalidPhysicalTableBucketMeta(Set physicalTab invalidPartitionIds.add(pid); } } - Map newBucketCountActualByPartition = new HashMap<>(); - for (Map.Entry entry : bucketCountActualByPartition.entrySet()) { + Map newBucketCountByPartition = new HashMap<>(); + for (Map.Entry entry : bucketCountByPartition.entrySet()) { if (!invalidPartitionIds.contains(entry.getKey().getPartitionId())) { - newBucketCountActualByPartition.put(entry.getKey(), entry.getValue()); + newBucketCountByPartition.put(entry.getKey(), entry.getValue()); } } // filter bucketCountByTable for non-partitioned tables whose path is in the invalid set @@ -188,7 +187,7 @@ public Cluster invalidPhysicalTableBucketMeta(Set physicalTab newBucketLocationsByPath, new HashMap<>(tableIdByPath), new HashMap<>(partitionsIdByPath), - newBucketCountActualByPartition, + newBucketCountByPartition, newBucketCountByTable); } @@ -206,7 +205,7 @@ public Cluster invalidPhysicalTableBucketAndPartitionMeta( new HashMap<>(cluster.availableLocationsByPath), new HashMap<>(tableIdByPath), newPartitionsIdByPath, - new HashMap<>(cluster.bucketCountActualByPartition), + new HashMap<>(cluster.bucketCountByPartition), new HashMap<>(cluster.bucketCountByTable)); } @@ -288,7 +287,7 @@ public Optional getPartitionId(PhysicalTablePath physicalTablePath) { /** * Resolve a {@link PhysicalTablePath} to its current {@link TablePartition} (tableId + * partitionId) from this snapshot. Retained for name resolution; the actual bucket-count lookup - * uses {@link #getBucketCountActual(TablePartition)}. Resolving both ids from the same snapshot + * uses {@link #getBucketCount(TablePartition)}. Resolving both ids from the same snapshot * avoids combining a stale tableId/partitionId with a newer one after a replacement. */ public Optional getTablePartition(PhysicalTablePath physicalTablePath) { @@ -355,13 +354,13 @@ public Map getPartitionIdByPath() { * Get the actual bucket count for the given table partition. Returns empty if the bucket count * is not known (old metadata without bucket count). */ - public Optional getBucketCountActual(TablePartition tablePartition) { - return Optional.ofNullable(bucketCountActualByPartition.get(tablePartition)); + public Optional getBucketCount(TablePartition tablePartition) { + return Optional.ofNullable(bucketCountByPartition.get(tablePartition)); } /** Get the table partition to bucket count map. */ - public Map getBucketCountActualByPartition() { - return bucketCountActualByPartition; + public Map getBucketCountByPartition() { + return bucketCountByPartition; } /** diff --git a/fluss-common/src/main/java/org/apache/fluss/lake/writer/WriterInitContext.java b/fluss-common/src/main/java/org/apache/fluss/lake/writer/WriterInitContext.java index 7ac5c4606df..0d031cc94f6 100644 --- a/fluss-common/src/main/java/org/apache/fluss/lake/writer/WriterInitContext.java +++ b/fluss-common/src/main/java/org/apache/fluss/lake/writer/WriterInitContext.java @@ -112,5 +112,5 @@ default String[] ioTmpDirs() { * so lake writers must stamp bucket layouts with this value instead of the lake table's current * schema-level bucket setting. */ - int bucketCountActual(); + int bucketCount(); } diff --git a/fluss-common/src/main/java/org/apache/fluss/metadata/PartitionInfo.java b/fluss-common/src/main/java/org/apache/fluss/metadata/PartitionInfo.java index e2a96b872c0..83b872b97ba 100644 --- a/fluss-common/src/main/java/org/apache/fluss/metadata/PartitionInfo.java +++ b/fluss-common/src/main/java/org/apache/fluss/metadata/PartitionInfo.java @@ -40,17 +40,17 @@ public class PartitionInfo { * that did not persist a per-partition bucket count, the table-level bucket count is filled in * at construction time. */ - private final int bucketCountActual; + private final int bucketCount; public PartitionInfo( long partitionId, ResolvedPartitionSpec partitionSpec, @Nullable String remoteDataDir, - int bucketCountActual) { + int bucketCount) { this.partitionId = partitionId; this.partitionSpec = partitionSpec; this.remoteDataDir = remoteDataDir; - this.bucketCountActual = bucketCountActual; + this.bucketCount = bucketCount; } /** Get the partition id. The id is globally unique in the Fluss cluster. */ @@ -83,8 +83,8 @@ public String getRemoteDataDir() { * Get the bucket count of this partition. For partitions created by older versions without a * persisted per-partition bucket count, this is the table-level bucket count. */ - public int getBucketCountActual() { - return bucketCountActual; + public int getBucketCount() { + return bucketCount; } /** @@ -93,9 +93,9 @@ public int getBucketCountActual() { * bucket count. The null case represents a non-partitioned table or a partition whose * PartitionInfo is not available. */ - public static int bucketCountActualOrDefault( + public static int bucketCountOrDefault( @Nullable PartitionInfo partitionInfo, int tableBucketCount) { - return partitionInfo != null ? partitionInfo.getBucketCountActual() : tableBucketCount; + return partitionInfo != null ? partitionInfo.getBucketCount() : tableBucketCount; } @Override @@ -110,12 +110,12 @@ public boolean equals(Object o) { return partitionId == that.partitionId && Objects.equals(partitionSpec, that.partitionSpec) && Objects.equals(remoteDataDir, that.remoteDataDir) - && bucketCountActual == that.bucketCountActual; + && bucketCount == that.bucketCount; } @Override public int hashCode() { - return Objects.hash(partitionId, partitionSpec, remoteDataDir, bucketCountActual); + return Objects.hash(partitionId, partitionSpec, remoteDataDir, bucketCount); } @Override @@ -127,8 +127,8 @@ public String toString() { + partitionId + ", remoteDataDir=" + remoteDataDir - + ", bucketCountActual=" - + bucketCountActual + + ", bucketCount=" + + bucketCount + '}'; } } diff --git a/fluss-common/src/main/java/org/apache/fluss/metadata/TableInfo.java b/fluss-common/src/main/java/org/apache/fluss/metadata/TableInfo.java index d7b61859bbb..b4be1f0049d 100644 --- a/fluss-common/src/main/java/org/apache/fluss/metadata/TableInfo.java +++ b/fluss-common/src/main/java/org/apache/fluss/metadata/TableInfo.java @@ -68,7 +68,7 @@ public final class TableInfo { private final long createdTime; private final long modifiedTime; - private final long bucketLayoutEpoch; + private final long bucketCountEpoch; private int[] cachedStatsIndexMapping = null; @@ -117,7 +117,7 @@ public TableInfo( @Nullable String comment, long createdTime, long modifiedTime, - long bucketLayoutEpoch) { + long bucketCountEpoch) { this.tablePath = tablePath; this.tableId = tableId; this.schemaId = schemaId; @@ -135,7 +135,7 @@ public TableInfo( this.comment = comment; this.createdTime = createdTime; this.modifiedTime = modifiedTime; - this.bucketLayoutEpoch = bucketLayoutEpoch; + this.bucketCountEpoch = bucketCountEpoch; } /** @@ -441,8 +441,8 @@ public long getModifiedTime() { * Returns the bucket layout epoch of the table. New tables start at 0; every committed * bucket.num change increments it (see {@code TableRegistration#withBucketCount(int)}). */ - public long getBucketLayoutEpoch() { - return bucketLayoutEpoch; + public long getBucketCountEpoch() { + return bucketCountEpoch; } /** @@ -496,7 +496,7 @@ public static TableInfo of( String remoteDataDir, long createdTime, long modifiedTime, - long bucketLayoutEpoch) { + long bucketCountEpoch) { Schema schema = tableDescriptor.getSchema(); int numBuckets = tableDescriptor @@ -520,7 +520,7 @@ public static TableInfo of( tableDescriptor.getComment().orElse(null), createdTime, modifiedTime, - bucketLayoutEpoch); + bucketCountEpoch); } @Override @@ -533,7 +533,7 @@ public boolean equals(Object o) { return tableId == that.tableId && schemaId == that.schemaId && numBuckets == that.numBuckets - && bucketLayoutEpoch == that.bucketLayoutEpoch + && bucketCountEpoch == that.bucketCountEpoch && Objects.equals(tablePath, that.tablePath) && Objects.equals(rowType, that.rowType) && Objects.equals(primaryKeys, that.primaryKeys) @@ -563,7 +563,7 @@ public int hashCode() { customProperties, remoteDataDir, comment, - bucketLayoutEpoch); + bucketCountEpoch); } @Override @@ -598,8 +598,8 @@ public String toString() { + createdTime + ", modifiedTime=" + modifiedTime - + ", bucketLayoutEpoch=" - + bucketLayoutEpoch + + ", bucketCountEpoch=" + + bucketCountEpoch + '}'; } diff --git a/fluss-common/src/test/java/org/apache/fluss/lake/writer/WriterInitContextTest.java b/fluss-common/src/test/java/org/apache/fluss/lake/writer/WriterInitContextTest.java index d3395f13859..ee460e3f72c 100644 --- a/fluss-common/src/test/java/org/apache/fluss/lake/writer/WriterInitContextTest.java +++ b/fluss-common/src/test/java/org/apache/fluss/lake/writer/WriterInitContextTest.java @@ -56,7 +56,7 @@ public TableInfo tableInfo() { } @Override - public int bucketCountActual() { + public int bucketCount() { throw new UnsupportedOperationException("not used in this test"); } }; diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/action/orphan/OrphanCleanUtils.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/action/orphan/OrphanCleanUtils.java index ce15dd6a663..624e62055cc 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/action/orphan/OrphanCleanUtils.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/action/orphan/OrphanCleanUtils.java @@ -64,7 +64,7 @@ public static PhysicalTablePath physicalPath( */ public static List enumerateBuckets( TableInfo tableInfo, @Nullable PartitionInfo partitionInfo) { - int n = PartitionInfo.bucketCountActualOrDefault(partitionInfo, tableInfo.getNumBuckets()); + int n = PartitionInfo.bucketCountOrDefault(partitionInfo, tableInfo.getNumBuckets()); List buckets = new ArrayList(n); long tableId = tableInfo.getTableId(); for (int b = 0; b < n; b++) { diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/lake/LakeSplitGenerator.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/lake/LakeSplitGenerator.java index 5ce2977d119..b13dc4e8f4e 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/lake/LakeSplitGenerator.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/lake/LakeSplitGenerator.java @@ -158,7 +158,7 @@ private List generatePartitionTableSplit( PartitionInfo flussPartition = flussPartitionByName.remove(partitionName); if (flussPartition != null) { // mean the partition also exist in fluss partition - int partitionBucketCount = flussPartition.getBucketCountActual(); + int partitionBucketCount = flussPartition.getBucketCount(); Map bucketEndOffset = stoppingOffsetInitializer.getBucketOffsets( partitionName, @@ -194,7 +194,7 @@ private List generatePartitionTableSplit( // iterate remain fluss splits for (PartitionInfo flussPartition : flussPartitionByName.values()) { String partitionName = flussPartition.getPartitionName(); - int partitionBucketCount = flussPartition.getBucketCountActual(); + int partitionBucketCount = flussPartition.getBucketCount(); Map bucketEndOffset = stoppingOffsetInitializer.getBucketOffsets( partitionName, diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManager.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManager.java index 9e019aa47c7..9ed1c6b530a 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManager.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManager.java @@ -489,7 +489,7 @@ private Set getAllBuckets() throws Exception { Set buckets = new HashSet<>(); if (isPartitioned) { for (PartitionInfo partition : getPartitionInfos()) { - int partitionBucketCount = partition.getBucketCountActual(); + int partitionBucketCount = partition.getBucketCount(); for (int bucketId = 0; bucketId < partitionBucketCount; bucketId++) { buckets.add(new TableBucket(tableId, partition.getPartitionId(), bucketId)); } @@ -628,7 +628,7 @@ private Map fetchAllBucketOffsets() throws Exception { fetchPartitionOffsets( partition.getPartitionName(), partition.getPartitionId(), - partition.getBucketCountActual(), + partition.getBucketCount(), offsets); } } else { diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java index 1da7645351d..bddc5835dc4 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java @@ -972,7 +972,7 @@ private PartitionChange getPartitionChange( new Partition( p.getPartitionId(), p.getPartitionName(), - p.getBucketCountActual())) + p.getBucketCount())) .collect(Collectors.toSet()); final Set removedPartitions = new HashSet<>(); @@ -1064,7 +1064,7 @@ private List initLogTablePartitionSplits( partition.getPartitionId(), partition.getPartitionName(), effectiveOffsetsInitializer, - partition.getBucketCountActual())); + partition.getBucketCount())); } return splits; } @@ -1842,16 +1842,16 @@ private static class Partition { * is {@link #NO_BUCKET_COUNT} only for instances created for diff comparison or removal * handling, which never generate splits. */ - final int bucketCountActual; + final int bucketCount; Partition(long partitionId, String partitionName) { this(partitionId, partitionName, NO_BUCKET_COUNT); } - Partition(long partitionId, String partitionName, int bucketCountActual) { + Partition(long partitionId, String partitionName, int bucketCount) { this.partitionId = partitionId; this.partitionName = partitionName; - this.bucketCountActual = bucketCountActual; + this.bucketCount = bucketCount; } public long getPartitionId() { @@ -1862,14 +1862,14 @@ public String getPartitionName() { return partitionName; } - public int getBucketCountActual() { + public int getBucketCount() { checkState( - bucketCountActual != NO_BUCKET_COUNT, + bucketCount != NO_BUCKET_COUNT, "Partition %s (id %s) does not carry a bucket count; comparison-only " + "instances must not be used to generate splits.", partitionName, partitionId); - return bucketCountActual; + return bucketCount; } @Override diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlussOnlyBatchSplitGenerator.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlussOnlyBatchSplitGenerator.java index dc46a4d80d8..acef868d319 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlussOnlyBatchSplitGenerator.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlussOnlyBatchSplitGenerator.java @@ -109,7 +109,7 @@ private List generateLogTableSplits(Collection p getLogSplits( partition.getPartitionId(), partition.getPartitionName(), - partition.getBucketCountActual())); + partition.getBucketCount())); } return splits; } diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java index 84fd230c87c..0e1a5958525 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java @@ -348,8 +348,7 @@ private Table getOrMoveToTable(TieringSplit split) { Admin admin = connection.getAdmin(); for (PartitionInfo partitionInfo : admin.listPartitionInfos(tablePath).get()) { currentTablePartitionBucketCounts.put( - partitionInfo.getPartitionId(), - partitionInfo.getBucketCountActual()); + partitionInfo.getPartitionId(), partitionInfo.getBucketCount()); } } catch (Exception e) { throw new FlussRuntimeException( diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContext.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContext.java index 8eb83cc6444..71b5ba7f397 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContext.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContext.java @@ -35,7 +35,7 @@ public class TieringWriterInitContext implements WriterInitContext { private final TableInfo tableInfo; private final int splitIndex; private final long tieringRoundTimestamp; - private final int bucketCountActual; + private final int bucketCount; @Nullable private final String[] ioTmpDirs; public TieringWriterInitContext( @@ -79,7 +79,7 @@ public TieringWriterInitContext( TableInfo tableInfo, int splitIndex, long tieringRoundTimestamp, - @Nullable Integer bucketCountActual, + @Nullable Integer bucketCount, @Nullable String[] ioTmpDirs) { this.tablePath = tablePath; this.tableBucket = tableBucket; @@ -89,13 +89,13 @@ public TieringWriterInitContext( this.tieringRoundTimestamp = tieringRoundTimestamp; this.ioTmpDirs = ioTmpDirs; if (tableBucket.getPartitionId() == null) { - this.bucketCountActual = tableInfo.getNumBuckets(); + this.bucketCount = tableInfo.getNumBuckets(); } else { // Writing with a wrong bucket count would silently corrupt the lake table's bucket // layout metadata, so a missing per-partition count must fail here. - this.bucketCountActual = + this.bucketCount = checkNotNull( - bucketCountActual, + bucketCount, "No actual bucket count known for partition %s (id %s) of table %s.", partition, tableBucket.getPartitionId(), @@ -141,7 +141,7 @@ public String[] ioTmpDirs() { } @Override - public int bucketCountActual() { - return bucketCountActual; + public int bucketCount() { + return bucketCount; } } diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/split/TieringSplitGenerator.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/split/TieringSplitGenerator.java index 5a217fcffd0..448bc7a773b 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/split/TieringSplitGenerator.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/split/TieringSplitGenerator.java @@ -104,7 +104,7 @@ public List generateTableSplits(TableInfo tableInfo) throws Except .collect( Collectors.toMap( PartitionInfo::getPartitionId, - PartitionInfo::getBucketCountActual)); + PartitionInfo::getBucketCount)); if (tableInfo.getTableConfig().isHistoricalPartitionEnabled()) { // The internal historical partition is intentionally omitted from // listPartitionInfos(), but tiering must consume it to synchronize historical @@ -125,7 +125,7 @@ public List generateTableSplits(TableInfo tableInfo) throws Except historicalPartitionId, metadataUpdater .getCluster() - .getBucketCountActual( + .getBucketCount( new TablePartition( tableInfo.getTableId(), historicalPartitionId)) .orElseThrow( diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/PushdownUtils.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/PushdownUtils.java index 1f6bdd1852a..ea4b47959a5 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/PushdownUtils.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/utils/PushdownUtils.java @@ -415,8 +415,7 @@ private static List> offsetLengthes( List> list = new ArrayList<>(); for (@Nullable PartitionInfo info : partitionInfos) { String partitionName = info != null ? info.getPartitionName() : null; - int partitionBucketCount = - PartitionInfo.bucketCountActualOrDefault(info, tableBucketCount); + int partitionBucketCount = PartitionInfo.bucketCountOrDefault(info, tableBucketCount); Collection buckets = IntStream.range(0, partitionBucketCount).boxed().collect(Collectors.toList()); ListOffsetsResult earliestOffsets = diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/lake/LakeSplitGeneratorTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/lake/LakeSplitGeneratorTest.java index 43245967412..0df5d6ab482 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/lake/LakeSplitGeneratorTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/lake/LakeSplitGeneratorTest.java @@ -62,12 +62,12 @@ class LakeSplitGeneratorTest { /** * Builds a {@link LakeSplitGenerator} for a partitioned primary-key table (schema: a INT, b - * STRING, c STRING; PK a+c) whose single partition "p" has {@code partitionBucketCountActual} + * STRING, c STRING; PK a+c) whose single partition "p" has {@code partitionBucketCount} * enumerated buckets and a single lake split landing in {@code lakeSplitBucket}. */ @SuppressWarnings("unchecked") - private static LakeSplitGenerator createGenerator( - int partitionBucketCountActual, int lakeSplitBucket) throws Exception { + private static LakeSplitGenerator createGenerator(int partitionBucketCount, int lakeSplitBucket) + throws Exception { TablePath tablePath = TablePath.of("db", "pk_table"); TableDescriptor descriptor = TableDescriptor.builder() @@ -101,20 +101,20 @@ private static LakeSplitGenerator createGenerator( mock(OffsetsInitializer.BucketOffsetsRetriever.class); OffsetsInitializer stoppingOffsetInitializer = mock(OffsetsInitializer.class); Map stoppingOffsets = new HashMap<>(); - for (int bucket = 0; bucket < partitionBucketCountActual; bucket++) { + for (int bucket = 0; bucket < partitionBucketCount; bucket++) { stoppingOffsets.put(bucket, 0L); } when(stoppingOffsetInitializer.getBucketOffsets(eq("p"), anyList(), any())) .thenReturn(stoppingOffsets); // the partition "p" carries its own bucket count (so an out-of-range lake bucket can be - // detected against the enumerated range [0, partitionBucketCountActual)) + // detected against the enumerated range [0, partitionBucketCount)) PartitionInfo partitionInfo = new PartitionInfo( 7L, ResolvedPartitionSpec.fromPartitionName(tableInfo.getPartitionKeys(), "p"), null, - partitionBucketCountActual); + partitionBucketCount); return new LakeSplitGenerator( tableInfo, @@ -122,7 +122,7 @@ private static LakeSplitGenerator createGenerator( lakeSource, retriever, stoppingOffsetInitializer, - partitionBucketCountActual, + partitionBucketCount, () -> Collections.singleton(partitionInfo)); } @@ -139,15 +139,15 @@ void testPrimaryKeyOutOfRangeLakeBucketFailsLoud() throws Exception { @Test void testPrimaryKeyInRangeLakeBucketSucceeds() throws Exception { // lake split lands in bucket 1, which is within [0, 4) - int partitionBucketCountActual = 4; - LakeSplitGenerator generator = createGenerator(partitionBucketCountActual, 1); + int partitionBucketCount = 4; + LakeSplitGenerator generator = createGenerator(partitionBucketCount, 1); // no out-of-range bucket: generation succeeds and produces one hybrid lake+log split per - // bucket of the partition's enumerated range [0, partitionBucketCountActual) + // bucket of the partition's enumerated range [0, partitionBucketCount) List splits = generator.generateHybridLakeFlussSplits(); - assertThat(partitionBucketCountActual).isNotEqualTo(TABLE_LEVEL_BUCKET_COUNT); - assertThat(splits).isNotNull().hasSize(partitionBucketCountActual); - for (int bucket = 0; bucket < partitionBucketCountActual; bucket++) { + assertThat(partitionBucketCount).isNotEqualTo(TABLE_LEVEL_BUCKET_COUNT); + assertThat(splits).isNotNull().hasSize(partitionBucketCount); + for (int bucket = 0; bucket < partitionBucketCount; bucket++) { SourceSplitBase split = splits.get(bucket); assertThat(split).isInstanceOf(LakeSnapshotAndFlussLogSplit.class); assertThat(split.getPartitionName()).isEqualTo("p"); diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManagerTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManagerTest.java index b96c484a158..e15cbd79aa3 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManagerTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/sink/undo/RecoveryOffsetManagerTest.java @@ -806,8 +806,8 @@ void testCleanupOffsetsNonTask0() { } @Test - void testCheckpointRecoveryEnumeratesPerPartitionBucketCountActual() throws Exception { - // Two partitions with different bucketCountActual: partition 1 has 2 buckets (old, + void testCheckpointRecoveryEnumeratesPerPartitionBucketCount() throws Exception { + // Two partitions with different bucketCount: partition 1 has 2 buckets (old, // pre-ALTER), partition 2 has 4 buckets (new, post-ALTER). If getAllBuckets used the // table-level count for both, the old partition would spuriously enumerate buckets 2/3 or // the new partition would be truncated. @@ -827,10 +827,8 @@ void testCheckpointRecoveryEnumeratesPerPartitionBucketCountActual() throws Exce RecoveryTestAdmin admin = new RecoveryTestAdmin(currentOffsets); admin.setPartitions( Arrays.asList( - createPartitionInfoWithBucketCountActual( - oldPartitionId, "old", oldBucketCount), - createPartitionInfoWithBucketCountActual( - newPartitionId, "new", newBucketCount))); + createPartitionInfoWithBucketCount(oldPartitionId, "old", oldBucketCount), + createPartitionInfoWithBucketCount(newPartitionId, "new", newBucketCount))); // Table-level bucket count is 4 (post-ALTER). Old partition must be enumerated at 2. TableInfo tableInfo = createTableInfo(4, true); RecoveryOffsetManager manager = @@ -865,10 +863,10 @@ void testCheckpointRecoveryEnumeratesPerPartitionBucketCountActual() throws Exce } @Test - void testProducerOffsetRegistrationUsesPerPartitionBucketCountActual() throws Exception { + void testProducerOffsetRegistrationUsesPerPartitionBucketCount() throws Exception { // Empty checkpoint on Task0 → registerCurrentOffsets writes ALL buckets it enumerates. - // fetchAllBucketOffsets must enumerate each partition using its own bucketCountActual so - // the registered set is exactly the union of per-partition [0, bucketCountActual) ranges. + // fetchAllBucketOffsets must enumerate each partition using its own bucketCount so + // the registered set is exactly the union of per-partition [0, bucketCount) ranges. long oldPartitionId = 1L; long newPartitionId = 2L; int oldBucketCount = 2; @@ -886,10 +884,8 @@ void testProducerOffsetRegistrationUsesPerPartitionBucketCountActual() throws Ex RecoveryTestAdmin admin = new RecoveryTestAdmin(currentOffsets); admin.setPartitions( Arrays.asList( - createPartitionInfoWithBucketCountActual( - oldPartitionId, "old", oldBucketCount), - createPartitionInfoWithBucketCountActual( - newPartitionId, "new", newBucketCount))); + createPartitionInfoWithBucketCount(oldPartitionId, "old", oldBucketCount), + createPartitionInfoWithBucketCount(newPartitionId, "new", newBucketCount))); admin.setInitialOffsetsForRegistration(currentOffsets); TableInfo tableInfo = createTableInfo(4, true); RecoveryOffsetManager manager = @@ -913,10 +909,10 @@ void testProducerOffsetRegistrationUsesPerPartitionBucketCountActual() throws Ex .doesNotContainKey(new TableBucket(TABLE_ID, oldPartitionId, 3)); } - private static PartitionInfo createPartitionInfoWithBucketCountActual( - long partitionId, String partitionName, int bucketCountActual) { + private static PartitionInfo createPartitionInfoWithBucketCount( + long partitionId, String partitionName, int bucketCount) { ResolvedPartitionSpec spec = ResolvedPartitionSpec.fromPartitionValue("pt", partitionName); - return new PartitionInfo(partitionId, spec, DEFAULT_REMOTE_DATA_DIR, bucketCountActual); + return new PartitionInfo(partitionId, spec, DEFAULT_REMOTE_DATA_DIR, bucketCount); } // ==================== Test Admin Implementation ==================== diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumeratorTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumeratorTest.java index d276c4e96c0..fdc8a722327 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumeratorTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumeratorTest.java @@ -2091,8 +2091,8 @@ private Object[] setupRescaledPartitionedTable() throws Exception { infos.stream().filter(i -> "old".equals(i.getPartitionName())).findFirst().get(); PartitionInfo newInfo = infos.stream().filter(i -> "new".equals(i.getPartitionName())).findFirst().get(); - assertThat(oldInfo.getBucketCountActual()).isEqualTo(OLD_BUCKET_NUM); - assertThat(newInfo.getBucketCountActual()).isEqualTo(NEW_BUCKET_NUM); + assertThat(oldInfo.getBucketCount()).isEqualTo(OLD_BUCKET_NUM); + assertThat(newInfo.getBucketCount()).isEqualTo(NEW_BUCKET_NUM); return new Object[] { tablePath, admin.getTableInfo(tablePath).get(), diff --git a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContextTest.java b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContextTest.java index 65f7438c198..640ce820431 100644 --- a/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContextTest.java +++ b/fluss-flink/fluss-flink-common/src/test/java/org/apache/fluss/flink/tiering/source/TieringWriterInitContextTest.java @@ -58,7 +58,7 @@ void testIoTmpDir() { void testNonPartitionedFallsBackToTableLevelCount() { // A non-partitioned bucket carries no per-partition count; the table-level count applies. TieringWriterInitContext context = newContext(new TableBucket(TABLE_ID, 0), null, null); - assertThat(context.bucketCountActual()).isEqualTo(TABLE_BUCKET_COUNT); + assertThat(context.bucketCount()).isEqualTo(TABLE_BUCKET_COUNT); } @Test @@ -66,7 +66,7 @@ void testPartitionedUsesPerPartitionCount() { // A partitioned bucket must use its own actual bucket count. TieringWriterInitContext context = newContext(new TableBucket(TABLE_ID, 1L, 0), "2024-01", 4); - assertThat(context.bucketCountActual()).isEqualTo(4); + assertThat(context.bucketCount()).isEqualTo(4); } @Test diff --git a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java index baf64b3cc54..eccdf6e40b1 100644 --- a/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java +++ b/fluss-lake/fluss-lake-hudi/src/test/java/org/apache/fluss/lake/hudi/tiering/HudiTieringTest.java @@ -656,7 +656,7 @@ public long tieringRoundTimestamp() { } @Override - public int bucketCountActual() { + public int bucketCount() { return tableInfo.getNumBuckets(); } } diff --git a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java index fd4fb8a0b9f..868a6bfd47b 100644 --- a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java +++ b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/IcebergLakeCatalogTest.java @@ -1530,7 +1530,7 @@ tablePath, pkTd, new TestingLakeCatalogContext())) * rejected. */ @Test - void testCreateTableFailsWithIncompatiblePartitionBucketCountActual() { + void testCreateTableFailsWithIncompatiblePartitionBucketCount() { String database = "spec_bucket_db"; String tableName = "spec_bucket_table"; TablePath tablePath = TablePath.of(database, tableName); diff --git a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergTieringTest.java b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergTieringTest.java index d42c13b89e5..d2a800935a5 100644 --- a/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergTieringTest.java +++ b/fluss-lake/fluss-lake-iceberg/src/test/java/org/apache/fluss/lake/iceberg/tiering/IcebergTieringTest.java @@ -318,7 +318,7 @@ public TableInfo tableInfo() { } @Override - public int bucketCountActual() { + public int bucketCount() { return tableInfo.getNumBuckets(); } }); diff --git a/fluss-lake/fluss-lake-lance/src/test/java/org/apache/fluss/lake/lance/tiering/LanceTieringTest.java b/fluss-lake/fluss-lake-lance/src/test/java/org/apache/fluss/lake/lance/tiering/LanceTieringTest.java index 63a2ba4accf..82756c98a48 100644 --- a/fluss-lake/fluss-lake-lance/src/test/java/org/apache/fluss/lake/lance/tiering/LanceTieringTest.java +++ b/fluss-lake/fluss-lake-lance/src/test/java/org/apache/fluss/lake/lance/tiering/LanceTieringTest.java @@ -409,7 +409,7 @@ public TableInfo tableInfo() { } @Override - public int bucketCountActual() { + public int bucketCount() { return tableInfo.getNumBuckets(); } }); diff --git a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/PaimonLakeWriter.java b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/PaimonLakeWriter.java index a8169518b67..05e7ca7eb75 100644 --- a/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/PaimonLakeWriter.java +++ b/fluss-lake/fluss-lake-paimon/src/main/java/org/apache/fluss/lake/paimon/tiering/PaimonLakeWriter.java @@ -58,7 +58,7 @@ public PaimonLakeWriter( // The context always resolves the actual bucket count. Integer bucketOverride = !writerInitContext.tableInfo().getBucketKeys().isEmpty() - ? writerInitContext.bucketCountActual() + ? writerInitContext.bucketCount() : null; TablePath lakeTablePath = writerInitContext.tableInfo().getLakeTablePath(); FileStoreTable fileStoreTable = diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/flink/FlinkUnionReadRescaleBucketITCase.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/flink/FlinkUnionReadRescaleBucketITCase.java index c92e9385fec..9e85aa634fd 100644 --- a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/flink/FlinkUnionReadRescaleBucketITCase.java +++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/flink/FlinkUnionReadRescaleBucketITCase.java @@ -75,7 +75,7 @@ protected static void beforeAll() { } @Test - void testUnionReadAcrossPartitionsWithDifferentBucketCountActuals() throws Exception { + void testUnionReadAcrossPartitionsWithDifferentBucketCounts() throws Exception { String tableName = "rescale_bucket_log_table"; TablePath tablePath = TablePath.of(DEFAULT_DB, tableName); createPartitionedLogTable(tablePath, OLD_BUCKET_NUM); @@ -91,9 +91,8 @@ void testUnionReadAcrossPartitionsWithDifferentBucketCountActuals() throws Excep createPartition(tablePath, "new"); expectedRows.addAll(writeRows(tablePath, "new", 0)); - Map bucketCountActualByPartition = - bucketCountActualByPartitionName(tablePath); - assertThat(bucketCountActualByPartition) + Map bucketCountByPartition = bucketCountByPartitionName(tablePath); + assertThat(bucketCountByPartition) .containsEntry("old", OLD_BUCKET_NUM) .containsEntry("new", NEW_BUCKET_NUM); @@ -146,7 +145,7 @@ void testUnionReadAcrossPartitionsWithDifferentBucketCountActuals() throws Excep } @Test - void testUnionReadPkTableAcrossPartitionsWithDifferentBucketCountActuals() throws Exception { + void testUnionReadPkTableAcrossPartitionsWithDifferentBucketCounts() throws Exception { String tableName = "rescale_bucket_pk_table"; TablePath tablePath = TablePath.of(DEFAULT_DB, tableName); createPartitionedPkTable(tablePath, OLD_BUCKET_NUM); @@ -158,7 +157,7 @@ void testUnionReadPkTableAcrossPartitionsWithDifferentBucketCountActuals() throw createPartition(tablePath, "new"); writeUpsertRows(tablePath, "new", 0); - assertThat(bucketCountActualByPartitionName(tablePath)) + assertThat(bucketCountByPartitionName(tablePath)) .containsEntry("old", OLD_BUCKET_NUM) .containsEntry("new", NEW_BUCKET_NUM); @@ -223,7 +222,7 @@ void testUnionReadPkTableAcrossPartitionsWithDifferentBucketCountActuals() throw } @Test - void testStreamUnionReadAcrossPartitionsWithDifferentBucketCountActuals() throws Exception { + void testStreamUnionReadAcrossPartitionsWithDifferentBucketCounts() throws Exception { String tableName = "rescale_bucket_stream_log_table"; TablePath tablePath = TablePath.of(DEFAULT_DB, tableName); createPartitionedLogTable(tablePath, OLD_BUCKET_NUM); @@ -234,7 +233,7 @@ void testStreamUnionReadAcrossPartitionsWithDifferentBucketCountActuals() throws createPartition(tablePath, "new"); expectedRows.addAll(writeRows(tablePath, "new", 0)); - assertThat(bucketCountActualByPartitionName(tablePath)) + assertThat(bucketCountByPartitionName(tablePath)) .containsEntry("old", OLD_BUCKET_NUM) .containsEntry("new", NEW_BUCKET_NUM); @@ -265,8 +264,7 @@ void testStreamUnionReadAcrossPartitionsWithDifferentBucketCountActuals() throws } @Test - void testStreamUnionReadPkTableAcrossPartitionsWithDifferentBucketCountActuals() - throws Exception { + void testStreamUnionReadPkTableAcrossPartitionsWithDifferentBucketCounts() throws Exception { String tableName = "rescale_bucket_stream_pk_table"; TablePath tablePath = TablePath.of(DEFAULT_DB, tableName); createPartitionedPkTable(tablePath, OLD_BUCKET_NUM); @@ -277,7 +275,7 @@ void testStreamUnionReadPkTableAcrossPartitionsWithDifferentBucketCountActuals() createPartition(tablePath, "new"); writeUpsertRows(tablePath, "new", 0); - assertThat(bucketCountActualByPartitionName(tablePath)) + assertThat(bucketCountByPartitionName(tablePath)) .containsEntry("old", OLD_BUCKET_NUM) .containsEntry("new", NEW_BUCKET_NUM); @@ -337,7 +335,7 @@ void testUnionReadLakeOnlyExpiredPartitionAfterRescale() throws Exception { createPartition(tablePath, "new"); List newRows = writeRows(tablePath, "new", 0); - assertThat(bucketCountActualByPartitionName(tablePath)) + assertThat(bucketCountByPartitionName(tablePath)) .containsEntry("old", OLD_BUCKET_NUM) .containsEntry("new", NEW_BUCKET_NUM); @@ -448,15 +446,13 @@ private List writeRows(TablePath tablePath, String partition, int keyOffset return flinkRows; } - private Map bucketCountActualByPartitionName(TablePath tablePath) - throws Exception { + private Map bucketCountByPartitionName(TablePath tablePath) throws Exception { List partitionInfos = admin.listPartitionInfos(tablePath).get(); - Map bucketCountActualByName = new java.util.HashMap<>(); + Map bucketCountByName = new java.util.HashMap<>(); for (PartitionInfo partitionInfo : partitionInfos) { - bucketCountActualByName.put( - partitionInfo.getPartitionName(), partitionInfo.getBucketCountActual()); + bucketCountByName.put(partitionInfo.getPartitionName(), partitionInfo.getBucketCount()); } - return bucketCountActualByName; + return bucketCountByName; } private void waitUntilPartitionBucketsSynced(TablePath tablePath, long tableId) @@ -467,14 +463,14 @@ private void waitUntilPartitionBucketsSynced(TablePath tablePath, long tableId) new BucketOffsetsRetrieverImpl(admin, tablePath); Set tableBuckets = new HashSet<>(); for (PartitionInfo partitionInfo : admin.listPartitionInfos(tablePath).get()) { - int bucketCountActual = partitionInfo.getBucketCountActual(); + int bucketCount = partitionInfo.getBucketCount(); List buckets = new ArrayList<>(); - for (int bucket = 0; bucket < bucketCountActual; bucket++) { + for (int bucket = 0; bucket < bucketCount; bucket++) { buckets.add(bucket); } Map latestOffsets = bucketOffsetsRetriever.latestOffsets(partitionInfo.getPartitionName(), buckets); - for (int bucket = 0; bucket < bucketCountActual; bucket++) { + for (int bucket = 0; bucket < bucketCount; bucket++) { Long latestOffset = latestOffsets.get(bucket); if (latestOffset != null && latestOffset > 0) { tableBuckets.add( diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java index 4d63b29f413..8e53120f02b 100644 --- a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java +++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java @@ -373,7 +373,7 @@ void testLookupExpiredPartitionAfterBucketNumRescale(boolean defaultBucketKey) admin.createPartition(tablePath, partitionSpec(EXPIRED_PARTITION_NAME), false).get(); long oldPartitionId = getPartitionId(tablePath, EXPIRED_PARTITION_NAME); FLUSS_CLUSTER_EXTENSION.waitUntilTablePartitionReady(tableId, oldPartitionId); - assertThat(bucketCountActualOf(tablePath, EXPIRED_PARTITION_NAME)) + assertThat(bucketCountOf(tablePath, EXPIRED_PARTITION_NAME)) .isEqualTo(PRE_RESCALE_BUCKET_NUM); InternalRow expectedOldRow = @@ -392,7 +392,7 @@ void testLookupExpiredPartitionAfterBucketNumRescale(boolean defaultBucketKey) admin.createPartition(tablePath, partitionSpec(SECOND_EXPIRED_PARTITION_NAME), false).get(); long newPartitionId = getPartitionId(tablePath, SECOND_EXPIRED_PARTITION_NAME); FLUSS_CLUSTER_EXTENSION.waitUntilTablePartitionReady(tableId, newPartitionId); - assertThat(bucketCountActualOf(tablePath, SECOND_EXPIRED_PARTITION_NAME)) + assertThat(bucketCountOf(tablePath, SECOND_EXPIRED_PARTITION_NAME)) .isEqualTo(POST_RESCALE_BUCKET_NUM); writeRows( tablePath, @@ -490,12 +490,11 @@ private static int lakeBucketOf( } /** Reads the bucket count a Fluss partition was created with. */ - private static int bucketCountActualOf(TablePath tablePath, String partitionName) - throws Exception { + private static int bucketCountOf(TablePath tablePath, String partitionName) throws Exception { Optional partition = FLUSS_CLUSTER_EXTENSION.getZooKeeperClient().getPartition(tablePath, partitionName); assertThat(partition).isPresent(); - return partition.get().getBucketCountActual(); + return partition.get().getBucketCount(); } /** Reads the bucket counts the tiered lake data of a partition was written with. */ diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/tiering/PaimonTieringTest.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/tiering/PaimonTieringTest.java index a03c1783a0c..2d3365ac4b7 100644 --- a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/tiering/PaimonTieringTest.java +++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/tiering/PaimonTieringTest.java @@ -481,7 +481,7 @@ void testThreePartitionTiering() throws Exception { } @Test - void testTieringStampsPartitionBucketCountActualAcrossRounds() throws Exception { + void testTieringStampsPartitionBucketCountAcrossRounds() throws Exception { // After ALTER bucket.num=8: files tiered for the "old" partition are stamped with its // actual count 4 (writer override) while the "new" partition inherits the schema value 8; // a second tiering round passes Paimon's native bucket-count check (historical 4 == @@ -1086,7 +1086,7 @@ public TableInfo tableInfo() { } @Override - public int bucketCountActual() { + public int bucketCount() { return partitionBucketCount != null ? partitionBucketCount : tableInfo.getNumBuckets(); diff --git a/fluss-rpc/src/main/proto/FlussApi.proto b/fluss-rpc/src/main/proto/FlussApi.proto index 9254885f653..9ab0ee43fca 100644 --- a/fluss-rpc/src/main/proto/FlussApi.proto +++ b/fluss-rpc/src/main/proto/FlussApi.proto @@ -157,7 +157,7 @@ message GetTableInfoResponse { required int64 created_time = 4; required int64 modified_time = 5; optional string remote_data_dir = 6; - optional int64 bucket_layout_epoch = 7; + optional int64 bucket_count_epoch = 7; } // list tables request and response @@ -865,7 +865,7 @@ message PbTableMetadata { required int64 modified_time = 7; optional string remote_data_dir = 8; // A table-level, monotonically increasing version for bucket.num changes. - optional int64 bucket_layout_epoch = 9; + optional int64 bucket_count_epoch = 9; // TODO add a new filed 'deleted_table' to indicate this table is deleted in UpdateMetadataRequest. // trace by: https://github.com/apache/fluss/issues/981 @@ -878,7 +878,7 @@ message PbPartitionMetadata { required int64 partition_id = 3; repeated PbBucketMetadata bucket_metadata = 4; // the actual bucket count for this partition, used for per-partition bucket rescale - optional int32 bucket_count_actual = 5; + optional int32 bucket_count = 5; } message PbBucketMetadata { @@ -1084,8 +1084,8 @@ message PbNotifyLeaderAndIsrReqForBucket { repeated int32 isr = 6 [packed = true]; required int32 bucket_epoch = 7; repeated int32 standby_replicas = 8 [packed = true]; - optional int32 bucket_count_actual = 9; - optional int64 bucket_layout_epoch = 10; + optional int32 bucket_count = 9; + optional int64 bucket_count_epoch = 10; } message PbNotifyLeaderAndIsrRespForBucket { @@ -1157,7 +1157,7 @@ message PbPartitionInfo { required PbPartitionSpec partition_spec = 2; optional string remote_data_dir = 3; // the actual bucket count for this partition, used for per-partition bucket rescale - optional int32 bucket_count_actual = 4; + optional int32 bucket_count = 4; } message PbPartitionSpec { diff --git a/fluss-rust/crates/fluss/proto/FlussApi.proto b/fluss-rust/crates/fluss/proto/FlussApi.proto index 9254885f653..9ab0ee43fca 100644 --- a/fluss-rust/crates/fluss/proto/FlussApi.proto +++ b/fluss-rust/crates/fluss/proto/FlussApi.proto @@ -157,7 +157,7 @@ message GetTableInfoResponse { required int64 created_time = 4; required int64 modified_time = 5; optional string remote_data_dir = 6; - optional int64 bucket_layout_epoch = 7; + optional int64 bucket_count_epoch = 7; } // list tables request and response @@ -865,7 +865,7 @@ message PbTableMetadata { required int64 modified_time = 7; optional string remote_data_dir = 8; // A table-level, monotonically increasing version for bucket.num changes. - optional int64 bucket_layout_epoch = 9; + optional int64 bucket_count_epoch = 9; // TODO add a new filed 'deleted_table' to indicate this table is deleted in UpdateMetadataRequest. // trace by: https://github.com/apache/fluss/issues/981 @@ -878,7 +878,7 @@ message PbPartitionMetadata { required int64 partition_id = 3; repeated PbBucketMetadata bucket_metadata = 4; // the actual bucket count for this partition, used for per-partition bucket rescale - optional int32 bucket_count_actual = 5; + optional int32 bucket_count = 5; } message PbBucketMetadata { @@ -1084,8 +1084,8 @@ message PbNotifyLeaderAndIsrReqForBucket { repeated int32 isr = 6 [packed = true]; required int32 bucket_epoch = 7; repeated int32 standby_replicas = 8 [packed = true]; - optional int32 bucket_count_actual = 9; - optional int64 bucket_layout_epoch = 10; + optional int32 bucket_count = 9; + optional int64 bucket_count_epoch = 10; } message PbNotifyLeaderAndIsrRespForBucket { @@ -1157,7 +1157,7 @@ message PbPartitionInfo { required PbPartitionSpec partition_spec = 2; optional string remote_data_dir = 3; // the actual bucket count for this partition, used for per-partition bucket rescale - optional int32 bucket_count_actual = 4; + optional int32 bucket_count = 4; } message PbPartitionSpec { diff --git a/fluss-rust/crates/fluss/src/client/write/sender.rs b/fluss-rust/crates/fluss/src/client/write/sender.rs index 4210dd915af..51fe3bf00f0 100644 --- a/fluss-rust/crates/fluss/src/client/write/sender.rs +++ b/fluss-rust/crates/fluss/src/client/write/sender.rs @@ -2105,7 +2105,7 @@ mod tests { created_time: 0, modified_time: 0, remote_data_dir: None, - bucket_layout_epoch: None, + bucket_count_epoch: None, } .encode(&mut body) .expect("encode GetTableInfoResponse"); diff --git a/fluss-rust/crates/fluss/src/metadata/partition.rs b/fluss-rust/crates/fluss/src/metadata/partition.rs index 7626305def2..5e00fe5657c 100644 --- a/fluss-rust/crates/fluss/src/metadata/partition.rs +++ b/fluss-rust/crates/fluss/src/metadata/partition.rs @@ -301,7 +301,7 @@ impl PartitionInfo { partition_id: self.partition_id, partition_spec: self.partition_spec.to_pb(), remote_data_dir: None, - bucket_count_actual: None, + bucket_count: None, } } diff --git a/fluss-rust/crates/fluss/src/proto/fluss.rs b/fluss-rust/crates/fluss/src/proto/fluss.rs index 7c546cf409c..4f60d21860b 100644 --- a/fluss-rust/crates/fluss/src/proto/fluss.rs +++ b/fluss-rust/crates/fluss/src/proto/fluss.rs @@ -186,7 +186,7 @@ pub struct GetTableInfoResponse { #[prost(string, optional, tag = "6")] pub remote_data_dir: ::core::option::Option<::prost::alloc::string::String>, #[prost(int64, optional, tag = "7")] - pub bucket_layout_epoch: ::core::option::Option, + pub bucket_count_epoch: ::core::option::Option, } /// list tables request and response #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -1141,7 +1141,7 @@ pub struct PbTableMetadata { pub remote_data_dir: ::core::option::Option<::prost::alloc::string::String>, /// A table-level, monotonically increasing version for bucket.num changes. #[prost(int64, optional, tag = "9")] - pub bucket_layout_epoch: ::core::option::Option, + pub bucket_count_epoch: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PbPartitionMetadata { @@ -1156,7 +1156,7 @@ pub struct PbPartitionMetadata { pub bucket_metadata: ::prost::alloc::vec::Vec, /// the actual bucket count for this partition, used for per-partition bucket rescale #[prost(int32, optional, tag = "5")] - pub bucket_count_actual: ::core::option::Option, + pub bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct PbBucketMetadata { @@ -1474,9 +1474,9 @@ pub struct PbNotifyLeaderAndIsrReqForBucket { #[prost(int32, repeated, tag = "8")] pub standby_replicas: ::prost::alloc::vec::Vec, #[prost(int32, optional, tag = "9")] - pub bucket_count_actual: ::core::option::Option, + pub bucket_count: ::core::option::Option, #[prost(int64, optional, tag = "10")] - pub bucket_layout_epoch: ::core::option::Option, + pub bucket_count_epoch: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct PbNotifyLeaderAndIsrRespForBucket { @@ -1582,7 +1582,7 @@ pub struct PbPartitionInfo { pub remote_data_dir: ::core::option::Option<::prost::alloc::string::String>, /// the actual bucket count for this partition, used for per-partition bucket rescale #[prost(int32, optional, tag = "4")] - pub bucket_count_actual: ::core::option::Option, + pub bucket_count: ::core::option::Option, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct PbPartitionSpec { diff --git a/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java b/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java index bbce2eb550b..60f29a59b9c 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/RpcServiceBase.java @@ -320,7 +320,7 @@ public CompletableFuture getTableInfo(GetTableInfoRequest .setRemoteDataDir(tableInfo.getRemoteDataDir()) .setCreatedTime(tableInfo.getCreatedTime()) .setModifiedTime(tableInfo.getModifiedTime()) - .setBucketLayoutEpoch(tableInfo.getBucketLayoutEpoch()); + .setBucketCountEpoch(tableInfo.getBucketCountEpoch()); return CompletableFuture.completedFuture(response); } @@ -398,8 +398,8 @@ public CompletableFuture getLatestKvSnapshots( getPartition(tablePath, request.getPartitionName()); partitionId = partition.getPartitionId(); numBuckets = - partition.getBucketCountActualOrDefault( - numBuckets, tableInfo.getBucketLayoutEpoch()); + partition.getBucketCountOrDefault( + numBuckets, tableInfo.getBucketCountEpoch()); } Map> snapshots; if (partitionId != null) { @@ -504,7 +504,7 @@ public CompletableFuture listPartitionInfos( authorizeTable(OperationType.DESCRIBE, tablePath); // Read table metadata before reading partitions. This prevents a read spanning ALTER from - // combining a pre-ALTER PartitionRegistration (without bucketCountActual) with a post-ALTER + // combining a pre-ALTER PartitionRegistration (without bucketCount) with a post-ALTER // TableInfo. TableInfo tableInfo = metadataManager.getTable(tablePath); List partitionKeys = tableInfo.getPartitionKeys(); @@ -525,7 +525,7 @@ public CompletableFuture listPartitionInfos( partitionKeys, partitionRegistrations, tableInfo.getNumBuckets(), - tableInfo.getBucketLayoutEpoch())); + tableInfo.getBucketCountEpoch())); } @Override diff --git a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java index 758532c2598..6e712328405 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorRequestBatch.java @@ -215,8 +215,8 @@ public void addNotifyLeaderRequestForTabletServers( // Leader activation requires the routing bucket count; skip the bucket when the // coordinator context has no assignment for it (e.g. raced by a drop) instead of // sending a notification without the count, which the TabletServer would reject. - Integer bucketCountActual = getBucketCountActual(tableBucket); - if (bucketCountActual == null) { + Integer bucketCount = getBucketCount(tableBucket); + if (bucketCount == null) { coordinatorContext.addPendingLeaderActivation(tableBucket); LOG.error( "Skip notifying leader and isr for {}: no bucket assignment in coordinator " @@ -224,8 +224,8 @@ public void addNotifyLeaderRequestForTabletServers( tableBucket); return; } - Long bucketLayoutEpoch = getBucketLayoutEpoch(tableBucket.getTableId()); - if (bucketLayoutEpoch == null) { + Long bucketCountEpoch = getBucketCountEpoch(tableBucket.getTableId()); + if (bucketCountEpoch == null) { coordinatorContext.addPendingLeaderActivation(tableBucket); LOG.error( "Skip notifying leader and isr for {}: no table info in coordinator context.", @@ -248,8 +248,8 @@ public void addNotifyLeaderRequestForTabletServers( tableBucket, bucketReplicas, leaderAndIsr, - bucketCountActual, - bucketLayoutEpoch)); + bucketCount, + bucketCountEpoch)); notifyBucketLeaderAndIsr.put(tableBucket, notifyLeaderAndIsrForBucket); }); @@ -267,7 +267,7 @@ public void addNotifyLeaderRequestForTabletServers( * in the coordinator context. The count is immutable per bucket, so it is carried with the * activation instead of waiting for the metadata push. */ - private @Nullable Integer getBucketCountActual(TableBucket tableBucket) { + private @Nullable Integer getBucketCount(TableBucket tableBucket) { Map> assignment; if (tableBucket.getPartitionId() != null) { assignment = @@ -280,9 +280,9 @@ public void addNotifyLeaderRequestForTabletServers( return assignment.isEmpty() ? null : assignment.size(); } - private @Nullable Long getBucketLayoutEpoch(long tableId) { + private @Nullable Long getBucketCountEpoch(long tableId) { TableInfo tableInfo = coordinatorContext.getTableInfoById(tableId); - return tableInfo == null ? null : tableInfo.getBucketLayoutEpoch(); + return tableInfo == null ? null : tableInfo.getBucketCountEpoch(); } public void addStopReplicaRequestForTabletServers( @@ -736,7 +736,7 @@ private UpdateMetadataRequest buildUpdateMetadataRequest() { Map> partitionAssignment = coordinatorContext.getPartitionAssignment( new TablePartition(tableId, partitionId)); - Integer bucketCountActual = + Integer bucketCount = partitionAssignment.isEmpty() ? null : partitionAssignment.size(); PartitionMetadata partitionMetadata; if (partitionName == null) { @@ -747,7 +747,7 @@ private UpdateMetadataRequest buildUpdateMetadataRequest() { DELETED_PARTITION_NAME, partitionId, kvEntry.getValue(), - bucketCountActual); + bucketCount); } else { throw new IllegalStateException( "Partition name is null for partition " + partitionId); @@ -761,7 +761,7 @@ private UpdateMetadataRequest buildUpdateMetadataRequest() { ? DELETED_PARTITION_ID : partitionId, kvEntry.getValue(), - bucketCountActual); + bucketCount); } // table partitionMetadataList.add(partitionMetadata); diff --git a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java index 082c05833f3..e4ac37f4545 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java @@ -781,7 +781,7 @@ private void doAlterTablePropertiesOnce( // of old partitions' current actual bucket count. partitionBucketCountBackfills = computePartitionBucketCountBackfill(tablePath); - // Update the structural bucketCount field and increment bucketLayoutEpoch + // Update the structural bucketCount field and increment bucketCountEpoch tableReg = tableReg.withBucketCount(newBucketNum); } @@ -896,7 +896,7 @@ private void doAlterTablePropertiesOnce( } PartitionRegistration reg = optReg.get().data(); int partitionZkVersion = optReg.get().zkVersion(); - if (reg.getBucketCountActual() != null) { + if (reg.getBucketCount() != null) { // Already has bucket count persisted, skip. Idempotent so retries are safe. continue; } @@ -914,13 +914,13 @@ private void doAlterTablePropertiesOnce( + "ALTER.", tablePath, partitionName, partitionId)); } - int bucketCountActual = optAssignment.get().getBucketAssignments().size(); + int bucketCount = optAssignment.get().getBucketAssignments().size(); PartitionRegistration updatedReg = new PartitionRegistration( reg.getTableId(), reg.getPartitionId(), reg.getRemoteDataDir(), - bucketCountActual); + bucketCount); backfills.put( partitionName, new ZooKeeperClient.VersionedData<>(updatedReg, partitionZkVersion)); @@ -1207,6 +1207,10 @@ public Set getPartitions(TablePath tablePath) { "Fail to get partitions from zookeeper for table " + tablePath); } + /** + * Creates a partition. The {@code bucketCount} is the table-level count the partition is + * created under; it becomes the partition's own persisted bucket count from here on. + */ public void createPartition( TablePath tablePath, long tableId, @@ -1214,7 +1218,7 @@ public void createPartition( PartitionAssignment partitionAssignment, ResolvedPartitionSpec partition, boolean ignoreIfExists, - int bucketCountActual) { + int bucketCount) { String partitionName = partition.getPartitionName(); Optional optionalPartitionRegistration = getOptionalPartitionRegistration(tablePath, partitionName); @@ -1267,7 +1271,7 @@ public void createPartition( remoteDataDir, tablePath, tableId, - bucketCountActual); + bucketCount); LOG.info( "Register partition {} to zookeeper for table [{}].", partitionName, tablePath); } catch (KeeperException.NodeExistsException nodeExistsException) { diff --git a/fluss-server/src/main/java/org/apache/fluss/server/entity/NotifyLeaderAndIsrData.java b/fluss-server/src/main/java/org/apache/fluss/server/entity/NotifyLeaderAndIsrData.java index 1351a76ef58..87baebd59b5 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/entity/NotifyLeaderAndIsrData.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/entity/NotifyLeaderAndIsrData.java @@ -33,8 +33,8 @@ public final class NotifyLeaderAndIsrData { private final List replicas; private final LeaderAndIsr leaderAndIsr; // null when a legacy coordinator omits the fields - private final @Nullable Integer bucketCountActual; - private final @Nullable Long bucketLayoutEpoch; + private final @Nullable Integer bucketCount; + private final @Nullable Long bucketCountEpoch; public NotifyLeaderAndIsrData( PhysicalTablePath physicalTablePath, @@ -49,14 +49,14 @@ public NotifyLeaderAndIsrData( TableBucket tableBucket, List replicas, LeaderAndIsr leaderAndIsr, - @Nullable Integer bucketCountActual, - @Nullable Long bucketLayoutEpoch) { + @Nullable Integer bucketCount, + @Nullable Long bucketCountEpoch) { this.physicalTablePath = physicalTablePath; this.tableBucket = tableBucket; this.replicas = replicas; this.leaderAndIsr = leaderAndIsr; - this.bucketCountActual = bucketCountActual; - this.bucketLayoutEpoch = bucketLayoutEpoch; + this.bucketCount = bucketCount; + this.bucketCountEpoch = bucketCountEpoch; } public PhysicalTablePath getPhysicalTablePath() { @@ -112,12 +112,12 @@ public int[] getStandbyReplicasArray() { } /** The actual bucket count of the owning table/partition, or null if not carried. */ - public @Nullable Integer getBucketCountActual() { - return bucketCountActual; + public @Nullable Integer getBucketCount() { + return bucketCount; } /** The bucket layout epoch of the owning table, or null if not carried. */ - public @Nullable Long getBucketLayoutEpoch() { - return bucketLayoutEpoch; + public @Nullable Long getBucketCountEpoch() { + return bucketCountEpoch; } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionMetadata.java b/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionMetadata.java index 1fd58810190..362518f616b 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionMetadata.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/metadata/PartitionMetadata.java @@ -42,7 +42,7 @@ public class PartitionMetadata { private final String partitionName; private final long partitionId; private final List bucketMetadataList; - @Nullable private final Integer bucketCountActual; + @Nullable private final Integer bucketCount; public PartitionMetadata( long tableId, @@ -57,12 +57,12 @@ public PartitionMetadata( String partitionName, long partitionId, List bucketMetadataList, - @Nullable Integer bucketCountActual) { + @Nullable Integer bucketCount) { this.tableId = tableId; this.partitionName = partitionName; this.partitionId = partitionId; this.bucketMetadataList = bucketMetadataList; - this.bucketCountActual = bucketCountActual; + this.bucketCount = bucketCount; } public long getTableId() { @@ -83,7 +83,7 @@ public List getBucketMetadataList() { /** Returns the actual bucket count for this partition, or null if not set (old data). */ @Nullable - public Integer getBucketCountActual() { - return bucketCountActual; + public Integer getBucketCount() { + return bucketCount; } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/metadata/ServerMetadataSnapshot.java b/fluss-server/src/main/java/org/apache/fluss/server/metadata/ServerMetadataSnapshot.java index 752f308e9a4..2ead046a951 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/metadata/ServerMetadataSnapshot.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/metadata/ServerMetadataSnapshot.java @@ -63,11 +63,11 @@ public class ServerMetadataSnapshot { // TablePartition -> bucket count; absent only when a legacy Coordinator omits it; a new // Coordinator always sends the field. - private final Map partitionBucketCountActuals; + private final Map partitionBucketCounts; - // tableId -> bucketLayoutEpoch; a TabletServer keeps the latest value and ignores a lower + // tableId -> bucketCountEpoch; a TabletServer keeps the latest value and ignores a lower // epoch to prevent an older bucket layout (ALTER bucket.num) from replacing a newer one. - private final Map bucketLayoutEpochByTableId; + private final Map bucketCountEpochByTableId; public ServerMetadataSnapshot( @Nullable ServerInfo coordinatorServer, @@ -77,8 +77,8 @@ public ServerMetadataSnapshot( Map partitionIdByPath, Map> bucketMetadataMapForTables, Map> bucketMetadataMapForPartitions, - Map partitionBucketCountActuals, - Map bucketLayoutEpochByTableId) { + Map partitionBucketCounts, + Map bucketCountEpochByTableId) { this.coordinatorServer = coordinatorServer; this.aliveTabletServers = Collections.unmodifiableMap(aliveTabletServers); @@ -95,8 +95,8 @@ public ServerMetadataSnapshot( this.bucketMetadataMapForTables = Collections.unmodifiableMap(bucketMetadataMapForTables); this.bucketMetadataMapForPartitions = Collections.unmodifiableMap(bucketMetadataMapForPartitions); - this.partitionBucketCountActuals = Collections.unmodifiableMap(partitionBucketCountActuals); - this.bucketLayoutEpochByTableId = Collections.unmodifiableMap(bucketLayoutEpochByTableId); + this.partitionBucketCounts = Collections.unmodifiableMap(partitionBucketCounts); + this.bucketCountEpochByTableId = Collections.unmodifiableMap(bucketCountEpochByTableId); } /** Create an empty cluster instance with no nodes and no table-buckets. */ @@ -178,25 +178,25 @@ public Map getBucketMetadataForPartition(long partition * Returns the actual bucket count for the given table partition, or null when the coordinator * didn't send an explicit bucket count for it. */ - public @Nullable Integer getPartitionBucketCountActual(TablePartition tablePartition) { - return partitionBucketCountActuals.get(tablePartition); + public @Nullable Integer getPartitionBucketCount(TablePartition tablePartition) { + return partitionBucketCounts.get(tablePartition); } - public Map getPartitionBucketCountActuals() { - return partitionBucketCountActuals; + public Map getPartitionBucketCounts() { + return partitionBucketCounts; } /** * Returns the bucket layout epoch for the given tableId, or empty if not known (legacy table * without the field, read as 0). */ - public OptionalLong getBucketLayoutEpoch(long tableId) { - Long epoch = bucketLayoutEpochByTableId.get(tableId); + public OptionalLong getBucketCountEpoch(long tableId) { + Long epoch = bucketCountEpochByTableId.get(tableId); return epoch == null ? OptionalLong.empty() : OptionalLong.of(epoch); } - public Map getBucketLayoutEpochByTableId() { - return bucketLayoutEpochByTableId; + public Map getBucketCountEpochByTableId() { + return bucketCountEpochByTableId; } public Map getPartitionIdByPath() { diff --git a/fluss-server/src/main/java/org/apache/fluss/server/metadata/TabletServerMetadataCache.java b/fluss-server/src/main/java/org/apache/fluss/server/metadata/TabletServerMetadataCache.java index f837839bc74..c314e8fa7fb 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/metadata/TabletServerMetadataCache.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/metadata/TabletServerMetadataCache.java @@ -156,8 +156,8 @@ public void updateLatestSchema(long tableId, SchemaInfo schemaInfo) { * Returns the bucket layout epoch for the table, or empty if not known (legacy table without * the field, read as 0). */ - public OptionalLong getBucketLayoutEpoch(long tableId) { - return serverMetadataSnapshot.getBucketLayoutEpoch(tableId); + public OptionalLong getBucketCountEpoch(long tableId) { + return serverMetadataSnapshot.getBucketCountEpoch(tableId); } public Optional getPartitionMetadata(PhysicalTablePath partitionPath) { @@ -174,18 +174,15 @@ public Optional getPartitionMetadata(PhysicalTablePath partit new ArrayList<>(snapshot.getBucketMetadataForPartition(partitionId).values()); // prefer the explicit bucket count sent by the coordinator; the merged bucket // metadata list may be transiently partial during incremental updates - Integer bucketCountActual = - snapshot.getPartitionBucketCountActual( - new TablePartition(tableId, partitionId)); + Integer bucketCount = + snapshot.getPartitionBucketCount(new TablePartition(tableId, partitionId)); return Optional.of( new PartitionMetadata( tableId, partitionName, partitionId, bucketMetadataList, - bucketCountActual != null - ? bucketCountActual - : bucketMetadataList.size())); + bucketCount != null ? bucketCount : bucketMetadataList.size())); } else { return Optional.empty(); @@ -225,8 +222,8 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { new HashMap<>(serverMetadataSnapshot.getTableIdByPath()); Map> bucketMetadataMapForTables = new HashMap<>(serverMetadataSnapshot.getBucketMetadataMapForTables()); - Map bucketLayoutEpochByTableId = - new HashMap<>(serverMetadataSnapshot.getBucketLayoutEpochByTableId()); + Map bucketCountEpochByTableId = + new HashMap<>(serverMetadataSnapshot.getBucketCountEpochByTableId()); for (TableMetadata tableMetadata : clusterMetadata.getTableMetadataList()) { TableInfo tableInfo = tableMetadata.getTableInfo(); @@ -238,7 +235,7 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { if (removedTableId != null) { bucketMetadataMapForTables.remove(removedTableId); deletedTableIds.add(removedTableId); - bucketLayoutEpochByTableId.remove(removedTableId); + bucketCountEpochByTableId.remove(removedTableId); } } else if (tablePath == DELETED_TABLE_PATH) { serverMetadataSnapshot @@ -246,17 +243,16 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { .ifPresent(tableIdByPath::remove); bucketMetadataMapForTables.remove(tableId); deletedTableIds.add(tableId); - bucketLayoutEpochByTableId.remove(tableId); + bucketCountEpochByTableId.remove(tableId); } else { // Ignore an older UpdateMetadata to prevent an older bucket // layout (ALTER bucket.num) from replacing a newer one. - long newEpoch = tableInfo.getBucketLayoutEpoch(); - long currentEpoch = - bucketLayoutEpochByTableId.getOrDefault(tableId, 0L); + long newEpoch = tableInfo.getBucketCountEpoch(); + long currentEpoch = bucketCountEpochByTableId.getOrDefault(tableId, 0L); if (newEpoch < currentEpoch) { continue; } - bucketLayoutEpochByTableId.put(tableId, newEpoch); + bucketCountEpochByTableId.put(tableId, newEpoch); // Update schema metadata. // todo: apply schema id and schema info if needs @@ -288,8 +284,8 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { Map> bucketMetadataMapForPartitions = new HashMap<>( serverMetadataSnapshot.getBucketMetadataMapForPartitions()); - Map partitionBucketCountActuals = - new HashMap<>(serverMetadataSnapshot.getPartitionBucketCountActuals()); + Map partitionBucketCounts = + new HashMap<>(serverMetadataSnapshot.getPartitionBucketCounts()); for (PartitionMetadata partitionMetadata : clusterMetadata.getPartitionMetadataList()) { @@ -303,7 +299,7 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { Long removedPartitionId = partitionIdByPath.remove(physicalTablePath); if (removedPartitionId != null) { bucketMetadataMapForPartitions.remove(removedPartitionId); - partitionBucketCountActuals + partitionBucketCounts .keySet() .removeIf(k -> k.getPartitionId() == removedPartitionId); } @@ -312,16 +308,13 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { .getPhysicalTablePath(partitionId) .ifPresent(partitionIdByPath::remove); bucketMetadataMapForPartitions.remove(partitionId); - partitionBucketCountActuals + partitionBucketCounts .keySet() .removeIf(k -> k.getPartitionId() == partitionId); } else { partitionIdByPath.put(physicalTablePath, partitionId); - mergePartitionBucketCountActual( - partitionBucketCountActuals, - tableId, - partitionId, - partitionMetadata); + mergePartitionBucketCount( + partitionBucketCounts, tableId, partitionId, partitionMetadata); partitionMetadata .getBucketMetadataList() .forEach( @@ -345,8 +338,8 @@ public Set updateClusterMetadata(ClusterMetadata clusterMetadata) { partitionIdByPath, bucketMetadataMapForTables, bucketMetadataMapForPartitions, - partitionBucketCountActuals, - bucketLayoutEpochByTableId); + partitionBucketCounts, + bucketCountEpochByTableId); return deletedTableIds; }); } @@ -392,10 +385,10 @@ public void updateTableMetadata(TableMetadata tableMetadata) { // Ignore an older UpdateMetadata for this table to prevent it from // overwriting newer state when messages arrive out of order. - long newEpoch = tableInfo.getBucketLayoutEpoch(); + long newEpoch = tableInfo.getBucketCountEpoch(); long currentEpoch = currentSnapshot - .getBucketLayoutEpochByTableId() + .getBucketCountEpochByTableId() .getOrDefault(tableId, 0L); if (newEpoch < currentEpoch) { return; @@ -429,9 +422,9 @@ public void updateTableMetadata(TableMetadata tableMetadata) { tableIdByPath.forEach((path, id) -> pathByTableId.put(id, path)); // Update epoch for this table - Map bucketLayoutEpochByTableId = - new HashMap<>(currentSnapshot.getBucketLayoutEpochByTableId()); - bucketLayoutEpochByTableId.put(tableId, tableInfo.getBucketLayoutEpoch()); + Map bucketCountEpochByTableId = + new HashMap<>(currentSnapshot.getBucketCountEpochByTableId()); + bucketCountEpochByTableId.put(tableId, tableInfo.getBucketCountEpoch()); // Create new snapshot serverMetadataSnapshot = @@ -443,8 +436,8 @@ public void updateTableMetadata(TableMetadata tableMetadata) { partitionIdByPath, bucketMetadataMapForTables, bucketMetadataMapForPartitions, - currentSnapshot.getPartitionBucketCountActuals(), - bucketLayoutEpochByTableId); + currentSnapshot.getPartitionBucketCounts(), + bucketCountEpochByTableId); }); } @@ -495,10 +488,10 @@ public void updatePartitionMetadata(PartitionMetadata partitionMetadata) { } bucketMetadataMapForPartitions.put(partitionId, partitionBucketMetadata); - Map partitionBucketCountActuals = - new HashMap<>(currentSnapshot.getPartitionBucketCountActuals()); - mergePartitionBucketCountActual( - partitionBucketCountActuals, tableId, partitionId, partitionMetadata); + Map partitionBucketCounts = + new HashMap<>(currentSnapshot.getPartitionBucketCounts()); + mergePartitionBucketCount( + partitionBucketCounts, tableId, partitionId, partitionMetadata); // Copy other existing data Map> bucketMetadataMapForTables = @@ -517,8 +510,8 @@ public void updatePartitionMetadata(PartitionMetadata partitionMetadata) { partitionIdByPath, bucketMetadataMapForTables, bucketMetadataMapForPartitions, - partitionBucketCountActuals, - currentSnapshot.getBucketLayoutEpochByTableId()); + partitionBucketCounts, + currentSnapshot.getBucketCountEpochByTableId()); }); } @@ -527,15 +520,14 @@ public void updatePartitionMetadata(PartitionMetadata partitionMetadata) { * older versions do not send it; in that case the cache keeps no entry and readers fall back to * the merged bucket metadata size (see {@link #getPartitionMetadata}). */ - private static void mergePartitionBucketCountActual( - Map partitionBucketCountActuals, + private static void mergePartitionBucketCount( + Map partitionBucketCounts, long tableId, long partitionId, PartitionMetadata partitionMetadata) { - if (partitionMetadata.getBucketCountActual() != null) { - partitionBucketCountActuals.put( - new TablePartition(tableId, partitionId), - partitionMetadata.getBucketCountActual()); + if (partitionMetadata.getBucketCount() != null) { + partitionBucketCounts.put( + new TablePartition(tableId, partitionId), partitionMetadata.getBucketCount()); } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java index c3dcaba7d71..7827c7a3520 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/Replica.java @@ -198,7 +198,7 @@ public final class Replica { // Routing state carried with activation: both values are immutable per bucket, so they are // set once and never change. Null until the coordinator notifies them. private volatile @Nullable Integer routingBucketCount; - private volatile @Nullable Long bucketLayoutEpoch; + private volatile @Nullable Long bucketCountEpoch; private final boolean historicalPartition; // logFormat and arrowCompressionInfo are immutable and used in hot-path, so cache them here. @@ -461,11 +461,11 @@ public LogFormat getLogFormat() { * so unset fields never overwrite known ones. */ public void updateRoutingState(NotifyLeaderAndIsrData data) { - if (data.getBucketCountActual() != null) { - this.routingBucketCount = data.getBucketCountActual(); + if (data.getBucketCount() != null) { + this.routingBucketCount = data.getBucketCount(); } - if (data.getBucketLayoutEpoch() != null) { - this.bucketLayoutEpoch = data.getBucketLayoutEpoch(); + if (data.getBucketCountEpoch() != null) { + this.bucketCountEpoch = data.getBucketCountEpoch(); } } @@ -474,7 +474,7 @@ public void updateRoutingState(NotifyLeaderAndIsrData data) { * coordinator is older than this server (upgrade contract: coordinator first). */ private void requireRoutingState(NotifyLeaderAndIsrData data) { - if (data.getBucketCountActual() == null) { + if (data.getBucketCount() == null) { throw new UnsupportedVersionException( "Leader activation for bucket " + tableBucket @@ -493,8 +493,8 @@ private void requireRoutingState(NotifyLeaderAndIsrData data) { } /** The bucket layout epoch of the owning table, or null if not yet notified. */ - public @Nullable Long getBucketLayoutEpoch() { - return bucketLayoutEpoch; + public @Nullable Long getBucketCountEpoch() { + return bucketCountEpoch; } public void makeLeader(NotifyLeaderAndIsrData data) throws IOException { diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java index 53311cf2fe6..6a62d48fa23 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java @@ -2557,7 +2557,7 @@ public void validateRoutingBucketCount(TableBucket tableBucket, int routingBucke if (routingBucketCount <= 0) { // Legacy client (no bucket count in request): reject only when a rescale is known, // because then the bucketId may come from an outdated count. - if (resolveBucketLayoutEpoch(replica) > 0) { + if (resolveBucketCountEpoch(replica) > 0) { throw new StaleMetadataException( "STALE_METADATA for " + tableBucket @@ -2587,12 +2587,10 @@ public void validateRoutingBucketCount(TableBucket tableBucket, int routingBucke * Resolves the effective bucket layout epoch as the maximum of the replica-local value and the * metadata cache: ALTER bucket.num advances only the cache, and the epoch is monotonic. */ - private long resolveBucketLayoutEpoch(Replica replica) { - Long replicaEpoch = replica.getBucketLayoutEpoch(); + private long resolveBucketCountEpoch(Replica replica) { + Long replicaEpoch = replica.getBucketCountEpoch(); long cachedEpoch = - metadataCache - .getBucketLayoutEpoch(replica.getTableBucket().getTableId()) - .orElse(0L); + metadataCache.getBucketCountEpoch(replica.getTableBucket().getTableId()).orElse(0L); return Math.max(replicaEpoch == null ? 0L : replicaEpoch, cachedEpoch); } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java index c6321a9eca1..8210d9ce8e5 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/historical/HistoricalLakeLookupManager.java @@ -365,7 +365,7 @@ private LookupContext createLookupContext( // The request's bucket id only routes the request. It matches the lake layout only while // the table was never rescaled; otherwise the lake lookuper resolves the bucket itself. Integer lakeBucketId = - tableInfo.getBucketLayoutEpoch() == 0 ? tableBucket.getBucket() : null; + tableInfo.getBucketCountEpoch() == 0 ? tableBucket.getBucket() : null; LakeTableLookuper.LookupContext lookupContext = new LakeTableLookuper.LookupContext( diff --git a/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java b/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java index 317b2adebbb..6bc14a2717f 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/utils/ServerRpcMessageUtils.java @@ -610,7 +610,7 @@ private static PbTableMetadata toPbTableMetadata(TableMetadata tableMetadata) { .setRemoteDataDir(tableInfo.getRemoteDataDir()) .setCreatedTime(tableInfo.getCreatedTime()) .setModifiedTime(tableInfo.getModifiedTime()) - .setBucketLayoutEpoch(tableInfo.getBucketLayoutEpoch()); + .setBucketCountEpoch(tableInfo.getBucketCountEpoch()); TablePath tablePath = tableInfo.getTablePath(); pbTableMetadata .setTablePath() @@ -629,15 +629,15 @@ private static PbPartitionMetadata toPbPartitionMetadata(PartitionMetadata parti .setPartitionName(partitionMetadata.getPartitionName()); pbPartitionMetadata.addAllBucketMetadatas( toPbBucketMetadata(partitionMetadata.getBucketMetadataList())); - Integer bucketCountActual = partitionMetadata.getBucketCountActual(); + Integer bucketCount = partitionMetadata.getBucketCount(); int effectiveBucketCount = - bucketCountActual != null - ? bucketCountActual + bucketCount != null + ? bucketCount : partitionMetadata.getBucketMetadataList().size(); // 0 means the partition assignment is not known yet, not a zero-bucket layout; // omitting the field keeps the client on its table-level fallback instead of 0. if (effectiveBucketCount > 0) { - pbPartitionMetadata.setBucketCountActual(effectiveBucketCount); + pbPartitionMetadata.setBucketCount(effectiveBucketCount); } return pbPartitionMetadata; } @@ -696,9 +696,9 @@ private static TableMetadata toTableMetaData(PbTableMetadata pbTableMetadata) { : null, pbTableMetadata.getCreatedTime(), pbTableMetadata.getModifiedTime(), - // legacy Coordinators predate bucketLayoutEpoch; read as 0 (never ALTERed) - pbTableMetadata.hasBucketLayoutEpoch() - ? pbTableMetadata.getBucketLayoutEpoch() + // legacy Coordinators predate bucketCountEpoch; read as 0 (never ALTERed) + pbTableMetadata.hasBucketCountEpoch() + ? pbTableMetadata.getBucketCountEpoch() : 0L); List bucketMetadata = new ArrayList<>(); @@ -729,9 +729,7 @@ private static PartitionMetadata toPartitionMetadata(PbPartitionMetadata pbParti pbPartitionMetadata.getBucketMetadatasList().stream() .map(ServerRpcMessageUtils::toBucketMetadata) .collect(Collectors.toList()), - pbPartitionMetadata.hasBucketCountActual() - ? pbPartitionMetadata.getBucketCountActual() - : null); + pbPartitionMetadata.hasBucketCount() ? pbPartitionMetadata.getBucketCount() : null); } public static NotifyLeaderAndIsrRequest makeNotifyLeaderAndIsrRequest( @@ -765,11 +763,11 @@ public static PbNotifyLeaderAndIsrReqForBucket makeNotifyBucketLeaderAndIsr( .setPhysicalTablePath(fromPhysicalTablePath(physicalTablePath)) .setReplicas(notifyLeaderAndIsrData.getReplicasArray()) .setIsrs(notifyLeaderAndIsrData.getIsrArray()); - if (notifyLeaderAndIsrData.getBucketCountActual() != null) { - reqForBucket.setBucketCountActual(notifyLeaderAndIsrData.getBucketCountActual()); + if (notifyLeaderAndIsrData.getBucketCount() != null) { + reqForBucket.setBucketCount(notifyLeaderAndIsrData.getBucketCount()); } - if (notifyLeaderAndIsrData.getBucketLayoutEpoch() != null) { - reqForBucket.setBucketLayoutEpoch(notifyLeaderAndIsrData.getBucketLayoutEpoch()); + if (notifyLeaderAndIsrData.getBucketCountEpoch() != null) { + reqForBucket.setBucketCountEpoch(notifyLeaderAndIsrData.getBucketCountEpoch()); } return reqForBucket; @@ -808,11 +806,9 @@ public static List getNotifyLeaderAndIsrRequestData( standbyReplicas, request.getCoordinatorEpoch(), reqForBucket.getBucketEpoch()), - reqForBucket.hasBucketCountActual() - ? reqForBucket.getBucketCountActual() - : null, - reqForBucket.hasBucketLayoutEpoch() - ? reqForBucket.getBucketLayoutEpoch() + reqForBucket.hasBucketCount() ? reqForBucket.getBucketCount() : null, + reqForBucket.hasBucketCountEpoch() + ? reqForBucket.getBucketCountEpoch() : null)); } return notifyLeaderAndIsrDataList; @@ -1885,7 +1881,7 @@ public static ListPartitionInfosResponse toListPartitionInfosResponse( List partitionKeys, Map partitionRegistrations, int tableBucketCount, - long bucketLayoutEpoch) { + long bucketCountEpoch) { ListPartitionInfosResponse listPartitionsResponse = new ListPartitionInfosResponse(); for (Map.Entry partitionRegistration : partitionRegistrations.entrySet()) { @@ -1898,9 +1894,8 @@ public static ListPartitionInfosResponse toListPartitionInfosResponse( .setPartitionId(partition.getPartitionId()) .setPartitionSpec(makePbPartitionSpec(spec)) .setRemoteDataDir(partition.getRemoteDataDir()) - .setBucketCountActual( - partition.getBucketCountActualOrDefault( - tableBucketCount, bucketLayoutEpoch)); + .setBucketCount( + partition.getBucketCountOrDefault(tableBucketCount, bucketCountEpoch)); } return listPartitionsResponse; } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java index 1bcfe4124e2..bb07eff264d 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/ZooKeeperClient.java @@ -1188,7 +1188,7 @@ public void registerPartitionAssignmentAndMetadata( String remoteDataDir, TablePath tablePath, long tableId, - int bucketCountActual) + int bucketCount) throws Exception { // Merge "registerPartitionAssignment()" and "registerPartition()" // into one transaction. This is to avoid the case that the partition assignment is @@ -1234,10 +1234,7 @@ public void registerPartitionAssignmentAndMetadata( metadataPath, PartitionZNode.encode( new PartitionRegistration( - tableId, - partitionId, - remoteDataDir, - bucketCountActual))); + tableId, partitionId, remoteDataDir, bucketCount))); ops.add(tabletServerPartitionNode); ops.add(metadataPartitionNode); diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistration.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistration.java index d1b27959a19..fea0f67c7d3 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistration.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistration.java @@ -51,7 +51,7 @@ public class PartitionRegistration { * does not persist per-partition bucket count. In that case, callers should fall back to the * table-level bucket count. */ - private final @Nullable Integer bucketCountActual; + private final @Nullable Integer bucketCount; public PartitionRegistration(long tableId, long partitionId, @Nullable String remoteDataDir) { this(tableId, partitionId, remoteDataDir, null); @@ -61,11 +61,11 @@ public PartitionRegistration( long tableId, long partitionId, @Nullable String remoteDataDir, - @Nullable Integer bucketCountActual) { + @Nullable Integer bucketCount) { this.tableId = tableId; this.partitionId = partitionId; this.remoteDataDir = remoteDataDir; - this.bucketCountActual = bucketCountActual; + this.bucketCount = bucketCount; } public long getTableId() { @@ -83,8 +83,8 @@ public String getRemoteDataDir() { /** Returns the bucket count of this partition, or null if not persisted (old data). */ @Nullable - public Integer getBucketCountActual() { - return bucketCountActual; + public Integer getBucketCount() { + return bucketCount; } /** @@ -92,23 +92,23 @@ public Integer getBucketCountActual() { * count when this partition was persisted by an older version that does not store the * per-partition count. * - *

The fallback is only valid at {@code bucketLayoutEpoch == 0} (legacy table or old server). - * At {@code bucketLayoutEpoch > 0}, the first ALTER must have backfilled the per-partition + *

The fallback is only valid at {@code bucketCountEpoch == 0} (legacy table or old server). + * At {@code bucketCountEpoch > 0}, the first ALTER must have backfilled the per-partition * count; a missing count indicates an incomplete backfill and throws {@link * StaleMetadataException} so the caller can refresh metadata and retry. */ - public int getBucketCountActualOrDefault(int tableBucketCount, long bucketLayoutEpoch) { - if (bucketCountActual != null) { - return bucketCountActual; + public int getBucketCountOrDefault(int tableBucketCount, long bucketCountEpoch) { + if (bucketCount != null) { + return bucketCount; } - if (bucketLayoutEpoch == 0) { + if (bucketCountEpoch == 0) { return tableBucketCount; } throw new StaleMetadataException( "Partition " + partitionId - + " is missing a per-partition bucket count at bucketLayoutEpoch " - + bucketLayoutEpoch); + + " is missing a per-partition bucket count at bucketCountEpoch " + + bucketCountEpoch); } public TablePartition toTablePartition() { @@ -124,7 +124,7 @@ public TablePartition toTablePartition() { * @return a new registration with the given remote data directory */ public PartitionRegistration newRemoteDataDir(String remoteDataDir) { - return new PartitionRegistration(tableId, partitionId, remoteDataDir, bucketCountActual); + return new PartitionRegistration(tableId, partitionId, remoteDataDir, bucketCount); } @Override @@ -136,12 +136,12 @@ public boolean equals(Object o) { return tableId == that.tableId && partitionId == that.partitionId && Objects.equals(remoteDataDir, that.remoteDataDir) - && Objects.equals(bucketCountActual, that.bucketCountActual); + && Objects.equals(bucketCount, that.bucketCount); } @Override public int hashCode() { - return Objects.hash(tableId, partitionId, remoteDataDir, bucketCountActual); + return Objects.hash(tableId, partitionId, remoteDataDir, bucketCount); } @Override @@ -154,8 +154,8 @@ public String toString() { + ", remoteDataDir='" + remoteDataDir + '\'' - + ", bucketCountActual=" - + bucketCountActual + + ", bucketCount=" + + bucketCount + '}'; } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerde.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerde.java index 4687e28c8eb..6e474ffa38a 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerde.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerde.java @@ -37,7 +37,7 @@ public class PartitionRegistrationJsonSerde private static final String TABLE_ID_KEY = "table_id"; private static final String PARTITION_ID_KEY = "partition_id"; private static final String REMOTE_DATA_DIR_KEY = "remote_data_dir"; - private static final String BUCKET_COUNT_ACTUAL_KEY = "bucket_count_actual"; + private static final String BUCKET_COUNT_ACTUAL_KEY = "bucket_count"; private static final int VERSION = 2; @Override @@ -50,9 +50,8 @@ public void serialize(PartitionRegistration registration, JsonGenerator generato if (registration.getRemoteDataDir() != null) { generator.writeStringField(REMOTE_DATA_DIR_KEY, registration.getRemoteDataDir()); } - if (registration.getBucketCountActual() != null) { - generator.writeNumberField( - BUCKET_COUNT_ACTUAL_KEY, registration.getBucketCountActual()); + if (registration.getBucketCount() != null) { + generator.writeNumberField(BUCKET_COUNT_ACTUAL_KEY, registration.getBucketCount()); } generator.writeEndObject(); } @@ -67,12 +66,12 @@ public PartitionRegistration deserialize(JsonNode node) { if (node.has(REMOTE_DATA_DIR_KEY)) { remoteDataDir = node.get(REMOTE_DATA_DIR_KEY).asText(); } - // When deserialize from an old version (v1), bucket_count_actual may not exist. + // When deserialize from an old version (v1), bucket_count may not exist. // Callers should fall back to table-level bucket count when this is null. - Integer bucketCountActual = null; + Integer bucketCount = null; if (node.has(BUCKET_COUNT_ACTUAL_KEY)) { - bucketCountActual = node.get(BUCKET_COUNT_ACTUAL_KEY).asInt(); + bucketCount = node.get(BUCKET_COUNT_ACTUAL_KEY).asInt(); } - return new PartitionRegistration(tableId, partitionId, remoteDataDir, bucketCountActual); + return new PartitionRegistration(tableId, partitionId, remoteDataDir, bucketCount); } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistration.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistration.java index 51b28010414..99e659909bc 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistration.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistration.java @@ -72,7 +72,7 @@ public class TableRegistration { * it. It is used to decide whether legacy clients without bucket count are still allowed (epoch * 0) and to let TabletServers ignore older UpdateMetadata messages. */ - public final long bucketLayoutEpoch; + public final long bucketCountEpoch; public TableRegistration( long tableId, @@ -107,7 +107,7 @@ public TableRegistration( @Nullable String remoteDataDir, long createdTime, long modifiedTime, - long bucketLayoutEpoch) { + long bucketCountEpoch) { checkArgument( tableDistribution.getBucketCount().isPresent(), "Bucket count is required for table registration."); @@ -121,7 +121,7 @@ public TableRegistration( this.remoteDataDir = remoteDataDir; this.createdTime = createdTime; this.modifiedTime = modifiedTime; - this.bucketLayoutEpoch = bucketLayoutEpoch; + this.bucketCountEpoch = bucketCountEpoch; } public boolean isPartitioned() { @@ -161,7 +161,7 @@ public TableInfo toTableInfo( this.comment, this.createdTime, this.modifiedTime, - this.bucketLayoutEpoch); + this.bucketCountEpoch); } public static TableRegistration newTable( @@ -195,13 +195,13 @@ public TableRegistration newProperties( remoteDataDir, createdTime, currentMillis, - bucketLayoutEpoch); + bucketCountEpoch); } /** - * Replaces the table-level bucket count and increments {@code bucketLayoutEpoch} atomically. - * For a partitioned table, the new count applies to partitions created after this ALTER; - * existing partitions retain their actual bucket counts in their partition registrations. + * Replaces the table-level bucket count and increments {@code bucketCountEpoch} atomically. For + * a partitioned table, the new count applies to partitions created after this ALTER; existing + * partitions retain their actual bucket counts in their partition registrations. */ public TableRegistration withBucketCount(int newBucketCount) { final long currentMillis = System.currentTimeMillis(); @@ -215,7 +215,7 @@ public TableRegistration withBucketCount(int newBucketCount) { remoteDataDir, createdTime, currentMillis, - bucketLayoutEpoch + 1); + bucketCountEpoch + 1); } /** @@ -237,7 +237,7 @@ public TableRegistration newRemoteDataDir(String remoteDataDir) { remoteDataDir, createdTime, modifiedTime, - bucketLayoutEpoch); + bucketCountEpoch); } @Override @@ -253,7 +253,7 @@ public boolean equals(Object o) { return tableId == that.tableId && createdTime == that.createdTime && modifiedTime == that.modifiedTime - && bucketLayoutEpoch == that.bucketLayoutEpoch + && bucketCountEpoch == that.bucketCountEpoch && Objects.equals(comment, that.comment) && Objects.equals(partitionKeys, that.partitionKeys) && Objects.equals(bucketCount, that.bucketCount) @@ -276,7 +276,7 @@ public int hashCode() { remoteDataDir, createdTime, modifiedTime, - bucketLayoutEpoch); + bucketCountEpoch); } @Override @@ -303,8 +303,8 @@ public String toString() { + createdTime + ", modifiedTime=" + modifiedTime - + ", bucketLayoutEpoch=" - + bucketLayoutEpoch + + ", bucketCountEpoch=" + + bucketCountEpoch + '}'; } } diff --git a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerde.java b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerde.java index 49d967152ac..a85d00fed7f 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerde.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerde.java @@ -48,7 +48,7 @@ public class TableRegistrationJsonSerde static final String REMOTE_DATA_DIR = "remote_data_dir"; static final String CREATED_TIME = "created_time"; static final String MODIFIED_TIME = "modified_time"; - static final String BUCKET_LAYOUT_EPOCH = "bucket_layout_epoch"; + static final String BUCKET_LAYOUT_EPOCH = "bucket_count_epoch"; private static final String VERSION_KEY = "version"; private static final int VERSION = 2; @@ -113,8 +113,8 @@ public void serialize(TableRegistration tableReg, JsonGenerator generator) throw // serialize modifiedTime generator.writeNumberField(MODIFIED_TIME, tableReg.modifiedTime); - // serialize bucketLayoutEpoch - generator.writeNumberField(BUCKET_LAYOUT_EPOCH, tableReg.bucketLayoutEpoch); + // serialize bucketCountEpoch + generator.writeNumberField(BUCKET_LAYOUT_EPOCH, tableReg.bucketCountEpoch); generator.writeEndObject(); } @@ -163,7 +163,7 @@ public TableRegistration deserialize(JsonNode node) { // When deserializing from a legacy version, the bucket layout epoch may not exist; // read it as 0 (the table has never been ALTERed). - long bucketLayoutEpoch = + long bucketCountEpoch = node.has(BUCKET_LAYOUT_EPOCH) ? node.get(BUCKET_LAYOUT_EPOCH).asLong() : 0L; return new TableRegistration( @@ -176,7 +176,7 @@ public TableRegistration deserialize(JsonNode node) { remoteDataDir, createdTime, modifiedTime, - bucketLayoutEpoch); + bucketCountEpoch); } private Map deserializeProperties(JsonNode node) { diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java index 7cfa03e7810..6a8f827ea0a 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java @@ -147,7 +147,7 @@ void testAlterBucketNumOnLakeTablePassesValidationButAbortsWithoutLakeCatalog() .isEqualTo(originalBucketCount); Optional pre = zookeeperClient.getPartition(tablePath, "2024-01"); assertThat(pre).isPresent(); - assertThat(pre.get().getBucketCountActual()).isEqualTo(originalBucketCount); + assertThat(pre.get().getBucketCount()).isEqualTo(originalBucketCount); } @Test @@ -175,7 +175,7 @@ void testAlterBucketNumLakePropagationFailureAbortsAlter() throws Exception { assertThat(mm.getTable(tablePath).getNumBuckets()).isEqualTo(originalBucketCount); Optional pre = zookeeperClient.getPartition(tablePath, "2024-01"); assertThat(pre).isPresent(); - assertThat(pre.get().getBucketCountActual()).isEqualTo(originalBucketCount); + assertThat(pre.get().getBucketCount()).isEqualTo(originalBucketCount); } @Test @@ -351,7 +351,7 @@ void testResetBucketNumRejected() throws Exception { partitionedLogTable(originalBucketCount), generateAssignment(originalBucketCount, 3, getTabletServers()), false); - long originalEpoch = metadataManager.getTable(tablePath).getBucketLayoutEpoch(); + long originalEpoch = metadataManager.getTable(tablePath).getBucketCountEpoch(); // RESET carries no target count, so it must be rejected instead of silently succeeding. assertThatThrownBy(() -> resetBucketNum(metadataManager, tablePath)) @@ -362,14 +362,14 @@ void testResetBucketNumRejected() throws Exception { // The bucket layout is untouched: neither the count nor the epoch moved. TableInfo afterTableInfo = metadataManager.getTable(tablePath); assertThat(afterTableInfo.getNumBuckets()).isEqualTo(originalBucketCount); - assertThat(afterTableInfo.getBucketLayoutEpoch()).isEqualTo(originalEpoch); + assertThat(afterTableInfo.getBucketCountEpoch()).isEqualTo(originalEpoch); } // ========================== Success Tests ========================== @ParameterizedTest(name = "bucketNum {0} -> {1}") @CsvSource({"3, 6", "6, 3"}) - void testBackfillOnlyAffectsPartitionsWithoutBucketCountActual( + void testBackfillOnlyAffectsPartitionsWithoutBucketCount( int originalBucketCount, int newBucketCount) throws Exception { TablePath tablePath = TablePath.of( @@ -386,7 +386,7 @@ void testBackfillOnlyAffectsPartitionsWithoutBucketCountActual( TableInfo tableInfo = metadataManager.getTable(tablePath); long tableId = tableInfo.getTableId(); - // Create two partitions with bucketCountActual = originalBucketCount + // Create two partitions with bucketCount = originalBucketCount PartitionAssignment partitionAssignment = new PartitionAssignment(tableId, tableAssignment.getBucketAssignments()); metadataManager.createPartition( @@ -406,7 +406,7 @@ void testBackfillOnlyAffectsPartitionsWithoutBucketCountActual( false, originalBucketCount); - // Simulate a legacy partition by overwriting its registration with null bucketCountActual. + // Simulate a legacy partition by overwriting its registration with null bucketCount. // This models a partition created before per-partition bucket count was introduced. Optional legacyReg = zookeeperClient.getPartition(tablePath, "legacy"); @@ -418,15 +418,15 @@ void testBackfillOnlyAffectsPartitionsWithoutBucketCountActual( legacyReg.get().getRemoteDataDir()); zookeeperClient.updatePartitionRegistration(tablePath, "legacy", nullBucketCountReg); - // Verify: "legacy" has null bucketCountActual, "new" has originalBucketCount + // Verify: "legacy" has null bucketCount, "new" has originalBucketCount Optional beforeLegacy = zookeeperClient.getPartition(tablePath, "legacy"); assertThat(beforeLegacy).isPresent(); - assertThat(beforeLegacy.get().getBucketCountActual()).isNull(); + assertThat(beforeLegacy.get().getBucketCount()).isNull(); Optional beforeNew = zookeeperClient.getPartition(tablePath, "new"); assertThat(beforeNew).isPresent(); - assertThat(beforeNew.get().getBucketCountActual()).isEqualTo(originalBucketCount); + assertThat(beforeNew.get().getBucketCount()).isEqualTo(originalBucketCount); // ALTER bucket.num in both directions (scale-up 3->6 and scale-down 6->3) alterBucketNum(metadataManager, tablePath, String.valueOf(newBucketCount)); @@ -438,12 +438,12 @@ void testBackfillOnlyAffectsPartitionsWithoutBucketCountActual( Optional afterLegacy = zookeeperClient.getPartition(tablePath, "legacy"); assertThat(afterLegacy).isPresent(); - assertThat(afterLegacy.get().getBucketCountActual()).isEqualTo(originalBucketCount); + assertThat(afterLegacy.get().getBucketCount()).isEqualTo(originalBucketCount); - // Verify: "new" still has the original bucketCountActual (not overwritten to the new value) + // Verify: "new" still has the original bucketCount (not overwritten to the new value) Optional afterNew = zookeeperClient.getPartition(tablePath, "new"); assertThat(afterNew).isPresent(); - assertThat(afterNew.get().getBucketCountActual()).isEqualTo(originalBucketCount); + assertThat(afterNew.get().getBucketCount()).isEqualTo(originalBucketCount); } @Test @@ -515,7 +515,7 @@ private static void assertPartitionCountMatchesAssignment( Optional assignment = zookeeperClient.getPartitionAssignment(partition.get().getPartitionId()); assertThat(assignment).isPresent(); - assertThat(partition.get().getBucketCountActual()) + assertThat(partition.get().getBucketCount()) .isEqualTo(assignment.get().getBucketAssignments().size()) .isEqualTo(expected); } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java index 91d98274acf..90a9c6b0f13 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AutoPartitionManagerTest.java @@ -745,7 +745,7 @@ void testMaxBucketNumPerPartition() throws Exception { * refresh path for bucket.num-only changes. */ @Test - void testAutoCreatedPartitionUsesUpdatedBucketCountActual() throws Exception { + void testAutoCreatedPartitionUsesUpdatedBucketCount() throws Exception { ZonedDateTime startTime = LocalDateTime.parse("2024-09-10T00:00:00").atZone(ZoneId.systemDefault()); long startMs = startTime.toInstant().toEpochMilli(); @@ -776,7 +776,7 @@ void testAutoCreatedPartitionUsesUpdatedBucketCountActual() throws Exception { .containsExactlyInAnyOrder("20240910", "20240911", "20240912", "20240913"); // all pre-created partitions carry the original bucket count 4 for (PartitionRegistration reg : partitions.values()) { - assertThat(reg.getBucketCountActual()).isEqualTo(4); + assertThat(reg.getBucketCount()).isEqualTo(4); } // simulate ALTER bucket.num 4 -> 8: a real ALTER first persists the new table-level bucket @@ -797,8 +797,8 @@ void testAutoCreatedPartitionUsesUpdatedBucketCountActual() throws Exception { partitions = zookeeperClient.getPartitionRegistrations(tablePath); assertThat(partitions.keySet()).contains("20240914"); // old partition keeps its original bucket count, new partition uses the updated one - assertThat(partitions.get("20240910").getBucketCountActual()).isEqualTo(4); - assertThat(partitions.get("20240914").getBucketCountActual()).isEqualTo(8); + assertThat(partitions.get("20240910").getBucketCount()).isEqualTo(4); + assertThat(partitions.get("20240914").getBucketCount()).isEqualTo(8); } @Test diff --git a/fluss-server/src/test/java/org/apache/fluss/server/metadata/TabletServerMetadataCacheTest.java b/fluss-server/src/test/java/org/apache/fluss/server/metadata/TabletServerMetadataCacheTest.java index e4ae9ad4f0c..e0dcdee3e83 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/metadata/TabletServerMetadataCacheTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/metadata/TabletServerMetadataCacheTest.java @@ -322,7 +322,7 @@ private void assertTableMetadataEquals( } @Test - void testPartitionBucketCountActualRemovedOnDelete() { + void testPartitionBucketCountRemovedOnDelete() { // Seed both partitions with explicit per-partition bucket counts. int explicitBucketCount = 8; serverMetadataCache.updateClusterMetadata( @@ -350,7 +350,7 @@ void testPartitionBucketCountActualRemovedOnDelete() { .getPartitionMetadata( PhysicalTablePath.of(partitionedTablePath, partitionName1)) .get() - .getBucketCountActual()) + .getBucketCount()) .isEqualTo(explicitBucketCount); // Delete partition1 via DELETED_PARTITION_ID (partitionId marks deletion). @@ -370,9 +370,9 @@ void testPartitionBucketCountActualRemovedOnDelete() { PhysicalTablePath.of(partitionedTablePath, partitionName1))) .isEmpty(); - // Re-add partition1 WITHOUT an explicit bucketCountActual. The cache must NOT return the + // Re-add partition1 WITHOUT an explicit bucketCount. The cache must NOT return the // stale 8; the DELETED_PARTITION_ID path must have removed the prior entry from the - // partitionBucketCountActuals map so the fallback (bucketMetadataList.size()) applies. + // partitionBucketCounts map so the fallback (bucketMetadataList.size()) applies. serverMetadataCache.updateClusterMetadata( new ClusterMetadata( coordinatorServer, @@ -389,7 +389,7 @@ void testPartitionBucketCountActualRemovedOnDelete() { .getPartitionMetadata( PhysicalTablePath.of(partitionedTablePath, partitionName1)) .get() - .getBucketCountActual()) + .getBucketCount()) .isEqualTo(initialBucketMetadata.size()); // Delete partition2 via DELETED_PARTITION_NAME (partitionName marks deletion). @@ -410,7 +410,7 @@ void testPartitionBucketCountActualRemovedOnDelete() { .isEmpty(); // Re-add partition2 WITHOUT explicit; the DELETED_PARTITION_NAME path must have also - // cleared partitionBucketCountActuals, so fallback (list size) applies rather than stale 8. + // cleared partitionBucketCounts, so fallback (list size) applies rather than stale 8. serverMetadataCache.updateClusterMetadata( new ClusterMetadata( coordinatorServer, @@ -427,12 +427,12 @@ void testPartitionBucketCountActualRemovedOnDelete() { .getPartitionMetadata( PhysicalTablePath.of(partitionedTablePath, partitionName2)) .get() - .getBucketCountActual()) + .getBucketCount()) .isEqualTo(initialBucketMetadata.size()); } @Test - void testUpdatePartitionMetadataPropagatesExplicitBucketCountActual() { + void testUpdatePartitionMetadataPropagatesExplicitBucketCount() { // Seed table metadata: updatePartitionMetadata bails out if the tableId is unknown. serverMetadataCache.updateClusterMetadata( new ClusterMetadata( @@ -442,7 +442,7 @@ void testUpdatePartitionMetadataPropagatesExplicitBucketCountActual() { Collections.emptyList())); // Route via the single-partition updatePartitionMetadata path (distinct from - // updateClusterMetadata). The explicit bucketCountActual must be applied to the cache. + // updateClusterMetadata). The explicit bucketCount must be applied to the cache. int explicitBucketCount = 8; serverMetadataCache.updatePartitionMetadata( new PartitionMetadata( @@ -456,7 +456,7 @@ void testUpdatePartitionMetadataPropagatesExplicitBucketCountActual() { .getPartitionMetadata( PhysicalTablePath.of(partitionedTablePath, partitionName1)) .get() - .getBucketCountActual()) + .getBucketCount()) .isEqualTo(explicitBucketCount); } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java index fb234e75344..5ad8eef3636 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java @@ -104,7 +104,7 @@ void testLeaderActivationRequiresRoutingState() throws Exception { 1L), Collections.emptyList())), Collections.emptyList())); - assertThat(replicaManager.getReplicaOrException(tb).getBucketLayoutEpoch()).isEqualTo(0L); + assertThat(replicaManager.getReplicaOrException(tb).getBucketCountEpoch()).isEqualTo(0L); assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(tb, 0)) .isInstanceOf(StaleMetadataException.class); @@ -113,19 +113,19 @@ void testLeaderActivationRequiresRoutingState() throws Exception { } private void makeLeaderWithRoutingState( - TableBucket tb, Integer bucketCountActual, Long bucketLayoutEpoch) throws Exception { + TableBucket tb, Integer bucketCount, Long bucketCountEpoch) throws Exception { CompletableFuture> future = new CompletableFuture<>(); replicaManager.becomeLeaderOrFollower( INITIAL_COORDINATOR_EPOCH, Collections.singletonList( - notifyDataWithRoutingState(tb, bucketCountActual, bucketLayoutEpoch)), + notifyDataWithRoutingState(tb, bucketCount, bucketCountEpoch)), future::complete); assertThat(future.get()).containsOnly(new NotifyLeaderAndIsrResultForBucket(tb)); } private static NotifyLeaderAndIsrData notifyDataWithRoutingState( - TableBucket tb, Integer bucketCountActual, Long bucketLayoutEpoch) { + TableBucket tb, Integer bucketCount, Long bucketCountEpoch) { return new NotifyLeaderAndIsrData( PhysicalTablePath.of(DATA1_TABLE_PATH), tb, @@ -137,7 +137,7 @@ private static NotifyLeaderAndIsrData notifyDataWithRoutingState( Collections.emptyList(), INITIAL_COORDINATOR_EPOCH, INITIAL_BUCKET_EPOCH), - bucketCountActual, - bucketLayoutEpoch); + bucketCount, + bucketCountEpoch); } } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java b/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java index 1511a0d4bad..cff3f788c99 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/testutils/FlussClusterExtension.java @@ -739,10 +739,10 @@ public void triggerAndWaitSnapshot(TablePath tablePath) throws Exception { zooKeeperClient.getPartitionRegistrations(tablePath); for (PartitionRegistration partition : partitions.values()) { // partitions diverge from the table-level count after ALTER bucket.num - int partitionBucketCountActual = - partition.getBucketCountActualOrDefault( - bucketCount, tableRegistration.bucketLayoutEpoch); - for (int bucketId = 0; bucketId < partitionBucketCountActual; bucketId++) { + int partitionBucketCount = + partition.getBucketCountOrDefault( + bucketCount, tableRegistration.bucketCountEpoch); + for (int bucketId = 0; bucketId < partitionBucketCount; bucketId++) { tableBuckets.add( new TableBucket(tableId, partition.getPartitionId(), bucketId)); } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/testutils/PartitionMetadataAssert.java b/fluss-server/src/test/java/org/apache/fluss/server/testutils/PartitionMetadataAssert.java index 6eb971a910a..89aabbb1ac6 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/testutils/PartitionMetadataAssert.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/testutils/PartitionMetadataAssert.java @@ -41,11 +41,11 @@ private PartitionMetadataAssert(PartitionMetadata actual) { public PartitionMetadataAssert isEqualTo(PartitionMetadata expected) { assertThat(expected.getPartitionName()).isEqualTo(actual.getPartitionName()); - // actual bucketCountActual is always non-null (falls back to bucketMetadataList size), so + // actual bucketCount is always non-null (falls back to bucketMetadataList size), so // only compare when expected sets it — otherwise legacy callers passing null fail // spuriously. - if (expected.getBucketCountActual() != null) { - assertThat(actual.getBucketCountActual()).isEqualTo(expected.getBucketCountActual()); + if (expected.getBucketCount() != null) { + assertThat(actual.getBucketCount()).isEqualTo(expected.getBucketCount()); } List bucketMetadataList = expected.getBucketMetadataList(); List actualBucketMetadataList = actual.getBucketMetadataList(); diff --git a/fluss-server/src/test/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerdeTest.java b/fluss-server/src/test/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerdeTest.java index 573df6fce07..2d43a849c48 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerdeTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/zk/data/PartitionRegistrationJsonSerdeTest.java @@ -52,7 +52,7 @@ protected String[] expectedJsons() { return new String[] { "{\"version\":2,\"table_id\":1234,\"partition_id\":5678,\"remote_data_dir\":\"file://local/remote\"}", "{\"version\":2,\"table_id\":246,\"partition_id\":135}", - "{\"version\":2,\"table_id\":1234,\"partition_id\":5678,\"remote_data_dir\":\"file://local/remote\",\"bucket_count_actual\":8}" + "{\"version\":2,\"table_id\":1234,\"partition_id\":5678,\"remote_data_dir\":\"file://local/remote\",\"bucket_count\":8}" }; } @@ -71,9 +71,9 @@ void testTablePartitionCompatibility() throws IOException { } @Test - void testBucketCountActualBackwardCompatibility() throws IOException { + void testBucketCountBackwardCompatibility() throws IOException { // A v1 registration (written before per-partition bucket count existed) has no - // bucket_count_actual field. It must deserialize with a null bucketCountActual so that + // bucket_count field. It must deserialize with a null bucketCount so that // callers fall back to the table-level bucket count. String v1Json = "{\"version\":1,\"table_id\":1234,\"partition_id\":5678,\"remote_data_dir\":\"file://local/remote\"}"; @@ -82,7 +82,7 @@ void testBucketCountActualBackwardCompatibility() throws IOException { v1Json.getBytes(StandardCharsets.UTF_8), PartitionRegistrationJsonSerde.INSTANCE); - assertThat(actual.getBucketCountActual()).isNull(); + assertThat(actual.getBucketCount()).isNull(); assertThat(actual) .isEqualTo(new PartitionRegistration(1234L, 5678L, "file://local/remote")); } diff --git a/fluss-server/src/test/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerdeTest.java b/fluss-server/src/test/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerdeTest.java index 0a4cb3aaf3a..7ba08371507 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerdeTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/zk/data/TableRegistrationJsonSerdeTest.java @@ -102,8 +102,8 @@ protected TableRegistration[] createObjects() { protected String[] expectedJsons() { return new String[] { "{\"version\":2,\"table_id\":1234,\"comment\":\"first-table\",\"partition_key\":[\"a\",\"b\"]," - + "\"bucket_key\":[\"b\",\"c\"],\"bucket_count\":16,\"properties\":{},\"custom_properties\":{\"custom-3\":\"\\\"300\\\"\"},\"remote_data_dir\":\"file://local/remote\",\"created_time\":1735538268,\"modified_time\":1735538270,\"bucket_layout_epoch\":0}", - "{\"version\":2,\"table_id\":1234,\"comment\":\"second-table\",\"bucket_count\":32,\"properties\":{\"option-3\":\"300\"},\"custom_properties\":{},\"created_time\":-1,\"modified_time\":-1,\"bucket_layout_epoch\":0}", + + "\"bucket_key\":[\"b\",\"c\"],\"bucket_count\":16,\"properties\":{},\"custom_properties\":{\"custom-3\":\"\\\"300\\\"\"},\"remote_data_dir\":\"file://local/remote\",\"created_time\":1735538268,\"modified_time\":1735538270,\"bucket_count_epoch\":0}", + "{\"version\":2,\"table_id\":1234,\"comment\":\"second-table\",\"bucket_count\":32,\"properties\":{\"option-3\":\"300\"},\"custom_properties\":{},\"created_time\":-1,\"modified_time\":-1,\"bucket_count_epoch\":0}", }; } } diff --git a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussMicroBatchStream.scala b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussMicroBatchStream.scala index c9b1e4c4810..4c8a56f3d0b 100644 --- a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussMicroBatchStream.scala +++ b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/FlussMicroBatchStream.scala @@ -67,11 +67,11 @@ abstract class FlussMicroBatchStream( val tableBucketCount = tableInfo.getNumBuckets infos.asScala.foreach { info => - if (info.getBucketCountActual != tableBucketCount) { + if (info.getBucketCount != tableBucketCount) { throw new UnsupportedOperationException( s"Spark does not yet support per-partition bucket count rescale. " + s"Table $tablePath partition ${info.getPartitionName} has bucket count " + - s"${info.getBucketCountActual} but the table-level count is $tableBucketCount.") + s"${info.getBucketCount} but the table-level count is $tableBucketCount.") } } infos diff --git a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/SplitPlanner.scala b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/SplitPlanner.scala index 3a314048fdf..e178ffd6891 100644 --- a/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/SplitPlanner.scala +++ b/fluss-spark/fluss-spark-common/src/main/scala/org/apache/fluss/spark/read/SplitPlanner.scala @@ -137,11 +137,11 @@ abstract class AbstractSplitPlanner( val tableBucketCount = tableInfo.getNumBuckets infos.asScala.foreach { info => - if (info.getBucketCountActual != tableBucketCount) { + if (info.getBucketCount != tableBucketCount) { throw new UnsupportedOperationException( s"Spark does not yet support per-partition bucket count rescale. " + s"Table $tablePath partition ${info.getPartitionName} has bucket count " + - s"${info.getBucketCountActual} but the table-level count is $tableBucketCount.") + s"${info.getBucketCount} but the table-level count is $tableBucketCount.") } } infos From a26a06b675e9a72a2a456bda28d7c135f2ffec30 Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Fri, 4 Sep 2026 09:52:08 +0800 Subject: [PATCH 04/16] Resolve the historical partition's bucket count in the tiering reader --- .../tiering/source/TieringSplitReader.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java index 0e1a5958525..afb9dd5b785 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/tiering/source/TieringSplitReader.java @@ -19,7 +19,9 @@ import org.apache.fluss.annotation.VisibleForTesting; import org.apache.fluss.client.Connection; +import org.apache.fluss.client.FlussConnection; import org.apache.fluss.client.admin.Admin; +import org.apache.fluss.client.metadata.MetadataUpdater; import org.apache.fluss.client.table.Table; import org.apache.fluss.client.table.scanner.ScanRecord; import org.apache.fluss.client.table.scanner.log.ArrowScanRecords; @@ -40,8 +42,10 @@ import org.apache.fluss.metadata.DataLakeFormat; import org.apache.fluss.metadata.LogFormat; import org.apache.fluss.metadata.PartitionInfo; +import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; +import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.record.ArrowBatchData; import org.apache.fluss.utils.CloseableIterator; @@ -71,6 +75,7 @@ import java.util.Set; import java.util.function.Function; +import static org.apache.fluss.utils.PartitionUtils.HISTORICAL_PARTITION_VALUE; import static org.apache.fluss.utils.Preconditions.checkArgument; import static org.apache.fluss.utils.Preconditions.checkNotNull; import static org.apache.fluss.utils.Preconditions.checkState; @@ -350,6 +355,12 @@ private Table getOrMoveToTable(TieringSplit split) { currentTablePartitionBucketCounts.put( partitionInfo.getPartitionId(), partitionInfo.getBucketCount()); } + if (currentTableInfo.getTableConfig().isHistoricalPartitionEnabled()) { + // listPartitionInfos omits the internal historical partition, but its + // records still go through a lake writer whose bucket layout must match + // the partition's own count; resolve it like the split generator does. + putHistoricalPartitionBucketCount(tablePath, currentTableInfo.getTableId()); + } } catch (Exception e) { throw new FlussRuntimeException( "Failed to list partition infos for table " + tablePath, e); @@ -360,6 +371,29 @@ private Table getOrMoveToTable(TieringSplit split) { return currentTable; } + /** + * Resolves the historical partition's own bucket count into {@link + * #currentTablePartitionBucketCounts}. + */ + private void putHistoricalPartitionBucketCount(TablePath tablePath, long tableId) { + PhysicalTablePath historicalPath = + PhysicalTablePath.of(tablePath, HISTORICAL_PARTITION_VALUE); + MetadataUpdater metadataUpdater = ((FlussConnection) connection).getMetadataUpdater(); + metadataUpdater.checkAndUpdateTableMetadata(Collections.singleton(tablePath)); + metadataUpdater.checkAndUpdatePartitionMetadata(historicalPath); + long historicalPartitionId = metadataUpdater.getPartitionIdOrElseThrow(historicalPath); + currentTablePartitionBucketCounts.put( + historicalPartitionId, + metadataUpdater + .getCluster() + .getBucketCount(new TablePartition(tableId, historicalPartitionId)) + .orElseThrow( + () -> + new FlussRuntimeException( + "Actual bucket count not available for " + + historicalPath))); + } + private void mayCreateLogScanner() { if (currentLogScanner == null) { currentLogScanner = checkNotNull(currentTable).newScan().createLogScanner(); From 8ad6c43ef71d582f814e240706aa20016dc9cf87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B5=BA?= Date: Fri, 4 Sep 2026 11:31:04 +0800 Subject: [PATCH 05/16] [server] Report a stale routing bucket count per bucket, not per request A rescale only changes newly created partitions, so a bucket routed by a stale count leaves the co-batched buckets of the other partitions correctly routed. Failing the whole request therefore took healthy buckets down with it: fetchLog spans several tables, so one stale bucket failed the reads of all of them, while produceLog and putKv failed the whole table's batch. Collect the offending buckets into a per-bucket error map and serve the rest, the way authorizeRequestData already reports authorization failures. The collector allocates nothing when every bucket is correctly routed, so the request path is unchanged in the common case. limitScan and scanKv carry a single bucket, and listOffsets carries a request-scoped partition and count, so failing those as a whole is exact and they keep the request-level check. --- .../fluss/server/tablet/TabletService.java | 248 +++++++++++++----- .../server/tablet/TabletServiceITCase.java | 61 +++++ 2 files changed, 246 insertions(+), 63 deletions(-) diff --git a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java index 12198e1c2de..4e3b78c14bf 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/tablet/TabletService.java @@ -37,7 +37,9 @@ import org.apache.fluss.rpc.entity.LookupResultForBucket; import org.apache.fluss.rpc.entity.PrefixLookupResultForBucket; import org.apache.fluss.rpc.entity.ProduceLogResultForBucket; +import org.apache.fluss.rpc.entity.PutKvResultForBucket; import org.apache.fluss.rpc.entity.ResultForBucket; +import org.apache.fluss.rpc.entity.TableStatsResultForBucket; import org.apache.fluss.rpc.gateway.CoordinatorGateway; import org.apache.fluss.rpc.gateway.TabletServerGateway; import org.apache.fluss.rpc.messages.FetchLogRequest; @@ -64,14 +66,8 @@ import org.apache.fluss.rpc.messages.NotifyLeaderAndIsrResponse; import org.apache.fluss.rpc.messages.NotifyRemoteLogOffsetsRequest; import org.apache.fluss.rpc.messages.NotifyRemoteLogOffsetsResponse; -import org.apache.fluss.rpc.messages.PbFetchLogReqForBucket; import org.apache.fluss.rpc.messages.PbFetchLogReqForTable; -import org.apache.fluss.rpc.messages.PbLookupReqForBucket; -import org.apache.fluss.rpc.messages.PbPrefixLookupReqForBucket; -import org.apache.fluss.rpc.messages.PbProduceLogReqForBucket; -import org.apache.fluss.rpc.messages.PbPutKvReqForBucket; import org.apache.fluss.rpc.messages.PbScanReqForBucket; -import org.apache.fluss.rpc.messages.PbTableStatsReqForBucket; import org.apache.fluss.rpc.messages.PrefixLookupRequest; import org.apache.fluss.rpc.messages.PrefixLookupResponse; import org.apache.fluss.rpc.messages.ProduceLogRequest; @@ -121,6 +117,7 @@ import javax.annotation.Nullable; +import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; @@ -131,6 +128,8 @@ import java.util.concurrent.ExecutorService; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.ToIntFunction; import java.util.stream.Collectors; import static org.apache.fluss.rpc.util.CommonRpcMessageUtils.hasHistoricalLookup; @@ -225,19 +224,35 @@ public void shutdown() {} @Override public CompletableFuture produceLog(ProduceLogRequest request) { authorizeTable(WRITE, request.getTableId()); - for (PbProduceLogReqForBucket pbBucket : request.getBucketsReqsList()) { - validateRoutingBucketCountOrThrow( - new TableBucket( - request.getTableId(), - pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, - pbBucket.getBucketId()), - pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); + long tableId = request.getTableId(); + Map routingErrors = new HashMap<>(); + collectStaleRoutingErrors( + request.getBucketsReqsList(), + pbBucket -> + new TableBucket( + tableId, + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket -> pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0, + ProduceLogResultForBucket::new, + routingErrors); + + List produceLogData = toProduceLogDataForBuckets(request); + if (!routingErrors.isEmpty()) { + produceLogData.removeIf( + bucketData -> routingErrors.containsKey(bucketData.tableBucket())); + if (produceLogData.isEmpty()) { + return CompletableFuture.completedFuture( + makeProduceLogResponse(routingErrors.values())); + } } + CompletableFuture response = new CompletableFuture<>(); - List produceLogData = toProduceLogDataForBuckets(request); UserContext userContext = new UserContext(currentSession().getPrincipal()); Consumer> responseCallback = - results -> response.complete(makeProduceLogResponse(results)); + results -> + response.complete( + makeProduceLogResponse(withRoutingErrors(results, routingErrors))); if (hasHistoricalProduce(request)) { replicaManager.appendHistoricalRecordsToLog( request.getTimeoutMs(), @@ -282,22 +297,29 @@ private static boolean isFromClient(int followerServerId) { @Override public CompletableFuture fetchLog(FetchLogRequest request) { + Map fetchLogData = getFetchLogData(request); + Map errorResponseMap = new HashMap<>(); if (isFromClient(request.getFollowerServerId())) { for (PbFetchLogReqForTable pbTable : request.getTablesReqsList()) { - for (PbFetchLogReqForBucket pbBucket : pbTable.getBucketsReqsList()) { - validateRoutingBucketCountOrThrow( - new TableBucket( - pbTable.getTableId(), - pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, - pbBucket.getBucketId()), - pbBucket.hasRoutingBucketCount() - ? pbBucket.getRoutingBucketCount() - : 0); - } + long tableId = pbTable.getTableId(); + collectStaleRoutingErrors( + pbTable.getBucketsReqsList(), + pbBucket -> + new TableBucket( + tableId, + pbBucket.hasPartitionId() + ? pbBucket.getPartitionId() + : null, + pbBucket.getBucketId()), + pbBucket -> + pbBucket.hasRoutingBucketCount() + ? pbBucket.getRoutingBucketCount() + : 0, + FetchLogResultForBucket::error, + errorResponseMap); } + fetchLogData.keySet().removeAll(errorResponseMap.keySet()); } - Map fetchLogData = getFetchLogData(request); - Map errorResponseMap = new HashMap<>(); Map interesting = // TODO: we should also authorize for follower, otherwise, users can mock follower // to skip the authorization. @@ -356,22 +378,37 @@ private static FetchParams getFetchParams(FetchLogRequest request) { @Override public CompletableFuture putKv(PutKvRequest request) { authorizeTable(WRITE, request.getTableId()); - for (PbPutKvReqForBucket pbBucket : request.getBucketsReqsList()) { - validateRoutingBucketCountOrThrow( - new TableBucket( - request.getTableId(), - pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, - pbBucket.getBucketId()), - pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); - } + long tableId = request.getTableId(); + Map routingErrors = new HashMap<>(); + collectStaleRoutingErrors( + request.getBucketsReqsList(), + pbBucket -> + new TableBucket( + tableId, + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket -> pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0, + PutKvResultForBucket::new, + routingErrors); List putKvData = toPutKvDataForBuckets(request); + if (!routingErrors.isEmpty()) { + putKvData.removeIf(bucketData -> routingErrors.containsKey(bucketData.tableBucket())); + if (putKvData.isEmpty()) { + return CompletableFuture.completedFuture(makePutKvResponse(routingErrors.values())); + } + } + // Get mergeMode from request, default to DEFAULT if not set MergeMode mergeMode = request.hasAggMode() ? MergeMode.fromValue(request.getAggMode()) : MergeMode.DEFAULT; CompletableFuture response = new CompletableFuture<>(); + Consumer> responseCallback = + results -> + response.complete( + makePutKvResponse(withRoutingErrors(results, routingErrors))); if (hasHistoricalPut(request)) { replicaManager.putHistoricalRecordsToKv( request.getTimeoutMs(), @@ -380,7 +417,7 @@ public CompletableFuture putKv(PutKvRequest request) { getTargetColumns(request), mergeMode, currentSession().getApiVersion(), - bucketResponse -> response.complete(makePutKvResponse(bucketResponse))); + responseCallback); } else { Map recordsByBucket = new HashMap<>(); putKvData.forEach( @@ -392,22 +429,25 @@ public CompletableFuture putKv(PutKvRequest request) { getTargetColumns(request), mergeMode, currentSession().getApiVersion(), - bucketResponse -> response.complete(makePutKvResponse(bucketResponse))); + responseCallback); } return response; } @Override public CompletableFuture lookup(LookupRequest request) { - for (PbLookupReqForBucket pbBucket : request.getBucketsReqsList()) { - validateRoutingBucketCountOrThrow( - new TableBucket( - request.getTableId(), - pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, - pbBucket.getBucketId()), - pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); - } + long tableId = request.getTableId(); Map errorResponseMap = new HashMap<>(); + collectStaleRoutingErrors( + request.getBucketsReqsList(), + pbBucket -> + new TableBucket( + tableId, + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket -> pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0, + LookupResultForBucket::new, + errorResponseMap); CompletableFuture response = new CompletableFuture<>(); if (request.hasInsertIfNotExists() && request.isInsertIfNotExists()) { @@ -418,6 +458,10 @@ public CompletableFuture lookup(LookupRequest request) { + "historical partition lookup."); } Map> normalLookupData = toLookupData(request); + normalLookupData.keySet().removeAll(errorResponseMap.keySet()); + if (normalLookupData.isEmpty()) { + return CompletableFuture.completedFuture(makeLookupResponse(errorResponseMap)); + } replicaManager.lookups( request.isInsertIfNotExists(), request.getTimeoutMs(), @@ -430,11 +474,20 @@ public CompletableFuture lookup(LookupRequest request) { if (historicalLookupRequest) { List historicalLookupData = toHistoricalLookupData(request); authorizeTable(READ, request.getTableId()); + historicalLookupData.removeIf( + bucketData -> errorResponseMap.containsKey(bucketData.tableBucket())); + if (historicalLookupData.isEmpty()) { + return CompletableFuture.completedFuture(makeLookupResponse(errorResponseMap)); + } replicaManager.historicalLookups( historicalLookupData, - value -> response.complete(makeLookupResponse(value))); + value -> + response.complete( + makeLookupResponse( + withRoutingErrors(value, errorResponseMap)))); } else { Map> normalLookupData = toLookupData(request); + normalLookupData.keySet().removeAll(errorResponseMap.keySet()); Map> interesting = authorizeRequestData( READ, @@ -455,16 +508,20 @@ public CompletableFuture lookup(LookupRequest request) { @Override public CompletableFuture prefixLookup(PrefixLookupRequest request) { - for (PbPrefixLookupReqForBucket pbBucket : request.getBucketsReqsList()) { - validateRoutingBucketCountOrThrow( - new TableBucket( - request.getTableId(), - pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, - pbBucket.getBucketId()), - pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); - } + long tableId = request.getTableId(); Map> prefixLookupData = toPrefixLookupData(request); Map errorResponseMap = new HashMap<>(); + collectStaleRoutingErrors( + request.getBucketsReqsList(), + pbBucket -> + new TableBucket( + tableId, + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket -> pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0, + PrefixLookupResultForBucket::new, + errorResponseMap); + prefixLookupData.keySet().removeAll(errorResponseMap.keySet()); Map> interesting = authorizeRequestData( READ, prefixLookupData, errorResponseMap, PrefixLookupResultForBucket::new); @@ -504,19 +561,35 @@ public CompletableFuture limitScan(LimitScanRequest request) @Override public CompletableFuture getTableStats(GetTableStatsRequest request) { authorizeTable(READ, request.getTableId()); - for (PbTableStatsReqForBucket pbBucket : request.getBucketsReqsList()) { - validateRoutingBucketCountOrThrow( - new TableBucket( - request.getTableId(), - pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, - pbBucket.getBucketId()), - pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0); + long tableId = request.getTableId(); + Map routingErrors = new HashMap<>(); + collectStaleRoutingErrors( + request.getBucketsReqsList(), + pbBucket -> + new TableBucket( + tableId, + pbBucket.hasPartitionId() ? pbBucket.getPartitionId() : null, + pbBucket.getBucketId()), + pbBucket -> pbBucket.hasRoutingBucketCount() ? pbBucket.getRoutingBucketCount() : 0, + TableStatsResultForBucket::new, + routingErrors); + + List requestedBuckets = getTableStatsRequestData(request); + if (!routingErrors.isEmpty()) { + requestedBuckets.removeAll(routingErrors.keySet()); + if (requestedBuckets.isEmpty()) { + return CompletableFuture.completedFuture( + makeGetTableStatsResponse(new ArrayList<>(routingErrors.values()))); + } } CompletableFuture response = new CompletableFuture<>(); replicaManager.getTableStats( - getTableStatsRequestData(request), - result -> response.complete(makeGetTableStatsResponse(result))); + requestedBuckets, + result -> + response.complete( + makeGetTableStatsResponse( + withRoutingErrors(result, routingErrors)))); return response; } @@ -584,6 +657,9 @@ public CompletableFuture stopReplica( public CompletableFuture listOffsets(ListOffsetsRequest request) { authorizeTable(DESCRIBE, request.getTableId()); if (isFromClient(request.getFollowerServerId())) { + // Unlike the per-bucket requests, both the partition and the routing count are + // request-scoped here, so every requested bucket shares one bucket layout: a stale + // route invalidates the whole request at once and failing it as a whole is exact. Long partitionId = request.hasPartitionId() ? request.getPartitionId() : null; int routingBucketCount = request.hasRoutingBucketCount() ? request.getRoutingBucketCount() : 0; @@ -1009,6 +1085,52 @@ private Map authorizeRequestData( return interesting; } + /** + * Records a per-bucket error for every request bucket whose bucket id was routed by a stale + * bucket count, accumulating into {@code errorsOut}. + * + *

A rescale only changes newly created partitions, so a stale route on one partition leaves + * the co-batched buckets of the other partitions correctly routed. Reporting the offending + * buckets individually, rather than failing the whole request, mirrors how authorization + * failures are reported by {@link #authorizeRequestData}. + * + *

Only requests that carry a per-bucket routing count need this. A request whose count is + * request-scoped (see {@link #listOffsets}) fails or succeeds as a whole by construction. + */ + private void collectStaleRoutingErrors( + List

bucketReqs, + Function toTableBucket, + ToIntFunction

routingBucketCountOf, + BiFunction resultCreator, + Map errorsOut) { + for (P bucketReq : bucketReqs) { + TableBucket tableBucket = toTableBucket.apply(bucketReq); + try { + replicaManager.validateRoutingBucketCount( + tableBucket, routingBucketCountOf.applyAsInt(bucketReq)); + } catch (StaleMetadataException e) { + errorsOut.put( + tableBucket, resultCreator.apply(tableBucket, ApiError.fromThrowable(e))); + } + } + } + + /** + * Appends the stale-routing errors to the results produced for the accepted buckets, so that + * the response covers every bucket the client asked about. Returns {@code results} untouched + * when no bucket was routed by a stale count. + */ + private static List withRoutingErrors( + List results, Map routingErrors) { + if (routingErrors.isEmpty()) { + return results; + } + List merged = new ArrayList<>(results.size() + routingErrors.size()); + merged.addAll(results); + merged.addAll(routingErrors.values()); + return merged; + } + private Set filterAuthorizedTables( Collection tableBuckets, OperationType operationType) { return tableBuckets.stream() diff --git a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java index b0848e4e299..dbc5ffc165d 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java @@ -38,6 +38,8 @@ import org.apache.fluss.row.encode.ValueEncoder; import org.apache.fluss.rpc.gateway.TabletServerGateway; import org.apache.fluss.rpc.messages.FetchLogResponse; +import org.apache.fluss.rpc.messages.GetTableStatsRequest; +import org.apache.fluss.rpc.messages.GetTableStatsResponse; import org.apache.fluss.rpc.messages.InitWriterRequest; import org.apache.fluss.rpc.messages.InitWriterResponse; import org.apache.fluss.rpc.messages.ListOffsetsRequest; @@ -51,6 +53,7 @@ import org.apache.fluss.rpc.messages.PbNotifyLeaderAndIsrReqForBucket; import org.apache.fluss.rpc.messages.PbPrefixLookupRespForBucket; import org.apache.fluss.rpc.messages.PbPutKvRespForBucket; +import org.apache.fluss.rpc.messages.PbTableStatsRespForBucket; import org.apache.fluss.rpc.messages.ProduceLogResponse; import org.apache.fluss.rpc.messages.PutKvResponse; import org.apache.fluss.rpc.messages.ScanKvRequest; @@ -832,6 +835,64 @@ private static ListOffsetsRequest newListOffsetsRequestWithRoutingBucketCount( .setRoutingBucketCount(routingBucketCount); } + @Test + void testStaleRoutingBucketCountOnlyFailsTheOffendingBucket() throws Exception { + // 9 buckets over 3 tablet servers, so at least one server necessarily leads two of them + // and a single request can carry two buckets hosted by the same leader. + int bucketCount = 9; + TablePath tablePath = TablePath.of("test_db_1", "test_stale_routing_per_bucket"); + long tableId = + createTable( + FLUSS_CLUSTER_EXTENSION, + tablePath, + TableDescriptor.builder() + .schema(DATA1_SCHEMA) + .distributedBy(bucketCount) + .build()); + + Map> bucketsByLeader = new HashMap<>(); + for (int bucketId = 0; bucketId < bucketCount; bucketId++) { + TableBucket tb = new TableBucket(tableId, bucketId); + FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb); + bucketsByLeader + .computeIfAbsent( + FLUSS_CLUSTER_EXTENSION.waitAndGetLeader(tb), k -> new ArrayList<>()) + .add(bucketId); + } + Map.Entry> coLocated = + bucketsByLeader.entrySet().stream() + .filter(entry -> entry.getValue().size() >= 2) + .findFirst() + .orElseThrow( + () -> + new AssertionError( + "9 buckets over 3 servers must co-locate two leaders")); + int healthyBucket = coLocated.getValue().get(0); + int staleBucket = coLocated.getValue().get(1); + + GetTableStatsRequest request = new GetTableStatsRequest().setTableId(tableId); + request.addBucketsReq().setBucketId(healthyBucket).setRoutingBucketCount(bucketCount); + request.addBucketsReq().setBucketId(staleBucket).setRoutingBucketCount(bucketCount + 1); + + GetTableStatsResponse response = + FLUSS_CLUSTER_EXTENSION + .newTabletServerClientForNode(coLocated.getKey()) + .getTableStats(request) + .get(); + + assertThat(response.getBucketsRespsCount()).isEqualTo(2); + Map respByBucket = new HashMap<>(); + for (PbTableStatsRespForBucket bucketResp : response.getBucketsRespsList()) { + respByBucket.put(bucketResp.getBucketId(), bucketResp); + } + + // the co-batched bucket whose routing is still valid is served as usual + assertThat(respByBucket.get(healthyBucket).hasErrorCode()).isFalse(); + // only the bucket routed by a stale count is rejected, and it stays retriable + assertThat(respByBucket.get(staleBucket).getErrorCode()) + .isEqualTo(Errors.STALE_METADATA.code()); + } + @Test void testListOffsets() throws Exception { long tableId = From 687d036475c3504fc54e105eebc86ef684f07ec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B5=BA?= Date: Fri, 4 Sep 2026 12:40:23 +0800 Subject: [PATCH 06/16] [server] Skip routing bucket count validation for keyless tables Only hash-distributed tables (those with a bucket key, including primary-key tables whose bucket key defaults to the primary key) place a record in a bucket deterministically, so only they can misroute a key when a client routes with a stale bucket count. A table without a bucket key (round-robin/sticky) may place a record in any bucket, so a stale routing count is harmless. Skip the STALE_METADATA check for such tables instead of failing otherwise-correct writes after a bucket.num rescale. --- .../fluss/server/replica/ReplicaManager.java | 9 ++ .../replica/ReplicaRoutingStateTest.java | 87 ++++++++++++++----- 2 files changed, 75 insertions(+), 21 deletions(-) diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java index 6a62d48fa23..5141a2c6f23 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java @@ -2554,6 +2554,15 @@ public void validateRoutingBucketCount(TableBucket tableBucket, int routingBucke return; } + // Only hash-distributed tables (those with a bucket key, including primary-key tables whose + // bucket key defaults to the primary key) place a record in a bucket deterministically, so + // only they can misroute a key when the client routes with a stale bucket count. A table + // without a bucket key (round-robin/sticky) may place a record in any bucket, so a stale + // routing count is harmless and must not fail the write. + if (!replica.getTableInfo().hasBucketKey()) { + return; + } + if (routingBucketCount <= 0) { // Legacy client (no bucket count in request): reject only when a rescale is known, // because then the bucketId may come from an outdated count. diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java index 5ad8eef3636..ca2f56fac24 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java @@ -37,6 +37,9 @@ import static org.apache.fluss.record.TestData.DATA1_TABLE_DESCRIPTOR; import static org.apache.fluss.record.TestData.DATA1_TABLE_ID; import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH; +import static org.apache.fluss.record.TestData.DATA2_TABLE_DESCRIPTOR; +import static org.apache.fluss.record.TestData.DATA2_TABLE_ID; +import static org.apache.fluss.record.TestData.DATA2_TABLE_PATH; import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR; import static org.apache.fluss.server.coordinator.CoordinatorContext.INITIAL_COORDINATOR_EPOCH; import static org.apache.fluss.server.zk.data.LeaderAndIsr.INITIAL_BUCKET_EPOCH; @@ -58,8 +61,8 @@ final class ReplicaRoutingStateTest extends ReplicaTestBase { private static final int TEST_BUCKET = 5; @Test - void testLeaderActivationRequiresRoutingState() throws Exception { - TableBucket tb = new TableBucket(DATA1_TABLE_ID, TEST_BUCKET); + void testRoutingBucketCountValidationAppliesOnlyToHashDistributedTables() throws Exception { + TableBucket keylessTb = new TableBucket(DATA1_TABLE_ID, TEST_BUCKET); // A legacy coordinator's notification (no routing fields) fails leader activation loudly: // the upgrade contract requires the CoordinatorServer to be upgraded first. @@ -67,23 +70,56 @@ void testLeaderActivationRequiresRoutingState() throws Exception { new CompletableFuture<>(); replicaManager.becomeLeaderOrFollower( INITIAL_COORDINATOR_EPOCH, - Collections.singletonList(notifyDataWithRoutingState(tb, null, null)), + Collections.singletonList( + notifyDataWithRoutingState( + PhysicalTablePath.of(DATA1_TABLE_PATH), keylessTb, null, null)), legacyFuture::complete); assertThat(legacyFuture.get().get(0).getError().error()) .isEqualTo(Errors.UNSUPPORTED_VERSION); assertThat(legacyFuture.get().get(0).getError().messageWithFallback()) .contains("upgrade the CoordinatorServer first"); - assertThat(replicaManager.getReplicaOrException(tb).isLeader()).isFalse(); + assertThat(replicaManager.getReplicaOrException(keylessTb).isLeader()).isFalse(); - // A new coordinator's notification activates the leader and arms the routing state. - makeLeaderWithRoutingState(tb, 3, 0L); - assertThat(replicaManager.getReplicaOrException(tb).isLeader()).isTrue(); - replicaManager.validateRoutingBucketCount(tb, 3); - assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(tb, 4)) + // DATA1 has no bucket key (round-robin/sticky distribution): which bucket a record lands in + // carries no semantic meaning, so routing bucket count validation is skipped for it. Even a + // mismatched count, and even after its metadata epoch advances, must not fail the write. + makeLeaderWithRoutingState(PhysicalTablePath.of(DATA1_TABLE_PATH), keylessTb, 3, 0L); + assertThat(replicaManager.getReplicaOrException(keylessTb).isLeader()).isTrue(); + replicaManager.validateRoutingBucketCount(keylessTb, 3); + replicaManager.validateRoutingBucketCount(keylessTb, 4); + replicaManager.validateRoutingBucketCount(keylessTb, 0); + replicaManager.maybeUpdateMetadataCache( + INITIAL_COORDINATOR_EPOCH, + new ClusterMetadata( + null, + Collections.emptySet(), + Collections.singletonList( + new TableMetadata( + TableInfo.of( + DATA1_TABLE_PATH, + DATA1_TABLE_ID, + 1, + DATA1_TABLE_DESCRIPTOR, + DEFAULT_REMOTE_DATA_DIR, + 1L, + 1L, + 1L), + Collections.emptyList())), + Collections.emptyList())); + replicaManager.validateRoutingBucketCount(keylessTb, 4); + replicaManager.validateRoutingBucketCount(keylessTb, 0); + + // DATA2 is DISTRIBUTED BY (a): a hash-distributed table where a stale count would misroute + // the key, so its routing bucket count IS validated. + TableBucket keyedTb = new TableBucket(DATA2_TABLE_ID, TEST_BUCKET); + makeLeaderWithRoutingState(PhysicalTablePath.of(DATA2_TABLE_PATH), keyedTb, 3, 0L); + assertThat(replicaManager.getReplicaOrException(keyedTb).isLeader()).isTrue(); + replicaManager.validateRoutingBucketCount(keyedTb, 3); + assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(keyedTb, 4)) .isInstanceOf(StaleMetadataException.class); - // A legacy client (no count) passes on a non-rescaled table... - replicaManager.validateRoutingBucketCount(tb, 0); + // A legacy client (no count) passes on a non-rescaled hash table... + replicaManager.validateRoutingBucketCount(keyedTb, 0); // ...but is rejected once an ALTER advances the metadata cache to epoch 1 through // UpdateMetadata, which does not re-notify the already active replica. replicaManager.maybeUpdateMetadataCache( @@ -94,40 +130,49 @@ void testLeaderActivationRequiresRoutingState() throws Exception { Collections.singletonList( new TableMetadata( TableInfo.of( - DATA1_TABLE_PATH, - DATA1_TABLE_ID, + DATA2_TABLE_PATH, + DATA2_TABLE_ID, 1, - DATA1_TABLE_DESCRIPTOR, + DATA2_TABLE_DESCRIPTOR, DEFAULT_REMOTE_DATA_DIR, 1L, 1L, 1L), Collections.emptyList())), Collections.emptyList())); - assertThat(replicaManager.getReplicaOrException(tb).getBucketCountEpoch()).isEqualTo(0L); - assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(tb, 0)) + assertThat(replicaManager.getReplicaOrException(keyedTb).getBucketCountEpoch()) + .isEqualTo(0L); + assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(keyedTb, 0)) .isInstanceOf(StaleMetadataException.class); // An unknown bucket keeps the downstream per-bucket error semantics: validation passes. - replicaManager.validateRoutingBucketCount(new TableBucket(DATA1_TABLE_ID, 99), 3); + replicaManager.validateRoutingBucketCount(new TableBucket(DATA2_TABLE_ID, 99), 3); } private void makeLeaderWithRoutingState( - TableBucket tb, Integer bucketCount, Long bucketCountEpoch) throws Exception { + PhysicalTablePath physicalTablePath, + TableBucket tb, + Integer bucketCount, + Long bucketCountEpoch) + throws Exception { CompletableFuture> future = new CompletableFuture<>(); replicaManager.becomeLeaderOrFollower( INITIAL_COORDINATOR_EPOCH, Collections.singletonList( - notifyDataWithRoutingState(tb, bucketCount, bucketCountEpoch)), + notifyDataWithRoutingState( + physicalTablePath, tb, bucketCount, bucketCountEpoch)), future::complete); assertThat(future.get()).containsOnly(new NotifyLeaderAndIsrResultForBucket(tb)); } private static NotifyLeaderAndIsrData notifyDataWithRoutingState( - TableBucket tb, Integer bucketCount, Long bucketCountEpoch) { + PhysicalTablePath physicalTablePath, + TableBucket tb, + Integer bucketCount, + Long bucketCountEpoch) { return new NotifyLeaderAndIsrData( - PhysicalTablePath.of(DATA1_TABLE_PATH), + physicalTablePath, tb, Collections.singletonList(TABLET_SERVER_ID), new LeaderAndIsr( From dc2028de4b6d4b85bcf1c5f50ffebe1ee12e29ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B5=BA?= Date: Fri, 4 Sep 2026 13:57:44 +0800 Subject: [PATCH 07/16] [client] Reclaim the batch sequence when a write is rejected with STALE_METADATA The server rejects a write with STALE_METADATA during pre-append routing validation, so the batch is provably never written. Fail it with adjustBatchSequences=true so its batch sequence is reclaimed. Otherwise a permanent hole is left at that sequence: the next batch that reaches the server on the same bucket (created after the metadata refresh, carrying a valid routing count) sends the following sequence against a lower expected one, raising OUT_OF_ORDER_SEQUENCE and resetting the writer id, which discards idempotence for every bucket of the writer. --- .../org/apache/fluss/client/write/Sender.java | 14 +++++-- .../apache/fluss/client/write/SenderTest.java | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java index 1edc7f2d8c8..be5cf01aefe 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/Sender.java @@ -654,14 +654,20 @@ private Set handleWriteBatchException( accumulator.updateThrottle(readyWriteBatch.tableBucket(), 1.0f); } if (error.error() == Errors.STALE_METADATA) { - // The bucketId in this batch was calculated with a stale bucket count. Fail the - // batch (do not re-enqueue — the bucketId is fixed), invalidate metadata, and remove - // the BucketAssigner so the next send creates a new one with the updated count. + // The bucketId in this batch was computed with a stale bucket count, and the server + // rejected it during pre-append routing validation, so it was provably never written. + // Reclaim its batch sequence (adjustBatchSequences=true): otherwise a permanent hole is + // left at this sequence, and the next batch that reaches the server on this bucket + // (created after the metadata refresh, carrying a valid routing count) would send the + // following sequence against a lower expected one, raising OUT_OF_ORDER_SEQUENCE and + // resetting the writer id — which discards idempotence for every bucket of this writer. + // Do not re-enqueue (the bucketId is fixed); invalidate metadata and drop the + // BucketAssigner so the next send re-routes with the updated count. LOG.warn( "Received STALE_METADATA error in write request on table bucket {}. " + "Failing batch and invalidating BucketAssigner.", readyWriteBatch.tableBucket()); - failBatch(readyWriteBatch, error.exception(), false); + failBatch(readyWriteBatch, error.exception(), true); invalidMetadataTables.add(writeBatch.physicalTablePath()); bucketAssignerInvalidator.accept(readyWriteBatch.tableBucket()); return invalidMetadataTables; diff --git a/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java b/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java index 1f9bd03c42a..d73bc7d44e3 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/write/SenderTest.java @@ -1565,6 +1565,48 @@ void testStaleMetadataFailsBatchAndInvalidatesBucketAssigner() throws Exception assertThat(exception).isInstanceOf(StaleMetadataException.class); } + @Test + void testStaleMetadataReclaimsBatchSequenceWhenIdempotenceEnabled() throws Exception { + // STALE_METADATA is only produced for hash-distributed tables (those with a bucket key; see + // ReplicaManager#validateRoutingBucketCount), so exercise the client reclaim path on a + // primary-key table bucket rather than a keyless one. + TableBucket keyedBucket = new TableBucket(DATA1_TABLE_ID_PK, 0); + IdempotenceManager idempotenceManager = createIdempotenceManager(true); + Sender staleSender = setupWithIdempotenceState(idempotenceManager); + staleSender.runOnce(); + long writerId = idempotenceManager.writerId(); + assertThat(idempotenceManager.isWriterIdValid()).isTrue(); + assertThat(idempotenceManager.nextSequence(keyedBucket)).isEqualTo(0); + + // Drain and send one batch: it takes batch sequence 0 and nextSequence advances to 1. + CompletableFuture future = new CompletableFuture<>(); + appendKvToAccumulator( + keyedBucket, + compactedRow(DATA1_ROW_TYPE, new Object[] {1, "a"}), + (tb, leo, e) -> future.complete(e)); + staleSender.runOnce(); + assertThat(idempotenceManager.nextSequence(keyedBucket)).isEqualTo(1); + + // The server rejects the batch during pre-append routing validation (STALE_METADATA), so it + // was provably never written. Its batch sequence (0) must be reclaimed. + finishRequest(keyedBucket, 0, createPutKvResponse(keyedBucket, Errors.STALE_METADATA)); + staleSender.runOnce(); + + // The write callback receives the StaleMetadataException. + assertThat(future.get()).isInstanceOf(StaleMetadataException.class); + + // The writer id must survive: nothing was accepted, so there is no lost message to guard. + assertThat(idempotenceManager.isWriterIdValid()).isTrue(); + assertThat(idempotenceManager.writerId()).isEqualTo(writerId); + + // The reclaimed sequence must roll nextSequence back to 0. Otherwise a permanent hole at + // sequence 0 remains: the next batch that reaches the server on this bucket (created after + // the metadata refresh, carrying a valid routing count) would send sequence 1 against an + // expected 0, triggering OUT_OF_ORDER_SEQUENCE_EXCEPTION and resetWriterId, which wipes + // idempotence for every bucket of this writer. + assertThat(idempotenceManager.nextSequence(keyedBucket)).isEqualTo(0); + } + private TestingMetadataUpdater initializeMetadataUpdater() { Map tableInfos = new HashMap<>(); tableInfos.put(DATA1_TABLE_PATH, DATA1_TABLE_INFO); From ae1593487d722d6b6cac620afbcf22a7441a3eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B5=BA?= Date: Fri, 4 Sep 2026 14:24:54 +0800 Subject: [PATCH 08/16] [client] Fall back to table-level bucket count for never-rescaled partitions The partitioned write path required the server-sent per-partition bucket count and otherwise stalled the caller in waitForPartitionMetadata until the request timeout (default 30s) before failing. An old server never sends that field, so during a rolling upgrade every partitioned-table write blocked 30s regardless of table type or whether the table was ever rescaled. Add a bucketCountEpoch == 0 fallback in both DynamicPartitionCreator and WriterClient: epoch 0 proves the table was never rescaled, so the table-level bucket count IS each partition's actual count and is a safe, provable fallback. Only epoch > 0 with a missing per-partition count is a real inconsistency and still fails loudly (StaleMetadataException), mirroring the existing read-path guard in FlussAdmin. --- .../client/write/DynamicPartitionCreator.java | 25 ++++++++++++------ .../fluss/client/write/WriterClient.java | 26 +++++++++++++++---- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java b/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java index 25d190dd7ac..87ddfb21d66 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/DynamicPartitionCreator.java @@ -88,7 +88,7 @@ public Cluster checkAndCreatePartition( } Cluster cluster = metadataUpdater.getCluster(); - if (isPartitionMetadataAvailable(cluster, physicalTablePath)) { + if (isPartitionMetadataAvailable(cluster, physicalTablePath, tableInfo)) { return cluster; } @@ -126,13 +126,14 @@ public Cluster checkAndCreatePartition( } } } - return waitForPartitionMetadata(physicalTablePath); + return waitForPartitionMetadata(physicalTablePath, tableInfo); } /** * Returns the metadata snapshot once the partition id and actual bucket count are available. */ - private Cluster waitForPartitionMetadata(PhysicalTablePath physicalTablePath) { + private Cluster waitForPartitionMetadata( + PhysicalTablePath physicalTablePath, TableInfo tableInfo) { long deadlineNanos = System.nanoTime() + metadataWaitTimeout.toNanos(); long backoffMs = 100; while (true) { @@ -144,7 +145,7 @@ private Cluster waitForPartitionMetadata(PhysicalTablePath physicalTablePath) { } Cluster cluster = metadataUpdater.getCluster(); - if (isPartitionMetadataAvailable(cluster, physicalTablePath)) { + if (isPartitionMetadataAvailable(cluster, physicalTablePath, tableInfo)) { partitionCreationFailures.remove(physicalTablePath); return cluster; } @@ -160,7 +161,7 @@ private Cluster waitForPartitionMetadata(PhysicalTablePath physicalTablePath) { } cluster = metadataUpdater.getCluster(); - if (isPartitionMetadataAvailable(cluster, physicalTablePath)) { + if (isPartitionMetadataAvailable(cluster, physicalTablePath, tableInfo)) { partitionCreationFailures.remove(physicalTablePath); return cluster; } @@ -185,10 +186,18 @@ private Cluster waitForPartitionMetadata(PhysicalTablePath physicalTablePath) { } private boolean isPartitionMetadataAvailable( - Cluster cluster, PhysicalTablePath physicalTablePath) { + Cluster cluster, PhysicalTablePath physicalTablePath, TableInfo tableInfo) { Optional tablePartition = cluster.getTablePartition(physicalTablePath); - return tablePartition.isPresent() - && cluster.getBucketCount(tablePartition.get()).isPresent(); + if (!tablePartition.isPresent()) { + return false; + } + if (cluster.getBucketCount(tablePartition.get()).isPresent()) { + return true; + } + // bucketCountEpoch == 0 proves the table was never rescaled, so the table-level bucket + // count IS this partition's actual count. Waiting for a per-partition count that an old + // server never sends would only stall the caller until the request timeout. + return tableInfo.getBucketCountEpoch() == 0; } private boolean forceCheckPartitionExist(PhysicalTablePath physicalTablePath) { diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java index 4f8b7f0d1af..5742b75ce45 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java @@ -29,6 +29,7 @@ import org.apache.fluss.exception.FlussRuntimeException; import org.apache.fluss.exception.IllegalConfigurationException; import org.apache.fluss.exception.PartitionNotExistException; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; @@ -242,11 +243,26 @@ && mayBeExpiredHistoricalPartition( + assignerPath)); bucketCount = cluster.getBucketCount(tablePartition) - .orElseThrow( - () -> - new FlussRuntimeException( - "Actual bucket count not available for " - + assignerPath)); + .orElseGet( + () -> { + // bucketCountEpoch == 0 proves the table was never + // rescaled, so the table-level bucket count IS this + // partition's actual count — a safe, provable fallback + // (e.g. an old server that never sends the + // per-partition + // count). Only epoch > 0 with a missing count is a real + // inconsistency worth failing on. + if (tableInfo.getBucketCountEpoch() > 0) { + throw new StaleMetadataException( + "Per-partition bucket count is unavailable for " + + assignerPath + + " at bucketCountEpoch " + + tableInfo.getBucketCountEpoch() + + "; refusing to fall back to the" + + " table-level count."); + } + return tableInfo.getNumBuckets(); + }); bucketAssigner = partitionBucketAssigners.computeIfAbsent( tablePartition, From 8f1f1fd32f94a5bf218dbb0801cdfa7e12519ec2 Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Fri, 4 Sep 2026 14:56:50 +0800 Subject: [PATCH 09/16] Use hash-distributed tables in the routing validation IT cases The stale-routing tests were written before keyless tables were exempted from routing bucket count validation; a keyless table can legitimately place a record in any bucket, so it never produces STALE_METADATA and the assertions failed. Switch both cases to bucket-keyed tables, which are the only ones the validation covers. --- .../fluss/server/tablet/TabletServiceITCase.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java index dbc5ffc165d..017a2da96a0 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/tablet/TabletServiceITCase.java @@ -773,8 +773,12 @@ void testLimitScanLogTable() throws Exception { @Test void testRoutingBucketCountValidationAppliesToClientRequestsOnly() throws Exception { + // Routing validation only applies to hash-distributed tables: a keyless table may place a + // record in any bucket, so a stale count is harmless there (see + // ReplicaManager#validateRoutingBucketCount). long tableId = - createTable(FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH, DATA1_TABLE_DESCRIPTOR); + createTable( + FLUSS_CLUSTER_EXTENSION, DATA1_TABLE_PATH_PK, DATA1_TABLE_DESCRIPTOR_PK); TableBucket tb = new TableBucket(tableId, 0); FLUSS_CLUSTER_EXTENSION.waitUntilAllReplicaReady(tb); @@ -847,7 +851,9 @@ void testStaleRoutingBucketCountOnlyFailsTheOffendingBucket() throws Exception { tablePath, TableDescriptor.builder() .schema(DATA1_SCHEMA) - .distributedBy(bucketCount) + // hash-distributed: routing validation is skipped for keyless + // tables + .distributedBy(bucketCount, "a") .build()); Map> bucketsByLeader = new HashMap<>(); From 9c7941787b6ac56d2d197712a2c3de5751ae0b3b Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Fri, 4 Sep 2026 15:18:18 +0800 Subject: [PATCH 10/16] [flink] Enumerate KV batch splits by the partition's own bucket count The server-scan KV batch path still enumerated every partition's buckets by the table-level bucket.num. On a rescaled table that generates splits for buckets an old partition never had and misses buckets a new partition does have. Take the count from the PartitionInfo, like the tiering and lake split generators already do; the table-level count only applies to a non-partitioned table. --- .../enumerator/FlinkSourceEnumerator.java | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java index bddc5835dc4..e99631a943b 100644 --- a/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java +++ b/fluss-flink/fluss-flink-common/src/main/java/org/apache/fluss/flink/source/enumerator/FlinkSourceEnumerator.java @@ -772,22 +772,25 @@ private List generateFlussOnlyBatchSplits( Set partitionInfos = listPartitions(); List splits = new ArrayList<>(); for (PartitionInfo partitionInfo : partitionInfos) { - splits.addAll( - buildKvBatchSplits( - partitionInfo.getPartitionId(), - partitionInfo.getPartitionName())); + splits.addAll(buildKvBatchSplits(partitionInfo)); } return splits; } - return buildKvBatchSplits(null, null); + return buildKvBatchSplits(null); } return flussOnlyBatchSplitGenerator.generate(); } - private List buildKvBatchSplits( - @Nullable Long partitionId, @Nullable String partitionName) { + private List buildKvBatchSplits(@Nullable PartitionInfo partitionInfo) { + // A partition keeps the bucket count it was created with, so its buckets must be + // enumerated by that count; the table-level count only applies to a non-partitioned + // table, whose single bucket layout is the table's own. + int bucketCount = + partitionInfo != null ? partitionInfo.getBucketCount() : tableInfo.getNumBuckets(); + Long partitionId = partitionInfo != null ? partitionInfo.getPartitionId() : null; + String partitionName = partitionInfo != null ? partitionInfo.getPartitionName() : null; List splits = new ArrayList<>(); - for (int bucketId = 0; bucketId < tableInfo.getNumBuckets(); bucketId++) { + for (int bucketId = 0; bucketId < bucketCount; bucketId++) { TableBucket tb = new TableBucket(tableInfo.getTableId(), partitionId, bucketId); if (ignoreTableBucket(tb)) { continue; From 3316d3bccba4d4db6a7644c8ee783044b3bc1fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD=E9=B5=BA?= Date: Fri, 4 Sep 2026 14:57:59 +0800 Subject: [PATCH 11/16] [client] Centralize the missing-bucket-count fallback into one policy The policy "a per-partition/per-table bucket count is missing: fall back to the table-level count only when the table was never rescaled (bucketCountEpoch == 0), otherwise fail loud" was duplicated across three code paths and one of them was wrong: the lookup path silently fell back, so after a rescale a primary-key/prefix lookup would route to the wrong bucket and return an empty or wrong result with no error (a read/write inconsistency, since writes route to the new bucket). Introduce ClientRpcMessageUtils.fallbackBucketCountOrFail as the single source of truth and delegate all four sites to it: - AbstractLookuper (bug fix: silent -> fail loud on epoch > 0); - WriterClient partitioned branch; - WriterClient non-partitioned branch (previously a silent table-level fallback that is safe only while non-partitioned tables cannot be rescaled; delegating makes it correct in advance for when they can); - FlussAdmin listPartitionInfos. The lookup paths deliver the resulting StaleMetadataException as a failed future, consistent with the historical-lookup path, rather than throwing it synchronously from the async lookup() method. A PrimaryKeyLookuperTest case proves that a rescaled partition with a missing per-partition count fails loud via a failed future (not a silent wrong bucket, not a synchronous throw). Also drops the now-redundant tableLevelNumBuckets parameter from resolvePartitionBucketCount. --- .../apache/fluss/client/admin/FlussAdmin.java | 26 +++------- .../fluss/client/lookup/AbstractLookuper.java | 13 +++-- .../client/lookup/PrefixKeyLookuper.java | 11 +++- .../client/lookup/PrimaryKeyLookuper.java | 12 +++-- .../client/utils/ClientRpcMessageUtils.java | 23 +++++++++ .../fluss/client/write/WriterClient.java | 30 ++++------- .../client/lookup/PrimaryKeyLookuperTest.java | 51 ++++++++++++++++++- 7 files changed, 113 insertions(+), 53 deletions(-) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java index ce5618f579c..bfb47862c68 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/admin/FlussAdmin.java @@ -34,7 +34,6 @@ import org.apache.fluss.config.cluster.ConfigEntry; import org.apache.fluss.exception.FlussRuntimeException; import org.apache.fluss.exception.LeaderNotAvailableException; -import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.DatabaseChange; import org.apache.fluss.metadata.DatabaseDescriptor; import org.apache.fluss.metadata.DatabaseInfo; @@ -424,25 +423,12 @@ public CompletableFuture> listPartitionInfos( // observe a half-upgraded cluster. return getTableInfo(tablePath) .thenApply( - tableInfo -> { - long epoch = tableInfo.getBucketCountEpoch(); - // Post-ALTER server must return the count; - // missing means inconsistency, so fail loud. - if (epoch > 0) { - throw new StaleMetadataException( - "Server omitted the per-partition " - + "bucket count for table " - + tablePath - + " at bucketCountEpoch " - + epoch - + " > 0; refusing to fall " - + "back to the table-level " - + "count."); - } - // epoch == 0: table-level count is safe. - return ClientRpcMessageUtils.toPartitionInfos( - response, tableInfo.getNumBuckets()); - }); + tableInfo -> + ClientRpcMessageUtils.toPartitionInfos( + response, + ClientRpcMessageUtils + .fallbackBucketCountOrFail( + tableInfo, tablePath))); }); } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java index 86a555f4711..434fa4906ed 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/AbstractLookuper.java @@ -18,6 +18,7 @@ package org.apache.fluss.client.lookup; import org.apache.fluss.client.metadata.MetadataUpdater; +import org.apache.fluss.client.utils.ClientRpcMessageUtils; import org.apache.fluss.memory.MemorySegment; import org.apache.fluss.metadata.Schema; import org.apache.fluss.metadata.SchemaGetter; @@ -79,15 +80,17 @@ abstract class AbstractLookuper implements Lookuper { /** * Resolves the effective bucket count for the target partition: the per-partition bucket count - * when the cluster metadata has it, falling back to the table-level bucket count otherwise - * (non-partitioned tables or partitions created by older versions). + * when the cluster metadata has it, otherwise the shared fallback policy (safe table-level + * count only when the table was never rescaled; fail loud otherwise). */ - protected int resolvePartitionBucketCount( - TablePartition tablePartition, int tableLevelNumBuckets) { + protected int resolvePartitionBucketCount(TablePartition tablePartition) { return metadataUpdater .getCluster() .getBucketCount(tablePartition) - .orElse(tableLevelNumBuckets); + .orElseGet( + () -> + ClientRpcMessageUtils.fallbackBucketCountOrFail( + tableInfo, tablePartition)); } protected void handleLookupResponse( diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java index fb1fe47a4cd..b9b11b052f5 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrefixKeyLookuper.java @@ -21,6 +21,7 @@ import org.apache.fluss.client.metadata.MetadataUpdater; import org.apache.fluss.client.table.getter.PartitionGetter; import org.apache.fluss.exception.PartitionNotExistException; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.DataLakeFormat; import org.apache.fluss.metadata.SchemaGetter; import org.apache.fluss.metadata.TableBucket; @@ -179,10 +180,16 @@ public CompletableFuture lookup(InternalRow prefixKey) { metadataUpdater); bucketCount = resolvePartitionBucketCount( - new TablePartition(tableInfo.getTableId(), partitionId), - numBuckets); + new TablePartition(tableInfo.getTableId(), partitionId)); } catch (PartitionNotExistException e) { return CompletableFuture.completedFuture(new LookupResult(Collections.emptyList())); + } catch (StaleMetadataException e) { + // The partition was rescaled but its per-partition bucket count is unavailable. + // Report it as a failed future (retriable) rather than throwing synchronously from + // this async method. + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(e); + return failed; } } diff --git a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java index 05b5eafba20..1dc0dc580f1 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/lookup/PrimaryKeyLookuper.java @@ -21,6 +21,7 @@ import org.apache.fluss.client.metadata.MetadataUpdater; import org.apache.fluss.client.table.getter.PartitionGetter; import org.apache.fluss.exception.PartitionNotExistException; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.DataLakeFormat; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.SchemaGetter; @@ -143,10 +144,14 @@ public CompletableFuture lookup(InternalRow lookupKey) { metadataUpdater); bucketCount = resolvePartitionBucketCount( - new TablePartition(tableInfo.getTableId(), partitionId), - numBuckets); + new TablePartition(tableInfo.getTableId(), partitionId)); } catch (PartitionNotExistException e) { return mayFallbackToHistoricalLookup(bkBytes, pkBytes, originalPartitionName); + } catch (StaleMetadataException e) { + // The partition was rescaled but its per-partition bucket count is unavailable. + // Report it as a failed future (retriable), consistent with historicalLookup, + // rather than throwing synchronously from this async method. + return completedExceptionally(e); } } @@ -213,8 +218,7 @@ private CompletableFuture historicalLookup( // change. The bucket the lake data lives in is resolved on the server. int historicalBucketCount = resolvePartitionBucketCount( - new TablePartition(tableInfo.getTableId(), historicalPartitionId), - numBuckets); + new TablePartition(tableInfo.getTableId(), historicalPartitionId)); int routingBucketId = bucketingFunction.bucketing(bucketKeyBytes, historicalBucketCount); TableBucket tableBucket = diff --git a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java index 86fc81793fb..8218eb327df 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/utils/ClientRpcMessageUtils.java @@ -39,6 +39,7 @@ import org.apache.fluss.config.cluster.AlterConfigOpType; import org.apache.fluss.config.cluster.ColumnPositionType; import org.apache.fluss.config.cluster.ConfigEntry; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.fs.FsPath; import org.apache.fluss.fs.FsPathAndFileName; import org.apache.fluss.fs.token.ObtainedSecurityToken; @@ -50,6 +51,7 @@ import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableChange; +import org.apache.fluss.metadata.TableInfo; import org.apache.fluss.metadata.TablePartition; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.rpc.messages.AcquireKvSnapshotLeaseRequest; @@ -684,6 +686,27 @@ public static List toPartitionInfos( .collect(Collectors.toList()); } + /** + * Resolves the routing bucket count when a per-partition (or per-table) bucket count is + * unavailable in the metadata. Falling back to the table-level count is safe only when {@code + * bucketCountEpoch == 0}, which proves the table was never rescaled; otherwise the table-level + * count may route to the wrong bucket, so this fails loud instead of silently returning a wrong + * answer. Shared by the write path (bucket assignment), the lookup path (bucket routing), and + * the admin path (partition info resolution) so the policy lives in one place. + */ + public static int fallbackBucketCountOrFail(TableInfo tableInfo, Object target) { + long epoch = tableInfo.getBucketCountEpoch(); + if (epoch > 0) { + throw new StaleMetadataException( + "Routing bucket count is unavailable for " + + target + + " at bucketCountEpoch " + + epoch + + "; refusing to fall back to the table-level count."); + } + return tableInfo.getNumBuckets(); + } + public static Map toKeyValueMap(List pbKeyValues) { return pbKeyValues.stream() .collect( diff --git a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java index 5742b75ce45..0b8779701ac 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/write/WriterClient.java @@ -22,6 +22,7 @@ import org.apache.fluss.client.admin.Admin; import org.apache.fluss.client.metadata.MetadataUpdater; import org.apache.fluss.client.metrics.WriterMetricGroup; +import org.apache.fluss.client.utils.ClientRpcMessageUtils; import org.apache.fluss.client.write.RecordAccumulator.RecordAppendResult; import org.apache.fluss.cluster.Cluster; import org.apache.fluss.config.ConfigOptions; @@ -29,7 +30,6 @@ import org.apache.fluss.exception.FlussRuntimeException; import org.apache.fluss.exception.IllegalConfigurationException; import org.apache.fluss.exception.PartitionNotExistException; -import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableInfo; @@ -244,25 +244,9 @@ && mayBeExpiredHistoricalPartition( bucketCount = cluster.getBucketCount(tablePartition) .orElseGet( - () -> { - // bucketCountEpoch == 0 proves the table was never - // rescaled, so the table-level bucket count IS this - // partition's actual count — a safe, provable fallback - // (e.g. an old server that never sends the - // per-partition - // count). Only epoch > 0 with a missing count is a real - // inconsistency worth failing on. - if (tableInfo.getBucketCountEpoch() > 0) { - throw new StaleMetadataException( - "Per-partition bucket count is unavailable for " - + assignerPath - + " at bucketCountEpoch " - + tableInfo.getBucketCountEpoch() - + "; refusing to fall back to the" - + " table-level count."); - } - return tableInfo.getNumBuckets(); - }); + () -> + ClientRpcMessageUtils.fallbackBucketCountOrFail( + tableInfo, assignerPath)); bucketAssigner = partitionBucketAssigners.computeIfAbsent( tablePartition, @@ -271,7 +255,11 @@ && mayBeExpiredHistoricalPartition( tableInfo, assignerPath, bucketCount, conf)); } else { bucketCount = - cluster.getBucketCountForTable(tableId).orElse(tableInfo.getNumBuckets()); + cluster.getBucketCountForTable(tableId) + .orElseGet( + () -> + ClientRpcMessageUtils.fallbackBucketCountOrFail( + tableInfo, physicalTablePath)); bucketAssigner = tableBucketAssigners.computeIfAbsent( tableId, diff --git a/fluss-client/src/test/java/org/apache/fluss/client/lookup/PrimaryKeyLookuperTest.java b/fluss-client/src/test/java/org/apache/fluss/client/lookup/PrimaryKeyLookuperTest.java index 63c7af9d42e..1f046765408 100644 --- a/fluss-client/src/test/java/org/apache/fluss/client/lookup/PrimaryKeyLookuperTest.java +++ b/fluss-client/src/test/java/org/apache/fluss/client/lookup/PrimaryKeyLookuperTest.java @@ -24,6 +24,7 @@ import org.apache.fluss.config.ConfigOptions; import org.apache.fluss.config.Configuration; import org.apache.fluss.exception.PartitionNotExistException; +import org.apache.fluss.exception.StaleMetadataException; import org.apache.fluss.metadata.DataLakeFormat; import org.apache.fluss.metadata.PhysicalTablePath; import org.apache.fluss.metadata.Schema; @@ -59,6 +60,7 @@ import static org.apache.fluss.client.metadata.TestingMetadataUpdater.NODE1; import static org.apache.fluss.utils.PartitionUtils.HISTORICAL_PARTITION_VALUE; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** Tests for {@link PrimaryKeyLookuper}. */ class PrimaryKeyLookuperTest { @@ -138,7 +140,53 @@ void testFallbackUsesOriginalPartitionWhenLookupRowIsReused() throws Exception { } } + @Test + void testRescaledPartitionMissingBucketCountFailsFutureInsteadOfThrowing() throws Exception { + // The table was rescaled (bucketCountEpoch > 0) but the per-partition bucket count is + // absent from metadata (e.g. an old server that never sends it). The lookup must not + // silently route with the table-level count (wrong bucket, empty result); it must fail + // loud — and because lookup() is async, via a failed future rather than a synchronous + // throw, consistent with the historical-lookup path. + TableInfo rescaledTableInfo = createTableInfo(1L); + ControllableLookupGateway gateway = new ControllableLookupGateway(); + TestingMetadataUpdater metadataUpdater = + TestingMetadataUpdater.builder( + Collections.singletonMap(TABLE_PATH, rescaledTableInfo)) + .withTabletServerGateway(NODE1.id(), gateway) + .build(); + // The cluster carries no per-partition bucket count for the active partition. + metadataUpdater.updateCluster(createCluster()); + + LookupClient lookupClient = new LookupClient(new Configuration(), metadataUpdater); + try { + PrimaryKeyLookuper lookuper = + new PrimaryKeyLookuper( + rescaledTableInfo, + new TestingSchemaGetter( + rescaledTableInfo.getSchemaId(), rescaledTableInfo.getSchema()), + metadataUpdater, + lookupClient, + false); + ProjectedRow lookupKey = + ProjectedRow.from(new int[] {0, 1}) + .replaceRow(GenericRow.of(1, BinaryString.fromString(PARTITION_A))); + + CompletableFuture resultFuture = lookuper.lookup(lookupKey); + + // Delivered as a failed future, not thrown synchronously from lookup(). + assertThat(resultFuture).isCompletedExceptionally(); + assertThatThrownBy(() -> resultFuture.get(5, TimeUnit.SECONDS)) + .hasCauseInstanceOf(StaleMetadataException.class); + } finally { + lookupClient.close(Duration.ofSeconds(5)); + } + } + private static TableInfo createTableInfo() { + return createTableInfo(0L); + } + + private static TableInfo createTableInfo(long bucketCountEpoch) { Schema schema = Schema.newBuilder() .column("id", DataTypes.INT()) @@ -156,7 +204,8 @@ private static TableInfo createTableInfo() { .property(ConfigOptions.TABLE_DATALAKE_FORMAT, DataLakeFormat.PAIMON) .property(ConfigOptions.TABLE_DATALAKE_HISTORICAL_PARTITION_ENABLED, true) .build(); - return TableInfo.of(TABLE_PATH, TABLE_ID, 1, tableDescriptor, null, 0L, 0L); + return TableInfo.of( + TABLE_PATH, TABLE_ID, 1, tableDescriptor, null, 0L, 0L, bucketCountEpoch); } private static Cluster createCluster() { From d7ae991ada58d72dcf0284615d115ddb4bb876c9 Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Fri, 4 Sep 2026 22:49:12 +0800 Subject: [PATCH 12/16] [server] Preserve bucketCountEpoch across schema changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processSchemaChange rebuilt the coordinator context's TableInfo with the 13-arg constructor, which hardcodes bucketCountEpoch to 0. After an ALTER bucket.num rescale, a later ALTER ADD COLUMN therefore rolled the context epoch back to 0: NotifyLeaderAndIsr carried epoch 0 and overwrote the replica's routing state, while the UpdateMetadata for the schema change was discarded whole by tablet servers' epoch-monotonic guard — dropping the schema update along with the stale epoch. Propagate the epoch from the old TableInfo (14-arg constructor), and fix the same loss in MultiTableWriterImpl.withSchema's historical-schema copies. Covered by testSchemaChangeKeepsBucketCountEpochAfterRescale, which fails on the unpatched code. --- .../table/writer/MultiTableWriterImpl.java | 3 +- .../CoordinatorEventProcessor.java | 3 +- .../CoordinatorEventProcessorTest.java | 101 ++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriterImpl.java b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriterImpl.java index 5702188d3a8..51337802873 100644 --- a/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriterImpl.java +++ b/fluss-client/src/main/java/org/apache/fluss/client/table/writer/MultiTableWriterImpl.java @@ -243,7 +243,8 @@ private static TableInfo withSchema(TableInfo base, int schemaId, Schema schema) base.getRemoteDataDir(), base.getComment().orElse(null), base.getCreatedTime(), - base.getModifiedTime()); + base.getModifiedTime(), + base.getBucketCountEpoch()); } private TableInfo getTableInfo(TablePath tablePath) { diff --git a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java index 598a708fda9..1e3b589e19a 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessor.java @@ -932,7 +932,8 @@ private void processSchemaChange(SchemaChangeEvent schemaChangeEvent) { oldTableInfo.getRemoteDataDir(), oldTableInfo.getComment().orElse(null), oldTableInfo.getCreatedTime(), - System.currentTimeMillis())); + System.currentTimeMillis(), + oldTableInfo.getBucketCountEpoch())); updateTabletServerMetadataCache( new HashSet<>(coordinatorContext.getLiveTabletServers().values()), diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java index 248bf484810..9f345f212ed 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java @@ -28,6 +28,8 @@ import org.apache.fluss.exception.InvalidCoordinatorException; import org.apache.fluss.fs.FsPath; import org.apache.fluss.metadata.DatabaseDescriptor; +import org.apache.fluss.metadata.PartitionSpec; +import org.apache.fluss.metadata.ResolvedPartitionSpec; import org.apache.fluss.metadata.Schema; import org.apache.fluss.metadata.TableBucket; import org.apache.fluss.metadata.TableBucketReplica; @@ -1773,6 +1775,105 @@ void testSchemaChange() throws Exception { 3, new TableMetadata(tableInfo2, Collections.emptyList()))); } + @Test + void testSchemaChangeKeepsBucketCountEpochAfterRescale() throws Exception { + initCoordinatorChannel(); + TablePath t1 = TablePath.of(defaultDatabase, "schema_change_keeps_epoch"); + int originalBucketCount = 3; + TableAssignment tableAssignment = + generateAssignment( + originalBucketCount, + REPLICATION_FACTOR, + new TabletServerInfo[] { + new TabletServerInfo(0, "rack0"), + new TabletServerInfo(1, "rack1"), + new TabletServerInfo(2, "rack2") + }); + TableDescriptor partitionedTable = + TableDescriptor.builder() + .schema( + Schema.newBuilder() + .column("a", DataTypes.INT()) + .column("b", DataTypes.STRING()) + .primaryKey("a", "b") + .build()) + .distributedBy(originalBucketCount) + .partitionedBy("b") + .build() + .withReplicationFactor(REPLICATION_FACTOR); + long tableId = + metadataManager.createTable( + t1, remoteDataDir, partitionedTable, tableAssignment, false); + // create one partition so the ALTER bucket.num rescale can commit + metadataManager.createPartition( + t1, + tableId, + remoteDataDir, + new PartitionAssignment( + tableId, + generateAssignment( + originalBucketCount, + REPLICATION_FACTOR, + new TabletServerInfo[] { + new TabletServerInfo(0, "rack0"), + new TabletServerInfo(1, "rack1"), + new TabletServerInfo(2, "rack2") + }) + .getBucketAssignments()), + ResolvedPartitionSpec.fromPartitionSpec( + Collections.singletonList("b"), + new PartitionSpec(Collections.singletonMap("b", "2024-01-01"))), + false, + originalBucketCount); + + // ALTER bucket.num advances the bucket count epoch (persisted in ZK TableRegistration) + TablePropertyChanges.Builder propertyBuilder = TablePropertyChanges.builder(); + propertyBuilder.setCustomProperty("bucket.num", "8"); + metadataManager.alterTableProperties( + t1, + Collections.singletonList(TableChange.set("bucket.num", "8")), + propertyBuilder.build(), + false, + null, + (currentTable, updatedTable) -> {}, + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion()); + + long epochAfterAlter = metadataManager.getTable(t1).getBucketCountEpoch(); + assertThat(epochAfterAlter).isGreaterThan(0L); + + // A later schema change rebuilds the context TableInfo; the epoch must survive it + alterTable( + t1, + Collections.singletonList( + TableChange.addColumn( + "add_column", + DataTypes.INT(), + null, + TableChange.ColumnPosition.last()))); + + retryVerifyContext( + ctx -> { + TableInfo tableInfoInCtx = ctx.getTableInfoById(tableId); + assertThat(tableInfoInCtx).isNotNull(); + // the schema change took effect + assertThat(tableInfoInCtx.getSchema().getColumnNames()).contains("add_column"); + // and the bucket count epoch did NOT roll back to 0 + assertThat(tableInfoInCtx.getBucketCountEpoch()).isEqualTo(epochAfterAlter); + }); + + // the UpdateMetadata pushed for the schema change carries the same epoch + TableInfo tableInfoAfterSchemaChange = metadataManager.getTable(t1); + assertThat(tableInfoAfterSchemaChange.getBucketCountEpoch()).isEqualTo(epochAfterAlter); + retry( + Duration.ofMinutes(1), + () -> + verifyMetadataUpdateRequest( + 3, + new TableMetadata( + tableInfoAfterSchemaChange, Collections.emptyList()))); + } + @Test void testTableRegistrationChange() throws Exception { // make sure all request to gateway should be successful From bec161c4db52620d042ac2203f3d90c8ea28b881 Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Tue, 8 Sep 2026 19:58:20 +0800 Subject: [PATCH 13/16] Revert "[server] Skip routing bucket count validation for keyless tables" This reverts commit 3bc6e5b9a41c63bf2786a15816f6b6d55b3301aa. --- .../fluss/server/replica/ReplicaManager.java | 9 -- .../replica/ReplicaRoutingStateTest.java | 87 +++++-------------- 2 files changed, 21 insertions(+), 75 deletions(-) diff --git a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java index 5141a2c6f23..6a62d48fa23 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/replica/ReplicaManager.java @@ -2554,15 +2554,6 @@ public void validateRoutingBucketCount(TableBucket tableBucket, int routingBucke return; } - // Only hash-distributed tables (those with a bucket key, including primary-key tables whose - // bucket key defaults to the primary key) place a record in a bucket deterministically, so - // only they can misroute a key when the client routes with a stale bucket count. A table - // without a bucket key (round-robin/sticky) may place a record in any bucket, so a stale - // routing count is harmless and must not fail the write. - if (!replica.getTableInfo().hasBucketKey()) { - return; - } - if (routingBucketCount <= 0) { // Legacy client (no bucket count in request): reject only when a rescale is known, // because then the bucketId may come from an outdated count. diff --git a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java index ca2f56fac24..5ad8eef3636 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/replica/ReplicaRoutingStateTest.java @@ -37,9 +37,6 @@ import static org.apache.fluss.record.TestData.DATA1_TABLE_DESCRIPTOR; import static org.apache.fluss.record.TestData.DATA1_TABLE_ID; import static org.apache.fluss.record.TestData.DATA1_TABLE_PATH; -import static org.apache.fluss.record.TestData.DATA2_TABLE_DESCRIPTOR; -import static org.apache.fluss.record.TestData.DATA2_TABLE_ID; -import static org.apache.fluss.record.TestData.DATA2_TABLE_PATH; import static org.apache.fluss.record.TestData.DEFAULT_REMOTE_DATA_DIR; import static org.apache.fluss.server.coordinator.CoordinatorContext.INITIAL_COORDINATOR_EPOCH; import static org.apache.fluss.server.zk.data.LeaderAndIsr.INITIAL_BUCKET_EPOCH; @@ -61,8 +58,8 @@ final class ReplicaRoutingStateTest extends ReplicaTestBase { private static final int TEST_BUCKET = 5; @Test - void testRoutingBucketCountValidationAppliesOnlyToHashDistributedTables() throws Exception { - TableBucket keylessTb = new TableBucket(DATA1_TABLE_ID, TEST_BUCKET); + void testLeaderActivationRequiresRoutingState() throws Exception { + TableBucket tb = new TableBucket(DATA1_TABLE_ID, TEST_BUCKET); // A legacy coordinator's notification (no routing fields) fails leader activation loudly: // the upgrade contract requires the CoordinatorServer to be upgraded first. @@ -70,56 +67,23 @@ void testRoutingBucketCountValidationAppliesOnlyToHashDistributedTables() throws new CompletableFuture<>(); replicaManager.becomeLeaderOrFollower( INITIAL_COORDINATOR_EPOCH, - Collections.singletonList( - notifyDataWithRoutingState( - PhysicalTablePath.of(DATA1_TABLE_PATH), keylessTb, null, null)), + Collections.singletonList(notifyDataWithRoutingState(tb, null, null)), legacyFuture::complete); assertThat(legacyFuture.get().get(0).getError().error()) .isEqualTo(Errors.UNSUPPORTED_VERSION); assertThat(legacyFuture.get().get(0).getError().messageWithFallback()) .contains("upgrade the CoordinatorServer first"); - assertThat(replicaManager.getReplicaOrException(keylessTb).isLeader()).isFalse(); + assertThat(replicaManager.getReplicaOrException(tb).isLeader()).isFalse(); - // DATA1 has no bucket key (round-robin/sticky distribution): which bucket a record lands in - // carries no semantic meaning, so routing bucket count validation is skipped for it. Even a - // mismatched count, and even after its metadata epoch advances, must not fail the write. - makeLeaderWithRoutingState(PhysicalTablePath.of(DATA1_TABLE_PATH), keylessTb, 3, 0L); - assertThat(replicaManager.getReplicaOrException(keylessTb).isLeader()).isTrue(); - replicaManager.validateRoutingBucketCount(keylessTb, 3); - replicaManager.validateRoutingBucketCount(keylessTb, 4); - replicaManager.validateRoutingBucketCount(keylessTb, 0); - replicaManager.maybeUpdateMetadataCache( - INITIAL_COORDINATOR_EPOCH, - new ClusterMetadata( - null, - Collections.emptySet(), - Collections.singletonList( - new TableMetadata( - TableInfo.of( - DATA1_TABLE_PATH, - DATA1_TABLE_ID, - 1, - DATA1_TABLE_DESCRIPTOR, - DEFAULT_REMOTE_DATA_DIR, - 1L, - 1L, - 1L), - Collections.emptyList())), - Collections.emptyList())); - replicaManager.validateRoutingBucketCount(keylessTb, 4); - replicaManager.validateRoutingBucketCount(keylessTb, 0); - - // DATA2 is DISTRIBUTED BY (a): a hash-distributed table where a stale count would misroute - // the key, so its routing bucket count IS validated. - TableBucket keyedTb = new TableBucket(DATA2_TABLE_ID, TEST_BUCKET); - makeLeaderWithRoutingState(PhysicalTablePath.of(DATA2_TABLE_PATH), keyedTb, 3, 0L); - assertThat(replicaManager.getReplicaOrException(keyedTb).isLeader()).isTrue(); - replicaManager.validateRoutingBucketCount(keyedTb, 3); - assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(keyedTb, 4)) + // A new coordinator's notification activates the leader and arms the routing state. + makeLeaderWithRoutingState(tb, 3, 0L); + assertThat(replicaManager.getReplicaOrException(tb).isLeader()).isTrue(); + replicaManager.validateRoutingBucketCount(tb, 3); + assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(tb, 4)) .isInstanceOf(StaleMetadataException.class); - // A legacy client (no count) passes on a non-rescaled hash table... - replicaManager.validateRoutingBucketCount(keyedTb, 0); + // A legacy client (no count) passes on a non-rescaled table... + replicaManager.validateRoutingBucketCount(tb, 0); // ...but is rejected once an ALTER advances the metadata cache to epoch 1 through // UpdateMetadata, which does not re-notify the already active replica. replicaManager.maybeUpdateMetadataCache( @@ -130,49 +94,40 @@ void testRoutingBucketCountValidationAppliesOnlyToHashDistributedTables() throws Collections.singletonList( new TableMetadata( TableInfo.of( - DATA2_TABLE_PATH, - DATA2_TABLE_ID, + DATA1_TABLE_PATH, + DATA1_TABLE_ID, 1, - DATA2_TABLE_DESCRIPTOR, + DATA1_TABLE_DESCRIPTOR, DEFAULT_REMOTE_DATA_DIR, 1L, 1L, 1L), Collections.emptyList())), Collections.emptyList())); - assertThat(replicaManager.getReplicaOrException(keyedTb).getBucketCountEpoch()) - .isEqualTo(0L); - assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(keyedTb, 0)) + assertThat(replicaManager.getReplicaOrException(tb).getBucketCountEpoch()).isEqualTo(0L); + assertThatThrownBy(() -> replicaManager.validateRoutingBucketCount(tb, 0)) .isInstanceOf(StaleMetadataException.class); // An unknown bucket keeps the downstream per-bucket error semantics: validation passes. - replicaManager.validateRoutingBucketCount(new TableBucket(DATA2_TABLE_ID, 99), 3); + replicaManager.validateRoutingBucketCount(new TableBucket(DATA1_TABLE_ID, 99), 3); } private void makeLeaderWithRoutingState( - PhysicalTablePath physicalTablePath, - TableBucket tb, - Integer bucketCount, - Long bucketCountEpoch) - throws Exception { + TableBucket tb, Integer bucketCount, Long bucketCountEpoch) throws Exception { CompletableFuture> future = new CompletableFuture<>(); replicaManager.becomeLeaderOrFollower( INITIAL_COORDINATOR_EPOCH, Collections.singletonList( - notifyDataWithRoutingState( - physicalTablePath, tb, bucketCount, bucketCountEpoch)), + notifyDataWithRoutingState(tb, bucketCount, bucketCountEpoch)), future::complete); assertThat(future.get()).containsOnly(new NotifyLeaderAndIsrResultForBucket(tb)); } private static NotifyLeaderAndIsrData notifyDataWithRoutingState( - PhysicalTablePath physicalTablePath, - TableBucket tb, - Integer bucketCount, - Long bucketCountEpoch) { + TableBucket tb, Integer bucketCount, Long bucketCountEpoch) { return new NotifyLeaderAndIsrData( - physicalTablePath, + PhysicalTablePath.of(DATA1_TABLE_PATH), tb, Collections.singletonList(TABLET_SERVER_ID), new LeaderAndIsr( From 3a3a0c325a0554a92d9745b53298d173a9b98159 Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Tue, 8 Sep 2026 23:42:12 +0800 Subject: [PATCH 14/16] [server] Make bucket rescale and historical partition mutually exclusive A table that rescales bucket.num while the historical partition feature is enabled ends up with a historical partition whose fixed layout diverges from post-rescale partitions: late writes of a retired partition are re-bucketed by the historical partition's count, but the tiering writer then restores them to the lake partition using the Fluss bucket id, which no longer matches that partition's own lake layout. Supporting the combination is left to future work. Reject both directions before any lake or ZK side effect: - ALTER bucket.num on a table with the historical partition enabled is rejected during validation, before the lake propagation; - enabling the historical partition on a table with bucketCountEpoch > 0 is rejected before validateTableDescriptor, so the rescale rejection is not masked by unrelated option-dependency errors. Since the epoch never decreases, this also covers a table that was rescaled while the feature was temporarily disabled. Creating a table with the historical partition enabled remains allowed: all layouts start identical (epoch 0), and later rescales are rejected by the first rule. Tests: AlterBucketNumTest gains four cases covering both rejections (with zero-side-effect assertions), the disable-rescale-re-enable path, and that a never-rescaled table can still enable the feature. --- .../server/coordinator/MetadataManager.java | 29 ++++ .../coordinator/AlterBucketNumTest.java | 132 +++++++++++++++++- 2 files changed, 158 insertions(+), 3 deletions(-) diff --git a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java index e4ac37f4545..21298ade99d 100644 --- a/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java +++ b/fluss-server/src/main/java/org/apache/fluss/server/coordinator/MetadataManager.java @@ -590,6 +590,17 @@ private void validateBucketNumRescale( + "Non-partitioned table rescale is not yet supported.", tablePath)); } + // A rescaled table routes late writes of retired partitions through the historical + // partition, whose own fixed layout can diverge from post-rescale partitions; supporting + // the combination is left to future work. + if (tableInfo.getTableConfig().isHistoricalPartitionEnabled()) { + throw new InvalidAlterTableException( + String.format( + "Cannot alter 'bucket.num' on table %s with historical partition " + + "enabled. Altering 'bucket.num' on such tables is not " + + "supported yet.", + tablePath)); + } if (newBucketNum < 1) { throw new InvalidAlterTableException( String.format( @@ -817,6 +828,24 @@ private void doAlterTablePropertiesOnce( newDescriptor = newDescriptor.withBucketCount(newBucketNum); } + // Enabling the historical partition on a rescaled table is unsupported: the + // historical partition would be created with the new table-level count while retired + // lake data still uses the pre-rescale layout. bucketCountEpoch never decreases, so + // this also rejects a table that was rescaled while the feature was temporarily + // disabled. Checked before validateTableDescriptor so the rescale rejection is not + // masked by unrelated option-dependency errors. + if (!tableInfo.getTableConfig().isHistoricalPartitionEnabled() + && Configuration.fromMap(newDescriptor.getProperties()) + .get(ConfigOptions.TABLE_DATALAKE_HISTORICAL_PARTITION_ENABLED) + && tableInfo.getBucketCountEpoch() > 0) { + throw new InvalidAlterTableException( + String.format( + "Cannot enable historical partition on table %s after " + + "'bucket.num' has been altered. Enabling it on a " + + "rescaled table is not supported yet.", + tablePath)); + } + // reuse the same validate logic with the createTable() method validateTableDescriptor(newDescriptor); diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java index 6a8f827ea0a..9ee3f7bf9ca 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/AlterBucketNumTest.java @@ -19,6 +19,7 @@ import org.apache.fluss.cluster.Endpoint; import org.apache.fluss.cluster.TabletServerInfo; +import org.apache.fluss.config.AutoPartitionTimeUnit; import org.apache.fluss.config.ConfigOptions; import org.apache.fluss.config.Configuration; import org.apache.fluss.exception.FlussRuntimeException; @@ -61,8 +62,10 @@ import org.junit.jupiter.params.provider.MethodSource; import java.lang.reflect.Field; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; @@ -255,7 +258,16 @@ private static MetadataManager buildMetadataManagerWithLakeCatalog(LakeCatalog s */ private static void createSeededLakePartitionedTable( MetadataManager mm, TablePath tablePath, int originalBucketCount) throws Exception { - TableDescriptor lakeTable = + createSeededLakePartitionedTable(mm, tablePath, originalBucketCount, false); + } + + private static void createSeededLakePartitionedTable( + MetadataManager mm, + TablePath tablePath, + int originalBucketCount, + boolean historicalPartitionEnabled) + throws Exception { + TableDescriptor.Builder builder = TableDescriptor.builder() .schema( Schema.newBuilder() @@ -266,8 +278,17 @@ private static void createSeededLakePartitionedTable( .partitionedBy("b") .property(ConfigOptions.TABLE_DATALAKE_ENABLED.key(), "true") .property(ConfigOptions.TABLE_DATALAKE_FORMAT.key(), "paimon") - .build() - .withReplicationFactor(3); + // the historical partition requires auto-partitioning + .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED.key(), "true") + .property(ConfigOptions.TABLE_AUTO_PARTITION_KEY.key(), "b") + .property( + ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT.key(), + AutoPartitionTimeUnit.DAY.toString()); + if (historicalPartitionEnabled) { + builder.property( + ConfigOptions.TABLE_DATALAKE_HISTORICAL_PARTITION_ENABLED.key(), "true"); + } + TableDescriptor lakeTable = builder.build().withReplicationFactor(3); TableAssignment tableAssignment = generateAssignment(originalBucketCount, 3, getTabletServers()); mm.createTable(tablePath, remoteDataDir, lakeTable, tableAssignment, false); @@ -365,6 +386,88 @@ void testResetBucketNumRejected() throws Exception { assertThat(afterTableInfo.getBucketCountEpoch()).isEqualTo(originalEpoch); } + @Test + void testAlterBucketNumRejectedOnHistoricalPartitionTable() throws Exception { + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_reject_rescale_on_historical"); + int originalBucketCount = 4; + createSeededLakePartitionedTable(metadataManager, tablePath, originalBucketCount, true); + + // The rejection happens during validation, before the lake propagation, so the default + // manager without a lake catalog never reaches the propagation failure. + assertThatThrownBy(() -> alterBucketNum(metadataManager, tablePath, "8")) + .isInstanceOf(InvalidAlterTableException.class) + .hasMessageContaining("with historical partition enabled") + .hasMessageContaining("not supported yet"); + + // The bucket layout is untouched: neither the count nor the epoch moved. + TableInfo afterTableInfo = metadataManager.getTable(tablePath); + assertThat(afterTableInfo.getNumBuckets()).isEqualTo(originalBucketCount); + assertThat(afterTableInfo.getBucketCountEpoch()).isEqualTo(0L); + Optional partition = + zookeeperClient.getPartition(tablePath, "2024-01"); + assertThat(partition).isPresent(); + assertThat(partition.get().getBucketCount()).isEqualTo(originalBucketCount); + } + + @Test + void testEnableHistoricalPartitionRejectedAfterRescale() throws Exception { + MetadataManager mm = buildMetadataManagerWithLakeCatalog(new CountingLakeCatalog(false)); + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_reject_historical_after_rescale"); + int originalBucketCount = 4; + createSeededLakePartitionedTable(mm, tablePath, originalBucketCount, false); + + // Rescale the table first, which advances the bucketCountEpoch. + alterBucketNum(mm, tablePath, "8"); + assertThat(mm.getTable(tablePath).getBucketCountEpoch()).isEqualTo(1L); + + // Enabling the historical partition on the rescaled table must be rejected. + assertThatThrownBy(() -> alterHistoricalPartition(mm, tablePath, true)) + .isInstanceOf(InvalidAlterTableException.class) + .hasMessageContaining("Cannot enable historical partition") + .hasMessageContaining("not supported yet"); + + // The rejection left no side effects: no historical partition was created and the + // bucket layout stays at the rescaled state. + assertThat(zookeeperClient.getPartition(tablePath, "__historical__")).isEmpty(); + assertThat(mm.getTable(tablePath).getNumBuckets()).isEqualTo(8); + assertThat(mm.getTable(tablePath).getBucketCountEpoch()).isEqualTo(1L); + } + + @Test + void testEnableHistoricalPartitionRejectedAfterRescaleWhileDisabled() throws Exception { + MetadataManager mm = buildMetadataManagerWithLakeCatalog(new CountingLakeCatalog(false)); + TablePath tablePath = + TablePath.of(DEFAULT_DB, "test_reject_historical_after_disable_rescale"); + int originalBucketCount = 4; + createSeededLakePartitionedTable(mm, tablePath, originalBucketCount, true); + + // Disable the historical partition, then rescale: both steps succeed apart. + alterHistoricalPartition(mm, tablePath, false); + alterBucketNum(mm, tablePath, "8"); + assertThat(mm.getTable(tablePath).getBucketCountEpoch()).isEqualTo(1L); + + // Re-enabling must still be rejected: the epoch never decreases, and the historical + // partition would be created with the new count while retired lake data keeps the + // pre-rescale layout. + assertThatThrownBy(() -> alterHistoricalPartition(mm, tablePath, true)) + .isInstanceOf(InvalidAlterTableException.class) + .hasMessageContaining("Cannot enable historical partition") + .hasMessageContaining("not supported yet"); + assertThat(mm.getTable(tablePath).getBucketCountEpoch()).isEqualTo(1L); + } + + @Test + void testEnableHistoricalPartitionOnNeverRescaledTableSucceeds() throws Exception { + MetadataManager mm = buildMetadataManagerWithLakeCatalog(new CountingLakeCatalog(false)); + TablePath tablePath = TablePath.of(DEFAULT_DB, "test_enable_historical_on_fresh_table"); + int originalBucketCount = 4; + createSeededLakePartitionedTable(mm, tablePath, originalBucketCount, false); + + // A never-rescaled table (epoch 0) can still enable the historical partition. + alterHistoricalPartition(mm, tablePath, true); + assertThat(mm.getTable(tablePath).getTableConfig().isHistoricalPartitionEnabled()).isTrue(); + } + // ========================== Success Tests ========================== @ParameterizedTest(name = "bucketNum {0} -> {1}") @@ -892,6 +995,29 @@ private static void resetBucketNum(MetadataManager manager, TablePath tablePath) ZkVersion.MATCH_ANY_VERSION.getVersion()); } + private static void alterHistoricalPartition( + MetadataManager manager, TablePath tablePath, boolean enable) { + String key = ConfigOptions.TABLE_DATALAKE_HISTORICAL_PARTITION_ENABLED.key(); + TablePropertyChanges.Builder builder = TablePropertyChanges.builder(); + List changes = new ArrayList<>(); + if (enable) { + builder.setTableProperty(key, "true"); + changes.add(TableChange.set(key, "true")); + } else { + builder.resetTableProperty(key); + changes.add(TableChange.reset(key)); + } + manager.alterTableProperties( + tablePath, + changes, + builder.build(), + false, + null, + (currentTable, updatedTable) -> {}, + (currentTable, updatedTable) -> {}, + ZkVersion.MATCH_ANY_VERSION.getVersion()); + } + /** * Creates a partitioned log table with one partition whose persisted bucket count is then * cleared, so an ALTER bucket.num must enter the backfill path for it. Returns the partition From 0e99fff4324574be5e2a55bb3ec9cb87914b3d32 Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Wed, 9 Sep 2026 01:28:03 +0800 Subject: [PATCH 15/16] [test] Extract CoordinatorEventProcessorTest harness into base class --- .../CoordinatorEventProcessorTest.java | 140 +------------- .../CoordinatorEventProcessorTestBase.java | 183 ++++++++++++++++++ 2 files changed, 184 insertions(+), 139 deletions(-) create mode 100644 fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTestBase.java diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java index 9f345f212ed..951f0192950 100644 --- a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTest.java @@ -27,7 +27,6 @@ import org.apache.fluss.exception.InvalidAlterTableException; import org.apache.fluss.exception.InvalidCoordinatorException; import org.apache.fluss.fs.FsPath; -import org.apache.fluss.metadata.DatabaseDescriptor; import org.apache.fluss.metadata.PartitionSpec; import org.apache.fluss.metadata.ResolvedPartitionSpec; import org.apache.fluss.metadata.Schema; @@ -62,7 +61,6 @@ import org.apache.fluss.server.coordinator.event.CoordinatorEventManager; import org.apache.fluss.server.coordinator.event.NotifyLeaderAndIsrResponseReceivedEvent; import org.apache.fluss.server.coordinator.event.RetryOfflineLeaderEvent; -import org.apache.fluss.server.coordinator.lease.KvSnapshotLeaseManager; import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader; import org.apache.fluss.server.coordinator.statemachine.BucketState; import org.apache.fluss.server.coordinator.statemachine.ReplicaState; @@ -81,34 +79,21 @@ import org.apache.fluss.server.metrics.group.TestingMetricGroups; import org.apache.fluss.server.tablet.TestTabletServerGateway; import org.apache.fluss.server.zk.NOPErrorHandler; -import org.apache.fluss.server.zk.ZkEpoch; import org.apache.fluss.server.zk.ZooKeeperClient; -import org.apache.fluss.server.zk.ZooKeeperExtension; import org.apache.fluss.server.zk.data.BucketAssignment; -import org.apache.fluss.server.zk.data.CoordinatorAddress; import org.apache.fluss.server.zk.data.LeaderAndIsr; import org.apache.fluss.server.zk.data.PartitionAssignment; import org.apache.fluss.server.zk.data.TableAssignment; import org.apache.fluss.server.zk.data.TabletServerRegistration; import org.apache.fluss.server.zk.data.ZkData; -import org.apache.fluss.server.zk.data.ZkData.PartitionIdsZNode; -import org.apache.fluss.server.zk.data.ZkData.TableIdsZNode; import org.apache.fluss.server.zk.data.ZkVersion; -import org.apache.fluss.testutils.common.AllCallbackWrapper; import org.apache.fluss.testutils.common.ManuallyTriggeredScheduledExecutorService; import org.apache.fluss.types.DataTypes; import org.apache.fluss.utils.ExceptionUtils; import org.apache.fluss.utils.clock.SystemClock; -import org.apache.fluss.utils.concurrent.ExecutorThreadFactory; -import org.apache.fluss.utils.concurrent.FlussScheduler; -import org.apache.fluss.utils.concurrent.Scheduler; import org.apache.fluss.utils.types.Tuple2; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.io.TempDir; import java.nio.file.Path; @@ -124,7 +109,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; @@ -152,7 +136,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** Test for {@link CoordinatorEventProcessor}. */ -class CoordinatorEventProcessorTest { +class CoordinatorEventProcessorTest extends CoordinatorEventProcessorTestBase { private static final int N_BUCKETS = 3; private static final int REPLICATION_FACTOR = 3; @@ -169,107 +153,6 @@ class CoordinatorEventProcessorTest { .build() .withReplicationFactor(REPLICATION_FACTOR); - @RegisterExtension - public static final AllCallbackWrapper ZOO_KEEPER_EXTENSION_WRAPPER = - new AllCallbackWrapper<>(new ZooKeeperExtension()); - - private static ZooKeeperClient zookeeperClient; - private static MetadataManager metadataManager; - private static ZkEpoch zkEpoch; - - private CoordinatorEventProcessor eventProcessor; - private final String defaultDatabase = "db"; - private TestCoordinatorChannelManager testCoordinatorChannelManager; - private AutoPartitionManager autoPartitionManager; - private LakeTableTieringManager lakeTableTieringManager; - private CompletedSnapshotStoreManager completedSnapshotStoreManager; - private CoordinatorMetadataCache serverMetadataCache; - private ReplicaCapacityController replicaCapacityController; - private KvSnapshotLeaseManager kvSnapshotLeaseManager; - private Scheduler scheduler; - private String remoteDataDir; - - @BeforeAll - static void baseBeforeAll() throws Exception { - zookeeperClient = - ZOO_KEEPER_EXTENSION_WRAPPER - .getCustomExtension() - .getZooKeeperClient(NOPErrorHandler.INSTANCE); - metadataManager = - new MetadataManager( - zookeeperClient, - new Configuration(), - new LakeCatalogDynamicLoader(new Configuration(), null, true)); - - // register coordinator server - zookeeperClient.registerCoordinatorLeader( - new CoordinatorAddress( - "2", Endpoint.fromListenersString("CLIENT://localhost:10012"))); - - zkEpoch = zookeeperClient.fenceBecomeCoordinatorLeader("2"); - // register 3 tablet servers - for (int i = 0; i < 3; i++) { - zookeeperClient.registerTabletServer( - i, - new TabletServerRegistration( - "rack" + i, - Collections.singletonList( - new Endpoint("host" + i, 1000, DEFAULT_LISTENER_NAME)), - System.currentTimeMillis())); - } - } - - @BeforeEach - void beforeEach() { - serverMetadataCache = new CoordinatorMetadataCache(); - // set a test channel manager for the context - testCoordinatorChannelManager = new TestCoordinatorChannelManager(); - lakeTableTieringManager = - new LakeTableTieringManager(TestingMetricGroups.LAKE_TIERING_METRICS); - remoteDataDir = zookeeperClient.getDefaultRemoteDataDir(); - Configuration conf = new Configuration(); - conf.setString(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir); - replicaCapacityController = new ReplicaCapacityController(conf, serverMetadataCache); - autoPartitionManager = - new AutoPartitionManager( - serverMetadataCache, - metadataManager, - new RemoteDirDynamicLoader(conf), - conf, - replicaCapacityController); - kvSnapshotLeaseManager = - new KvSnapshotLeaseManager( - Duration.ofMinutes(10).toMillis(), - zookeeperClient, - remoteDataDir, - SystemClock.getInstance(), - TestingMetricGroups.COORDINATOR_METRICS); - kvSnapshotLeaseManager.start(); - - scheduler = new FlussScheduler(1); - scheduler.startup(); - - eventProcessor = buildCoordinatorEventProcessor(); - eventProcessor.startup(); - metadataManager.createDatabase( - defaultDatabase, DatabaseDescriptor.builder().build(), false); - completedSnapshotStoreManager = eventProcessor.completedSnapshotStoreManager(); - } - - @AfterEach - void afterEach() throws Exception { - if (eventProcessor != null) { - eventProcessor.shutdown(); - } - if (scheduler != null) { - scheduler.shutdown(); - } - metadataManager.dropDatabase(defaultDatabase, false, true); - // clear the assignment info for all tables; - ZOO_KEEPER_EXTENSION_WRAPPER.getCustomExtension().cleanupPath(TableIdsZNode.path()); - ZOO_KEEPER_EXTENSION_WRAPPER.getCustomExtension().cleanupPath(PartitionIdsZNode.path()); - } - @Test void testLoadedAssignmentsTrackKnownKvAndUnknownTablesConservatively() throws Exception { long kvTableId = 10001L; @@ -2432,27 +2315,6 @@ private void verifyIsr(TableBucket tb, int expectedLeader, List expecte .hasSameElementsAs(expectedIsr); } - private CoordinatorEventProcessor buildCoordinatorEventProcessor() { - Configuration conf = new Configuration(); - conf.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir); - conf.set(ConfigOptions.COORDINATOR_OFFLINE_LEADER_RETRY_DELAY, Duration.ofDays(1)); - return new CoordinatorEventProcessor( - zookeeperClient, - serverMetadataCache, - testCoordinatorChannelManager, - new CoordinatorContext(zkEpoch), - replicaCapacityController, - autoPartitionManager, - lakeTableTieringManager, - TestingMetricGroups.COORDINATOR_METRICS, - conf, - Executors.newFixedThreadPool(1, new ExecutorThreadFactory("test-coordinator-io")), - metadataManager, - kvSnapshotLeaseManager, - scheduler, - SystemClock.getInstance()); - } - private static class FailingUpdateMetadataChannelManager extends TestCoordinatorChannelManager { private final CountDownLatch failureObserved = new CountDownLatch(1); diff --git a/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTestBase.java b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTestBase.java new file mode 100644 index 00000000000..372abf19f8e --- /dev/null +++ b/fluss-server/src/test/java/org/apache/fluss/server/coordinator/CoordinatorEventProcessorTestBase.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.server.coordinator; + +import org.apache.fluss.cluster.Endpoint; +import org.apache.fluss.config.ConfigOptions; +import org.apache.fluss.config.Configuration; +import org.apache.fluss.metadata.DatabaseDescriptor; +import org.apache.fluss.server.coordinator.lease.KvSnapshotLeaseManager; +import org.apache.fluss.server.coordinator.remote.RemoteDirDynamicLoader; +import org.apache.fluss.server.metadata.CoordinatorMetadataCache; +import org.apache.fluss.server.metrics.group.TestingMetricGroups; +import org.apache.fluss.server.zk.NOPErrorHandler; +import org.apache.fluss.server.zk.ZkEpoch; +import org.apache.fluss.server.zk.ZooKeeperClient; +import org.apache.fluss.server.zk.ZooKeeperExtension; +import org.apache.fluss.server.zk.data.CoordinatorAddress; +import org.apache.fluss.server.zk.data.TabletServerRegistration; +import org.apache.fluss.server.zk.data.ZkData.PartitionIdsZNode; +import org.apache.fluss.server.zk.data.ZkData.TableIdsZNode; +import org.apache.fluss.testutils.common.AllCallbackWrapper; +import org.apache.fluss.utils.clock.SystemClock; +import org.apache.fluss.utils.concurrent.ExecutorThreadFactory; +import org.apache.fluss.utils.concurrent.FlussScheduler; +import org.apache.fluss.utils.concurrent.Scheduler; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.time.Duration; +import java.util.Collections; +import java.util.concurrent.Executors; + +import static org.apache.fluss.config.ConfigOptions.DEFAULT_LISTENER_NAME; + +/** + * The shared lifecycle harness for {@link CoordinatorEventProcessor} unit tests: a ZooKeeper test + * cluster, a coordinator event processor rebuilt per test, and the cleanup between tests. + * + *

Extracted from {@code CoordinatorEventProcessorTest} to deduplicate the harness and keep the + * test class within the checkstyle file-length limit. + */ +class CoordinatorEventProcessorTestBase { + + @RegisterExtension + public static final AllCallbackWrapper ZOO_KEEPER_EXTENSION_WRAPPER = + new AllCallbackWrapper<>(new ZooKeeperExtension()); + + protected static ZooKeeperClient zookeeperClient; + protected static MetadataManager metadataManager; + protected static ZkEpoch zkEpoch; + + protected CoordinatorEventProcessor eventProcessor; + protected final String defaultDatabase = "db"; + protected TestCoordinatorChannelManager testCoordinatorChannelManager; + protected AutoPartitionManager autoPartitionManager; + protected LakeTableTieringManager lakeTableTieringManager; + protected CompletedSnapshotStoreManager completedSnapshotStoreManager; + protected CoordinatorMetadataCache serverMetadataCache; + protected ReplicaCapacityController replicaCapacityController; + protected KvSnapshotLeaseManager kvSnapshotLeaseManager; + protected Scheduler scheduler; + protected String remoteDataDir; + + @BeforeAll + static void baseBeforeAll() throws Exception { + zookeeperClient = + ZOO_KEEPER_EXTENSION_WRAPPER + .getCustomExtension() + .getZooKeeperClient(NOPErrorHandler.INSTANCE); + metadataManager = + new MetadataManager( + zookeeperClient, + new Configuration(), + new LakeCatalogDynamicLoader(new Configuration(), null, true)); + + // register coordinator server + zookeeperClient.registerCoordinatorLeader( + new CoordinatorAddress( + "2", Endpoint.fromListenersString("CLIENT://localhost:10012"))); + + zkEpoch = zookeeperClient.fenceBecomeCoordinatorLeader("2"); + // register 3 tablet servers + for (int i = 0; i < 3; i++) { + zookeeperClient.registerTabletServer( + i, + new TabletServerRegistration( + "rack" + i, + Collections.singletonList( + new Endpoint("host" + i, 1000, DEFAULT_LISTENER_NAME)), + System.currentTimeMillis())); + } + } + + @BeforeEach + void beforeEach() { + serverMetadataCache = new CoordinatorMetadataCache(); + // set a test channel manager for the context + testCoordinatorChannelManager = new TestCoordinatorChannelManager(); + lakeTableTieringManager = + new LakeTableTieringManager(TestingMetricGroups.LAKE_TIERING_METRICS); + remoteDataDir = zookeeperClient.getDefaultRemoteDataDir(); + Configuration conf = new Configuration(); + conf.setString(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir); + replicaCapacityController = new ReplicaCapacityController(conf, serverMetadataCache); + autoPartitionManager = + new AutoPartitionManager( + serverMetadataCache, + metadataManager, + new RemoteDirDynamicLoader(conf), + conf, + replicaCapacityController); + kvSnapshotLeaseManager = + new KvSnapshotLeaseManager( + Duration.ofMinutes(10).toMillis(), + zookeeperClient, + remoteDataDir, + SystemClock.getInstance(), + TestingMetricGroups.COORDINATOR_METRICS); + kvSnapshotLeaseManager.start(); + + scheduler = new FlussScheduler(1); + scheduler.startup(); + + eventProcessor = buildCoordinatorEventProcessor(); + eventProcessor.startup(); + metadataManager.createDatabase( + defaultDatabase, DatabaseDescriptor.builder().build(), false); + completedSnapshotStoreManager = eventProcessor.completedSnapshotStoreManager(); + } + + @AfterEach + void afterEach() throws Exception { + if (eventProcessor != null) { + eventProcessor.shutdown(); + } + if (scheduler != null) { + scheduler.shutdown(); + } + metadataManager.dropDatabase(defaultDatabase, false, true); + // clear the assignment info for all tables; + ZOO_KEEPER_EXTENSION_WRAPPER.getCustomExtension().cleanupPath(TableIdsZNode.path()); + ZOO_KEEPER_EXTENSION_WRAPPER.getCustomExtension().cleanupPath(PartitionIdsZNode.path()); + } + + protected CoordinatorEventProcessor buildCoordinatorEventProcessor() { + Configuration conf = new Configuration(); + conf.set(ConfigOptions.REMOTE_DATA_DIR, remoteDataDir); + conf.set(ConfigOptions.COORDINATOR_OFFLINE_LEADER_RETRY_DELAY, Duration.ofDays(1)); + return new CoordinatorEventProcessor( + zookeeperClient, + serverMetadataCache, + testCoordinatorChannelManager, + new CoordinatorContext(zkEpoch), + replicaCapacityController, + autoPartitionManager, + lakeTableTieringManager, + TestingMetricGroups.COORDINATOR_METRICS, + conf, + Executors.newFixedThreadPool(1, new ExecutorThreadFactory("test-coordinator-io")), + metadataManager, + kvSnapshotLeaseManager, + scheduler, + SystemClock.getInstance()); + } +} From c60e5b4f846f02b742a5d5c34d554a0859f0d0ea Mon Sep 17 00:00:00 2001 From: duankaixuan <1417048384@qq.com> Date: Wed, 9 Sep 2026 03:33:24 +0800 Subject: [PATCH 16/16] [test] Remove the historical-partition rescale lookup ITCase --- .../lookup/HistoricalPartitionITCase.java | 226 ------------------ 1 file changed, 226 deletions(-) diff --git a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java index 8e53120f02b..20b05d55f28 100644 --- a/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java +++ b/fluss-lake/fluss-lake-paimon/src/test/java/org/apache/fluss/lake/paimon/lookup/HistoricalPartitionITCase.java @@ -17,7 +17,6 @@ package org.apache.fluss.lake.paimon.lookup; -import org.apache.fluss.bucketing.BucketingFunction; import org.apache.fluss.client.Connection; import org.apache.fluss.client.ConnectionFactory; import org.apache.fluss.client.lookup.LookupResult; @@ -26,7 +25,6 @@ import org.apache.fluss.config.AutoPartitionTimeUnit; import org.apache.fluss.config.ConfigOptions; import org.apache.fluss.lake.paimon.testutils.FlinkPaimonTieringTestBase; -import org.apache.fluss.metadata.DataLakeFormat; import org.apache.fluss.metadata.PartitionInfo; import org.apache.fluss.metadata.PartitionSpec; import org.apache.fluss.metadata.Schema; @@ -35,16 +33,11 @@ import org.apache.fluss.metadata.TableDescriptor; import org.apache.fluss.metadata.TablePath; import org.apache.fluss.row.InternalRow; -import org.apache.fluss.row.encode.KeyEncoder; import org.apache.fluss.server.testutils.FlussClusterExtension; import org.apache.fluss.server.zk.data.PartitionRegistration; import org.apache.fluss.types.DataTypes; -import org.apache.fluss.types.RowType; import org.apache.flink.core.execution.JobClient; -import org.apache.paimon.table.FileStoreTable; -import org.apache.paimon.table.source.DataSplit; -import org.apache.paimon.table.source.Split; import org.apache.paimon.utils.CloseableIterator; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -56,13 +49,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.concurrent.CompletableFuture; -import static org.apache.fluss.lake.paimon.utils.PaimonConversions.toPaimon; import static org.apache.fluss.testutils.DataTestUtils.row; import static org.apache.fluss.testutils.InternalRowAssert.assertThatRow; import static org.apache.fluss.testutils.common.CommonTestUtils.retry; @@ -76,9 +66,6 @@ class HistoricalPartitionITCase extends FlinkPaimonTieringTestBase { private static final String SECOND_EXPIRED_PARTITION_NAME = "20240102"; private static final int INITIAL_PARTITION_RETENTION = 100000; private static final int EXPIRED_PARTITION_RETENTION = 1; - private static final int PRE_RESCALE_BUCKET_NUM = 2; - private static final int POST_RESCALE_BUCKET_NUM = 4; - private static final int MAX_CANDIDATE_ID = 64; @RegisterExtension public static final FlussClusterExtension FLUSS_CLUSTER_EXTENSION = @@ -328,199 +315,6 @@ void testLookupExpiredPartitionFromPaimon(boolean defaultBucketKey) throws Excep dropTable(tablePath); } - /** - * Changing bucket.num must not disable historical point lookup. The old partition keeps the - * bucket layout it was tiered with while later partitions use the new count, so the lookup has - * to be served from the bucket the lake data actually lives in. - */ - @ParameterizedTest(name = "defaultBucketKey={0}") - @ValueSource(booleans = {true, false}) - void testLookupExpiredPartitionAfterBucketNumRescale(boolean defaultBucketKey) - throws Exception { - TablePath tablePath = - TablePath.of( - DEFAULT_DB, - defaultBucketKey - ? "historical_rescale_default_bucket" - : "historical_rescale_bucket_subset"); - Schema schema = partitionedPkSchema(defaultBucketKey); - long tableId = - createTable(tablePath, partitionedPkDescriptor(schema, PRE_RESCALE_BUCKET_NUM)); - - // A key that lands in different buckets under the two layouts, so routing with the wrong - // bucket count cannot accidentally hit the right lake bucket. - int lookupId = idRoutedDifferentlyAcrossLayouts(defaultBucketKey, schema); - - admin.alterTable( - tablePath, - Collections.singletonList( - TableChange.set( - ConfigOptions.TABLE_DATALAKE_HISTORICAL_PARTITION_ENABLED - .key(), - "true")), - false) - .get(); - Optional historicalPartition = - FLUSS_CLUSTER_EXTENSION - .getZooKeeperClient() - .getPartition(tablePath, HISTORICAL_PARTITION_VALUE); - assertThat(historicalPartition).isPresent(); - FLUSS_CLUSTER_EXTENSION.waitUntilTablePartitionReady( - tableId, historicalPartition.get().getPartitionId()); - - // The old partition is created and written before the rescale, so it is laid out with - // PRE_RESCALE_BUCKET_NUM buckets. - admin.createPartition(tablePath, partitionSpec(EXPIRED_PARTITION_NAME), false).get(); - long oldPartitionId = getPartitionId(tablePath, EXPIRED_PARTITION_NAME); - FLUSS_CLUSTER_EXTENSION.waitUntilTablePartitionReady(tableId, oldPartitionId); - assertThat(bucketCountOf(tablePath, EXPIRED_PARTITION_NAME)) - .isEqualTo(PRE_RESCALE_BUCKET_NUM); - - InternalRow expectedOldRow = - dataRow(defaultBucketKey, lookupId, "sub-" + lookupId, "Alice"); - writeRows(tablePath, Collections.singletonList(expectedOldRow), false); - - admin.alterTable( - tablePath, - Collections.singletonList( - TableChange.set( - "bucket.num", String.valueOf(POST_RESCALE_BUCKET_NUM))), - false) - .get(); - - // A partition created after the rescale uses the new count, establishing mixed layouts. - admin.createPartition(tablePath, partitionSpec(SECOND_EXPIRED_PARTITION_NAME), false).get(); - long newPartitionId = getPartitionId(tablePath, SECOND_EXPIRED_PARTITION_NAME); - FLUSS_CLUSTER_EXTENSION.waitUntilTablePartitionReady(tableId, newPartitionId); - assertThat(bucketCountOf(tablePath, SECOND_EXPIRED_PARTITION_NAME)) - .isEqualTo(POST_RESCALE_BUCKET_NUM); - writeRows( - tablePath, - Collections.singletonList( - dataRow( - defaultBucketKey, - lookupId, - "sub-" + lookupId, - "Carol", - SECOND_EXPIRED_PARTITION_NAME)), - false); - - // Snapshot only the buckets the rows were actually written to; a primary-key table is - // tiered from its KV snapshots. - Set tableBuckets = new HashSet<>(); - tableBuckets.add( - new TableBucket( - tableId, - oldPartitionId, - lakeBucketOf(defaultBucketKey, schema, lookupId, PRE_RESCALE_BUCKET_NUM))); - tableBuckets.add( - new TableBucket( - tableId, - newPartitionId, - lakeBucketOf(defaultBucketKey, schema, lookupId, POST_RESCALE_BUCKET_NUM))); - FLUSS_CLUSTER_EXTENSION.triggerAndWaitSnapshots(tableBuckets); - - JobClient jobClient = buildTieringJob(execEnv); - try { - // The tiered lake data of the old partition keeps the bucket count it was written with, - // not the new table-level one. - retry( - Duration.ofMinutes(2), - () -> - assertThat(totalBucketsOfPartition(tablePath, EXPIRED_PARTITION_NAME)) - .containsExactly(PRE_RESCALE_BUCKET_NUM)); - } finally { - jobClient.cancel().get(); - } - - try (Connection lookupConn = ConnectionFactory.createConnection(clientConf); - Table table = lookupConn.getTable(tablePath)) { - Lookuper lookuper = table.newLookup().createLookuper(); - - admin.alterTable( - tablePath, - Collections.singletonList( - TableChange.set( - ConfigOptions.TABLE_AUTO_PARTITION_NUM_RETENTION.key(), - String.valueOf(EXPIRED_PARTITION_RETENTION))), - false) - .get(); - waitUntilPartitionDropped(tablePath, EXPIRED_PARTITION_NAME); - - InternalRow lookupRow = - lookuper.lookup(lookupKey(defaultBucketKey, lookupId, "sub-" + lookupId)) - .get() - .getSingletonRow(); - assertThatRow(lookupRow).withSchema(schema.getRowType()).isEqualTo(expectedOldRow); - } - dropTable(tablePath); - } - - /** - * Returns an id whose bucket differs between the pre-rescale and post-rescale layouts, so a - * lookup routed with the wrong bucket count cannot accidentally read the right lake bucket. - */ - private static int idRoutedDifferentlyAcrossLayouts(boolean defaultBucketKey, Schema schema) { - for (int id = 1; id <= MAX_CANDIDATE_ID; id++) { - if (lakeBucketOf(defaultBucketKey, schema, id, PRE_RESCALE_BUCKET_NUM) - != lakeBucketOf(defaultBucketKey, schema, id, POST_RESCALE_BUCKET_NUM)) { - return id; - } - } - throw new AssertionError( - "No id within " - + MAX_CANDIDATE_ID - + " candidates is routed to different buckets by the " - + PRE_RESCALE_BUCKET_NUM - + "- and " - + POST_RESCALE_BUCKET_NUM - + "-bucket layouts, so this test could not detect routing with the wrong " - + "bucket count."); - } - - /** Computes the lake bucket of a lookup key the same way the write and lookup paths do. */ - private static int lakeBucketOf( - boolean defaultBucketKey, Schema schema, int id, int bucketNum) { - RowType lookupRowType = schema.getRowType().project(schema.getPrimaryKeyColumnNames()); - KeyEncoder bucketKeyEncoder = - KeyEncoder.ofBucketKeyEncoder( - lookupRowType, Collections.singletonList("id"), DataLakeFormat.PAIMON); - byte[] bucketKey = bucketKeyEncoder.encodeKey(lookupKey(defaultBucketKey, id, "sub-" + id)); - return BucketingFunction.of(DataLakeFormat.PAIMON).bucketing(bucketKey, bucketNum); - } - - /** Reads the bucket count a Fluss partition was created with. */ - private static int bucketCountOf(TablePath tablePath, String partitionName) throws Exception { - Optional partition = - FLUSS_CLUSTER_EXTENSION.getZooKeeperClient().getPartition(tablePath, partitionName); - assertThat(partition).isPresent(); - return partition.get().getBucketCount(); - } - - /** Reads the bucket counts the tiered lake data of a partition was written with. */ - private static Set totalBucketsOfPartition(TablePath tablePath, String partitionName) - throws Exception { - FileStoreTable fileStoreTable = - (FileStoreTable) paimonCatalog.getTable(toPaimon(tablePath)); - List splits = - fileStoreTable - .newReadBuilder() - .withPartitionFilter(Collections.singletonMap("dt", partitionName)) - .newScan() - .plan() - .splits(); - assertThat(splits) - .withFailMessage( - "No lake splits for partition %s; all lake splits: %s", - partitionName, fileStoreTable.newReadBuilder().newScan().plan().splits()) - .isNotEmpty(); - Set totalBuckets = new HashSet<>(); - for (Split split : splits) { - totalBuckets.add(((DataSplit) split).totalBuckets()); - } - return totalBuckets; - } - @Override protected FlussClusterExtension getFlussClusterExtension() { return FLUSS_CLUSTER_EXTENSION; @@ -674,26 +468,6 @@ private static TableDescriptor partitionedDescriptor( return builder.build(); } - /** Same as {@link #partitionedDescriptor} but with an explicit table-level bucket count. */ - private static TableDescriptor partitionedPkDescriptor(Schema schema, int bucketNum) { - return TableDescriptor.builder() - .schema(schema) - // This is the default bucket key for (id, dt), and a strict subset of the physical - // primary key for (id, sub_id, dt). - .distributedBy(bucketNum, "id") - .partitionedBy("dt") - .property(ConfigOptions.TABLE_AUTO_PARTITION_ENABLED, true) - .property(ConfigOptions.TABLE_AUTO_PARTITION_KEY, "dt") - .property(ConfigOptions.TABLE_AUTO_PARTITION_TIME_UNIT, AutoPartitionTimeUnit.DAY) - .property( - ConfigOptions.TABLE_AUTO_PARTITION_NUM_RETENTION, - INITIAL_PARTITION_RETENTION) - .property(ConfigOptions.TABLE_AUTO_PARTITION_TIMEZONE, "UTC") - .property(ConfigOptions.TABLE_DATALAKE_ENABLED, true) - .property(ConfigOptions.TABLE_DATALAKE_FRESHNESS, Duration.ofMillis(500)) - .build(); - } - private static InternalRow dataRow( boolean defaultBucketKey, int id, String subId, String name) { return dataRow(defaultBucketKey, id, subId, name, EXPIRED_PARTITION_NAME);