Skip to content

Commit c3bc2d6

Browse files
committed
cluster iceberg rewrite_manifests output by order-preserving partition ranges
Signed-off-by: dontknow9179 <clin56322@gmail.com>
1 parent 23376eb commit c3bc2d6

4 files changed

Lines changed: 380 additions & 18 deletions

File tree

fe/fe-core/src/main/java/com/starrocks/connector/iceberg/procedure/RewriteManifestsProcedure.java

Lines changed: 124 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,45 @@
1414

1515
package com.starrocks.connector.iceberg.procedure;
1616

17+
import com.google.common.collect.ImmutableList;
18+
import com.google.common.collect.Iterables;
1719
import com.starrocks.connector.exception.StarRocksConnectorException;
1820
import com.starrocks.connector.iceberg.IcebergTableOperation;
1921
import com.starrocks.qe.ShowResultSet;
2022
import com.starrocks.sql.optimizer.operator.scalar.ConstantOperator;
23+
import org.apache.iceberg.DataFile;
2124
import org.apache.iceberg.ManifestFile;
25+
import org.apache.iceberg.ManifestFiles;
26+
import org.apache.iceberg.PartitionSpec;
2227
import org.apache.iceberg.RewriteManifests;
2328
import org.apache.iceberg.Snapshot;
29+
import org.apache.iceberg.StructLike;
2430
import org.apache.iceberg.Table;
31+
import org.apache.iceberg.io.CloseableIterable;
32+
import org.apache.iceberg.io.FileIO;
33+
import org.apache.iceberg.types.Comparators;
34+
import org.apache.iceberg.types.Type;
2535
import org.apache.iceberg.types.Types;
36+
import org.apache.iceberg.util.PartitionUtil;
2637
import org.apache.iceberg.util.PropertyUtil;
27-
import org.apache.iceberg.util.StructLikeWrapper;
2838

39+
import java.io.IOException;
2940
import java.util.Collections;
41+
import java.util.Comparator;
3042
import java.util.List;
3143
import java.util.Map;
32-
import java.util.Objects;
44+
import java.util.NavigableMap;
45+
import java.util.NavigableSet;
46+
import java.util.TreeMap;
47+
import java.util.TreeSet;
3348

3449
import static org.apache.iceberg.TableProperties.MANIFEST_TARGET_SIZE_BYTES;
3550
import static org.apache.iceberg.TableProperties.MANIFEST_TARGET_SIZE_BYTES_DEFAULT;
3651

3752
public class RewriteManifestsProcedure extends IcebergTableProcedure {
3853
private static final String PROCEDURE_NAME = "rewrite_manifests";
3954
private static final int MAX_MANIFEST_CLUSTERS = 100;
55+
private static final int NULL_PARTITION_CLUSTER = -1;
4056

4157
private static final RewriteManifestsProcedure INSTANCE = new RewriteManifestsProcedure();
4258

@@ -65,8 +81,8 @@ public ShowResultSet execute(IcebergTableProcedureContext context, Map<String, C
6581
return null;
6682
}
6783

68-
List<ManifestFile> manifests = currentSnapshot.allManifests(icebergTable.io());
69-
if (manifests.isEmpty()) {
84+
List<ManifestFile> dataManifests = currentSnapshot.dataManifests(icebergTable.io());
85+
if (dataManifests.isEmpty()) {
7086
return null;
7187
}
7288

@@ -76,28 +92,122 @@ public ShowResultSet execute(IcebergTableProcedureContext context, Map<String, C
7692
manifestTargetSizeBytes = MANIFEST_TARGET_SIZE_BYTES_DEFAULT;
7793
}
7894

79-
if (manifests.size() == 1 && manifests.get(0).length() < manifestTargetSizeBytes) {
95+
if (dataManifests.size() == 1 && dataManifests.get(0).length() < manifestTargetSizeBytes) {
8096
return null;
8197
}
8298

83-
long totalManifestsSize = manifests.stream().mapToLong(ManifestFile::length).sum();
99+
long totalManifestsSize = dataManifests.stream().mapToLong(ManifestFile::length).sum();
84100
// Having too many open manifest writers can potentially cause OOM on the coordinator
85-
// Floor to at least 1 to avoid modulo-by-zero when totalManifestsSize is 0 (e.g., manifests report length 0)
86-
long targetManifestClusters = Math.max(1, Math.min(
101+
long targetManifestClusters = Math.min(
87102
((totalManifestsSize + manifestTargetSizeBytes - 1) / manifestTargetSizeBytes),
88-
MAX_MANIFEST_CLUSTERS));
103+
MAX_MANIFEST_CLUSTERS);
104+
105+
Types.StructType partitionType = icebergTable.spec().partitionType();
106+
Map<Integer, PartitionSpec> specsById = icebergTable.specs();
107+
108+
// Assign each distinct top-level partition value an order-preserving bucket so that
109+
// sorted-adjacent partitions land in the same manifest. This keeps each manifest's
110+
// partition min/max range narrow (effective manifest pruning at read time) while bounding
111+
// the number of concurrently open writers.
112+
NavigableMap<Object, Integer> partitionValueToCluster;
113+
if (icebergTable.spec().isPartitioned() && targetManifestClusters > 1) {
114+
partitionValueToCluster = buildClusteredPartitionValues(
115+
icebergTable, currentSnapshot, (int) targetManifestClusters);
116+
} else {
117+
partitionValueToCluster = Collections.emptyNavigableMap();
118+
}
89119

90120
RewriteManifests rewriteManifests = context.transaction().rewriteManifests();
91-
Types.StructType structType = icebergTable.spec().partitionType();
92121

93122
rewriteManifests
94123
.clusterBy(file -> {
95-
// Cluster by partitions for better locality when reading data files
96-
StructLikeWrapper partitionWrapper = StructLikeWrapper.forType(structType).set(file.partition());
97-
// Limit the number of clustering buckets to avoid creating too many small manifest files
98-
return Integer.toUnsignedLong(Objects.hash(partitionWrapper)) % targetManifestClusters;
124+
if (partitionValueToCluster.isEmpty()) {
125+
return 0;
126+
}
127+
PartitionSpec fileSpec = specsById.get(file.specId());
128+
if (fileSpec == null) {
129+
// Unknown spec: the partition spec evolved under a concurrent commit. Evolution
130+
// is rare, so abort and let the next maintenance run re-cluster against the new
131+
// spec rather than guessing an order for it.
132+
throw new StarRocksConnectorException(
133+
"rewrite_manifests aborted: the table's partition spec changed under a " +
134+
"concurrent commit; it will be retried on the next maintenance run");
135+
}
136+
// Coerce into the current spec's partition type so files written under an older
137+
// partition spec still cluster correctly.
138+
StructLike partition =
139+
PartitionUtil.coercePartition(partitionType, fileSpec, file.partition());
140+
Object value = partition.get(0, Object.class);
141+
if (value == null) {
142+
return NULL_PARTITION_CLUSTER;
143+
}
144+
Integer cluster = partitionValueToCluster.get(value);
145+
if (cluster != null) {
146+
return cluster;
147+
}
148+
// Value not seen up front (typically a new partition added by a concurrent write to
149+
// the same spec). Slot it next to its sort-order neighbor.
150+
Map.Entry<Object, Integer> neighbor = partitionValueToCluster.floorEntry(value);
151+
if (neighbor == null) {
152+
neighbor = partitionValueToCluster.firstEntry();
153+
}
154+
return neighbor.getValue();
99155
})
100156
.commit();
101157
return null;
102158
}
159+
160+
// Collect the distinct top-level partition values across all data manifests of the snapshot,
161+
// sort them, and assign each a bucket in [0, targetClusters) grouping sorted-adjacent values
162+
// into the same bucket. Clustering is limited to the top-level partition field because it is
163+
// usually the most selective for read-time filters and keeps the distinct-value set small.
164+
private static NavigableMap<Object, Integer> buildClusteredPartitionValues(
165+
Table table, Snapshot snapshot, int targetClusters) {
166+
PartitionSpec spec = table.spec();
167+
Type.PrimitiveType firstFieldType =
168+
spec.partitionType().fields().get(0).type().asPrimitiveType();
169+
Comparator<Object> comparator = partitionValueComparator(firstFieldType);
170+
Types.StructType partitionType = spec.partitionType();
171+
Map<Integer, PartitionSpec> specsById = table.specs();
172+
FileIO io = table.io();
173+
List<ManifestFile> dataManifests = snapshot.dataManifests(io);
174+
Iterable<CloseableIterable<DataFile>> perManifest = Iterables.transform(
175+
dataManifests,
176+
manifest -> (CloseableIterable<DataFile>)
177+
ManifestFiles.read(manifest, io, specsById).select(ImmutableList.of("partition")));
178+
179+
// Comparator-based dedup mirrors Iceberg's own equality for partition value types.
180+
NavigableSet<Object> uniqueValues = new TreeSet<>(comparator);
181+
try (CloseableIterable<DataFile> dataFiles = CloseableIterable.concat(perManifest)) {
182+
for (DataFile file : dataFiles) {
183+
StructLike partition = PartitionUtil.coercePartition(
184+
partitionType, specsById.get(file.specId()), file.partition());
185+
Object value = partition.get(0, Object.class);
186+
if (value != null) {
187+
uniqueValues.add(value);
188+
}
189+
}
190+
} catch (IOException e) {
191+
throw new StarRocksConnectorException(
192+
"Unable to read manifests while clustering partitions for snapshot " + snapshot.snapshotId(), e);
193+
}
194+
195+
if (uniqueValues.isEmpty()) {
196+
return Collections.emptyNavigableMap();
197+
}
198+
199+
NavigableMap<Object, Integer> valueToCluster = new TreeMap<>(comparator);
200+
int index = 0;
201+
int size = uniqueValues.size();
202+
for (Object value : uniqueValues) {
203+
valueToCluster.put(value, (int) ((long) index * targetClusters / size));
204+
index++;
205+
}
206+
return valueToCluster;
207+
}
208+
209+
@SuppressWarnings("unchecked")
210+
private static Comparator<Object> partitionValueComparator(Type.PrimitiveType type) {
211+
return (Comparator<Object>) (Comparator<?>) Comparators.forType(type);
212+
}
103213
}

fe/fe-core/src/test/java/com/starrocks/connector/iceberg/procedure/RewriteManifestsProcedureTest.java

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,39 @@
1717
import com.starrocks.connector.HdfsEnvironment;
1818
import com.starrocks.connector.exception.StarRocksConnectorException;
1919
import com.starrocks.connector.iceberg.IcebergTableOperation;
20+
import com.starrocks.connector.iceberg.TestTables;
2021
import com.starrocks.connector.iceberg.hive.IcebergHiveCatalog;
2122
import com.starrocks.qe.ConnectContext;
2223
import com.starrocks.sql.ast.AlterTableOperationClause;
2324
import com.starrocks.sql.ast.AlterTableStmt;
2425
import com.starrocks.sql.optimizer.operator.scalar.ConstantOperator;
26+
import org.apache.iceberg.AppendFiles;
27+
import org.apache.iceberg.DataFile;
28+
import org.apache.iceberg.DataFiles;
2529
import org.apache.iceberg.ManifestFile;
2630
import org.apache.iceberg.PartitionSpec;
2731
import org.apache.iceberg.RewriteManifests;
32+
import org.apache.iceberg.Schema;
2833
import org.apache.iceberg.Snapshot;
2934
import org.apache.iceberg.Table;
35+
import org.apache.iceberg.TableProperties;
3036
import org.apache.iceberg.Transaction;
37+
import org.apache.iceberg.types.Types;
38+
import org.junit.jupiter.api.AfterEach;
3139
import org.junit.jupiter.api.Test;
40+
import org.junit.jupiter.api.io.TempDir;
41+
import org.mockito.ArgumentCaptor;
3242
import org.mockito.Mockito;
3343

44+
import java.io.File;
45+
import java.util.Arrays;
3446
import java.util.Collections;
3547
import java.util.HashMap;
3648
import java.util.List;
3749
import java.util.Map;
50+
import java.util.function.Function;
3851

52+
import static org.apache.iceberg.types.Types.NestedField.required;
3953
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
4054
import static org.junit.jupiter.api.Assertions.assertEquals;
4155
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -51,6 +65,19 @@ public class RewriteManifestsProcedureTest {
5165

5266
public static final HdfsEnvironment HDFS_ENVIRONMENT = new HdfsEnvironment();
5367

68+
private static final Schema SCHEMA =
69+
new Schema(required(1, "k1", Types.IntegerType.get()), required(2, "k2", Types.IntegerType.get()));
70+
// Identity partition on an int column, so partition values have a natural order to cluster by.
71+
private static final PartitionSpec SPEC = PartitionSpec.builderFor(SCHEMA).identity("k2").build();
72+
73+
@TempDir
74+
public File warehouse;
75+
76+
@AfterEach
77+
public void clearNativeTables() {
78+
TestTables.clearTables();
79+
}
80+
5481
@Test
5582
void testProcedureCreation() {
5683
RewriteManifestsProcedure procedure = RewriteManifestsProcedure.getInstance();
@@ -93,7 +120,7 @@ void testExecuteWithNoSnapshot() {
93120
void testExecuteWithEmptyManifests() {
94121
RewriteManifestsProcedure procedure = RewriteManifestsProcedure.getInstance();
95122
Snapshot snapshot = Mockito.mock(Snapshot.class);
96-
when(snapshot.allManifests(any())).thenReturn(Collections.emptyList());
123+
when(snapshot.dataManifests(any())).thenReturn(Collections.emptyList());
97124

98125
Table table = Mockito.mock(Table.class);
99126
when(table.currentSnapshot()).thenReturn(snapshot);
@@ -112,7 +139,7 @@ void testExecuteWithSingleSmallManifest() {
112139
when(smallManifest.length()).thenReturn(100L);
113140

114141
Snapshot snapshot = Mockito.mock(Snapshot.class);
115-
when(snapshot.allManifests(any())).thenReturn(List.of(smallManifest));
142+
when(snapshot.dataManifests(any())).thenReturn(List.of(smallManifest));
116143

117144
Table table = Mockito.mock(Table.class);
118145
when(table.currentSnapshot()).thenReturn(snapshot);
@@ -136,7 +163,7 @@ void testExecuteRewriteSuccess() {
136163
when(m2.length()).thenReturn(10 * 1024 * 1024L);
137164

138165
Snapshot snapshot = Mockito.mock(Snapshot.class);
139-
when(snapshot.allManifests(any())).thenReturn(List.of(m1, m2));
166+
when(snapshot.dataManifests(any())).thenReturn(List.of(m1, m2));
140167

141168
Table table = Mockito.mock(Table.class);
142169
when(table.currentSnapshot()).thenReturn(snapshot);
@@ -167,7 +194,7 @@ void testExecuteWithSingleLargeManifestTriggersRewrite() {
167194
when(largeManifest.length()).thenReturn(10 * 1024 * 1024L);
168195

169196
Snapshot snapshot = Mockito.mock(Snapshot.class);
170-
when(snapshot.allManifests(any())).thenReturn(List.of(largeManifest));
197+
when(snapshot.dataManifests(any())).thenReturn(List.of(largeManifest));
171198

172199
Table table = Mockito.mock(Table.class);
173200
when(table.currentSnapshot()).thenReturn(snapshot);
@@ -190,6 +217,96 @@ void testExecuteWithSingleLargeManifestTriggersRewrite() {
190217
verify(rewriteManifests).commit();
191218
}
192219

220+
@Test
221+
void testPartitionedManifestsClusteredByOrderPreservingRange() throws Exception {
222+
// 200 distinct partitions, cluster count capped at MAX_MANIFEST_CLUSTERS(=100),
223+
// so the order-preserving assignment is bucket = rank * 100 / 200 = rank / 2.
224+
int numPartitions = 200;
225+
int expectedClusters = 100;
226+
227+
Function<DataFile, Object> clusterFn = runAndCaptureClusterFunction(numPartitions);
228+
229+
int[] buckets = new int[numPartitions];
230+
for (int i = 0; i < numPartitions; i++) {
231+
Object bucket = clusterFn.apply(dataFileForPartition(i));
232+
assertNotNull(bucket);
233+
buckets[i] = ((Number) bucket).intValue();
234+
}
235+
236+
for (int i = 0; i < numPartitions; i++) {
237+
// partition value equals its rank here, so the expected bucket is a pure function of i
238+
assertEquals(i / 2, buckets[i], "partition k2=" + i + " landed in an unexpected bucket");
239+
assertTrue(buckets[i] >= 0 && buckets[i] < expectedClusters, "bucket out of range: " + buckets[i]);
240+
if (i > 0) {
241+
// monotonic non-decreasing in partition-value order: the property a hash would break
242+
assertTrue(buckets[i] >= buckets[i - 1], "bucketing is not order-preserving");
243+
}
244+
}
245+
// grouping actually happened and stayed within the cap
246+
assertEquals(expectedClusters, (int) Arrays.stream(buckets).distinct().count());
247+
}
248+
249+
@Test
250+
void testUnmappedPartitionValueSlotsIntoNeighborBucket() throws Exception {
251+
int numPartitions = 10;
252+
Function<DataFile, Object> clusterFn = runAndCaptureClusterFunction(numPartitions);
253+
// A value not seen at planning time (e.g. a new partition from a concurrent write to the same
254+
// spec) must not abort and must not be hash-scattered: an above-max value slots into the last
255+
// known partition's bucket, preserving the order-preserving clustering.
256+
Object maxBucket = clusterFn.apply(dataFileForPartition(numPartitions - 1));
257+
Object unmappedBucket = clusterFn.apply(dataFileForPartition(9999));
258+
assertNotNull(unmappedBucket);
259+
assertEquals(maxBucket, unmappedBucket);
260+
}
261+
262+
@Test
263+
void testUnknownSpecAbortsRewrite() throws Exception {
264+
Function<DataFile, Object> clusterFn = runAndCaptureClusterFunction(10);
265+
// A file under a spec not seen at planning time (concurrent partition-spec evolution) aborts,
266+
// deferring to the next maintenance run rather than guessing an order.
267+
DataFile unknownSpecFile = Mockito.mock(DataFile.class);
268+
when(unknownSpecFile.specId()).thenReturn(999);
269+
assertThrows(StarRocksConnectorException.class, () -> clusterFn.apply(unknownSpecFile));
270+
}
271+
272+
// Builds a real partitioned table with `numPartitions` single-file partitions, runs the
273+
// procedure against it, and returns the clustering Function it hands to RewriteManifests.
274+
// The manifest target size is forced to 1 byte so the cluster count is pinned to the cap.
275+
private Function<DataFile, Object> runAndCaptureClusterFunction(int numPartitions) throws Exception {
276+
TestTables.TestTable table = TestTables.create(warehouse, "t_range_" + numPartitions, SCHEMA, SPEC, 2);
277+
AppendFiles append = table.newAppend();
278+
for (int i = 0; i < numPartitions; i++) {
279+
append.appendFile(dataFileForPartition(i));
280+
}
281+
append.commit();
282+
table.updateProperties().set(TableProperties.MANIFEST_TARGET_SIZE_BYTES, "1").commit();
283+
284+
RewriteManifests rewriteManifests = Mockito.mock(RewriteManifests.class);
285+
when(rewriteManifests.clusterBy(any())).thenReturn(rewriteManifests);
286+
doNothing().when(rewriteManifests).commit();
287+
Transaction txn = Mockito.mock(Transaction.class);
288+
when(txn.rewriteManifests()).thenReturn(rewriteManifests);
289+
290+
IcebergTableProcedureContext context = createContext(table, txn);
291+
RewriteManifestsProcedure procedure = RewriteManifestsProcedure.getInstance();
292+
assertDoesNotThrow(() -> procedure.execute(context, Collections.emptyMap()));
293+
294+
@SuppressWarnings("unchecked")
295+
ArgumentCaptor<Function<DataFile, Object>> captor = ArgumentCaptor.forClass(Function.class);
296+
verify(rewriteManifests).clusterBy(captor.capture());
297+
verify(rewriteManifests).commit();
298+
return captor.getValue();
299+
}
300+
301+
private static DataFile dataFileForPartition(int k2) {
302+
return DataFiles.builder(SPEC)
303+
.withPath("/path/to/data-k2-" + k2 + ".parquet")
304+
.withFileSizeInBytes(10)
305+
.withPartitionPath("k2=" + k2)
306+
.withRecordCount(1)
307+
.build();
308+
}
309+
193310
private IcebergTableProcedureContext createContext(Table table, Transaction transaction) {
194311
IcebergHiveCatalog catalog = Mockito.mock(IcebergHiveCatalog.class);
195312
ConnectContext ctx = Mockito.mock(ConnectContext.class);

0 commit comments

Comments
 (0)