This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 88d349219b [core][python] Support adding video columns to existing
tables (#10506)
88d349219b is described below
commit 88d349219be527e9d6127f75bbf8508947b9220b
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Oct 12 10:18:41 2026 +0800
[core][python] Support adding video columns to existing tables (#10506)
---
docs/docs/pypaimon/video.md | 17 +++
.../apache/paimon/schema/ColumnDirectiveUtils.java | 29 ++--
.../paimon/table/AbstractFileStoreTable.java | 5 +-
.../org/apache/paimon/append/BlobTableTest.java | 157 +++++++++++++++++++++
.../DataEvolutionRowIdReassignerTest.java | 118 ++++++++++++++++
.../paimon/schema/ColumnDirectiveUtilsTest.java | 60 ++++++++
paimon-python/pypaimon/read/split_read.py | 6 +-
.../pypaimon/schema/column_directive_utils.py | 12 +-
paimon-python/pypaimon/table/file_store_table.py | 6 +-
.../pypaimon/tests/column_directive_utils_test.py | 36 +++++
.../pypaimon/tests/multimodal_table_test.py | 107 ++++++++++++++
.../pypaimon/tests/native_merge_into_test.py | 11 +-
.../table/time_travel_schema_evolution_test.py | 37 +++++
.../pypaimon/tests/video_column_reassign_test.py | 140 ++++++++++++++++++
paimon-python/pypaimon/write/file_store_commit.py | 2 +-
15 files changed, 723 insertions(+), 20 deletions(-)
diff --git a/docs/docs/pypaimon/video.md b/docs/docs/pypaimon/video.md
index 2c2b9961f3..01e3521e80 100644
--- a/docs/docs/pypaimon/video.md
+++ b/docs/docs/pypaimon/video.md
@@ -123,6 +123,23 @@ file. Its video grouping policy coordinates normal, BLOB,
and vector rolling
at payload boundaries. A file may exceed its target before the next boundary.
The normal `.blob` format is unchanged.
+### Add a Video Column
+
+Use `__VIDEO_FRAME_FIELD` to add and automatically register a nullable video
+column. Existing rows read NULL in the new column:
+
+```python
+from pypaimon.schema.data_types import AtomicType
+from pypaimon.schema.schema_change import SchemaChange
+
+conn.catalog.alter_table("default.images", [
+ SchemaChange.add_column("image_video", AtomicType("BYTES"),
"__VIDEO_FRAME_FIELD"),
+])
+images = conn.get_table("images")
+```
+
+Existing video fields are preserved automatically.
+
### Update frame rows and replace a video
Frame rows use the ordinary table update API for non-video columns. This writes
diff --git
a/paimon-core/src/main/java/org/apache/paimon/schema/ColumnDirectiveUtils.java
b/paimon-core/src/main/java/org/apache/paimon/schema/ColumnDirectiveUtils.java
index 6d9c77d2f0..6cf4c1e7f2 100644
---
a/paimon-core/src/main/java/org/apache/paimon/schema/ColumnDirectiveUtils.java
+++
b/paimon-core/src/main/java/org/apache/paimon/schema/ColumnDirectiveUtils.java
@@ -48,6 +48,7 @@ public final class ColumnDirectiveUtils {
public static final String BLOB_FIELD_DIRECTIVE = "__BLOB_FIELD";
public static final String BLOB_DESCRIPTOR_FIELD_DIRECTIVE =
"__BLOB_DESCRIPTOR_FIELD";
public static final String BLOB_VIEW_FIELD_DIRECTIVE = "__BLOB_VIEW_FIELD";
+ public static final String VIDEO_FRAME_FIELD_DIRECTIVE =
"__VIDEO_FRAME_FIELD";
public static final String VECTOR_FIELD_DIRECTIVE = "__VECTOR_FIELD";
private ColumnDirectiveUtils() {}
@@ -56,7 +57,8 @@ public final class ColumnDirectiveUtils {
* Parses the comment of an {@code ALTER TABLE ADD COLUMN} statement.
Returns {@code null} when
* the comment is a regular user comment; returns a {@link
ParsedDirective} when the comment
* begins with a supported directive. Throws {@link
IllegalArgumentException} when the comment
- * begins with {@code __BLOB} or {@code __VECTOR} but is not one of the
supported directives.
+ * begins with {@code __BLOB}, {@code __VIDEO} or {@code __VECTOR} but is
not one of the
+ * supported directives.
*/
@Nullable
public static ParsedDirective parseAddColumnComment(@Nullable String
comment) {
@@ -67,11 +69,15 @@ public final class ColumnDirectiveUtils {
if (comment.startsWith("__VECTOR")) {
return parseVectorDirective(comment);
}
- if (!comment.startsWith("__BLOB")) {
+ if (!comment.startsWith("__BLOB") && !comment.startsWith("__VIDEO")) {
return null;
}
- String optionKey = matchDirective(comment, BLOB_VIEW_FIELD_DIRECTIVE);
- String marker = BLOB_VIEW_FIELD_DIRECTIVE;
+ String optionKey = matchDirective(comment,
VIDEO_FRAME_FIELD_DIRECTIVE);
+ String marker = VIDEO_FRAME_FIELD_DIRECTIVE;
+ if (optionKey == null) {
+ optionKey = matchDirective(comment, BLOB_VIEW_FIELD_DIRECTIVE);
+ marker = BLOB_VIEW_FIELD_DIRECTIVE;
+ }
if (optionKey == null) {
optionKey = matchDirective(comment,
BLOB_DESCRIPTOR_FIELD_DIRECTIVE);
marker = BLOB_DESCRIPTOR_FIELD_DIRECTIVE;
@@ -82,12 +88,14 @@ public final class ColumnDirectiveUtils {
}
Preconditions.checkArgument(
optionKey != null,
- "Unsupported BLOB directive in column comment: '%s'. Supported
directives are "
- + "'%s', '%s' and '%s'.",
+ "Unsupported %s directive in column comment: '%s'. Supported
directives are "
+ + "'%s', '%s', '%s' and '%s'.",
+ comment.startsWith("__VIDEO") ? "VIDEO" : "BLOB",
comment,
BLOB_FIELD_DIRECTIVE,
BLOB_DESCRIPTOR_FIELD_DIRECTIVE,
- BLOB_VIEW_FIELD_DIRECTIVE);
+ BLOB_VIEW_FIELD_DIRECTIVE,
+ VIDEO_FRAME_FIELD_DIRECTIVE);
String realComment =
comment.length() == marker.length()
? null
@@ -156,6 +164,8 @@ public final class ColumnDirectiveUtils {
return CoreOptions.BLOB_DESCRIPTOR_FIELD.key();
} else if (BLOB_VIEW_FIELD_DIRECTIVE.equals(marker)) {
return CoreOptions.BLOB_VIEW_FIELD.key();
+ } else if (VIDEO_FRAME_FIELD_DIRECTIVE.equals(marker)) {
+ return CoreOptions.VIDEO_FRAME_FIELD.key();
} else if (VECTOR_FIELD_DIRECTIVE.equals(marker)) {
return CoreOptions.VECTOR_FIELD.key();
} else {
@@ -298,6 +308,8 @@ public final class ColumnDirectiveUtils {
option = CoreOptions.BLOB_DESCRIPTOR_FIELD;
} else if (CoreOptions.BLOB_VIEW_FIELD.key().equals(optionKey)) {
option = CoreOptions.BLOB_VIEW_FIELD;
+ } else if (CoreOptions.VIDEO_FRAME_FIELD.key().equals(optionKey)) {
+ option = CoreOptions.VIDEO_FRAME_FIELD;
} else if (CoreOptions.VECTOR_FIELD.key().equals(optionKey)) {
option = CoreOptions.VECTOR_FIELD;
} else {
@@ -333,7 +345,8 @@ public final class ColumnDirectiveUtils {
new ConfigOption[] {
CoreOptions.BLOB_FIELD,
CoreOptions.BLOB_DESCRIPTOR_FIELD,
- CoreOptions.BLOB_VIEW_FIELD
+ CoreOptions.BLOB_VIEW_FIELD,
+ CoreOptions.VIDEO_FRAME_FIELD
};
private static final ConfigOption<String>[] VECTOR_OPTIONS =
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
b/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
index 5a04aeb116..cb4349a59e 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
@@ -584,7 +584,7 @@ abstract class AbstractFileStoreTable implements
FileStoreTable {
/** Prevents current column declarations from overriding a historical
schema's field options. */
private static Map<String, String> excludeCurrentSchemaFieldOptions(
TableSchema historicalSchema, Options options, Set<String>
dynamicOptionKeys) {
- // Keep scan and runtime options. Only these directive-managed column
declarations
+ // Keep scan and runtime options. These column storage declarations
// must follow the historical schema, since columns may have been
added or dropped.
Map<String, String> historicalOptions = new HashMap<>(options.toMap());
for (ConfigOption<String> option :
@@ -592,7 +592,8 @@ abstract class AbstractFileStoreTable implements
FileStoreTable {
CoreOptions.VECTOR_FIELD,
CoreOptions.BLOB_FIELD,
CoreOptions.BLOB_DESCRIPTOR_FIELD,
- CoreOptions.BLOB_VIEW_FIELD)) {
+ CoreOptions.BLOB_VIEW_FIELD,
+ CoreOptions.VIDEO_FRAME_FIELD)) {
// Restore the canonical key and aliases together, or a stale
alias may take effect
// when the historical schema has no canonical value.
List<String> keys = new ArrayList<>();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/append/BlobTableTest.java
b/paimon-core/src/test/java/org/apache/paimon/append/BlobTableTest.java
index 4660b0f998..f67f3fc582 100644
--- a/paimon-core/src/test/java/org/apache/paimon/append/BlobTableTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/append/BlobTableTest.java
@@ -1529,6 +1529,163 @@ public class BlobTableTest extends TableTestBase {
assertThat(tasks2.stream().anyMatch(task -> task.type() ==
BLOB)).isFalse();
}
+ @Test
+ public void testAddVideoColumnToExistingImages() throws Exception {
+ catalog.createTable(
+ identifier(),
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("image", DataTypes.BLOB())
+ .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+ .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
+ .build(),
+ false);
+ FileStoreTable oldTable = getTableDefault();
+ byte[] image = "original-image".getBytes();
+ writeRows(oldTable, Collections.singletonList(GenericRow.of(0, new
BlobData(image))));
+ long oldSnapshot = oldTable.latestSnapshot().get().id();
+ catalog.alterTable(
+ identifier(),
+ SchemaChange.addColumn("video", DataTypes.BYTES(),
"__VIDEO_FRAME_FIELD"),
+ false);
+ FileStoreTable table = getTableDefault();
+ List<InternalRow> rows = read(table);
+ assertThat(rows.size()).isEqualTo(1);
+ assertThat(rows.get(0).getBlob(1).toData()).isEqualTo(image);
+ assertThat(rows.get(0).isNullAt(2)).isTrue();
+
+ // An already running writer can still append rows using the old
schema.
+ writeRows(oldTable, Collections.singletonList(GenericRow.of(1, new
BlobData(image))));
+ byte[] payload = "complete-video".getBytes();
+ java.nio.file.Path source = tempPath.resolve("evolved-video.mp4");
+ java.nio.file.Files.write(source, payload);
+ writeRows(
+ table,
+ Collections.singletonList(
+ GenericRow.of(
+ 2,
+ new BlobData(image),
+ Blob.fromDescriptor(
+
UriReader.fromFile(LocalFileIO.create()),
+ new VideoFrameDescriptor(
+ new
Path(source.toUri()).toString(),
+ 0,
+ payload.length,
+ 7,
+ -1,
+ 0)))));
+ rows = read(table,
org.apache.paimon.utils.Pair.of(CoreOptions.BLOB_AS_DESCRIPTOR, "true"));
+ rows.sort((a, b) -> Integer.compare(a.getInt(0), b.getInt(0)));
+ assertThat(rows.size()).isEqualTo(3);
+ assertThat(rows.get(0).isNullAt(2)).isTrue();
+ assertThat(rows.get(1).isNullAt(2)).isTrue();
+ assertThat(rows.get(2).getBlob(2).toData()).isEqualTo(payload);
+ assertThat(((VideoFrameDescriptor)
rows.get(2).getBlob(2).toDescriptor()).frameIndex())
+ .isEqualTo(7);
+ assertThat(liveVideoFiles(table).size()).isEqualTo(1);
+ assertThat(
+ read(table.copy(
+ Collections.singletonMap(
+ "scan.snapshot-id", "" +
oldSnapshot)))
+ .size())
+ .isEqualTo(1);
+
+ // Adding another video column automatically preserves the existing
video field.
+ catalog.alterTable(
+ identifier(),
+ SchemaChange.addColumn("second", DataTypes.BLOB(),
"__VIDEO_FRAME_FIELD"),
+ false);
+
assertThat(getTableDefault().schema().options().get("video-frame-field"))
+ .isEqualTo("video,second");
+ for (InternalRow row : read(getTableDefault())) {
+ assertThat(row.isNullAt(3)).isTrue();
+ }
+
+ catalog.alterTable(identifier(), SchemaChange.dropColumn("video"),
false);
+
assertThat(getTableDefault().schema().options().get("video-frame-field"))
+ .isEqualTo("second");
+ assertThat(read(getTableDefault()).size()).isEqualTo(3);
+ catalog.alterTable(identifier(), SchemaChange.dropColumn("second"),
false);
+
assertThat(getTableDefault().schema().options().containsKey("video-frame-field")).isFalse();
+ assertThat(read(getTableDefault()).size()).isEqualTo(3);
+ }
+
+ @Test
+ public void testRejectUnsafeVideoColumnEvolution() throws Exception {
+ catalog.createTable(
+ identifier(),
+ Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("image", DataTypes.BLOB())
+ .option(CoreOptions.ROW_TRACKING_ENABLED.key(), "true")
+ .option(CoreOptions.DATA_EVOLUTION_ENABLED.key(),
"true")
+ .build(),
+ false);
+ writeRows(
+ getTableDefault(),
+ Collections.singletonList(GenericRow.of(0, new BlobData(new
byte[] {1}))));
+ for (List<SchemaChange> changes :
+ Arrays.asList(
+ Collections.singletonList(
+ SchemaChange.setOption("video-frame-field",
"image")),
+ Arrays.asList(
+ SchemaChange.addColumn("bad",
DataTypes.BLOB()),
+ SchemaChange.setOption("video-frame-field",
"bad")))) {
+ assertThatThrownBy(() -> catalog.alterTable(identifier(), changes,
false))
+ .isInstanceOf(UnsupportedOperationException.class);
+ assertThat(getTableDefault().schema().id()).isZero();
+ }
+ for (org.apache.paimon.types.DataType type :
+ Arrays.asList(DataTypes.INT(), DataTypes.BLOB().notNull())) {
+ assertThatThrownBy(
+ () ->
+ catalog.alterTable(
+ identifier(),
+ SchemaChange.addColumn(
+ "bad", type,
"__VIDEO_FRAME_FIELD"),
+ false))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThat(getTableDefault().schema().id()).isZero();
+ }
+ // Editing an existing column's comment must not change its storage
mode.
+ assertThatThrownBy(
+ () ->
+ catalog.alterTable(
+ identifier(),
+ SchemaChange.updateColumnComment(
+ "image",
"__VIDEO_FRAME_FIELD"),
+ false))
+ .isInstanceOf(IllegalArgumentException.class);
+
assertThat(getTableDefault().schema().options().containsKey("video-frame-field")).isFalse();
+ catalog.alterTable(
+ identifier(),
+ SchemaChange.addColumn("video", DataTypes.BLOB(),
"__VIDEO_FRAME_FIELD"),
+ false);
+ for (String value : Arrays.asList("", "image", "video,image")) {
+ assertThatThrownBy(
+ () ->
+ catalog.alterTable(
+ identifier(),
+
SchemaChange.setOption("video-frame-field", value),
+ false))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+ assertThatThrownBy(
+ () ->
+ catalog.alterTable(
+ identifier(),
+
SchemaChange.removeOption("video-frame-field"),
+ false))
+ .isInstanceOf(UnsupportedOperationException.class);
+ assertThatThrownBy(
+ () ->
+ getTableDefault()
+ .copy(
+ Collections.singletonMap(
+ "video-frame-field",
"video,image")))
+ .isInstanceOf(UnsupportedOperationException.class);
+ }
+
@Test
public void testVideoRollingAndCompaction() throws Exception {
Schema.Builder schemaBuilder = Schema.newBuilder();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
index 13ece4e019..5c8bda6fdf 100644
---
a/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/append/dataevolution/DataEvolutionRowIdReassignerTest.java
@@ -25,8 +25,10 @@ import org.apache.paimon.catalog.RenamingSnapshotCommit;
import org.apache.paimon.catalog.SnapshotCommit;
import org.apache.paimon.data.BinaryRow;
import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.Blob;
import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.VideoFrameDescriptor;
import org.apache.paimon.data.serializer.InternalRowSerializer;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
@@ -61,6 +63,7 @@ import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
import org.apache.paimon.stats.SimpleStats;
import org.apache.paimon.stats.Statistics;
import org.apache.paimon.table.CatalogEnvironment;
@@ -81,8 +84,11 @@ import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.Range;
import org.apache.paimon.utils.SegmentsCache;
import org.apache.paimon.utils.SnapshotManager;
+import org.apache.paimon.utils.UriReader;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
import javax.annotation.Nullable;
@@ -124,6 +130,8 @@ import static
org.assertj.core.api.Assertions.assertThatThrownBy;
/** Tests for {@link DataEvolutionRowIdReassigner}. */
public class DataEvolutionRowIdReassignerTest extends TableTestBase {
+ private static final byte[] VIDEO_PAYLOAD = new byte[] {11, 22, 33, 44};
+
private static final int LARGE_ENTRY_COUNT = 10_000;
private static final int LARGE_MANIFEST_FILE_COUNT = 20;
private static final long LARGE_ENTRY_ROW_COUNT = 2L;
@@ -1309,6 +1317,116 @@ public class DataEvolutionRowIdReassignerTest extends
TableTestBase {
.containsExactly("0|a|v0", "100|c|v100", "1|b|v1", "2|a|v2",
"3|b|v3", "4|a|v4");
}
+ @ParameterizedTest
+ @ValueSource(strings = {"a", "b"})
+ void testReassignBeforeVideoBackfillCommit(String reassignedPartition)
throws Exception {
+ FileStoreTable table = createTableAndAddVideo();
+ String originalFile = currentEntriesAtRange(table, "pt=a/", 0,
0).get(0).file().fileName();
+ List<CommitMessage> pending = prepareVideo(table, 0);
+ DataEvolutionRowIdReassigner.Result result =
+ new DataEvolutionRowIdReassigner(
+ table, partitionPredicate(table,
reassignedPartition))
+ .reassign();
+
assertThat(result.newSnapshotId).isGreaterThan(result.previousSnapshotId);
+ long afterReassign = table.latestSnapshot().get().id();
+ if (reassignedPartition.equals("a")) {
+ assertThatThrownBy(() -> commitDefault(pending))
+ .hasMessageContaining("Row ID existence conflict");
+
assertThat(table.latestSnapshot().get().id()).isEqualTo(afterReassign);
+ assertVideoBackfillRows(table, false);
+ long newRowId =
+ currentEntries(table).stream()
+ .map(ManifestEntry::file)
+ .filter(file ->
file.fileName().equals(originalFile))
+ .findFirst()
+ .get()
+ .nonNullFirstRowId();
+ assertThat(newRowId).isGreaterThanOrEqualTo(5);
+ commitDefault(prepareVideo(table, newRowId));
+ } else {
+ commitDefault(pending);
+ }
+ assertVideoBackfillRows(table, true);
+ }
+
+ @Test
+ void testVideoBackfillCommitsAfterReassignPlanning() throws Exception {
+ FileStoreTable table = createTableAndAddVideo();
+ List<CommitMessage> pending = prepareVideo(table, 0);
+ AtomicBoolean injected = new AtomicBoolean();
+ DataEvolutionRowIdReassigner.Result result =
+ new DataEvolutionRowIdReassigner(
+ table,
+ partitionPredicate(table, "a"),
+ () -> {
+ if (injected.compareAndSet(false, true)) {
+ try {
+ commitDefault(pending);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ })
+ .reassign();
+ assertThat(injected).isTrue();
+
assertThat(result.newSnapshotId).isGreaterThan(result.previousSnapshotId);
+ assertVideoBackfillRows(table, true);
+ }
+
+ private FileStoreTable createTableAndAddVideo() throws Exception {
+ createTableWithInterleavedPartitions();
+ catalog.alterTable(
+ identifier(),
+ SchemaChange.addColumn("video", DataTypes.BYTES(),
"__VIDEO_FRAME_FIELD"),
+ false);
+ return getTableDefault();
+ }
+
+ private List<CommitMessage> prepareVideo(FileStoreTable table, long
firstRowId)
+ throws Exception {
+ long snapshot = table.latestSnapshot().get().id();
+ java.nio.file.Path source = tempPath.resolve("source.mp4");
+ java.nio.file.Files.write(source, VIDEO_PAYLOAD);
+ BatchWriteBuilder builder = table.newBatchWriteBuilder();
+ try (BatchTableWrite write =
+ builder.newWrite()
+
.withWriteType(table.rowType().project(Arrays.asList("pt", "video")))) {
+ VideoFrameDescriptor frame =
+ new VideoFrameDescriptor(
+ new Path(source.toUri()).toString(), 0,
VIDEO_PAYLOAD.length, 7, -1, 0);
+ write.write(
+ GenericRow.of(
+ BinaryString.fromString("a"),
+
Blob.fromDescriptor(UriReader.fromFile(LocalFileIO.create()), frame)));
+ List<CommitMessage> messages = write.prepareCommit();
+ assignFirstRowId(messages, firstRowId);
+ messages.replaceAll(
+ message -> ((CommitMessageImpl)
message).withCheckFromSnapshot(snapshot));
+ return messages;
+ }
+ }
+
+ private void assertVideoBackfillRows(FileStoreTable table, boolean
backfilled)
+ throws Exception {
+ List<InternalRow> rows = read(table,
Pair.of(CoreOptions.BLOB_AS_DESCRIPTOR, "true"));
+ rows.sort((a, b) -> Integer.compare(a.getInt(1), b.getInt(1)));
+ assertThat(rows).hasSize(5);
+ for (int id = 0; id < 5; id++) {
+ InternalRow row = rows.get(id);
+ assertThat(row.getInt(1)).isEqualTo(id);
+ assertThat(row.getString(2).toString()).isEqualTo("v" + id);
+ if (backfilled && id == 0) {
+ VideoFrameDescriptor descriptor =
+ (VideoFrameDescriptor) row.getBlob(3).toDescriptor();
+ assertThat(descriptor.frameIndex()).isEqualTo(7 + id);
+ assertThat(descriptor.uri()).endsWith(".video");
+ assertThat(row.getBlob(3).toData()).isEqualTo(VIDEO_PAYLOAD);
+ } else {
+ assertThat(row.isNullAt(3)).isTrue();
+ }
+ }
+ }
+
@Test
public void testReassignRetriesAfterConcurrentPartialColumnAppend() throws
Exception {
createTableDefault();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/schema/ColumnDirectiveUtilsTest.java
b/paimon-core/src/test/java/org/apache/paimon/schema/ColumnDirectiveUtilsTest.java
index c8ab49525d..64f734e7fd 100644
---
a/paimon-core/src/test/java/org/apache/paimon/schema/ColumnDirectiveUtilsTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/schema/ColumnDirectiveUtilsTest.java
@@ -22,6 +22,7 @@ import org.apache.paimon.CoreOptions;
import org.apache.paimon.types.ArrayType;
import org.apache.paimon.types.BlobType;
import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
import org.apache.paimon.types.DataTypeRoot;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.MapType;
@@ -58,6 +59,53 @@ public class ColumnDirectiveUtilsTest {
assertThat(opts).isEmpty();
}
+ @Test
+ public void testVideoFrameFieldDirective() {
+ for (DataType type :
+ new DataType[] {DataTypes.BYTES(), DataTypes.BINARY(8),
DataTypes.BLOB()}) {
+ Map<String, String> opts = new HashMap<>();
+ opts.put(CoreOptions.VIDEO_FRAME_FIELD.key(), "existing");
+ ColumnDirectiveUtils.ConvertedColumn result =
+ ColumnDirectiveUtils.applyAddColumnDirective(
+ "__VIDEO_FRAME_FIELD; camera frame", "video",
type, opts);
+ assertThat(result.type()).isEqualTo(DataTypes.BLOB());
+ assertThat(result.comment()).isEqualTo("camera frame");
+
assertThat(opts).containsEntry(CoreOptions.VIDEO_FRAME_FIELD.key(),
"existing,video");
+ result =
+ ColumnDirectiveUtils.applyAddColumnDirective(
+ "__VIDEO_FRAME_FIELD", "second", type, opts);
+ assertThat(result.comment()).isNull();
+ assertThat(opts)
+ .containsEntry(CoreOptions.VIDEO_FRAME_FIELD.key(),
"existing,video,second");
+ }
+ }
+
+ @Test
+ public void testInvalidVideoFrameFieldDirective() {
+ Map<String, String> opts = new HashMap<>();
+ for (String comment : new String[] {"__VIDEO_UNKNOWN",
"__VIDEO_FRAME_FIELD_typo"}) {
+ assertThatThrownBy(
+ () ->
+
ColumnDirectiveUtils.applyAddColumnDirective(
+ comment, "video",
DataTypes.BYTES(), opts))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThat(opts).isEmpty();
+ }
+ for (DataType type :
+ new DataType[] {
+ DataTypes.INT(),
+ DataTypes.ARRAY(DataTypes.BYTES()),
+ DataTypes.MAP(DataTypes.STRING(), DataTypes.BYTES())
+ }) {
+ assertThatThrownBy(
+ () ->
+
ColumnDirectiveUtils.applyAddColumnDirective(
+ "__VIDEO_FRAME_FIELD", "video",
type, opts))
+ .isInstanceOf(IllegalArgumentException.class);
+ assertThat(opts).isEmpty();
+ }
+ }
+
@Test
public void testBlobFieldDirective() {
Map<String, String> opts = new HashMap<>();
@@ -426,6 +474,18 @@ public class ColumnDirectiveUtilsTest {
assertThat(opts).containsEntry(CoreOptions.VECTOR_FIELD.key(), "v");
}
+ @Test
+ public void testRemoveDroppedVideoOptions() {
+ Map<String, String> opts = new HashMap<>();
+ opts.put(CoreOptions.VIDEO_FRAME_FIELD.key(), "video,second");
+ opts.put(CoreOptions.BLOB_FIELD.key(), "image");
+ ColumnDirectiveUtils.removeDroppedDirectiveOptions("video",
DataTypes.BLOB(), opts);
+ assertThat(opts).containsEntry(CoreOptions.VIDEO_FRAME_FIELD.key(),
"second");
+ ColumnDirectiveUtils.removeDroppedDirectiveOptions("second",
DataTypes.BLOB(), opts);
+
assertThat(opts).doesNotContainKey(CoreOptions.VIDEO_FRAME_FIELD.key());
+ assertThat(opts).containsEntry(CoreOptions.BLOB_FIELD.key(), "image");
+ }
+
@Test
public void testRemoveDroppedArrayBlobOptions() {
Map<String, String> opts = new HashMap<>();
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index d0e875426d..e6faeba3f1 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -1836,12 +1836,12 @@ class DataEvolutionSplitRead(SplitRead):
if not file.write_cols or len(file.write_cols) == 0:
raise ValueError("Blob/vector file must have write columns")
- # Find the field by name in the table schema
+ # Resolve the original field ID even after the column is dropped or
re-added.
field_name = file.write_cols[0]
- for field in self.table.fields:
+ for field in self._resolve_schema(file.schema_id).fields:
if field.name == field_name:
return field.id
- raise ValueError(f"Field {field_name} not found in table schema")
+ raise ValueError(f"Field {field_name} not found in file schema
{file.schema_id}")
def _get_field_ids_from_write_cols(self, write_cols: List[str]) ->
List[int]:
field_ids = []
diff --git a/paimon-python/pypaimon/schema/column_directive_utils.py
b/paimon-python/pypaimon/schema/column_directive_utils.py
index 005664b09a..4059a0ef28 100644
--- a/paimon-python/pypaimon/schema/column_directive_utils.py
+++ b/paimon-python/pypaimon/schema/column_directive_utils.py
@@ -21,6 +21,7 @@ Mirrors Java's ColumnDirectiveUtils. Supported directives:
__BLOB_FIELD -> blob-field
__BLOB_DESCRIPTOR_FIELD -> blob-descriptor-field
__BLOB_VIEW_FIELD -> blob-view-field
+ __VIDEO_FRAME_FIELD -> video-frame-field
__VECTOR_FIELD;dim -> vector-field
"""
@@ -34,9 +35,11 @@ from pypaimon.schema.data_types import (ArrayType,
AtomicType, DataField,
BLOB_FIELD_DIRECTIVE = "__BLOB_FIELD"
BLOB_DESCRIPTOR_FIELD_DIRECTIVE = "__BLOB_DESCRIPTOR_FIELD"
BLOB_VIEW_FIELD_DIRECTIVE = "__BLOB_VIEW_FIELD"
+VIDEO_FRAME_FIELD_DIRECTIVE = "__VIDEO_FRAME_FIELD"
VECTOR_FIELD_DIRECTIVE = "__VECTOR_FIELD"
_BLOB_DIRECTIVES = [
+ (VIDEO_FRAME_FIELD_DIRECTIVE, CoreOptions.VIDEO_FRAME_FIELD.key()),
(BLOB_VIEW_FIELD_DIRECTIVE, CoreOptions.BLOB_VIEW_FIELD.key()),
(BLOB_DESCRIPTOR_FIELD_DIRECTIVE, CoreOptions.BLOB_DESCRIPTOR_FIELD.key()),
(BLOB_FIELD_DIRECTIVE, CoreOptions.BLOB_FIELD.key()),
@@ -46,6 +49,7 @@ _BLOB_OPTIONS = [
CoreOptions.BLOB_FIELD,
CoreOptions.BLOB_DESCRIPTOR_FIELD,
CoreOptions.BLOB_VIEW_FIELD,
+ CoreOptions.VIDEO_FRAME_FIELD,
]
_VECTOR_OPTIONS = [
@@ -82,7 +86,7 @@ def parse_add_column_comment(comment: Optional[str]) ->
Optional[ParsedDirective
if comment.startswith("__VECTOR"):
return _parse_vector_directive(comment)
- if not comment.startswith("__BLOB"):
+ if not comment.startswith(("__BLOB", "__VIDEO")):
return None
for marker, option_key in _BLOB_DIRECTIVES:
@@ -96,10 +100,12 @@ def parse_add_column_comment(comment: Optional[str]) ->
Optional[ParsedDirective
continue
return ParsedDirective(option_key, real_comment, False, 0)
+ family = "VIDEO" if comment.startswith("__VIDEO") else "BLOB"
raise ValueError(
- f"Unsupported BLOB directive in column comment: '{comment}'. "
+ f"Unsupported {family} directive in column comment: '{comment}'. "
f"Supported directives are '{BLOB_FIELD_DIRECTIVE}', "
- f"'{BLOB_DESCRIPTOR_FIELD_DIRECTIVE}' and
'{BLOB_VIEW_FIELD_DIRECTIVE}'."
+ f"'{BLOB_DESCRIPTOR_FIELD_DIRECTIVE}', '{BLOB_VIEW_FIELD_DIRECTIVE}' "
+ f"and '{VIDEO_FRAME_FIELD_DIRECTIVE}'."
)
diff --git a/paimon-python/pypaimon/table/file_store_table.py
b/paimon-python/pypaimon/table/file_store_table.py
index 220f3b56f2..9527111d28 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -536,6 +536,9 @@ class FileStoreTable(Table):
return table
def _copy(self, options: dict, resolve_time_travel: bool) ->
'FileStoreTable':
+ video_key = CoreOptions.VIDEO_FRAME_FIELD.key()
+ if video_key in options and options[video_key] !=
self.table_schema.options.get(video_key):
+ raise ValueError("Cannot change immutable option " + video_key)
directory_key = CoreOptions.DATA_FILE_PATH_DIRECTORY.key()
if directory_key in options and options[directory_key] !=
self.options.data_file_path_directory():
raise ValueError('Cannot change immutable option ' + directory_key)
@@ -619,7 +622,8 @@ class FileStoreTable(Table):
(CoreOptions.VECTOR_FIELD.key(),),
(CoreOptions.BLOB_FIELD.key(),),
(CoreOptions.BLOB_DESCRIPTOR_FIELD.key(),
'blob.stored-descriptor-fields'),
- (CoreOptions.BLOB_VIEW_FIELD.key(),)):
+ (CoreOptions.BLOB_VIEW_FIELD.key(),),
+ (CoreOptions.VIDEO_FRAME_FIELD.key(),)):
if any(key in dynamic_option_keys for key in keys):
# Preserve explicit overrides and removals, including those
from earlier copies.
continue
diff --git a/paimon-python/pypaimon/tests/column_directive_utils_test.py
b/paimon-python/pypaimon/tests/column_directive_utils_test.py
index 5e0c11730f..2b93c942ac 100644
--- a/paimon-python/pypaimon/tests/column_directive_utils_test.py
+++ b/paimon-python/pypaimon/tests/column_directive_utils_test.py
@@ -81,6 +81,33 @@ class TestParseAddColumnComment(unittest.TestCase):
class TestApplyAddColumnDirective(unittest.TestCase):
+ def test_video_frame_field(self):
+ for source in ("BYTES", "BINARY(8)", "BLOB"):
+ with self.subTest(source=source):
+ opts = {CoreOptions.VIDEO_FRAME_FIELD.key(): "existing"}
+ result = apply_add_column_directive(
+ "__VIDEO_FRAME_FIELD; camera frame", "video",
AtomicType(source), opts)
+ self.assertEqual(result.type.type, "BLOB")
+ self.assertTrue(result.type.nullable)
+ self.assertEqual(result.comment, "camera frame")
+ self.assertEqual(opts[CoreOptions.VIDEO_FRAME_FIELD.key()],
"existing,video")
+ result = apply_add_column_directive(
+ "__VIDEO_FRAME_FIELD", "second", AtomicType(source), opts)
+ self.assertIsNone(result.comment)
+ self.assertEqual(opts[CoreOptions.VIDEO_FRAME_FIELD.key()],
"existing,video,second")
+
+ def test_invalid_video_frame_field(self):
+ opts = {}
+ for comment in ("__VIDEO_UNKNOWN", "__VIDEO_FRAME_FIELD_typo"):
+ with self.subTest(comment=comment), self.assertRaises(ValueError):
+ apply_add_column_directive(comment, "video",
AtomicType("BYTES"), opts)
+ self.assertEqual(opts, {})
+ for source in (AtomicType("INT"), ArrayType(True, AtomicType("BYTES")),
+ MapType(True, AtomicType("STRING"),
AtomicType("BYTES"))):
+ with self.subTest(source=source), self.assertRaises(ValueError):
+ apply_add_column_directive("__VIDEO_FRAME_FIELD", "video",
source, opts)
+ self.assertEqual(opts, {})
+
def test_non_directive_returns_none(self):
opts = {}
result = apply_add_column_directive(
@@ -259,6 +286,15 @@ class TestRemoveDroppedDirectiveOptions(unittest.TestCase):
self.assertNotIn(CoreOptions.BLOB_DESCRIPTOR_FIELD.key(), opts)
self.assertEqual(opts[CoreOptions.VECTOR_FIELD.key()], "v")
+ def test_drop_video(self):
+ opts = {CoreOptions.VIDEO_FRAME_FIELD.key(): "video,second",
+ CoreOptions.BLOB_FIELD.key(): "image"}
+ remove_dropped_directive_options("video", "BLOB", opts)
+ self.assertEqual(opts[CoreOptions.VIDEO_FRAME_FIELD.key()], "second")
+ remove_dropped_directive_options("second", "BLOB", opts)
+ self.assertNotIn(CoreOptions.VIDEO_FRAME_FIELD.key(), opts)
+ self.assertEqual(opts[CoreOptions.BLOB_FIELD.key()], "image")
+
def test_drop_vector(self):
opts = {
CoreOptions.VECTOR_FIELD.key(): "emb,emb2",
diff --git a/paimon-python/pypaimon/tests/multimodal_table_test.py
b/paimon-python/pypaimon/tests/multimodal_table_test.py
index e2f665e8b1..13d306269c 100644
--- a/paimon-python/pypaimon/tests/multimodal_table_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_table_test.py
@@ -144,6 +144,113 @@ class MultimodalTableTest(unittest.TestCase):
self.assertEqual(["id", "content", "embedding", "payload"],
[field.name for field in table.raw_table.fields])
+ def test_add_video_column_to_existing_images(self):
+ from pypaimon.schema.data_types import AtomicType
+ from pypaimon.schema.schema_change import SchemaChange
+
+ table = self.conn.create_table(
+ "evolved_video", schema=_schema({"id": pa.int32(), "image":
pa.large_binary()}),
+ options=_PARQUET_OPTIONS)
+ table.add([{"id": 0, "image": b"image-0"}, {"id": 1, "image":
b"image-1"}])
+ table.raw_table.create_tag("before_video")
+ old_table = table
+ self.conn.catalog.alter_table("default.evolved_video", [
+ SchemaChange.add_column("video", AtomicType("BYTES"),
"__VIDEO_FRAME_FIELD"),
+ ])
+ table = self.conn.get_table("evolved_video")
+ self.assertEqual([None, None], [r["video"] for r in
table.scan().to_list()])
+ old_table.add([{"id": 2, "image": b"image-2"}])
+ video_path = os.path.join(self.temp_dir, "backfill.mp4")
+ with open(video_path, "wb") as out:
+ out.write(b"complete-video-payload")
+ table.replace_video("id < 2", pmm.Blob.from_local(video_path),
first_frame=7)
+ # A retained writer must not stamp its old schema onto a new snapshot.
+ old_table.add([{"id": 3, "image": b"image-3"}])
+ snapshot = table.raw_table.snapshot_manager().get_latest_snapshot()
+ self.assertEqual(table.raw_table.table_schema.id, snapshot.schema_id)
+ table.raw_table.create_tag("after_old_append")
+ rows = sorted(table.scan().select(["id", "image", "video",
"_ROW_ID"]).to_list(),
+ key=lambda row: row["id"])
+ self.assertEqual([0, 1, 2, 3], [row["_ROW_ID"] for row in rows])
+ self.assertEqual([7, 8],
[pmm.VideoFrameDescriptor.deserialize(row["video"]).frame_index
+ for row in rows[:2]])
+ self.assertTrue(all(row["video"] is None for row in rows[2:]))
+ _, images = table.scan().read_blobs("image")
+ self.assertEqual([b"image-0", b"image-1", b"image-2", b"image-3"],
images["image"])
+ _, videos = table.scan().where("id < 2").read_blobs("video")
+ self.assertEqual([b"complete-video-payload"] * 2, videos["video"])
+ for options in ({"scan.snapshot-id": str(snapshot.id)},
{"scan.tag-name": "after_old_append"}):
+ with self.subTest(options=options):
+ historical = table.raw_table.copy(options)
+ self.assertEqual(["id", "image", "video"], [field.name for
field in historical.fields])
+ read = historical.new_read_builder()
+ restored =
sorted(read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist(),
+ key=lambda row: row["id"])
+ self.assertEqual([0, 1, 2, 3], [row["id"] for row in restored])
+ self.assertEqual([row["video"] for row in rows], [row["video"]
for row in restored])
+ historical =
self.conn.catalog.get_table("default.evolved_video").copy({"scan.tag-name":
"before_video"})
+ self.assertNotIn("video-frame-field", historical.table_schema.options)
+ # The retained old schema still reads the original rows after the
backfill.
+ read = historical.new_read_builder()
+ historical_rows =
read.new_read().to_arrow(read.new_scan().plan().splits()).to_pylist()
+ self.assertEqual([0, 1], sorted(row["id"] for row in historical_rows))
+ self.assertTrue(all(row.get("video") is None for row in
historical_rows))
+
+ self.conn.catalog.alter_table("default.evolved_video", [
+ SchemaChange.add_column("second", AtomicType("BYTES"),
"__VIDEO_FRAME_FIELD")])
+ for column, remaining in (("video", "second"), ("second", None)):
+ self.conn.catalog.alter_table("default.evolved_video",
[SchemaChange.drop_column(column)])
+ current = self.conn.get_table("evolved_video")
+ self.assertEqual(remaining,
current.raw_table.table_schema.options.get("video-frame-field"))
+ self.assertEqual(4, len(current.scan().to_list()))
+ self.conn.catalog.alter_table("default.evolved_video", [
+ SchemaChange.add_column("video", AtomicType("BYTES"),
"__VIDEO_FRAME_FIELD")])
+ current = self.conn.get_table("evolved_video")
+ self.assertEqual([None] * 4, [row["video"] for row in
current.scan().to_list()])
+
+ def test_add_video_column_validation(self):
+ from pypaimon.schema.data_types import AtomicType
+ from pypaimon.schema.schema_change import SchemaChange as C
+
+ table = self.conn.create_table(
+ "video_evolution_validation",
+ schema=_schema({"id": pa.int32(), "image": pa.large_binary()}),
+ options=_PARQUET_OPTIONS)
+ table.add([{"id": 0, "image": b"original"}])
+ identifier = "default.video_evolution_validation"
+
+ with self.assertRaisesRegex(ValueError, "immutable option"):
+ table.raw_table.copy({"video-frame-field": "image"})
+
+ def alter(changes):
+ self.conn.catalog.alter_table(identifier, changes)
+
+ for changes in [
+ [C.set_option("video-frame-field", "image")],
+ [C.set_option("video-frame-field", "missing")],
+ [C.add_column("bad", AtomicType("INT"), "__VIDEO_FRAME_FIELD")],
+ [C.add_column("bad", AtomicType("BLOB", False),
"__VIDEO_FRAME_FIELD")],
+ [C.add_column("bad", AtomicType("BLOB")),
C.set_option("video-frame-field", "image,bad")],
+ ]:
+ with self.subTest(changes=changes):
+ with self.assertRaises(RuntimeError):
+ alter(changes)
+ self.assertEqual(0,
self.conn.catalog.get_table(identifier).table_schema.id)
+ alter([C.update_column_comment("image", "__VIDEO_FRAME_FIELD")])
+ self.assertNotIn("video-frame-field",
self.conn.catalog.get_table(identifier).table_schema.options)
+ alter([C.add_column("video", AtomicType("BYTES"),
"__VIDEO_FRAME_FIELD")])
+ alter([C.add_column("second", AtomicType("BLOB"),
"__VIDEO_FRAME_FIELD")])
+ self.assertEqual(
+ "video,second",
self.conn.catalog.get_table(identifier).table_schema.options["video-frame-field"])
+ for change in [C.set_option("video-frame-field", "second"),
+ C.set_option("video-frame-field", ""),
C.remove_option("video-frame-field")]:
+ with self.assertRaises(RuntimeError):
+ alter([change])
+ # A column added by a previous commit may already contain ordinary
BLOB data.
+ alter([C.add_column("later", AtomicType("BLOB"))])
+ with self.assertRaisesRegex(RuntimeError, "not supported yet"):
+ alter([C.set_option("video-frame-field", "video,second,later")])
+
def test_add_video_embeds_frame_ordinals_outside_normal_data(self):
table = self.conn.create_table(
"video_frames",
diff --git a/paimon-python/pypaimon/tests/native_merge_into_test.py
b/paimon-python/pypaimon/tests/native_merge_into_test.py
index fe15bac7ca..738cf43cea 100644
--- a/paimon-python/pypaimon/tests/native_merge_into_test.py
+++ b/paimon-python/pypaimon/tests/native_merge_into_test.py
@@ -195,8 +195,15 @@ def
test_native_merge_keeps_video_inserts_on_python_path(tmp_path):
from pypaimon.write.native_merge_into import create_native_merge_into
from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER
- table = _table(tmp_path).copy({'video-frame-field': 'name'})
- source = pa.Table.from_pylist([dict(id=1, value=10, name='frame')],
schema=_SCHEMA)
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+ catalog.create_database('db', True)
+ schema = pa.schema([('id', pa.int32()), ('video', pa.large_binary())])
+ catalog.create_table('db.t', Schema.from_pyarrow_schema(schema, options={
+ 'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true',
+ 'video-frame-field': 'video', 'write.native.enabled': 'true',
+ }), False)
+ table = catalog.get_table('db.t')
+ source = pa.Table.from_pylist([dict(id=1, video=None)], schema=schema)
assert create_native_merge_into(table, source, ['id'], [],
[WhenNotMatched('*')],
'merge', BATCH_COMMIT_IDENTIFIER) is None
diff --git
a/paimon-python/pypaimon/tests/table/time_travel_schema_evolution_test.py
b/paimon-python/pypaimon/tests/table/time_travel_schema_evolution_test.py
index 17ba35de6a..45fdfe4683 100644
--- a/paimon-python/pypaimon/tests/table/time_travel_schema_evolution_test.py
+++ b/paimon-python/pypaimon/tests/table/time_travel_schema_evolution_test.py
@@ -63,6 +63,43 @@ class TestTimeTravelSchemaEvolution:
builder = table.new_read_builder()
return
builder.new_read().to_arrow(builder.new_scan().plan().splits()).to_pylist()
+ def test_commit_retry_refreshes_snapshot_schema(self, monkeypatch):
+ original = self._create([DataField(0, 'id', AtomicType('INT'))])
+ self._write(original, {'id': 1})
+ builder = original.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ snapshot_commit = commit.file_store_commit.snapshot_commit
+ publish = snapshot_commit.commit
+ attempted_schemas = []
+
+ def publish_after_schema_change(*args):
+ attempted_schemas.append(args[1].schema_id)
+ if len(attempted_schemas) == 1:
+ self.catalog.alter_table('test.t', [
+ SchemaChange.add_column('new_column',
AtomicType('STRING'))])
+ return False
+ return publish(*args)
+
+ monkeypatch.setattr(snapshot_commit, 'commit',
publish_after_schema_change)
+ try:
+ writer.write_arrow(pa.Table.from_pylist([{'id': 2}],
schema=pa.schema([('id', pa.int32())])))
+ messages = writer.prepare_commit()
+ commit.commit(messages)
+ finally:
+ writer.close()
+ commit.close()
+
+ current = self.catalog.get_table('test.t')
+ snapshot = current.snapshot_manager().get_latest_snapshot()
+ assert attempted_schemas == [0, 1]
+ assert snapshot.schema_id == current.table_schema.id == 1
+ historical = current.copy({'scan.snapshot-id': str(snapshot.id)})
+ assert sorted(self._read(historical), key=lambda row: row['id']) == [
+ {'id': 1, 'new_column': None}, {'id': 2, 'new_column': None}]
+ assert all(file.schema_id == 0
+ for split in
current.new_read_builder().new_scan().plan().splits()
+ for file in split.files)
+
@pytest.mark.parametrize('key,directive,field_type', [
('vector-field', '__VECTOR_FIELD;3', ArrayType(True,
AtomicType('FLOAT'))),
('blob-field', '__BLOB_FIELD', AtomicType('BYTES')),
diff --git a/paimon-python/pypaimon/tests/video_column_reassign_test.py
b/paimon-python/pypaimon/tests/video_column_reassign_test.py
new file mode 100644
index 0000000000..bcd8cd3ba0
--- /dev/null
+++ b/paimon-python/pypaimon/tests/video_column_reassign_test.py
@@ -0,0 +1,140 @@
+# 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.
+
+from dataclasses import replace
+from uuid import uuid4
+
+import pyarrow as pa
+import pytest
+import pypaimon.multimodal as pm
+
+from pypaimon.manifest.manifest_file_manager import ManifestFileManager
+from pypaimon.manifest.manifest_list_manager import ManifestListManager
+from pypaimon.schema.data_types import AtomicType
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.snapshot.renaming_snapshot_commit import RenamingSnapshotCommit
+from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
+
+
+def _publish_reassigned_partition(table, partition_value):
+ """Publish the metadata-only OVERWRITE shape emitted by Java reassign.
+
+ The real Java reassign algorithm is exercised by
DataEvolutionRowIdReassignerTest.
+ Preserve shared row ranges across normal/image column files in this
fixture.
+ """
+ latest = table.snapshot_manager().get_latest_snapshot()
+ manifest_file = ManifestFileManager(table)
+ manifest_list = ManifestListManager(table)
+ entries =
manifest_file.read_entries_parallel(manifest_list.read_all(latest),
drop_stats=False)
+ next_row_id = latest.next_row_id
+ mapping = {}
+ reassigned = []
+ for entry in entries:
+ if entry.partition.values[0] == partition_value:
+ key = (entry.file.first_row_id, entry.file.row_count)
+ if key not in mapping:
+ mapping[key] = next_row_id
+ next_row_id += entry.file.row_count
+ entry = entry.assign_first_row_id(mapping[key])
+ reassigned.append(entry)
+ token = str(uuid4())
+ manifests = manifest_file.rolling_write(reassigned, 1 << 30, 'manifest-' +
token)
+ base, delta = 'manifest-list-' + token + '-0', 'manifest-list-' + token +
'-1'
+ manifest_list.write(base, manifests)
+ manifest_list.write(delta, [])
+ snapshot = replace(latest, id=latest.id + 1, base_manifest_list=base,
+ delta_manifest_list=delta, delta_record_count=0,
+ commit_user='video-reassign-test', commit_identifier=0,
+ commit_kind='OVERWRITE', time_millis=latest.time_millis
+ 1,
+ next_row_id=next_row_id, uuid=str(uuid4()))
+ assert
RenamingSnapshotCommit(table.snapshot_manager()).commit(latest.uuid, snapshot,
[])
+
+
[email protected]('phase', ['after_mapping', 'commit_cas'])
[email protected]('concurrent_change', ['same_partition',
'other_partition', 'append'])
+def test_video_backfill_reassign_interleavings(tmp_path, monkeypatch, phase,
concurrent_change):
+ conn = pm.connect(options={'warehouse': str(tmp_path / 'warehouse')})
+ table = conn.create_table('images', schema=pa.schema([
+ ('pt', pa.string()), ('id', pa.int32()), ('image',
pa.large_binary())]),
+ partitioned=['pt'], options={'file.format': 'parquet'})
+ for group in range(4):
+ table.add([{'pt': 'a' if group % 2 == 0 else 'b', 'id': i, 'image':
bytes([i])}
+ for i in range(group * 2, group * 2 + 2)])
+ conn.catalog.alter_table('default.images', [
+ SchemaChange.add_column('video', AtomicType('BYTES'),
'__VIDEO_FRAME_FIELD')])
+ table = conn.get_table('images')
+ raw = table.raw_table
+ source = tmp_path / 'source.mp4'
+ source.write_bytes(b'complete-video-payload')
+ video = pm.Blob.from_local(str(source))
+ injected = []
+
+ def concurrent_commit():
+ injected.append(True)
+ if concurrent_change == 'append':
+ table.add([{'pt': 'b', 'id': 8, 'image': bytes([8]), 'video':
None}])
+ else:
+ _publish_reassigned_partition(raw, 'a' if concurrent_change ==
'same_partition' else 'b')
+ injected.append(raw.snapshot_manager().get_latest_snapshot().id)
+
+ real_init = TableUpdateByRowId.__init__
+ real_commit = RenamingSnapshotCommit.commit
+
+ def init_after_reassign(updater, *args, **kwargs):
+ if not injected:
+ concurrent_commit()
+ real_init(updater, *args, **kwargs)
+
+ def commit_after_reassign(committer, base_uuid, snapshot, statistics):
+ if not injected and snapshot.commit_kind == 'APPEND':
+ concurrent_commit()
+ # Let the real compare-and-swap lose to the concurrent snapshot,
+ # then exercise the updater's retry validation.
+ return real_commit(committer, base_uuid, snapshot, statistics)
+
+ with monkeypatch.context() as patch:
+ if phase == 'after_mapping':
+ patch.setattr(TableUpdateByRowId, '__init__', init_after_reassign)
+ else:
+ patch.setattr(RenamingSnapshotCommit, 'commit',
commit_after_reassign)
+ if concurrent_change == 'same_partition':
+ with pytest.raises((ValueError, RuntimeError), match='valid
range|Row ID existence conflict'):
+ table.replace_video('id < 2', video, first_frame=7)
+ else:
+ table.replace_video('id < 2', video, first_frame=7)
+ assert injected
+
+ if concurrent_change == 'same_partition':
+ assert raw.snapshot_manager().get_latest_snapshot().id == injected[1]
+ assert all(row['video'] is None for row in table.scan().to_list())
+ # A fresh read rebuilds row-ID -> frame mapping; never reuse stale
commit messages.
+ table.replace_video('id < 2', video, first_frame=7)
+
+ rows = sorted(table.scan().select(['id', 'video']).to_list(), key=lambda
row: row['id'])
+ assert [r['id'] for r in rows] == list(range(9 if concurrent_change ==
'append' else 8))
+ for row in rows:
+ if row['id'] < 2:
+ descriptor = pm.VideoFrameDescriptor.deserialize(row['video'])
+ assert descriptor.frame_index == row['id'] + 7
+ assert descriptor.uri.endswith('.video')
+ else:
+ assert row['video'] is None
+ scalar, images = table.scan().read_blobs('image')
+ assert dict(zip(scalar['id'].to_pylist(), images['image'])) == {
+ row['id']: bytes([row['id']]) for row in rows}
+ _, videos = table.scan().where('id < 2').read_blobs('video')
+ assert videos['video'] == [b'complete-video-payload'] * 2
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index c00f138921..d5390088e3 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -689,7 +689,7 @@ class FileStoreCommit:
snapshot_data = Snapshot(
version=3,
id=new_snapshot_id,
- schema_id=self.table.table_schema.id,
+ schema_id=self.table.schema_manager.latest().id,
base_manifest_list=base_manifest_list,
base_manifest_list_size=base_manifest_list_size,
delta_manifest_list=delta_manifest_list,