1717import com .starrocks .connector .HdfsEnvironment ;
1818import com .starrocks .connector .exception .StarRocksConnectorException ;
1919import com .starrocks .connector .iceberg .IcebergTableOperation ;
20+ import com .starrocks .connector .iceberg .TestTables ;
2021import com .starrocks .connector .iceberg .hive .IcebergHiveCatalog ;
2122import com .starrocks .qe .ConnectContext ;
2223import com .starrocks .sql .ast .AlterTableOperationClause ;
2324import com .starrocks .sql .ast .AlterTableStmt ;
2425import 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 ;
2529import org .apache .iceberg .ManifestFile ;
2630import org .apache .iceberg .PartitionSpec ;
2731import org .apache .iceberg .RewriteManifests ;
32+ import org .apache .iceberg .Schema ;
2833import org .apache .iceberg .Snapshot ;
2934import org .apache .iceberg .Table ;
35+ import org .apache .iceberg .TableProperties ;
3036import org .apache .iceberg .Transaction ;
37+ import org .apache .iceberg .types .Types ;
38+ import org .junit .jupiter .api .AfterEach ;
3139import org .junit .jupiter .api .Test ;
40+ import org .junit .jupiter .api .io .TempDir ;
41+ import org .mockito .ArgumentCaptor ;
3242import org .mockito .Mockito ;
3343
44+ import java .io .File ;
45+ import java .util .Arrays ;
3446import java .util .Collections ;
3547import java .util .HashMap ;
3648import java .util .List ;
3749import java .util .Map ;
50+ import java .util .function .Function ;
3851
52+ import static org .apache .iceberg .types .Types .NestedField .required ;
3953import static org .junit .jupiter .api .Assertions .assertDoesNotThrow ;
4054import static org .junit .jupiter .api .Assertions .assertEquals ;
4155import 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