This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 54486a58f7 [Cherry-pick to branch-1.3] [#12988] fix(lance): reject 
nonempty Arrow input before table creation (#12989) (#13025)
54486a58f7 is described below

commit 54486a58f736383844ed0dea1d9634a3a820aaa5
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Sep 9 17:27:08 2026 +0800

    [Cherry-pick to branch-1.3] [#12988] fix(lance): reject nonempty Arrow 
input before table creation (#12989) (#13025)
    
    **Cherry-pick Information:**
    - Original commit: ade9e1b8caecaed925293cd183018e776ba7eb8f
    - Target branch: `branch-1.3`
    - Status: ✅ Clean cherry-pick (no conflicts)
    
    Co-authored-by: Qi Yu <[email protected]>
---
 .../gravitino/GravitinoLanceTableOperations.java   |   4 +-
 .../gravitino/lance/common/utils/ArrowUtils.java   |  54 ++++++++
 .../lance/common/utils/TestArrowUtils.java         | 153 +++++++++++++++++++++
 .../lance/integration/test/LanceRESTServiceIT.java |  79 +++++++++++
 4 files changed, 288 insertions(+), 2 deletions(-)

diff --git 
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
 
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
index 50ba09df30..bd3a67fcc0 100644
--- 
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
+++ 
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/ops/gravitino/GravitinoLanceTableOperations.java
@@ -167,11 +167,11 @@ public class GravitinoLanceTableOperations implements 
LanceTableOperations {
     Preconditions.checkArgument(
         nsId.levels() == 3, "Expected at 3-level namespace but got: %s", 
nsId.levels());
 
-    // Parser column information.
+    // Reject unsupported record batches before any metadata or storage 
mutation.
     List<Column> columns = Lists.newArrayList();
     if (arrowStreamBody != null) {
       org.apache.arrow.vector.types.pojo.Schema schema =
-          ArrowUtils.parseArrowIpcStream(arrowStreamBody);
+          ArrowUtils.parseSchemaOnlyIpcStream(arrowStreamBody);
       columns = extractColumns(schema);
     }
 
diff --git 
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/ArrowUtils.java
 
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/ArrowUtils.java
index 5d8508ee45..b72ed08f7e 100644
--- 
a/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/ArrowUtils.java
+++ 
b/lance/lance-common/src/main/java/org/apache/gravitino/lance/common/utils/ArrowUtils.java
@@ -23,11 +23,17 @@ import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.nio.channels.Channels;
+import org.apache.arrow.flatbuf.Message;
+import org.apache.arrow.flatbuf.MessageHeader;
+import org.apache.arrow.flatbuf.RecordBatch;
 import org.apache.arrow.memory.BufferAllocator;
 import org.apache.arrow.memory.RootAllocator;
 import org.apache.arrow.vector.VectorSchemaRoot;
 import org.apache.arrow.vector.ipc.ArrowStreamReader;
 import org.apache.arrow.vector.ipc.ArrowStreamWriter;
+import org.apache.arrow.vector.ipc.ReadChannel;
+import org.apache.arrow.vector.ipc.message.MessageMetadataResult;
+import org.apache.arrow.vector.ipc.message.MessageSerializer;
 import org.apache.arrow.vector.types.pojo.Schema;
 
 public class ArrowUtils {
@@ -57,16 +63,64 @@ public class ArrowUtils {
   }
 
   public static Schema parseArrowIpcStream(byte[] stream) {
+    return parseArrowIpcStream(stream, false);
+  }
+
+  /**
+   * Parses a schema-only Arrow IPC stream, rejecting record batches 
containing rows.
+   *
+   * @param stream the Arrow IPC stream
+   * @return the stream schema
+   * @throws UnsupportedOperationException if any record batch contains rows
+   * @throws IllegalArgumentException if the stream cannot be parsed
+   */
+  public static Schema parseSchemaOnlyIpcStream(byte[] stream) {
+    return parseArrowIpcStream(stream, true);
+  }
+
+  private static Schema parseArrowIpcStream(byte[] stream, boolean 
requireEmpty) {
     Schema schema;
+    boolean containsRows = false;
     try (BufferAllocator allocator = new RootAllocator();
         ByteArrayInputStream bais = new ByteArrayInputStream(stream);
         ArrowStreamReader reader = new ArrowStreamReader(bais, allocator)) {
       schema = reader.getVectorSchemaRoot().getSchema();
+      if (requireEmpty) {
+        containsRows = containsRecordBatchRows(bais);
+      }
     } catch (Exception e) {
       throw new IllegalArgumentException("Failed to parse Arrow IPC stream", 
e);
     }
 
     Preconditions.checkArgument(schema != null, "No schema found in Arrow IPC 
stream");
+    if (containsRows) {
+      throw new UnsupportedOperationException(
+          "CreateTable only supports schema-only Arrow streams; "
+              + "write records through a Lance client or engine after 
creation");
+    }
     return schema;
   }
+
+  private static boolean containsRecordBatchRows(ByteArrayInputStream input) 
throws IOException {
+    // The schema reader has consumed the schema message. Inspect only 
subsequent message headers;
+    // skipping bodies avoids allocating or decoding vectors, including 
dictionary values.
+    try (ReadChannel channel = new ReadChannel(Channels.newChannel(input))) {
+      MessageMetadataResult metadata;
+      while ((metadata = MessageSerializer.readMessage(channel)) != null) {
+        Message message = metadata.getMessage();
+        if (message.headerType() == MessageHeader.RecordBatch) {
+          RecordBatch batch = (RecordBatch) message.header(new RecordBatch());
+          Preconditions.checkArgument(batch.length() >= 0, "Invalid Arrow 
record batch row count");
+          if (batch.length() > 0) {
+            return true;
+          }
+        } else if (message.headerType() != MessageHeader.DictionaryBatch) {
+          throw new IOException("Unexpected Arrow message type: " + 
message.headerType());
+        }
+        Preconditions.checkArgument(message.bodyLength() >= 0, "Invalid Arrow 
message body length");
+        input.skipNBytes(message.bodyLength());
+      }
+      return false;
+    }
+  }
 }
diff --git 
a/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestArrowUtils.java
 
b/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestArrowUtils.java
index 43f0bf6ec6..7f1a7ec482 100644
--- 
a/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestArrowUtils.java
+++ 
b/lance/lance-common/src/test/java/org/apache/gravitino/lance/common/utils/TestArrowUtils.java
@@ -18,9 +18,25 @@
  */
 package org.apache.gravitino.lance.common.utils;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.channels.Channels;
 import java.util.Arrays;
+import java.util.List;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.dictionary.Dictionary;
+import 
org.apache.arrow.vector.dictionary.DictionaryProvider.MapDictionaryProvider;
+import org.apache.arrow.vector.ipc.ArrowStreamWriter;
+import org.apache.arrow.vector.ipc.ReadChannel;
+import org.apache.arrow.vector.ipc.message.MessageMetadataResult;
+import org.apache.arrow.vector.ipc.message.MessageSerializer;
 import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.DictionaryEncoding;
 import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
 import org.apache.arrow.vector.types.pojo.Schema;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -39,4 +55,141 @@ public class TestArrowUtils {
 
     Assertions.assertEquals(schema, parsedSchema);
   }
+  /** Verifies schema-only streams and zero-row batches remain supported. */
+  @Test
+  public void testSchemaOnlyStreams() throws Exception {
+    Schema expected = new Schema(List.of(Field.nullable("id", new 
ArrowType.Int(32, true))));
+    Assertions.assertEquals(expected, 
ArrowUtils.parseSchemaOnlyIpcStream(streamWithRows()));
+    Assertions.assertEquals(expected, 
ArrowUtils.parseSchemaOnlyIpcStream(streamWithRows(0, 0)));
+  }
+
+  /** Verifies that a non-empty batch is rejected, including after empty 
batches. */
+  @Test
+  public void testRejectRecordBatchesWithRows() throws Exception {
+    for (byte[] stream : List.of(streamWithRows(1), streamWithRows(0, 1))) {
+      UnsupportedOperationException exception =
+          Assertions.assertThrows(
+              UnsupportedOperationException.class,
+              () -> ArrowUtils.parseSchemaOnlyIpcStream(stream));
+      Assertions.assertTrue(exception.getMessage().contains("schema-only"));
+      // Existing callers of the general schema parser retain their previous 
behavior.
+      Assertions.assertEquals(1, 
ArrowUtils.parseArrowIpcStream(stream).getFields().size());
+    }
+  }
+
+  /** Verifies malformed input is reported as invalid rather than as 
unsupported data. */
+  @Test
+  public void testRejectMalformedSchemaOnlyStream() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> ArrowUtils.parseSchemaOnlyIpcStream(new byte[] {1, 2, 3}));
+  }
+
+  /** Verifies non-empty batches are rejected from metadata without decoding 
their bodies. */
+  @Test
+  public void testRejectRowsBeforeReadingBatchBody() throws Exception {
+    byte[] stream = streamWithRows(1);
+    try (ReadChannel channel =
+        new ReadChannel(Channels.newChannel(new 
ByteArrayInputStream(stream)))) {
+      MessageSerializer.deserializeSchema(channel);
+      MessageMetadataResult batch = MessageSerializer.readMessage(channel);
+      Assertions.assertTrue(batch.getMessageBodyLength() > 0);
+      byte[] headersOnly = Arrays.copyOf(stream, (int) channel.bytesRead());
+      Assertions.assertThrows(
+          UnsupportedOperationException.class,
+          () -> ArrowUtils.parseSchemaOnlyIpcStream(headersOnly));
+    }
+  }
+
+  /** Verifies dictionary values are skipped and do not count as table rows. */
+  @Test
+  public void testDictionaryBatches() throws Exception {
+    for (int rows : new int[] {0, 1}) {
+      byte[] stream = dictionaryStreamWithRows(rows);
+      if (rows == 0) {
+        Assertions.assertEquals(
+            ArrowUtils.parseArrowIpcStream(stream), 
ArrowUtils.parseSchemaOnlyIpcStream(stream));
+      } else {
+        Assertions.assertThrows(
+            UnsupportedOperationException.class, () -> 
ArrowUtils.parseSchemaOnlyIpcStream(stream));
+      }
+    }
+  }
+
+  /** Verifies skipping a truncated dictionary body still reports malformed 
input. */
+  @Test
+  public void testRejectTruncatedDictionaryBody() throws Exception {
+    byte[] stream = dictionaryStreamWithRows(0);
+    try (ReadChannel channel =
+        new ReadChannel(Channels.newChannel(new 
ByteArrayInputStream(stream)))) {
+      MessageSerializer.deserializeSchema(channel);
+      MessageMetadataResult dictionary = 
MessageSerializer.readMessage(channel);
+      Assertions.assertTrue(dictionary.getMessageBodyLength() > 0);
+      byte[] truncated = Arrays.copyOf(stream, (int) channel.bytesRead() + 1);
+      Assertions.assertThrows(
+          IllegalArgumentException.class, () -> 
ArrowUtils.parseSchemaOnlyIpcStream(truncated));
+    }
+  }
+
+  /** Verifies a schema message cannot appear where a record batch is 
expected. */
+  @Test
+  public void testRejectUnexpectedMessage() throws Exception {
+    byte[] stream = streamWithRows();
+    ByteArrayOutputStream output = new ByteArrayOutputStream();
+    try (ReadChannel channel =
+        new ReadChannel(Channels.newChannel(new 
ByteArrayInputStream(stream)))) {
+      MessageSerializer.deserializeSchema(channel);
+      output.write(stream, 0, (int) channel.bytesRead());
+      output.write(stream);
+    }
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> ArrowUtils.parseSchemaOnlyIpcStream(output.toByteArray()));
+  }
+
+  private byte[] dictionaryStreamWithRows(int rows) throws Exception {
+    DictionaryEncoding encoding = new DictionaryEncoding(0, false, new 
ArrowType.Int(32, true));
+    Schema schema =
+        new Schema(
+            List.of(new Field("id", new FieldType(true, 
encoding.getIndexType(), encoding), null)));
+    try (RootAllocator allocator = new RootAllocator();
+        VarCharVector values = new VarCharVector("values", allocator);
+        VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator);
+        ByteArrayOutputStream output = new ByteArrayOutputStream()) {
+      values.allocateNew();
+      values.setSafe(0, new byte[] {42});
+      values.setValueCount(1);
+      MapDictionaryProvider dictionaries =
+          new MapDictionaryProvider(new Dictionary(values, encoding));
+      try (ArrowStreamWriter writer = new ArrowStreamWriter(root, 
dictionaries, output)) {
+        root.allocateNew();
+        ((IntVector) root.getVector("id")).setSafe(0, 0);
+        root.setRowCount(rows);
+        writer.start();
+        writer.writeBatch();
+        writer.end();
+      }
+      return output.toByteArray();
+    }
+  }
+
+  private byte[] streamWithRows(int... batches) throws Exception {
+    Schema schema = new Schema(List.of(Field.nullable("id", new 
ArrowType.Int(32, true))));
+    try (RootAllocator allocator = new RootAllocator();
+        VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator);
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        ArrowStreamWriter writer = new ArrowStreamWriter(root, null, output)) {
+      root.allocateNew();
+      writer.start();
+      for (int rows : batches) {
+        for (int i = 0; i < rows; i++) {
+          ((IntVector) root.getVector("id")).setSafe(i, i);
+        }
+        root.setRowCount(rows);
+        writer.writeBatch();
+      }
+      writer.end();
+      return output.toByteArray();
+    }
+  }
 }
diff --git 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceRESTServiceIT.java
 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceRESTServiceIT.java
index dcaa97c7f2..b4762fedf0 100644
--- 
a/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceRESTServiceIT.java
+++ 
b/lance/lance-rest-server/src/test/java/org/apache/gravitino/lance/integration/test/LanceRESTServiceIT.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.lance.integration.test;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
+import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.nio.charset.Charset;
@@ -34,6 +35,10 @@ import java.util.Objects;
 import java.util.Set;
 import org.apache.arrow.memory.BufferAllocator;
 import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.ipc.ArrowStreamWriter;
 import org.apache.arrow.vector.types.pojo.ArrowType;
 import org.apache.arrow.vector.types.pojo.Field;
 import org.apache.commons.io.FileUtils;
@@ -52,6 +57,7 @@ import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
+import org.lance.Dataset;
 import org.lance.namespace.LanceNamespace;
 import org.lance.namespace.client.apache.ApiClient;
 import org.lance.namespace.client.apache.ApiException;
@@ -449,6 +455,45 @@ public class LanceRESTServiceIT extends BaseIT {
     assertLanceErrorCode(exception, ErrorCode.NAMESPACE_NOT_FOUND);
   }
 
+  /** Verifies unsupported input cannot silently discard records or destroy an 
existing table. */
+  @Test
+  void testCreateRejectsNonEmptyArrowWithoutSideEffects() throws IOException {
+    catalog = createCatalog(CATALOG_NAME);
+    createSchema();
+    byte[] data = arrowStreamWithEmptyThenNonEmptyBatch();
+    for (String mode : List.of("create", "exist_ok")) {
+      String name = "nonempty_" + mode;
+      Path location = tempDir.resolve(name);
+      assertNonEmptyCreateRejected(
+          List.of(CATALOG_NAME, SCHEMA_NAME, name), location.toString(), data, 
mode);
+      Assertions.assertFalse(
+          catalog.asTableCatalog().tableExists(NameIdentifier.of(SCHEMA_NAME, 
name)));
+      Assertions.assertFalse(Files.exists(location));
+    }
+
+    String original = "nonempty_overwrite";
+    List<String> ids = List.of(CATALOG_NAME, SCHEMA_NAME, original);
+    String location = tempDir.resolve(original).toString();
+    try (VectorSchemaRoot root =
+        VectorSchemaRoot.of(
+            new IntVector("id", allocator), new VarCharVector("value", 
allocator))) {
+      createTable(
+          ids, location, Map.of(), 
ArrowUtils.generateIpcStream(root.getSchema()), "create");
+    }
+    assertNonEmptyCreateRejected(ids, location, data, "overwrite");
+    DescribeTableRequest describe = new DescribeTableRequest();
+    describe.setId(ids);
+    Assertions.assertEquals(
+        List.of("id", "value"),
+        ns.describeTable(describe).getSchema().getFields().stream()
+            .map(JsonArrowField::getName)
+            .toList());
+    try (Dataset dataset = Dataset.open().uri(location).build()) {
+      Assertions.assertEquals(0, dataset.countRows());
+      Assertions.assertEquals(2, dataset.getSchema().getFields().size());
+    }
+  }
+
   @Test
   void testCreateTable() throws IOException {
     catalog = createCatalog(CATALOG_NAME);
@@ -968,6 +1013,40 @@ public class LanceRESTServiceIT extends BaseIT {
     Assertions.assertFalse(new File(anotherLocation).exists());
   }
 
+  private void assertNonEmptyCreateRejected(
+      List<String> ids, String location, byte[] data, String mode) {
+    ApiException error =
+        Assertions.assertThrows(
+            ApiException.class,
+            () ->
+                createTableApi()
+                    .createTable(
+                        String.join(DELIMITER, ids),
+                        data,
+                        DELIMITER,
+                        mode,
+                        null,
+                        null,
+                        Map.of(LanceConstants.LANCE_TABLE_LOCATION_HEADER, 
location)));
+    Assertions.assertEquals(406, error.getCode());
+  }
+
+  private byte[] arrowStreamWithEmptyThenNonEmptyBatch() throws IOException {
+    try (VectorSchemaRoot root = VectorSchemaRoot.of(new IntVector("id", 
allocator));
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        ArrowStreamWriter writer = new ArrowStreamWriter(root, null, output)) {
+      root.allocateNew();
+      root.setRowCount(0);
+      writer.start();
+      writer.writeBatch();
+      ((IntVector) root.getVector("id")).setSafe(0, 42);
+      root.setRowCount(1);
+      writer.writeBatch();
+      writer.end();
+      return output.toByteArray();
+    }
+  }
+
   private CreateTableResponse createTable(
       List<String> ids,
       String location,

Reply via email to