diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java index 919100ec1f8f..de0f8fd5eef7 100644 --- a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java +++ b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionSplitRead.java @@ -278,7 +278,7 @@ private RecordReader createReader(IndexedSplit indexedSplit) throws createReader(dataSplit, rowRanges, info.actualReadType), info); } - private DataEvolutionFileReader createUnionReader( + private RecordReader createUnionReader( List needMergeFiles, BinaryRow partition, DataFilePathFactory dataFilePathFactory, @@ -286,6 +286,54 @@ private DataEvolutionFileReader createUnionReader( RowType readRowType, @Nullable DeletionVectorWithRange deletionVector) throws IOException { + List vectorRanges = + DataEvolutionVectorReadPlanner.plan( + needMergeFiles, + readRowType, + file -> + schemaFetcher + .apply(file.schemaId()) + .dataFileSchema(file.writeCols()) + .logicalRowType()); + if (vectorRanges != null) { + List nonVectorBunches = + splitFieldBunches( + needMergeFiles.stream() + .filter(file -> !isVectorStoreFile(file.fileName())) + .collect(Collectors.toList()), + file -> schemaFetcher.apply(file.schemaId()).logicalRowType(), + rowRanges != null); + List selectedRanges = Range.sortAndMergeOverlap(rowRanges, true); + List> suppliers = new ArrayList<>(); + for (DataEvolutionVectorReadPlanner.ReadRange vectorRange : vectorRanges) { + List ranges = Collections.singletonList(vectorRange.range); + if (rowRanges != null) { + ranges = Range.and(ranges, selectedRanges); + } + if (ranges.isEmpty()) { + continue; + } + // Union readers have fixed field offsets. Apply the same selection to every + // column reader so their rows remain aligned when a vector provider changes. + List readRanges = ranges; + List bunches = new ArrayList<>(nonVectorBunches); + // Providers are newest-first within this range. Keep fields from the same + // physical file together so the column planner opens that file only once. + vectorRange.files.forEach(file -> bunches.add(new DataBunch(file))); + suppliers.add( + () -> + createUnionReader( + bunches, + needMergeFiles, + partition, + dataFilePathFactory, + readRanges, + readRowType, + deletionVector)); + } + return ConcatRecordReader.create(suppliers); + } + List fieldsFiles = splitFieldBunches( needMergeFiles, @@ -298,6 +346,26 @@ private DataEvolutionFileReader createUnionReader( }, rowRanges != null); + return createUnionReader( + fieldsFiles, + needMergeFiles, + partition, + dataFilePathFactory, + rowRanges, + readRowType, + deletionVector); + } + + private RecordReader createUnionReader( + List fieldsFiles, + List needMergeFiles, + BinaryRow partition, + DataFilePathFactory dataFilePathFactory, + List rowRanges, + RowType readRowType, + @Nullable DeletionVectorWithRange deletionVector) + throws IOException { + long rowCount = fieldsFiles.get(0).rowCount(); long firstRowId = bunchFirstRowId(fieldsFiles.get(0)); @@ -331,6 +399,21 @@ private DataEvolutionFileReader createUnionReader( DataEvolutionReadPlanner.DataEvolutionReadPlan plan = new DataEvolutionReadPlanner(readRowType, bunchAvailTypes, nestedFieldEnabled) .plan(); + if (plan.bunchReadFields.stream().allMatch(List::isEmpty)) { + // For example, a newly added vector column may cover only part of the normal file's + // row range. Projecting only that column leaves no fields to merge in uncovered ranges, + // but the selected rows must still be emitted. + // Read one bunch and let schema evolution fill the missing fields with NULL. + return createMissingFieldsReader( + partition, + fieldsFiles.get(0), + bunchDataSchemas[0], + dataFilePathFactory, + formatBuilder, + rowRanges, + readRowType, + deletionVector); + } // Build the per-bunch readers from the planned partial read row types. for (int i = 0; i < numBunches; i++) { @@ -379,6 +462,35 @@ private DataEvolutionFileReader createUnionReader( plan.rowOffsets, plan.fieldOffsets, fileRecordReaders); } + private RecordReader createMissingFieldsReader( + BinaryRow partition, + FieldBunch bunch, + TableSchema dataSchema, + DataFilePathFactory dataFilePathFactory, + Builder formatBuilder, + List rowRanges, + RowType readRowType, + @Nullable DeletionVectorWithRange deletionVector) + throws IOException { + DataFileMeta firstFile = bunch.files().get(0); + // Use the physical schema: the full table schema may declare columns this file never wrote. + FormatReaderMapping mapping = + formatBuilder.build( + readTarget(firstFile, dataFilePathFactory, rowRanges).formatIdentifier, + schema, + dataSchema, + readRowType.getFields(), + false); + return createFieldBunchReader( + partition, + bunch, + dataFilePathFactory, + mapping, + rowRanges, + readRowType, + deletionVector); + } + private boolean nestedFieldEnabledFor(List files) { if (coreOptions.dataEvolutionNestedFieldEnabled()) { return true; diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionVectorReadPlanner.java b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionVectorReadPlanner.java new file mode 100644 index 000000000000..1b77489cbf7c --- /dev/null +++ b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionVectorReadPlanner.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.paimon.operation; + +import org.apache.paimon.io.DataFileMeta; +import org.apache.paimon.io.DataFilePathFactory; +import org.apache.paimon.operation.DataEvolutionSplitRead.VectorStoreBunchKey; +import org.apache.paimon.types.DataField; +import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.Range; + +import javax.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.apache.paimon.format.blob.BlobFileFormat.isBlobFile; +import static org.apache.paimon.types.VectorType.isVectorStoreFile; + +/** Plans ranges with fixed vector field providers before positional column merging. */ +class DataEvolutionVectorReadPlanner { + + /** Returns null when the existing sequential column-group readers suffice. */ + @Nullable + static List plan( + List files, + RowType readType, + Function fileToRowType) { + Set readIds = + readType.getFields().stream().map(DataField::id).collect(Collectors.toSet()); + boolean vectorOnly = files.stream().allMatch(file -> isVectorStoreFile(file.fileName())); + Map fieldGroups = new HashMap<>(); + Set vectorReadIds = new HashSet<>(); + List candidates = new ArrayList<>(); + boolean overlappingGroups = false; + Range logicalRange = null; + long firstRowId = Long.MAX_VALUE; + long lastRowId = Long.MIN_VALUE; + for (DataFileMeta file : files) { + Range range = file.nonNullRowIdRange(); + firstRowId = Math.min(firstRowId, range.from); + lastRowId = Math.max(lastRowId, range.to); + if (!isVectorStoreFile(file.fileName())) { + if (!isBlobFile(file.fileName())) { + logicalRange = range; + } + continue; + } + RowType rowType = fileToRowType.apply(file); + VectorStoreBunchKey key = + new VectorStoreBunchKey( + file.schemaId(), + DataFilePathFactory.formatIdentifier(file.fileName()), + file.writeCols(), + rowType); + Set fieldIds = new HashSet<>(); + for (DataField field : rowType.getFields()) { + // Match historical fields by id: a rename must not create a different provider. + VectorStoreBunchKey previous = fieldGroups.putIfAbsent(field.id(), key); + overlappingGroups |= previous != null && !previous.equals(key); + if (readIds.contains(field.id())) { + fieldIds.add(field.id()); + vectorReadIds.add(field.id()); + } + } + if (!fieldIds.isEmpty() || vectorOnly) { + candidates.add(new Candidate(file, fieldIds)); + } + } + + // Keep the sequential path for disjoint column groups, including rolled vector files. + if (!overlappingGroups) { + return null; + } + if (logicalRange == null) { + logicalRange = new Range(firstRowId, lastRowId); + } + + // Resolve the original files before VectorFileBunch can discard older overlapping files. + // A bunch may contain different sequences in adjacent ranges, so sorting whole bunches + // by their maximum sequence cannot establish the latest provider for every row. + TreeMap> boundaries = new TreeMap<>(); + boundaries.put(logicalRange.from, new ArrayList<>()); + boundaries.put(logicalRange.to + 1, new ArrayList<>()); + // Candidate ranges lie within the normal anchor, or the enclosing range computed above. + for (Candidate candidate : candidates) { + Range range = candidate.file.nonNullRowIdRange(); + boundaries.computeIfAbsent(range.from, ignored -> new ArrayList<>()).add(candidate); + boundaries.computeIfAbsent(range.to + 1, ignored -> new ArrayList<>()).add(candidate); + } + + TreeSet active = + new TreeSet<>( + Comparator.comparingLong(c -> c.file.maxSequenceNumber()) + .reversed() + .thenComparing(c -> c.file.fileName())); + List result = new ArrayList<>(); + long start = logicalRange.from; + for (Map.Entry> boundary : boundaries.entrySet()) { + long end = boundary.getKey(); + if (start < end) { + Set assigned = new HashSet<>(); + List providers = new ArrayList<>(); + for (Candidate candidate : active) { + // Presence in writeCols is what matters. An explicit NULL in the newest + // file must overwrite the old value, just like any other partial update. + if (assigned.addAll(candidate.fieldIds)) { + providers.add(candidate.file); + } + if (assigned.size() == vectorReadIds.size()) { + break; + } + } + if (providers.isEmpty() && vectorOnly && !active.isEmpty()) { + // Column pruning can remove the normal anchor. Retain a row-count provider + // for ranges where the projected vector has not been populated yet. + providers.add(active.first().file); + } + ReadRange previous = result.isEmpty() ? null : result.get(result.size() - 1); + if (previous != null && previous.files.equals(providers)) { + previous.range = new Range(previous.range.from, end - 1); + } else { + result.add(new ReadRange(new Range(start, end - 1), providers)); + } + } + // Each file enters at its first row id and leaves just after its last row id. + for (Candidate candidate : boundary.getValue()) { + if (!active.remove(candidate)) { + active.add(candidate); + } + } + start = end; + } + return result; + } + + static class ReadRange { + + Range range; + final List files; + + private ReadRange(Range range, List files) { + this.range = range; + this.files = files; + } + } + + private static class Candidate { + + final DataFileMeta file; + final Set fieldIds; + + private Candidate(DataFileMeta file, Set fieldIds) { + this.file = file; + this.fieldIds = fieldIds; + } + } +} diff --git a/paimon-core/src/test/java/org/apache/paimon/append/VectorStoreTableTest.java b/paimon-core/src/test/java/org/apache/paimon/append/VectorStoreTableTest.java index c27f34d97613..63d6618197a9 100644 --- a/paimon-core/src/test/java/org/apache/paimon/append/VectorStoreTableTest.java +++ b/paimon-core/src/test/java/org/apache/paimon/append/VectorStoreTableTest.java @@ -19,27 +19,51 @@ package org.apache.paimon.append; import org.apache.paimon.CoreOptions; +import org.apache.paimon.Snapshot; +import org.apache.paimon.append.dataevolution.DataEvolutionCompactCoordinator; +import org.apache.paimon.append.dataevolution.DataEvolutionCompactTask; +import org.apache.paimon.append.dataevolution.DataEvolutionCompactionCommitPreparation; import org.apache.paimon.data.BinaryString; import org.apache.paimon.data.BinaryVector; import org.apache.paimon.data.BlobData; import org.apache.paimon.data.GenericRow; import org.apache.paimon.data.InternalRow; +import org.apache.paimon.deletionvectors.BitmapDeletionVector; +import org.apache.paimon.deletionvectors.DeletionVector; +import org.apache.paimon.deletionvectors.DeletionVectorsIndexFile; +import org.apache.paimon.globalindex.IndexedSplit; import org.apache.paimon.io.DataFileMeta; import org.apache.paimon.manifest.ManifestEntry; import org.apache.paimon.operation.DataEvolutionSplitRead; +import org.apache.paimon.reader.RecordReader; import org.apache.paimon.schema.Schema; +import org.apache.paimon.schema.SchemaChange; +import org.apache.paimon.table.DataEvolutionTestBase; +import org.apache.paimon.table.FileStoreTable; import org.apache.paimon.table.Table; -import org.apache.paimon.table.TableTestBase; +import org.apache.paimon.table.sink.BatchTableCommit; +import org.apache.paimon.table.sink.BatchTableWrite; +import org.apache.paimon.table.sink.BatchWriteBuilder; +import org.apache.paimon.table.sink.BatchWriteBuilderImpl; import org.apache.paimon.table.sink.CommitMessage; import org.apache.paimon.table.sink.StreamTableWrite; import org.apache.paimon.table.sink.StreamWriteBuilder; +import org.apache.paimon.table.source.DataSplit; +import org.apache.paimon.table.source.DeletionFile; +import org.apache.paimon.table.source.ReadBuilder; +import org.apache.paimon.table.source.Split; import org.apache.paimon.types.DataField; import org.apache.paimon.types.DataTypes; import org.apache.paimon.types.RowType; +import org.apache.paimon.utils.Range; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -48,16 +72,289 @@ import java.util.function.Function; import java.util.stream.Collectors; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.apache.paimon.utils.DataEvolutionUtils.retrieveAnchorFile; +import static org.assertj.core.api.Assertions.assertThat; /** Tests for table with vector-store and data evolution. */ -public class VectorStoreTableTest extends TableTestBase { +public class VectorStoreTableTest extends DataEvolutionTestBase { private static final int VECTOR_DIM = 12; private final AtomicInteger uniqueIdGen = new AtomicInteger(0); private final Map rowsWritten = new HashMap<>(); + @Test + public void testPartialUpdateOfSharedVectorFile() throws Exception { + Schema schema = + vectorSchema("json") + .column("embedding_v2", DataTypes.VECTOR(2, DataTypes.FLOAT())) + .build(); + catalog.createTable(identifier(), schema, false); + BinaryVector original = vector(1); + BinaryVector updated = vector(2); + write( + getTableDefault(), + GenericRow.of(0, original, null), + GenericRow.of(1, original, original)); + + List vectorFiles = + getTableDefault().store().newScan().plan().files().stream() + .map(ManifestEntry::file) + .filter(file -> file.fileName().contains(".vector.")) + .collect(Collectors.toList()); + assertThat(vectorFiles.size()).isEqualTo(1); + assertThat(vectorFiles.get(0).writeCols()) + .isEqualTo(Arrays.asList("embedding", "embedding_v2")); + + updateVectors( + 0, + Collections.singletonList("embedding_v2"), + GenericRow.of(updated), + GenericRow.of((Object) null)); + + List actual = read(getTableDefault()); + assertThat(actual).extracting(row -> row.getInt(0)).containsExactly(0, 1); + actual.forEach( + row -> + assertThat(row.getVector(1).toFloatArray()) + .isEqualTo(original.toFloatArray())); + assertThat(actual.get(0).getVector(2).toFloatArray()).isEqualTo(updated.toFloatArray()); + assertThat(actual.get(1).isNullAt(2)).isTrue(); + + List projected = read(getTableDefault(), new int[] {2}); + assertThat(projected.size()).isEqualTo(2); + assertThat(projected.get(0).getVector(0).toFloatArray()).isEqualTo(updated.toFloatArray()); + assertThat(projected.get(1).isNullAt(0)).isTrue(); + + Table historical = + getTableDefault() + .copy(Collections.singletonMap(CoreOptions.SCAN_SNAPSHOT_ID.key(), "1")); + List beforeUpdate = read(historical); + assertThat(beforeUpdate.size()).isEqualTo(2); + assertThat(beforeUpdate.get(0).isNullAt(2)).isTrue(); + assertThat(beforeUpdate.get(1).getVector(2).toFloatArray()) + .isEqualTo(original.toFloatArray()); + } + + @ParameterizedTest + @ValueSource(strings = {"json", "parquet"}) + public void testPartialUpdateAcrossCompactedRanges(String format) throws Exception { + catalog.createTable( + identifier(), + vectorSchema(format) + .column("embedding_v2", DataTypes.VECTOR(2, DataTypes.FLOAT())) + .build(), + false); + write( + getTableDefault(), + GenericRow.of(0, vector(1), vector(10)), + GenericRow.of(1, vector(1), vector(10))); + write( + getTableDefault(), + GenericRow.of(2, vector(1), vector(10)), + GenericRow.of(3, vector(1), vector(10))); + updateVectors( + 2, + Collections.singletonList("embedding_v2"), + GenericRow.of(vector(100)), + GenericRow.of(vector(100))); + updateVectors( + 2, + Arrays.asList("embedding", "embedding_v2"), + GenericRow.of(vector(2), vector(200)), + GenericRow.of(vector(2), vector(200))); + updateVectors( + 0, + Collections.singletonList("embedding_v2"), + GenericRow.of(vector(300)), + GenericRow.of(vector(300))); + compactVectorTable(); + catalog.alterTable( + identifier(), + Collections.singletonList( + SchemaChange.renameColumn("embedding_v2", "query_embedding")), + false); + + // The single-column file wins for rows 0-1, but the shared file wins for rows 2-3. + // Sorting whole column groups by their maximum sequence would return 100 for rows 2-3. + List rows = read(getTableDefault()); + assertThat(rows).extracting(row -> row.getInt(0)).containsExactly(0, 1, 2, 3); + assertThat(rows) + .extracting(row -> row.getVector(1).toFloatArray()[0]) + .containsExactly(1F, 1F, 2F, 2F); + assertThat(rows) + .extracting(row -> row.getVector(2).toFloatArray()[0]) + .containsExactly(300F, 300F, 200F, 200F); + assertThat(read(getTableDefault(), new int[] {2})) + .extracting(row -> row.getVector(0).toFloatArray()[0]) + .containsExactly(300F, 300F, 200F, 200F); + assertThat(read(getTableDefault(), new int[] {1})) + .extracting(row -> row.getVector(0).toFloatArray()[0]) + .containsExactly(1F, 1F, 2F, 2F); + + ReadBuilder builder = + getTableDefault() + .newReadBuilder() + .withProjection(new int[] {2}) + .withRowRanges(Arrays.asList(new Range(1, 1), new Range(2, 2))); + List selected = new ArrayList<>(); + try (RecordReader reader = + builder.newRead().createReader(builder.newScan().plan())) { + reader.forEachRemaining(row -> selected.add(row.getVector(0).toFloatArray()[0])); + } + assertThat(selected).containsExactly(300F, 200F); + } + + @Test + public void testPartialUpdateWithMissingVectorRange() throws Exception { + catalog.createTable( + identifier(), + vectorSchema("json") + // Keep the normal anchor when pruning columns to preserve missing rows. + .option(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true") + .build(), + false); + write(getTableDefault(), GenericRow.of(0, vector(1)), GenericRow.of(1, vector(1))); + catalog.alterTable( + identifier(), + Collections.singletonList( + SchemaChange.addColumn( + "embedding_v2", DataTypes.VECTOR(2, DataTypes.FLOAT()))), + false); + write( + getTableDefault(), + GenericRow.of(2, vector(1), vector(10)), + GenericRow.of(3, vector(1), vector(10))); + updateVectors( + 2, + Collections.singletonList("embedding_v2"), + GenericRow.of(vector(200)), + GenericRow.of(vector(200))); + compactVectorTable(); + + ReadBuilder builder = getTableDefault().newReadBuilder().withProjection(new int[] {2}); + List splits = builder.newScan().plan().splits(); + assertThat(splits).hasSize(1); + DataSplit split = (DataSplit) splits.get(0); + assertThat(readVectorValues(builder.newRead().createReader(split))) + .containsExactly(null, null, 200F, 200F); + assertThat( + readVectorValues( + builder.newRead() + .createReader( + new IndexedSplit( + split, + Arrays.asList( + new Range(1, 1), new Range(3, 3)), + null)))) + .containsExactly(null, 200F); + + // Delete a row from both the missing and populated vector ranges. + DeletionVector deletionVector = new BitmapDeletionVector(); + deletionVector.delete(0); + deletionVector.delete(2); + DeletionVectorsIndexFile indexFile = + getTableDefault() + .store() + .newIndexFileHandler() + .dvIndex(split.partition(), split.bucket()); + String anchor = retrieveAnchorFile(split.dataFiles(), Function.identity()).fileName(); + Map deletionFiles = + indexFile.toDeletionFiles( + Collections.singletonList( + indexFile.writeSingleFile( + Collections.singletonMap(anchor, deletionVector)))); + DataSplit deletedSplit = + DataSplit.builder() + .withPartition(split.partition()) + .withBucket(split.bucket()) + .withBucketPath(split.bucketPath()) + .withDataFiles(split.dataFiles()) + .withDataDeletionFiles( + split.dataFiles().stream() + .map(file -> deletionFiles.get(file.fileName())) + .collect(Collectors.toList())) + .build(); + assertThat(readVectorValues(builder.newRead().createReader(deletedSplit))) + .containsExactly(null, 200F); + assertThat( + readVectorValues( + builder.newRead() + .createReader( + new IndexedSplit( + deletedSplit, + Collections.singletonList(new Range(0, 2)), + null)))) + .containsExactly((Float) null); + } + + private List readVectorValues(RecordReader reader) throws IOException { + List actual = new ArrayList<>(); + try (RecordReader closeable = reader) { + closeable.forEachRemaining( + row -> { + // Fail promptly if an all-missing projection produces unbounded NULL rows. + assertThat(actual.size()).isLessThan(4); + actual.add(row.isNullAt(0) ? null : row.getVector(0).toFloatArray()[0]); + }); + } + return actual; + } + + private Schema.Builder vectorSchema(String format) { + return Schema.newBuilder() + .column("id", DataTypes.INT()) + .column("embedding", DataTypes.VECTOR(2, DataTypes.FLOAT())) + .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true") + .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(), "true") + .option(CoreOptions.VECTOR_FILE_FORMAT.key(), format) + .option(CoreOptions.FILE_COMPRESSION.key(), "none") + .option(CoreOptions.COMPACTION_MIN_FILE_NUM.key(), "2"); + } + + private static BinaryVector vector(float value) { + return BinaryVector.fromPrimitiveArray(new float[] {value, 0}); + } + + private void updateVectors(long firstRowId, List columns, InternalRow... rows) + throws Exception { + FileStoreTable table = getTableDefault(); + BatchWriteBuilder builder = table.newBatchWriteBuilder(); + ((BatchWriteBuilderImpl) builder).rowIdCheckConflict(table.latestSnapshot().get().id()); + try (BatchTableWrite writer = + builder.newWrite().withWriteType(table.rowType().project(columns)); + BatchTableCommit commit = builder.newCommit()) { + for (InternalRow row : rows) { + writer.write(row); + } + List messages = writer.prepareCommit(); + setFirstRowId(messages, firstRowId); + commit.commit(messages); + } + } + + private void compactVectorTable() throws Exception { + FileStoreTable table = getTableDefault(); + Snapshot snapshot = table.latestSnapshot().get(); + List messages = new ArrayList<>(); + for (DataEvolutionCompactTask task : + new DataEvolutionCompactCoordinator(table, false, false, snapshot).plan()) { + messages.add(task.doCompact(table, commitUser)); + } + messages.addAll( + new DataEvolutionCompactionCommitPreparation(table, snapshot).prepare(messages)); + try (BatchTableCommit commit = table.newBatchWriteBuilder().newCommit()) { + commit.commit(messages); + } + List normalFiles = + getTableDefault().store().newScan().plan().files().stream() + .map(ManifestEntry::file) + .filter(file -> !file.fileName().contains(".vector.")) + .collect(Collectors.toList()); + assertThat(normalFiles).hasSize(1); + assertThat(normalFiles.get(0).nonNullRowIdRange()).isEqualTo(new Range(0, 3)); + } + @Test public void testBasic() throws Exception { int rowNum = RANDOM.nextInt(64) + 1;