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 7e22e56330 [format] Support format metadata for ORC and Parquet (#8347)
7e22e56330 is described below

commit 7e22e56330af73c3de7747f760fb6a4a8af42d1d
Author: lxy <[email protected]>
AuthorDate: Thu Jun 25 10:42:19 2026 +0800

    [format] Support format metadata for ORC and Parquet (#8347)
    
    This is a sub-PR for [shared-shredding MAP write
    
support](https://cwiki.apache.org/confluence/display/PAIMON/PIP-43%3A+Columnar+Storage+Optimization+for+MAP+Type+in+Paimon).
    
    Shared-shredding needs to attach file-level and per-field metadata, such
    as field dictionaries and physical column mappings, before ORC/Parquet
    files are finalized. This PR adds a format-level metadata write/read
    path so upper layers can pass metadata to format writers and read back
    per-field metadata from file schemas.
    
    The per-field metadata is stored through Arrow IPC schema metadata under
    the standard `ARROW:schema` key, matching the Arrow schema metadata
    representation used by Arrow Parquet/ORC integrations. To avoid
    introducing Arrow runtime dependencies into `paimon-format` and
    `paimon-bundle`, this PR implements a small Arrow schema metadata
    encoder/decoder in `paimon-format` based on the Arrow IPC FlatBuffers
    layout. RowType-to-Arrow field conversion is kept in sync with
    `paimon-arrow`'s `ArrowUtils`, and compatibility is covered in
    `paimon-arrow` tests.
---
 paimon-arrow/pom.xml                               |   7 +
 .../ArrowSchemaMetadataCompatibilityTest.java      | 113 ++++
 paimon-format/pom.xml                              |  16 +
 .../apache/paimon/format/ArrowSchemaMetadata.java  | 691 +++++++++++++++++++++
 .../apache/paimon/format/FormatMetadataUtils.java  | 102 +++
 .../paimon/format/SupportsReaderFieldMetadata.java |  35 ++
 .../paimon/format/SupportsWriterMetadata.java      |  33 +
 .../apache/paimon/format/orc/OrcReaderFactory.java |  64 +-
 .../paimon/format/orc/writer/OrcBulkWriter.java    |  29 +-
 .../format/parquet/ParquetWriterFactory.java       |  25 +-
 .../reader/VectorizedParquetRecordReader.java      |  25 +-
 .../parquet/writer/MetadataParquetBuilder.java     |  37 ++
 .../parquet/writer/ParquetMetadataBulkWriter.java  |  60 ++
 .../parquet/writer/ParquetRowDataBuilder.java      |  18 +
 .../parquet/writer/RowDataParquetBuilder.java      |  13 +-
 .../paimon/format/FormatMetadataUtilsTest.java     | 148 +++++
 .../paimon/format/orc/OrcFormatReadWriteTest.java  |  67 ++
 .../format/parquet/ParquetFormatReadWriteTest.java |  68 ++
 18 files changed, 1536 insertions(+), 15 deletions(-)

diff --git a/paimon-arrow/pom.xml b/paimon-arrow/pom.xml
index 8adc682015..7472dd7b0b 100644
--- a/paimon-arrow/pom.xml
+++ b/paimon-arrow/pom.xml
@@ -41,6 +41,13 @@ under the License.
             <scope>provided</scope>
         </dependency>
 
+        <dependency>
+            <groupId>org.apache.paimon</groupId>
+            <artifactId>paimon-format</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
+
         <dependency>
             <groupId>org.apache.arrow</groupId>
             <artifactId>arrow-vector</artifactId>
diff --git 
a/paimon-arrow/src/test/java/org/apache/paimon/arrow/ArrowSchemaMetadataCompatibilityTest.java
 
b/paimon-arrow/src/test/java/org/apache/paimon/arrow/ArrowSchemaMetadataCompatibilityTest.java
new file mode 100644
index 0000000000..165383bc34
--- /dev/null
+++ 
b/paimon-arrow/src/test/java/org/apache/paimon/arrow/ArrowSchemaMetadataCompatibilityTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.arrow;
+
+import org.apache.paimon.format.FormatMetadataUtils;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+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.Test;
+
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Compatibility tests between Arrow Java schema serialization and format 
metadata utilities. */
+public class ArrowSchemaMetadataCompatibilityTest {
+
+    @Test
+    public void testFormatMetadataCanBeReadByArrowJava() {
+        RowType rowType = rowType();
+        Map<String, String> tagsMetadata = tagsMetadata();
+        Map<String, Map<String, String>> fieldMetadata = new LinkedHashMap<>();
+        fieldMetadata.put("tags", tagsMetadata);
+
+        byte[] schemaBytes =
+                FormatMetadataUtils.buildArrowSchemaMetadata(
+                        rowType, fieldMetadata, 
FormatMetadataUtils.PARQUET_FIELD_ID_KEY);
+        Schema schema = 
Schema.deserializeMessage(ByteBuffer.wrap(schemaBytes));
+
+        
assertThat(schema.getFields()).extracting(Field::getName).containsExactly("id", 
"tags");
+        assertThat(schema.findField("id").getMetadata())
+                .containsEntry(ArrowUtils.PARQUET_FIELD_ID, "0");
+        assertThat(schema.findField("tags").getMetadata())
+                .containsEntry(ArrowUtils.PARQUET_FIELD_ID, "1")
+                .containsAllEntriesOf(tagsMetadata);
+    }
+
+    @Test
+    public void testArrowJavaSchemaCanBeReadByFormatMetadata() {
+        RowType rowType = rowType();
+        Map<String, String> tagsMetadata = tagsMetadata();
+        List<Field> fields = new ArrayList<>();
+        for (DataField field : rowType.getFields()) {
+            Field arrowField = ArrowUtils.toArrowField(field.name(), 
field.id(), field.type(), 0);
+            if ("tags".equals(field.name())) {
+                arrowField = withMetadata(arrowField, tagsMetadata);
+            }
+            fields.add(arrowField);
+        }
+        byte[] schemaBytes = new Schema(fields).serializeAsMessage();
+
+        Map<String, Map<String, String>> metadata =
+                FormatMetadataUtils.readFieldMetadata(schemaBytes);
+
+        assertThat(metadata).containsOnlyKeys("id", "tags");
+        
assertThat(metadata.get("id")).containsEntry(ArrowUtils.PARQUET_FIELD_ID, "0");
+        assertThat(metadata.get("tags"))
+                .containsEntry(ArrowUtils.PARQUET_FIELD_ID, "1")
+                .containsAllEntriesOf(tagsMetadata);
+    }
+
+    private static RowType rowType() {
+        return DataTypes.ROW(
+                DataTypes.FIELD(0, "id", DataTypes.INT()),
+                DataTypes.FIELD(1, "tags", DataTypes.MAP(DataTypes.STRING(), 
DataTypes.INT())));
+    }
+
+    private static Map<String, String> tagsMetadata() {
+        Map<String, String> metadata = new LinkedHashMap<>();
+        metadata.put("paimon.test.tags", "enabled");
+        metadata.put("paimon.test.version", "1");
+        return metadata;
+    }
+
+    private static Field withMetadata(Field field, Map<String, String> 
metadata) {
+        Map<String, String> merged = new LinkedHashMap<>();
+        merged.putAll(metadata);
+        merged.putAll(field.getMetadata());
+        FieldType fieldType = field.getFieldType();
+        return new Field(
+                field.getName(),
+                new FieldType(
+                        fieldType.isNullable(),
+                        fieldType.getType(),
+                        fieldType.getDictionary(),
+                        merged),
+                field.getChildren());
+    }
+}
diff --git a/paimon-format/pom.xml b/paimon-format/pom.xml
index b4136797b8..867831fe02 100644
--- a/paimon-format/pom.xml
+++ b/paimon-format/pom.xml
@@ -37,6 +37,7 @@ under the License.
         <commons.lang3.version>3.18.0</commons.lang3.version>
         <storage-api.version>2.8.1</storage-api.version>
         <commons.io.version>2.16.1</commons.io.version>
+        <flatbuffers.version>23.5.26</flatbuffers.version>
     </properties>
 
     <dependencies>
@@ -53,6 +54,12 @@ under the License.
             <version>${snappy.version}</version>
         </dependency>
 
+        <dependency>
+            <groupId>com.google.flatbuffers</groupId>
+            <artifactId>flatbuffers-java</artifactId>
+            <version>${flatbuffers.version}</version>
+        </dependency>
+
         <dependency>
             <groupId>com.github.luben</groupId>
             <artifactId>zstd-jni</artifactId>
@@ -353,6 +360,9 @@ under the License.
                                     
<include>commons-pool:commons-pool</include>
                                     
<include>org.locationtech.jts:jts-core</include>
 
+                                    <!-- Arrow schema metadata encoding -->
+                                    
<include>com.google.flatbuffers:flatbuffers-java</include>
+
                                     <!-- compress -->
                                     
<include>com.github.luben:zstd-jni</include>
                                 </includes>
@@ -417,6 +427,12 @@ under the License.
                                     
<shadedPattern>org.apache.paimon.shade.org.locationtech.jts</shadedPattern>
                                 </relocation>
 
+                                <!-- Relocate Arrow schema metadata encoding 
dependency. -->
+                                <relocation>
+                                    <pattern>com.google.flatbuffers</pattern>
+                                    
<shadedPattern>org.apache.paimon.shade.com.google.flatbuffers</shadedPattern>
+                                </relocation>
+
                                 <!-- Relocate Common. -->
                                 <relocation>
                                     <pattern>org.apache.commons</pattern>
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/ArrowSchemaMetadata.java 
b/paimon-format/src/main/java/org/apache/paimon/format/ArrowSchemaMetadata.java
new file mode 100644
index 0000000000..371d6d8593
--- /dev/null
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/ArrowSchemaMetadata.java
@@ -0,0 +1,691 @@
+/*
+ * 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.format;
+
+import org.apache.paimon.data.variant.Variant;
+import org.apache.paimon.table.SpecialFields;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.BigIntType;
+import org.apache.paimon.types.BinaryType;
+import org.apache.paimon.types.BlobType;
+import org.apache.paimon.types.BooleanType;
+import org.apache.paimon.types.CharType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeVisitor;
+import org.apache.paimon.types.DateType;
+import org.apache.paimon.types.DecimalType;
+import org.apache.paimon.types.DoubleType;
+import org.apache.paimon.types.FloatType;
+import org.apache.paimon.types.IntType;
+import org.apache.paimon.types.LocalZonedTimestampType;
+import org.apache.paimon.types.MapType;
+import org.apache.paimon.types.MultisetType;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.types.SmallIntType;
+import org.apache.paimon.types.TimeType;
+import org.apache.paimon.types.TimestampType;
+import org.apache.paimon.types.TinyIntType;
+import org.apache.paimon.types.VarBinaryType;
+import org.apache.paimon.types.VarCharType;
+import org.apache.paimon.types.VariantType;
+import org.apache.paimon.types.VectorType;
+
+import com.google.flatbuffers.FlatBufferBuilder;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * Minimal Arrow IPC schema metadata encoder and decoder used by format 
metadata.
+ *
+ * <p>NOTE: The RowType-to-Arrow-field conversion in this class is copied from 
{@code
+ * org.apache.paimon.arrow.ArrowUtils} and must be kept in sync with that 
class. The IPC
+ * serialization code is a minimal implementation of the public Arrow IPC 
FlatBuffers layout used by
+ * {@code ARROW:schema}. It implements only the subset needed by Paimon field 
metadata so that
+ * {@code paimon-format} can stay compatible with Arrow metadata without 
depending on the Arrow
+ * runtime.
+ */
+class ArrowSchemaMetadata {
+
+    private static final String LIST_DATA_VECTOR_NAME = "$data$";
+    private static final String MAP_DATA_VECTOR_NAME = "entries";
+    private static final String MAP_KEY_NAME = "key";
+    private static final String MAP_VALUE_NAME = "value";
+
+    private static final int CONTINUATION_TOKEN = -1;
+
+    private static final short METADATA_VERSION_V5 = 4;
+    private static final short ENDIANNESS_LITTLE = 0;
+
+    private static final byte MESSAGE_HEADER_SCHEMA = 1;
+
+    private static final byte TYPE_NULL = 1;
+    private static final byte TYPE_INT = 2;
+    private static final byte TYPE_FLOATING_POINT = 3;
+    private static final byte TYPE_BINARY = 4;
+    private static final byte TYPE_UTF8 = 5;
+    private static final byte TYPE_BOOL = 6;
+    private static final byte TYPE_DECIMAL = 7;
+    private static final byte TYPE_DATE = 8;
+    private static final byte TYPE_TIME = 9;
+    private static final byte TYPE_TIMESTAMP = 10;
+    private static final byte TYPE_LIST = 12;
+    private static final byte TYPE_STRUCT = 13;
+    private static final byte TYPE_FIXED_SIZE_LIST = 16;
+    private static final byte TYPE_MAP = 17;
+
+    private static final short PRECISION_SINGLE = 1;
+    private static final short PRECISION_DOUBLE = 2;
+
+    private static final short DATE_UNIT_DAY = 0;
+
+    private static final short TIME_UNIT_SECOND = 0;
+    private static final short TIME_UNIT_MILLISECOND = 1;
+    private static final short TIME_UNIT_MICROSECOND = 2;
+    private static final short TIME_UNIT_NANOSECOND = 3;
+
+    private ArrowSchemaMetadata() {}
+
+    static byte[] serialize(
+            RowType rowType, Map<String, Map<String, String>> fieldMetadata, 
String fieldIdKey) {
+        FlatBufferBuilder builder = new FlatBufferBuilder();
+        int schemaOffset =
+                buildSchema(
+                        builder,
+                        rowType.getFields().stream()
+                                .map(
+                                        field ->
+                                                withMetadata(
+                                                        toArrowField(
+                                                                field.name(),
+                                                                field.id(),
+                                                                field.type(),
+                                                                0,
+                                                                fieldIdKey),
+                                                        
fieldMetadata.get(field.name())))
+                                .collect(Collectors.toList()));
+        int messageOffset =
+                buildMessage(builder, MESSAGE_HEADER_SCHEMA, schemaOffset, 0L, 
METADATA_VERSION_V5);
+        builder.finish(messageOffset);
+        return withIpcMessagePrefix(builder.sizedByteArray());
+    }
+
+    /** Reads top-level field metadata from serialized Arrow IPC 
schema-message bytes. */
+    static Map<String, Map<String, String>> readFieldMetadata(byte[] bytes) {
+        ByteBuffer buffer = metadataBuffer(bytes);
+        int messageTable = rootTable(buffer);
+        if (readByte(buffer, messageTable, 1, (byte) 0) != 
MESSAGE_HEADER_SCHEMA) {
+            return Collections.emptyMap();
+        }
+        int schemaTable = readTableUnion(buffer, messageTable, 2);
+        if (schemaTable == 0) {
+            return Collections.emptyMap();
+        }
+        int fieldsVector = readVector(buffer, schemaTable, 1);
+        Map<String, Map<String, String>> result = new LinkedHashMap<>();
+        for (int i = 0; i < vectorLength(buffer, fieldsVector); i++) {
+            int fieldTable = vectorTable(buffer, fieldsVector, i);
+            Map<String, String> metadata = readMetadata(buffer, fieldTable);
+            if (!metadata.isEmpty()) {
+                result.put(
+                        readString(buffer, fieldTable, 0), 
Collections.unmodifiableMap(metadata));
+            }
+        }
+        return Collections.unmodifiableMap(result);
+    }
+
+    private static int buildSchema(FlatBufferBuilder builder, List<ArrowField> 
fields) {
+        int[] fieldOffsets = new int[fields.size()];
+        for (int i = 0; i < fields.size(); i++) {
+            fieldOffsets[i] = buildField(builder, fields.get(i));
+        }
+        int fieldsVector = createVectorOfTables(builder, fieldOffsets);
+
+        builder.startTable(4);
+        builder.addShort(0, ENDIANNESS_LITTLE, 0);
+        builder.addOffset(1, fieldsVector, 0);
+        return builder.endTable();
+    }
+
+    private static int buildField(FlatBufferBuilder builder, ArrowField field) 
{
+        int name = builder.createString(field.name);
+        int type = buildType(builder, field.type);
+        int[] childOffsets = new int[field.children.size()];
+        for (int i = 0; i < field.children.size(); i++) {
+            childOffsets[i] = buildField(builder, field.children.get(i));
+        }
+        int children = createVectorOfTables(builder, childOffsets);
+        int metadata = buildMetadata(builder, field.metadata);
+
+        builder.startTable(7);
+        builder.addOffset(0, name, 0);
+        builder.addBoolean(1, field.nullable, false);
+        builder.addByte(2, field.type.typeType, 0);
+        builder.addOffset(3, type, 0);
+        builder.addOffset(5, children, 0);
+        builder.addOffset(6, metadata, 0);
+        return builder.endTable();
+    }
+
+    private static int buildMetadata(FlatBufferBuilder builder, Map<String, 
String> metadata) {
+        int[] offsets = new int[metadata.size()];
+        int index = 0;
+        for (Map.Entry<String, String> entry : metadata.entrySet()) {
+            int key = builder.createString(entry.getKey());
+            int value = builder.createString(entry.getValue());
+            builder.startTable(2);
+            builder.addOffset(0, key, 0);
+            builder.addOffset(1, value, 0);
+            offsets[index++] = builder.endTable();
+        }
+        return createVectorOfTables(builder, offsets);
+    }
+
+    private static int buildMessage(
+            FlatBufferBuilder builder,
+            byte headerType,
+            int header,
+            long bodyLength,
+            short version) {
+        builder.startTable(5);
+        builder.addShort(0, version, 0);
+        builder.addByte(1, headerType, 0);
+        builder.addOffset(2, header, 0);
+        builder.addLong(3, bodyLength, 0L);
+        return builder.endTable();
+    }
+
+    private static int buildType(FlatBufferBuilder builder, ArrowTypeInfo 
type) {
+        switch (type.typeType) {
+            case TYPE_NULL:
+            case TYPE_BINARY:
+            case TYPE_UTF8:
+            case TYPE_BOOL:
+            case TYPE_LIST:
+            case TYPE_STRUCT:
+                builder.startTable(0);
+                return builder.endTable();
+            case TYPE_INT:
+                builder.startTable(2);
+                builder.addInt(0, type.bitWidth, 0);
+                builder.addBoolean(1, type.signed, false);
+                return builder.endTable();
+            case TYPE_FLOATING_POINT:
+                builder.startTable(1);
+                builder.addShort(0, type.precision, 0);
+                return builder.endTable();
+            case TYPE_DECIMAL:
+                builder.startTable(3);
+                builder.addInt(0, type.precisionValue, 0);
+                builder.addInt(1, type.scale, 0);
+                builder.addInt(2, type.bitWidth, 0);
+                return builder.endTable();
+            case TYPE_DATE:
+                builder.startTable(1);
+                builder.addShort(0, DATE_UNIT_DAY, 0);
+                return builder.endTable();
+            case TYPE_TIME:
+                builder.startTable(2);
+                builder.addShort(0, type.unit, 0);
+                builder.addInt(1, type.bitWidth, 0);
+                return builder.endTable();
+            case TYPE_TIMESTAMP:
+                int timezone = type.timezone == null ? 0 : 
builder.createString(type.timezone);
+                builder.startTable(2);
+                builder.addShort(0, type.unit, 0);
+                if (timezone != 0) {
+                    builder.addOffset(1, timezone, 0);
+                }
+                return builder.endTable();
+            case TYPE_FIXED_SIZE_LIST:
+                builder.startTable(1);
+                builder.addInt(0, type.listSize, 0);
+                return builder.endTable();
+            case TYPE_MAP:
+                builder.startTable(1);
+                builder.addBoolean(0, false, false);
+                return builder.endTable();
+            default:
+                throw new UnsupportedOperationException("Unsupported Arrow 
type " + type.typeType);
+        }
+    }
+
+    private static int createVectorOfTables(FlatBufferBuilder builder, int[] 
offsets) {
+        builder.startVector(4, offsets.length, 4);
+        for (int i = offsets.length - 1; i >= 0; i--) {
+            builder.addOffset(offsets[i]);
+        }
+        return builder.endVector();
+    }
+
+    private static byte[] withIpcMessagePrefix(byte[] metadata) {
+        int paddedLength = align(metadata.length, 8);
+        ByteBuffer buffer = ByteBuffer.allocate(8 + 
paddedLength).order(ByteOrder.LITTLE_ENDIAN);
+        buffer.putInt(CONTINUATION_TOKEN);
+        buffer.putInt(paddedLength);
+        buffer.put(metadata);
+        return buffer.array();
+    }
+
+    private static int align(int value, int alignment) {
+        int remainder = value % alignment;
+        return remainder == 0 ? value : value + alignment - remainder;
+    }
+
+    private static ByteBuffer metadataBuffer(byte[] bytes) {
+        ByteBuffer buffer = 
ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);
+        if (buffer.remaining() >= 8 && buffer.getInt(0) == CONTINUATION_TOKEN) 
{
+            int metadataLength = buffer.getInt(4);
+            ByteBuffer metadata = 
buffer.duplicate().order(ByteOrder.LITTLE_ENDIAN);
+            metadata.position(8);
+            metadata.limit(8 + metadataLength);
+            return metadata.slice().order(ByteOrder.LITTLE_ENDIAN);
+        }
+        return buffer;
+    }
+
+    private static int rootTable(ByteBuffer buffer) {
+        return buffer.position() + buffer.getInt(buffer.position());
+    }
+
+    private static int vtable(ByteBuffer buffer, int table) {
+        return table - buffer.getInt(table);
+    }
+
+    private static int fieldOffset(ByteBuffer buffer, int table, int field) {
+        int vtable = vtable(buffer, table);
+        int offset = 4 + field * 2;
+        return offset < buffer.getShort(vtable) ? buffer.getShort(vtable + 
offset) : 0;
+    }
+
+    private static byte readByte(ByteBuffer buffer, int table, int field, byte 
defaultValue) {
+        int offset = fieldOffset(buffer, table, field);
+        return offset == 0 ? defaultValue : buffer.get(table + offset);
+    }
+
+    private static int readVector(ByteBuffer buffer, int table, int field) {
+        int offset = fieldOffset(buffer, table, field);
+        if (offset == 0) {
+            return 0;
+        }
+        int vectorOffset = table + offset;
+        return vectorOffset + buffer.getInt(vectorOffset);
+    }
+
+    private static int readTableUnion(ByteBuffer buffer, int table, int field) 
{
+        int offset = fieldOffset(buffer, table, field);
+        if (offset == 0) {
+            return 0;
+        }
+        int unionOffset = table + offset;
+        return unionOffset + buffer.getInt(unionOffset);
+    }
+
+    private static int vectorLength(ByteBuffer buffer, int vector) {
+        return vector == 0 ? 0 : buffer.getInt(vector);
+    }
+
+    private static int vectorTable(ByteBuffer buffer, int vector, int index) {
+        int element = vector + 4 + index * 4;
+        return element + buffer.getInt(element);
+    }
+
+    private static String readString(ByteBuffer buffer, int table, int field) {
+        int offset = fieldOffset(buffer, table, field);
+        if (offset == 0) {
+            return null;
+        }
+        int stringOffset = table + offset;
+        int string = stringOffset + buffer.getInt(stringOffset);
+        int length = buffer.getInt(string);
+        byte[] bytes = new byte[length];
+        ByteBuffer duplicate = buffer.duplicate();
+        duplicate.position(string + 4);
+        duplicate.get(bytes);
+        return new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
+    }
+
+    private static Map<String, String> readMetadata(ByteBuffer buffer, int 
fieldTable) {
+        int metadataVector = readVector(buffer, fieldTable, 6);
+        Map<String, String> metadata = new LinkedHashMap<>();
+        for (int i = 0; i < vectorLength(buffer, metadataVector); i++) {
+            int keyValue = vectorTable(buffer, metadataVector, i);
+            metadata.put(readString(buffer, keyValue, 0), readString(buffer, 
keyValue, 1));
+        }
+        return metadata;
+    }
+
+    private static ArrowField withMetadata(ArrowField field, Map<String, 
String> metadata) {
+        if (metadata == null || metadata.isEmpty()) {
+            return field;
+        }
+        Map<String, String> result = new LinkedHashMap<>();
+        result.putAll(metadata);
+        result.putAll(field.metadata);
+        return new ArrowField(field.name, field.nullable, field.type, 
field.children, result);
+    }
+
+    private static ArrowField toArrowField(
+            String fieldName, int fieldId, DataType dataType, int depth, 
String fieldIdKey) {
+        ArrowTypeInfo type = dataType.accept(ArrowFieldTypeVisitor.INSTANCE);
+        Map<String, String> metadata = fieldIdMetadata(fieldId, fieldIdKey);
+        List<ArrowField> children = Collections.emptyList();
+        if (dataType instanceof ArrayType || dataType instanceof VectorType) {
+            DataType elementType =
+                    dataType instanceof VectorType
+                            ? ((VectorType) dataType).getElementType()
+                            : ((ArrayType) dataType).getElementType();
+            ArrowField field =
+                    toArrowField(
+                            LIST_DATA_VECTOR_NAME, fieldId, elementType, depth 
+ 1, fieldIdKey);
+            if (fieldIdKey != null) {
+                field =
+                        field.withMetadata(
+                                Collections.singletonMap(
+                                        fieldIdKey,
+                                        String.valueOf(
+                                                
SpecialFields.getArrayElementFieldId(
+                                                        fieldId, depth + 1))));
+            }
+            children = Collections.singletonList(field);
+        } else if (dataType instanceof MapType) {
+            children =
+                    Collections.singletonList(
+                            toArrowMapEntryField(fieldId, (MapType) dataType, 
depth, fieldIdKey));
+        } else if (dataType instanceof VariantType) {
+            children =
+                    Arrays.asList(
+                            new ArrowField(
+                                    Variant.VALUE,
+                                    false,
+                                    ArrowTypeInfo.simple(TYPE_BINARY),
+                                    Collections.emptyList(),
+                                    Collections.emptyMap()),
+                            new ArrowField(
+                                    Variant.METADATA,
+                                    false,
+                                    ArrowTypeInfo.simple(TYPE_BINARY),
+                                    Collections.emptyList(),
+                                    Collections.emptyMap()));
+        } else if (dataType instanceof RowType) {
+            RowType rowType = (RowType) dataType;
+            List<ArrowField> rowChildren = new ArrayList<>();
+            for (DataField field : rowType.getFields()) {
+                rowChildren.add(
+                        toArrowField(field.name(), field.id(), field.type(), 
0, fieldIdKey));
+            }
+            children = rowChildren;
+        }
+        return new ArrowField(fieldName, dataType.isNullable(), type, 
children, metadata);
+    }
+
+    private static ArrowField toArrowMapEntryField(
+            int fieldId, MapType mapType, int depth, String fieldIdKey) {
+        ArrowField keyField =
+                toArrowField(
+                        MAP_KEY_NAME,
+                        fieldId,
+                        mapType.getKeyType().notNull(),
+                        depth + 1,
+                        fieldIdKey);
+        if (fieldIdKey != null) {
+            keyField =
+                    keyField.withMetadata(
+                            Collections.singletonMap(
+                                    fieldIdKey,
+                                    String.valueOf(
+                                            
SpecialFields.getMapKeyFieldId(fieldId, depth + 1))));
+        }
+
+        ArrowField valueField =
+                toArrowField(
+                        MAP_VALUE_NAME, fieldId, mapType.getValueType(), depth 
+ 1, fieldIdKey);
+        if (fieldIdKey != null) {
+            valueField =
+                    valueField.withMetadata(
+                            Collections.singletonMap(
+                                    fieldIdKey,
+                                    String.valueOf(
+                                            
SpecialFields.getMapValueFieldId(fieldId, depth + 1))));
+        }
+
+        return new ArrowField(
+                MAP_DATA_VECTOR_NAME,
+                false,
+                ArrowTypeInfo.simple(TYPE_STRUCT),
+                Arrays.asList(keyField, valueField),
+                fieldIdMetadata(fieldId, fieldIdKey));
+    }
+
+    private static Map<String, String> fieldIdMetadata(int fieldId, String 
fieldIdKey) {
+        return fieldIdKey == null
+                ? Collections.emptyMap()
+                : Collections.singletonMap(fieldIdKey, 
String.valueOf(fieldId));
+    }
+
+    private static class ArrowField {
+        private final String name;
+        private final boolean nullable;
+        private final ArrowTypeInfo type;
+        private final List<ArrowField> children;
+        private final Map<String, String> metadata;
+
+        private ArrowField(
+                String name,
+                boolean nullable,
+                ArrowTypeInfo type,
+                List<ArrowField> children,
+                Map<String, String> metadata) {
+            this.name = name;
+            this.nullable = nullable;
+            this.type = type;
+            this.children = children;
+            this.metadata = metadata;
+        }
+
+        private ArrowField withMetadata(Map<String, String> metadata) {
+            return new ArrowField(name, nullable, type, children, metadata);
+        }
+    }
+
+    private static class ArrowTypeInfo {
+        private final byte typeType;
+        private int bitWidth;
+        private boolean signed;
+        private short precision;
+        private int precisionValue;
+        private int scale;
+        private short unit;
+        private String timezone;
+        private int listSize;
+
+        private ArrowTypeInfo(byte typeType) {
+            this.typeType = typeType;
+        }
+
+        private static ArrowTypeInfo simple(byte typeType) {
+            return new ArrowTypeInfo(typeType);
+        }
+    }
+
+    private static class ArrowFieldTypeVisitor implements 
DataTypeVisitor<ArrowTypeInfo> {
+
+        private static final ArrowFieldTypeVisitor INSTANCE = new 
ArrowFieldTypeVisitor();
+
+        @Override
+        public ArrowTypeInfo visit(CharType charType) {
+            return ArrowTypeInfo.simple(TYPE_UTF8);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(VarCharType varCharType) {
+            return ArrowTypeInfo.simple(TYPE_UTF8);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(BooleanType booleanType) {
+            return ArrowTypeInfo.simple(TYPE_BOOL);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(BinaryType binaryType) {
+            return ArrowTypeInfo.simple(TYPE_BINARY);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(VarBinaryType varBinaryType) {
+            return ArrowTypeInfo.simple(TYPE_BINARY);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(DecimalType decimalType) {
+            ArrowTypeInfo type = ArrowTypeInfo.simple(TYPE_DECIMAL);
+            type.precisionValue = decimalType.getPrecision();
+            type.scale = decimalType.getScale();
+            type.bitWidth = 128;
+            return type;
+        }
+
+        @Override
+        public ArrowTypeInfo visit(TinyIntType tinyIntType) {
+            return integer(8);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(SmallIntType smallIntType) {
+            return integer(16);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(IntType intType) {
+            return integer(32);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(BigIntType bigIntType) {
+            return integer(64);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(FloatType floatType) {
+            ArrowTypeInfo type = ArrowTypeInfo.simple(TYPE_FLOATING_POINT);
+            type.precision = PRECISION_SINGLE;
+            return type;
+        }
+
+        @Override
+        public ArrowTypeInfo visit(DoubleType doubleType) {
+            ArrowTypeInfo type = ArrowTypeInfo.simple(TYPE_FLOATING_POINT);
+            type.precision = PRECISION_DOUBLE;
+            return type;
+        }
+
+        @Override
+        public ArrowTypeInfo visit(DateType dateType) {
+            return ArrowTypeInfo.simple(TYPE_DATE);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(TimeType timeType) {
+            ArrowTypeInfo type = ArrowTypeInfo.simple(TYPE_TIME);
+            type.unit = TIME_UNIT_MILLISECOND;
+            type.bitWidth = 32;
+            return type;
+        }
+
+        @Override
+        public ArrowTypeInfo visit(TimestampType timestampType) {
+            ArrowTypeInfo type = ArrowTypeInfo.simple(TYPE_TIMESTAMP);
+            type.unit = getTimeUnit(timestampType.getPrecision());
+            return type;
+        }
+
+        @Override
+        public ArrowTypeInfo visit(LocalZonedTimestampType 
localZonedTimestampType) {
+            ArrowTypeInfo type = ArrowTypeInfo.simple(TYPE_TIMESTAMP);
+            type.unit = getTimeUnit(localZonedTimestampType.getPrecision());
+            type.timezone = "UTC";
+            return type;
+        }
+
+        @Override
+        public ArrowTypeInfo visit(VariantType variantType) {
+            return ArrowTypeInfo.simple(TYPE_STRUCT);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(BlobType blobType) {
+            throw new UnsupportedOperationException("Doesn't support 
BlobType.");
+        }
+
+        @Override
+        public ArrowTypeInfo visit(ArrayType arrayType) {
+            return ArrowTypeInfo.simple(TYPE_LIST);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(VectorType vectorType) {
+            ArrowTypeInfo type = ArrowTypeInfo.simple(TYPE_FIXED_SIZE_LIST);
+            type.listSize = vectorType.getLength();
+            return type;
+        }
+
+        @Override
+        public ArrowTypeInfo visit(MultisetType multisetType) {
+            throw new UnsupportedOperationException("Doesn't support 
MultisetType.");
+        }
+
+        @Override
+        public ArrowTypeInfo visit(MapType mapType) {
+            return ArrowTypeInfo.simple(TYPE_MAP);
+        }
+
+        @Override
+        public ArrowTypeInfo visit(RowType rowType) {
+            return ArrowTypeInfo.simple(TYPE_STRUCT);
+        }
+
+        private ArrowTypeInfo integer(int bitWidth) {
+            ArrowTypeInfo type = ArrowTypeInfo.simple(TYPE_INT);
+            type.bitWidth = bitWidth;
+            type.signed = true;
+            return type;
+        }
+
+        private short getTimeUnit(int precision) {
+            if (precision == 0) {
+                return TIME_UNIT_SECOND;
+            } else if (precision >= 1 && precision <= 3) {
+                return TIME_UNIT_MILLISECOND;
+            } else if (precision >= 4 && precision <= 6) {
+                return TIME_UNIT_MICROSECOND;
+            } else {
+                return TIME_UNIT_NANOSECOND;
+            }
+        }
+    }
+}
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/FormatMetadataUtils.java 
b/paimon-format/src/main/java/org/apache/paimon/format/FormatMetadataUtils.java
new file mode 100644
index 0000000000..342f1ad442
--- /dev/null
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/FormatMetadataUtils.java
@@ -0,0 +1,102 @@
+/*
+ * 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.format;
+
+import org.apache.paimon.types.RowType;
+
+import javax.annotation.Nullable;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/** Utilities for format metadata encoded at file boundaries. */
+public class FormatMetadataUtils {
+
+    public static final String ARROW_SCHEMA_METADATA_KEY = "ARROW:schema";
+    public static final String PARQUET_FIELD_ID_KEY = "PARQUET:field_id";
+
+    private FormatMetadataUtils() {}
+
+    /**
+     * Encodes raw metadata values as base64 strings so they can be stored in 
format key-value
+     * metadata.
+     */
+    public static Map<String, String> encodeMetadata(Map<String, byte[]> 
metadata) {
+        Map<String, String> encoded = new LinkedHashMap<>();
+        for (Map.Entry<String, byte[]> entry : metadata.entrySet()) {
+            encoded.put(entry.getKey(), 
Base64.getEncoder().encodeToString(entry.getValue()));
+        }
+        return encoded;
+    }
+
+    /**
+     * Decodes base64-encoded metadata values. Values that are not valid 
base64 are returned as
+     * UTF-8 bytes.
+     */
+    public static Map<String, byte[]> decodeMetadata(Map<String, String> 
metadata) {
+        Map<String, byte[]> decoded = new LinkedHashMap<>();
+        for (Map.Entry<String, String> entry : metadata.entrySet()) {
+            try {
+                decoded.put(entry.getKey(), 
Base64.getDecoder().decode(entry.getValue()));
+            } catch (IllegalArgumentException e) {
+                decoded.put(entry.getKey(), 
entry.getValue().getBytes(StandardCharsets.UTF_8));
+            }
+        }
+        return decoded;
+    }
+
+    /**
+     * Builds serialized Arrow schema metadata from a Paimon row type and 
injects metadata into
+     * top-level fields.
+     *
+     * <p>The keys of {@code fieldMetadata} are top-level field names. Nested 
fields are converted
+     * from the {@link RowType} but do not receive metadata from this map. If 
injected metadata
+     * conflicts with metadata produced during Arrow conversion, the Arrow 
conversion metadata wins
+     * to preserve format-specific field information. Set {@code fieldIdKey} 
to the field id
+     * metadata key used by the target format, or {@code null} if field id 
metadata should not be
+     * written.
+     */
+    public static byte[] buildArrowSchemaMetadata(
+            RowType rowType,
+            Map<String, Map<String, String>> fieldMetadata,
+            @Nullable String fieldIdKey) {
+        return ArrowSchemaMetadata.serialize(rowType, fieldMetadata, 
fieldIdKey);
+    }
+
+    /**
+     * Reads field metadata from serialized Arrow schema metadata.
+     *
+     * <p>The returned map contains top-level fields only, keyed by field 
name. If the input is
+     * {@code null} or cannot be parsed as an Arrow schema message, this 
method returns an empty
+     * map. Fields without any metadata are omitted from the returned map.
+     */
+    public static Map<String, Map<String, String>> readFieldMetadata(@Nullable 
byte[] schemaBytes) {
+        if (schemaBytes == null) {
+            return Collections.emptyMap();
+        }
+        try {
+            return ArrowSchemaMetadata.readFieldMetadata(schemaBytes);
+        } catch (RuntimeException e) {
+            return Collections.emptyMap();
+        }
+    }
+}
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/SupportsReaderFieldMetadata.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/SupportsReaderFieldMetadata.java
new file mode 100644
index 0000000000..0dc9f191fb
--- /dev/null
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/SupportsReaderFieldMetadata.java
@@ -0,0 +1,35 @@
+/*
+ * 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.format;
+
+import java.io.IOException;
+import java.util.Map;
+
+/** Reader capability for formats that can recover top-level field metadata. */
+public interface SupportsReaderFieldMetadata {
+
+    /**
+     * Reads metadata from top-level file fields.
+     *
+     * <p>The returned map is keyed by field name. Each value contains the 
metadata key-value pairs
+     * attached to that field. Implementations return an empty map when the 
file does not contain
+     * field metadata.
+     */
+    Map<String, Map<String, String>> readFieldMetadata() throws IOException;
+}
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/SupportsWriterMetadata.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/SupportsWriterMetadata.java
new file mode 100644
index 0000000000..06aa89029b
--- /dev/null
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/SupportsWriterMetadata.java
@@ -0,0 +1,33 @@
+/*
+ * 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.format;
+
+import java.util.Map;
+
+/** Writer capability for formats that can attach key-value metadata before 
close. */
+public interface SupportsWriterMetadata {
+
+    /**
+     * Adds file-level metadata to the writer.
+     *
+     * <p>The metadata values are raw bytes. Format implementations are 
responsible for converting
+     * them to the physical file metadata representation.
+     */
+    void addMetadata(Map<String, byte[]> metadata);
+}
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcReaderFactory.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcReaderFactory.java
index b1de74242a..63f07ad152 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcReaderFactory.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/orc/OrcReaderFactory.java
@@ -24,8 +24,10 @@ import org.apache.paimon.data.columnar.ColumnarRow;
 import org.apache.paimon.data.columnar.ColumnarRowIterator;
 import org.apache.paimon.data.columnar.VectorizedColumnBatch;
 import org.apache.paimon.data.columnar.VectorizedRowIterator;
+import org.apache.paimon.format.FormatMetadataUtils;
 import org.apache.paimon.format.FormatReaderFactory;
 import org.apache.paimon.format.OrcFormatReaderContext;
+import org.apache.paimon.format.SupportsReaderFieldMetadata;
 import org.apache.paimon.format.fs.HadoopReadOnlyFileSystem;
 import org.apache.paimon.format.orc.filter.OrcFilters;
 import org.apache.paimon.fs.FileIO;
@@ -54,7 +56,10 @@ import org.apache.orc.impl.RecordReaderImpl;
 import javax.annotation.Nullable;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 
 import static org.apache.paimon.format.orc.OrcTypeUtil.convertToOrcSchema;
 import static 
org.apache.paimon.format.orc.reader.AbstractOrcColumnVector.createPaimonVector;
@@ -104,7 +109,7 @@ public class OrcReaderFactory implements 
FormatReaderFactory {
         Pool<OrcReaderBatch> poolOfBatches =
                 createPoolOfBatches(context.filePath(), poolSize, 
context.fileIO());
 
-        RecordReader orcReader =
+        OrcRecordReader orcReader =
                 createRecordReader(
                         hadoopConfig,
                         schema,
@@ -224,12 +229,14 @@ public class OrcReaderFactory implements 
FormatReaderFactory {
      * batch is addressed by the starting row number of the batch, plus the 
number of records to be
      * skipped before.
      */
-    private static final class OrcVectorizedReader implements 
FileRecordReader<InternalRow> {
+    private static final class OrcVectorizedReader
+            implements FileRecordReader<InternalRow>, 
SupportsReaderFieldMetadata {
 
-        private final RecordReader orcReader;
+        private final OrcRecordReader orcReader;
         private final Pool<OrcReaderBatch> pool;
 
-        private OrcVectorizedReader(final RecordReader orcReader, final 
Pool<OrcReaderBatch> pool) {
+        private OrcVectorizedReader(
+                final OrcRecordReader orcReader, final Pool<OrcReaderBatch> 
pool) {
             this.orcReader = checkNotNull(orcReader, "orcReader");
             this.pool = checkNotNull(pool, "pool");
         }
@@ -240,8 +247,8 @@ public class OrcReaderFactory implements 
FormatReaderFactory {
             final OrcReaderBatch batch = getCachedEntry();
             final VectorizedRowBatch orcVectorBatch = 
batch.orcVectorizedRowBatch();
 
-            long rowNumber = orcReader.getRowNumber();
-            if (!nextBatch(orcReader, orcVectorBatch)) {
+            long rowNumber = orcReader.recordReader.getRowNumber();
+            if (!nextBatch(orcReader.recordReader, orcVectorBatch)) {
                 batch.recycle();
                 return null;
             }
@@ -249,9 +256,37 @@ public class OrcReaderFactory implements 
FormatReaderFactory {
             return batch.convertAndGetIterator(orcVectorBatch, rowNumber);
         }
 
+        @Override
+        public Map<String, Map<String, String>> readFieldMetadata() {
+            org.apache.orc.Reader fileReader = orcReader.fileReader;
+            if (!fileReader
+                    .getMetadataKeys()
+                    .contains(FormatMetadataUtils.ARROW_SCHEMA_METADATA_KEY)) {
+                return Collections.emptyMap();
+            }
+            String encodedSchema =
+                    StandardCharsets.UTF_8
+                            .decode(
+                                    fileReader
+                                            .getMetadataValue(
+                                                    
FormatMetadataUtils.ARROW_SCHEMA_METADATA_KEY)
+                                            .duplicate())
+                            .toString();
+            return FormatMetadataUtils.readFieldMetadata(
+                    FormatMetadataUtils.decodeMetadata(
+                                    Collections.singletonMap(
+                                            
FormatMetadataUtils.ARROW_SCHEMA_METADATA_KEY,
+                                            encodedSchema))
+                            
.get(FormatMetadataUtils.ARROW_SCHEMA_METADATA_KEY));
+        }
+
         @Override
         public void close() throws IOException {
-            orcReader.close();
+            try {
+                orcReader.recordReader.close();
+            } finally {
+                orcReader.fileReader.close();
+            }
         }
 
         private OrcReaderBatch getCachedEntry() throws IOException {
@@ -264,7 +299,18 @@ public class OrcReaderFactory implements 
FormatReaderFactory {
         }
     }
 
-    private static RecordReader createRecordReader(
+    private static final class OrcRecordReader {
+
+        private final org.apache.orc.Reader fileReader;
+        private final RecordReader recordReader;
+
+        private OrcRecordReader(org.apache.orc.Reader fileReader, RecordReader 
recordReader) {
+            this.fileReader = fileReader;
+            this.recordReader = recordReader;
+        }
+    }
+
+    private static OrcRecordReader createRecordReader(
             org.apache.hadoop.conf.Configuration conf,
             TypeDescription schema,
             List<OrcFilters.Predicate> conjunctPredicates,
@@ -314,7 +360,7 @@ public class OrcReaderFactory implements 
FormatReaderFactory {
             // assign ids
             schema.getId();
 
-            return orcRowsReader;
+            return new OrcRecordReader(orcReader, orcRowsReader);
         } catch (IOException e) {
             // exception happened, we need to close the reader
             IOUtils.closeQuietly(orcReader);
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/orc/writer/OrcBulkWriter.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/orc/writer/OrcBulkWriter.java
index c44e3f26d6..9655e193d6 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/orc/writer/OrcBulkWriter.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/orc/writer/OrcBulkWriter.java
@@ -20,7 +20,9 @@ package org.apache.paimon.format.orc.writer;
 
 import org.apache.paimon.annotation.VisibleForTesting;
 import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.format.FormatMetadataUtils;
 import org.apache.paimon.format.FormatWriter;
+import org.apache.paimon.format.SupportsWriterMetadata;
 import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.options.MemorySize;
 
@@ -28,19 +30,27 @@ import 
org.apache.hadoop.hive.ql.exec.vector.VectorizedRowBatch;
 import org.apache.orc.Writer;
 
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
 
 import static org.apache.paimon.utils.Preconditions.checkNotNull;
+import static org.apache.paimon.utils.Preconditions.checkState;
 
 /** A {@link FormatWriter} implementation that writes data in ORC format. */
-public class OrcBulkWriter implements FormatWriter {
+public class OrcBulkWriter implements FormatWriter, SupportsWriterMetadata {
 
     private final Writer writer;
     private final Vectorizer<InternalRow> vectorizer;
     private final VectorizedRowBatch rowBatch;
     private final PositionOutputStream underlyingStream;
+    private final Map<String, byte[]> metadata;
 
     private long currentBatchMemoryUsage = 0;
     private final long memoryLimit;
+    private boolean closed = false;
 
     public OrcBulkWriter(
             Vectorizer<InternalRow> vectorizer,
@@ -54,6 +64,16 @@ public class OrcBulkWriter implements FormatWriter {
         this.rowBatch = vectorizer.getSchema().createRowBatch(batchSize);
         this.underlyingStream = underlyingStream;
         this.memoryLimit = memoryLimit.getBytes();
+        this.metadata = new HashMap<>();
+    }
+
+    @Override
+    public void addMetadata(Map<String, byte[]> metadata) {
+        checkState(!closed, "Cannot add metadata after writer is closed.");
+        for (Map.Entry<String, byte[]> entry : metadata.entrySet()) {
+            this.metadata.put(
+                    entry.getKey(), Arrays.copyOf(entry.getValue(), 
entry.getValue().length));
+        }
     }
 
     @Override
@@ -74,7 +94,14 @@ public class OrcBulkWriter implements FormatWriter {
 
     @Override
     public void close() throws IOException {
+        this.closed = true;
         flush();
+        for (Map.Entry<String, String> entry :
+                FormatMetadataUtils.encodeMetadata(metadata).entrySet()) {
+            writer.addUserMetadata(
+                    entry.getKey(),
+                    
ByteBuffer.wrap(entry.getValue().getBytes(StandardCharsets.UTF_8)));
+        }
         writer.close();
     }
 
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetWriterFactory.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetWriterFactory.java
index 282805897a..a26a0f3d1a 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetWriterFactory.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/ParquetWriterFactory.java
@@ -22,8 +22,10 @@ import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.format.FormatWriter;
 import org.apache.paimon.format.FormatWriterFactory;
 import org.apache.paimon.format.HadoopCompressionType;
+import org.apache.paimon.format.parquet.writer.MetadataParquetBuilder;
 import org.apache.paimon.format.parquet.writer.ParquetBuilder;
 import org.apache.paimon.format.parquet.writer.ParquetBulkWriter;
+import org.apache.paimon.format.parquet.writer.ParquetMetadataBulkWriter;
 import org.apache.paimon.format.parquet.writer.RowDataParquetBuilder;
 import org.apache.paimon.format.parquet.writer.StreamOutputFile;
 import org.apache.paimon.format.variant.SupportsVariantInference;
@@ -34,6 +36,8 @@ import org.apache.parquet.hadoop.ParquetWriter;
 import org.apache.parquet.io.OutputFile;
 
 import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
 
 /** A factory that creates a Parquet {@link FormatWriter}. */
 public class ParquetWriterFactory implements FormatWriterFactory, 
SupportsVariantInference {
@@ -57,6 +61,11 @@ public class ParquetWriterFactory implements 
FormatWriterFactory, SupportsVarian
             compression = null;
         }
 
+        if (writerBuilder instanceof MetadataParquetBuilder) {
+            return createMetadataWriter(
+                    (MetadataParquetBuilder<InternalRow>) writerBuilder, out, 
compression);
+        }
+
         final ParquetWriter<InternalRow> writer = 
writerBuilder.createWriter(out, compression);
         return new ParquetBulkWriter(writer);
     }
@@ -70,10 +79,20 @@ public class ParquetWriterFactory implements 
FormatWriterFactory, SupportsVarian
             compression = null;
         }
 
-        ParquetBuilder<InternalRow> newBuilder =
+        RowDataParquetBuilder newBuilder =
                 ((RowDataParquetBuilder) writerBuilder)
                         .withShreddingSchemas(inferredShreddingSchema);
-        final ParquetWriter<InternalRow> writer = newBuilder.createWriter(out, 
compression);
-        return new ParquetBulkWriter(writer);
+        return createMetadataWriter(newBuilder, out, compression);
+    }
+
+    private FormatWriter createMetadataWriter(
+            MetadataParquetBuilder<InternalRow> builder, OutputFile out, 
String compression)
+            throws IOException {
+        // Keep this exact map instance shared by ParquetBulkWriter and 
WriteSupport. The writer
+        // collects metadata before close, and WriteSupport reads it when 
finalizing the footer.
+        Map<String, byte[]> metadata = new HashMap<>();
+        final ParquetWriter<InternalRow> writer =
+                builder.createWriter(out, compression, () -> metadata);
+        return new ParquetMetadataBulkWriter(writer, metadata);
     }
 }
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/VectorizedParquetRecordReader.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/VectorizedParquetRecordReader.java
index af63a7d9f9..ebc38e31a7 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/VectorizedParquetRecordReader.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/reader/VectorizedParquetRecordReader.java
@@ -20,6 +20,8 @@ package org.apache.paimon.format.parquet.reader;
 
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.data.columnar.writable.WritableColumnVector;
+import org.apache.paimon.format.FormatMetadataUtils;
+import org.apache.paimon.format.SupportsReaderFieldMetadata;
 import org.apache.paimon.format.parquet.type.ParquetField;
 import org.apache.paimon.format.parquet.type.ParquetPrimitiveField;
 import org.apache.paimon.fs.FileIO;
@@ -39,8 +41,10 @@ import javax.annotation.Nullable;
 
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -48,7 +52,8 @@ import static java.lang.String.format;
 import static 
org.apache.paimon.format.parquet.reader.ParquetReaderUtil.createReadableColumnVectors;
 
 /** Record reader for parquet. */
-public class VectorizedParquetRecordReader implements 
FileRecordReader<InternalRow> {
+public class VectorizedParquetRecordReader
+        implements FileRecordReader<InternalRow>, SupportsReaderFieldMetadata {
 
     private ParquetFileReader reader;
 
@@ -269,6 +274,24 @@ public class VectorizedParquetRecordReader implements 
FileRecordReader<InternalR
         }
     }
 
+    @Override
+    public Map<String, Map<String, String>> readFieldMetadata() throws 
IOException {
+        String encodedSchema =
+                reader.getFooter()
+                        .getFileMetaData()
+                        .getKeyValueMetaData()
+                        .get(FormatMetadataUtils.ARROW_SCHEMA_METADATA_KEY);
+        if (encodedSchema == null) {
+            return FormatMetadataUtils.readFieldMetadata(null);
+        }
+        return FormatMetadataUtils.readFieldMetadata(
+                FormatMetadataUtils.decodeMetadata(
+                                Collections.singletonMap(
+                                        
FormatMetadataUtils.ARROW_SCHEMA_METADATA_KEY,
+                                        encodedSchema))
+                        .get(FormatMetadataUtils.ARROW_SCHEMA_METADATA_KEY));
+    }
+
     @Override
     public void close() throws IOException {
         if (reader != null) {
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/MetadataParquetBuilder.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/MetadataParquetBuilder.java
new file mode 100644
index 0000000000..463414163d
--- /dev/null
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/MetadataParquetBuilder.java
@@ -0,0 +1,37 @@
+/*
+ * 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.format.parquet.writer;
+
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.io.OutputFile;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.function.Supplier;
+
+/** A {@link ParquetBuilder} that can provide writer metadata before the file 
is finalized. */
+public interface MetadataParquetBuilder<T> extends ParquetBuilder<T> {
+
+    /**
+     * Creates and configures a parquet writer that can read metadata before 
finalizing the file.
+     */
+    ParquetWriter<T> createWriter(
+            OutputFile out, String compression, Supplier<Map<String, byte[]>> 
metadataSupplier)
+            throws IOException;
+}
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/ParquetMetadataBulkWriter.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/ParquetMetadataBulkWriter.java
new file mode 100644
index 0000000000..a1d24cf2ed
--- /dev/null
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/ParquetMetadataBulkWriter.java
@@ -0,0 +1,60 @@
+/*
+ * 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.format.parquet.writer;
+
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.format.SupportsWriterMetadata;
+
+import org.apache.parquet.hadoop.ParquetWriter;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Map;
+
+import static org.apache.paimon.utils.Preconditions.checkNotNull;
+import static org.apache.paimon.utils.Preconditions.checkState;
+
+/** A {@link ParquetBulkWriter} that supports adding file metadata before 
close. */
+public class ParquetMetadataBulkWriter extends ParquetBulkWriter implements 
SupportsWriterMetadata {
+
+    private final Map<String, byte[]> metadata;
+
+    private boolean closed = false;
+
+    public ParquetMetadataBulkWriter(
+            ParquetWriter<InternalRow> parquetWriter, Map<String, byte[]> 
metadata) {
+        super(parquetWriter);
+        this.metadata = checkNotNull(metadata, "metadata");
+    }
+
+    @Override
+    public void addMetadata(Map<String, byte[]> metadata) {
+        checkState(!closed, "Cannot add metadata after writer is closed.");
+        for (Map.Entry<String, byte[]> entry : metadata.entrySet()) {
+            this.metadata.put(
+                    entry.getKey(), Arrays.copyOf(entry.getValue(), 
entry.getValue().length));
+        }
+    }
+
+    @Override
+    public void close() throws IOException {
+        this.closed = true;
+        super.close();
+    }
+}
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/ParquetRowDataBuilder.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/ParquetRowDataBuilder.java
index 14970e548e..60fd1097fe 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/ParquetRowDataBuilder.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/ParquetRowDataBuilder.java
@@ -19,12 +19,14 @@
 package org.apache.paimon.format.parquet.writer;
 
 import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.format.FormatMetadataUtils;
 import org.apache.paimon.format.parquet.VariantUtils;
 import org.apache.paimon.types.RowType;
 
 import org.apache.hadoop.conf.Configuration;
 import org.apache.parquet.hadoop.ParquetWriter;
 import org.apache.parquet.hadoop.api.WriteSupport;
+import org.apache.parquet.hadoop.api.WriteSupport.FinalizedWriteContext;
 import org.apache.parquet.io.OutputFile;
 import org.apache.parquet.io.api.RecordConsumer;
 import org.apache.parquet.schema.MessageType;
@@ -32,6 +34,8 @@ import org.apache.parquet.schema.MessageType;
 import javax.annotation.Nullable;
 
 import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Supplier;
 
 import static 
org.apache.paimon.format.parquet.ParquetSchemaConverter.convertToParquetMessageType;
 
@@ -41,12 +45,20 @@ public class ParquetRowDataBuilder
 
     private final RowType rowType;
     @Nullable private final RowType shreddingSchemas;
+    private Supplier<Map<String, byte[]>> metadataSupplier;
 
     public ParquetRowDataBuilder(
             OutputFile path, RowType rowType, @Nullable RowType 
shreddingSchemas) {
         super(path);
         this.rowType = rowType;
         this.shreddingSchemas = shreddingSchemas;
+        this.metadataSupplier = HashMap::new;
+    }
+
+    public ParquetRowDataBuilder withMetadataSupplier(
+            Supplier<Map<String, byte[]>> metadataSupplier) {
+        this.metadataSupplier = metadataSupplier;
+        return this;
     }
 
     @Override
@@ -89,5 +101,11 @@ public class ParquetRowDataBuilder
         public void write(InternalRow record) {
             this.writer.write(record);
         }
+
+        @Override
+        public FinalizedWriteContext finalizeWrite() {
+            return new FinalizedWriteContext(
+                    
FormatMetadataUtils.encodeMetadata(metadataSupplier.get()));
+        }
     }
 }
diff --git 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/RowDataParquetBuilder.java
 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/RowDataParquetBuilder.java
index 2e84df0932..6239fc546b 100644
--- 
a/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/RowDataParquetBuilder.java
+++ 
b/paimon-format/src/main/java/org/apache/paimon/format/parquet/writer/RowDataParquetBuilder.java
@@ -35,9 +35,12 @@ import org.apache.parquet.io.OutputFile;
 import javax.annotation.Nullable;
 
 import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Supplier;
 
 /** A {@link ParquetBuilder} for {@link InternalRow}. */
-public class RowDataParquetBuilder implements ParquetBuilder<InternalRow> {
+public class RowDataParquetBuilder implements 
MetadataParquetBuilder<InternalRow> {
 
     private final RowType rowType;
     private final Configuration conf;
@@ -58,8 +61,16 @@ public class RowDataParquetBuilder implements 
ParquetBuilder<InternalRow> {
     @Override
     public ParquetWriter<InternalRow> createWriter(OutputFile out, String 
compression)
             throws IOException {
+        return createWriter(out, compression, HashMap::new);
+    }
+
+    @Override
+    public ParquetWriter<InternalRow> createWriter(
+            OutputFile out, String compression, Supplier<Map<String, byte[]>> 
metadataSupplier)
+            throws IOException {
         ParquetRowDataBuilder builder =
                 new ParquetRowDataBuilder(out, rowType, shreddingSchemas)
+                        .withMetadataSupplier(metadataSupplier)
                         .withConf(conf)
                         
.withCompressionCodec(getCompressionCodec(getCompression(compression)))
                         .withRowGroupSize(
diff --git 
a/paimon-format/src/test/java/org/apache/paimon/format/FormatMetadataUtilsTest.java
 
b/paimon-format/src/test/java/org/apache/paimon/format/FormatMetadataUtilsTest.java
new file mode 100644
index 0000000000..f01854f9e3
--- /dev/null
+++ 
b/paimon-format/src/test/java/org/apache/paimon/format/FormatMetadataUtilsTest.java
@@ -0,0 +1,148 @@
+/*
+ * 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.format;
+
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link FormatMetadataUtils}. */
+public class FormatMetadataUtilsTest {
+
+    @Test
+    public void testEncodeAndDecodeMetadata() {
+        Map<String, byte[]> metadata = new LinkedHashMap<>();
+        metadata.put("encoded", 
"paimon-value".getBytes(StandardCharsets.UTF_8));
+
+        Map<String, String> encoded = 
FormatMetadataUtils.encodeMetadata(metadata);
+        encoded.put("plain", "plain-value");
+        assertThat(encoded)
+                .containsEntry(
+                        "encoded",
+                        Base64.getEncoder()
+                                
.encodeToString("paimon-value".getBytes(StandardCharsets.UTF_8)));
+
+        Map<String, byte[]> decoded = 
FormatMetadataUtils.decodeMetadata(encoded);
+
+        assertThat(new String(decoded.get("encoded"), StandardCharsets.UTF_8))
+                .isEqualTo("paimon-value");
+        assertThat(new String(decoded.get("plain"), StandardCharsets.UTF_8))
+                .isEqualTo("plain-value");
+    }
+
+    @Test
+    public void testReadFieldMetadataFromArrowSchemaMetadata() {
+        RowType rowType = DataTypes.ROW(DataTypes.FIELD(0, "field", 
DataTypes.STRING()));
+        Map<String, String> fieldMetadata = new LinkedHashMap<>();
+        fieldMetadata.put("paimon.test.field-key", "field-value");
+        Map<String, Map<String, String>> expected = new LinkedHashMap<>();
+        expected.put("field", fieldMetadata);
+        byte[] schemaBytes = 
FormatMetadataUtils.buildArrowSchemaMetadata(rowType, expected, null);
+
+        
assertThat(FormatMetadataUtils.readFieldMetadata(schemaBytes).get("field"))
+                .containsAllEntriesOf(fieldMetadata);
+        assertThat(FormatMetadataUtils.readFieldMetadata(null)).isEmpty();
+        assertThat(
+                        FormatMetadataUtils.readFieldMetadata(
+                                
"not-arrow-schema".getBytes(StandardCharsets.UTF_8)))
+                .isEmpty();
+    }
+
+    @Test
+    public void testReadFieldMetadata() {
+        RowType rowType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(0, "with_metadata", DataTypes.INT()),
+                        DataTypes.FIELD(1, "without_metadata", 
DataTypes.INT()));
+        Map<String, String> fieldMetadata = new LinkedHashMap<>();
+        fieldMetadata.put("paimon.test.field-key", "field-value");
+        Map<String, Map<String, String>> expected = new LinkedHashMap<>();
+        expected.put("with_metadata", fieldMetadata);
+        byte[] schemaBytes = 
FormatMetadataUtils.buildArrowSchemaMetadata(rowType, expected, null);
+
+        Map<String, Map<String, String>> metadata =
+                FormatMetadataUtils.readFieldMetadata(schemaBytes);
+        
assertThat(metadata.get("with_metadata")).containsAllEntriesOf(fieldMetadata);
+        assertThat(metadata).doesNotContainKey("without_metadata");
+    }
+
+    @Test
+    public void testBuildArrowSchemaWithFieldMetadata() {
+        RowType rowType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(0, "id", DataTypes.INT()),
+                        DataTypes.FIELD(
+                                1, "tags", DataTypes.MAP(DataTypes.STRING(), 
DataTypes.INT())),
+                        DataTypes.FIELD(
+                                2,
+                                "nested",
+                                DataTypes.ROW(
+                                        DataTypes.FIELD(3, "name", 
DataTypes.STRING()),
+                                        DataTypes.FIELD(
+                                                4, "scores", 
DataTypes.ARRAY(DataTypes.INT())))));
+        Map<String, String> tagsMetadata = new LinkedHashMap<>();
+        tagsMetadata.put("paimon.test.tags", "enabled");
+
+        Map<String, Map<String, String>> fieldMetadata = new LinkedHashMap<>();
+        fieldMetadata.put("tags", tagsMetadata);
+
+        byte[] schemaBytes =
+                FormatMetadataUtils.buildArrowSchemaMetadata(
+                        rowType, fieldMetadata, 
FormatMetadataUtils.PARQUET_FIELD_ID_KEY);
+
+        Map<String, Map<String, String>> metadata =
+                FormatMetadataUtils.readFieldMetadata(schemaBytes);
+        assertThat(metadata).containsOnlyKeys("id", "tags", "nested");
+        
assertThat(metadata.get("id")).containsEntry(FormatMetadataUtils.PARQUET_FIELD_ID_KEY,
 "0");
+        assertThat(metadata.get("tags")).containsAllEntriesOf(tagsMetadata);
+        assertThat(metadata.get("tags"))
+                .containsEntry(FormatMetadataUtils.PARQUET_FIELD_ID_KEY, "1");
+        
assertThat(metadata.get("nested")).doesNotContainKey("paimon.test.tags");
+    }
+
+    @Test
+    public void testBuildArrowSchemaWithoutFieldIdMetadata() {
+        RowType rowType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(0, "id", DataTypes.INT()),
+                        DataTypes.FIELD(1, "name", DataTypes.STRING()));
+        Map<String, String> nameMetadata = new LinkedHashMap<>();
+        nameMetadata.put("paimon.test.name", "enabled");
+        Map<String, Map<String, String>> fieldMetadata = new LinkedHashMap<>();
+        fieldMetadata.put("name", nameMetadata);
+
+        byte[] schemaBytes =
+                FormatMetadataUtils.buildArrowSchemaMetadata(rowType, 
fieldMetadata, null);
+
+        Map<String, Map<String, String>> metadata =
+                FormatMetadataUtils.readFieldMetadata(schemaBytes);
+        assertThat(metadata).containsOnlyKeys("name");
+        assertThat(metadata.get("name")).containsAllEntriesOf(nameMetadata);
+        assertThat(metadata.get("name"))
+                .doesNotContainKey(FormatMetadataUtils.PARQUET_FIELD_ID_KEY);
+    }
+}
diff --git 
a/paimon-format/src/test/java/org/apache/paimon/format/orc/OrcFormatReadWriteTest.java
 
b/paimon-format/src/test/java/org/apache/paimon/format/orc/OrcFormatReadWriteTest.java
index fb625c68da..a03af8dc25 100644
--- 
a/paimon-format/src/test/java/org/apache/paimon/format/orc/OrcFormatReadWriteTest.java
+++ 
b/paimon-format/src/test/java/org/apache/paimon/format/orc/OrcFormatReadWriteTest.java
@@ -24,12 +24,16 @@ import org.apache.paimon.data.Timestamp;
 import org.apache.paimon.data.serializer.InternalRowSerializer;
 import org.apache.paimon.format.FileFormat;
 import org.apache.paimon.format.FileFormatFactory;
+import org.apache.paimon.format.FormatMetadataUtils;
 import org.apache.paimon.format.FormatReadWriteTest;
 import org.apache.paimon.format.FormatReaderContext;
 import org.apache.paimon.format.FormatWriter;
 import org.apache.paimon.format.OrcOptions;
+import org.apache.paimon.format.SupportsReaderFieldMetadata;
+import org.apache.paimon.format.SupportsWriterMetadata;
 import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.FileRecordReader;
 import org.apache.paimon.reader.RecordReader;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
@@ -37,14 +41,19 @@ import org.apache.paimon.types.RowType;
 import org.junit.jupiter.api.Test;
 
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
 import java.time.LocalDateTime;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.TimeZone;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** An orc {@link FormatReadWriteTest}. */
 public class OrcFormatReadWriteTest extends FormatReadWriteTest {
@@ -81,6 +90,64 @@ public class OrcFormatReadWriteTest extends 
FormatReadWriteTest {
         super("orc");
     }
 
+    @Test
+    public void testWriteMetadata() throws IOException {
+        RowType rowType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(0, "id", DataTypes.INT()),
+                        DataTypes.FIELD(1, "name", DataTypes.STRING()));
+
+        PositionOutputStream out = fileIO.newOutputStream(file, false);
+        FormatWriter writer = 
newFormat.createWriterFactory(rowType).create(out, "zstd");
+        Map<String, String> fieldMetadata = new HashMap<>();
+        fieldMetadata.put("paimon.test.field-key", "field-value");
+        fieldMetadata.put("paimon.test.field-version", "1");
+        Map<String, Map<String, String>> fieldMetadataByName = new HashMap<>();
+        fieldMetadataByName.put("name", fieldMetadata);
+        byte[] arrowSchemaBytes =
+                FormatMetadataUtils.buildArrowSchemaMetadata(
+                        rowType, fieldMetadataByName, 
OrcTypeUtil.PAIMON_ORC_FIELD_ID_KEY);
+        Map<String, byte[]> metadata = new HashMap<>();
+        metadata.put("paimon.test.key", 
"paimon-test-value".getBytes(StandardCharsets.UTF_8));
+        metadata.put(FormatMetadataUtils.ARROW_SCHEMA_METADATA_KEY, 
arrowSchemaBytes);
+        ((SupportsWriterMetadata) writer).addMetadata(metadata);
+        writer.addElement(GenericRow.of(1, 
org.apache.paimon.data.BinaryString.fromString("one")));
+        writer.close();
+        assertThatThrownBy(() -> ((SupportsWriterMetadata) 
writer).addMetadata(metadata))
+                .isInstanceOf(IllegalStateException.class);
+        out.close();
+
+        try (org.apache.orc.Reader reader =
+                OrcReaderFactory.createReader(
+                        new org.apache.hadoop.conf.Configuration(false), 
fileIO, file, null)) {
+            ByteBuffer value = reader.getMetadataValue("paimon.test.key");
+            Map<String, byte[]> decodedMetadata =
+                    FormatMetadataUtils.decodeMetadata(
+                            Collections.singletonMap(
+                                    "paimon.test.key",
+                                    
StandardCharsets.UTF_8.decode(value.duplicate()).toString()));
+            assertThat(new String(decodedMetadata.get("paimon.test.key"), 
StandardCharsets.UTF_8))
+                    .isEqualTo("paimon-test-value");
+        }
+
+        FormatReaderContext context =
+                new FormatReaderContext(fileIO, file, 
fileIO.getFileSize(file));
+        RowType emptyRowType = new RowType(Collections.emptyList());
+        try (FileRecordReader<InternalRow> reader =
+                newFormat
+                        .createReaderFactory(emptyRowType, emptyRowType, 
Collections.emptyList())
+                        .createReader(context)) {
+            Map<String, Map<String, String>> readFieldMetadata =
+                    ((SupportsReaderFieldMetadata) reader).readFieldMetadata();
+            assertThat(readFieldMetadata).containsOnlyKeys("id", "name");
+            assertThat(readFieldMetadata.get("id"))
+                    .containsEntry(OrcTypeUtil.PAIMON_ORC_FIELD_ID_KEY, "0");
+            
assertThat(readFieldMetadata.get("name")).containsAllEntriesOf(fieldMetadata);
+            assertThat(readFieldMetadata.get("name"))
+                    .containsEntry(OrcTypeUtil.PAIMON_ORC_FIELD_ID_KEY, "1");
+        }
+    }
+
     @Test
     public void testTimestampLTZWithLegacyWriteAndRead() throws IOException {
         RowType rowType = 
DataTypes.ROW(DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE());
diff --git 
a/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetFormatReadWriteTest.java
 
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetFormatReadWriteTest.java
index 137da29aa6..e4d03b8afc 100644
--- 
a/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetFormatReadWriteTest.java
+++ 
b/paimon-format/src/test/java/org/apache/paimon/format/parquet/ParquetFormatReadWriteTest.java
@@ -20,12 +20,18 @@ package org.apache.paimon.format.parquet;
 
 import org.apache.paimon.data.BinaryString;
 import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.format.FileFormat;
 import org.apache.paimon.format.FileFormatFactory;
+import org.apache.paimon.format.FormatMetadataUtils;
 import org.apache.paimon.format.FormatReadWriteTest;
+import org.apache.paimon.format.FormatReaderContext;
 import org.apache.paimon.format.FormatWriter;
+import org.apache.paimon.format.SupportsReaderFieldMetadata;
+import org.apache.paimon.format.SupportsWriterMetadata;
 import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.options.Options;
+import org.apache.paimon.reader.FileRecordReader;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 
@@ -40,6 +46,8 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -58,6 +66,66 @@ public class ParquetFormatReadWriteTest extends 
FormatReadWriteTest {
                 new FileFormatFactory.FormatContext(new Options(), 1024, 
1024));
     }
 
+    @Test
+    public void testWriteMetadata() throws Exception {
+        ParquetFileFormat format =
+                new ParquetFileFormat(
+                        new FileFormatFactory.FormatContext(new Options(), 
1024, 1024));
+        RowType rowType =
+                DataTypes.ROW(
+                        DataTypes.FIELD(0, "id", DataTypes.INT()),
+                        DataTypes.FIELD(1, "name", DataTypes.STRING()));
+
+        PositionOutputStream out = fileIO.newOutputStream(file, false);
+        FormatWriter writer = format.createWriterFactory(rowType).create(out, 
"zstd");
+        Map<String, String> fieldMetadata = new HashMap<>();
+        fieldMetadata.put("paimon.test.field-key", "field-value");
+        fieldMetadata.put("paimon.test.field-version", "1");
+        Map<String, Map<String, String>> fieldMetadataByName = new HashMap<>();
+        fieldMetadataByName.put("name", fieldMetadata);
+        byte[] arrowSchemaBytes =
+                FormatMetadataUtils.buildArrowSchemaMetadata(
+                        rowType, fieldMetadataByName, 
FormatMetadataUtils.PARQUET_FIELD_ID_KEY);
+        Map<String, byte[]> metadata = new HashMap<>();
+        metadata.put("paimon.test.key", 
"paimon-test-value".getBytes(StandardCharsets.UTF_8));
+        metadata.put(FormatMetadataUtils.ARROW_SCHEMA_METADATA_KEY, 
arrowSchemaBytes);
+        ((SupportsWriterMetadata) writer).addMetadata(metadata);
+        writer.addElement(GenericRow.of(1, BinaryString.fromString("one")));
+        writer.close();
+        Assertions.assertThatThrownBy(() -> ((SupportsWriterMetadata) 
writer).addMetadata(metadata))
+                .isInstanceOf(IllegalStateException.class);
+        out.close();
+
+        try (ParquetFileReader reader =
+                ParquetUtil.getParquetReader(
+                        fileIO, file, fileIO.getFileSize(file), new 
Options())) {
+            Map<String, String> fileMetadata =
+                    reader.getFooter().getFileMetaData().getKeyValueMetaData();
+            Map<String, byte[]> decodedMetadata = 
FormatMetadataUtils.decodeMetadata(fileMetadata);
+            Assertions.assertThat(
+                            new String(
+                                    decodedMetadata.get("paimon.test.key"), 
StandardCharsets.UTF_8))
+                    .isEqualTo("paimon-test-value");
+        }
+
+        FormatReaderContext context =
+                new FormatReaderContext(fileIO, file, 
fileIO.getFileSize(file));
+        RowType emptyRowType = new RowType(Collections.emptyList());
+        try (FileRecordReader<InternalRow> reader =
+                format.createReaderFactory(emptyRowType, emptyRowType, 
Collections.emptyList())
+                        .createReader(context)) {
+            Map<String, Map<String, String>> readFieldMetadata =
+                    ((SupportsReaderFieldMetadata) reader).readFieldMetadata();
+            
Assertions.assertThat(readFieldMetadata).containsKey("id").containsKey("name");
+            Assertions.assertThat(readFieldMetadata.get("id"))
+                    .containsEntry(FormatMetadataUtils.PARQUET_FIELD_ID_KEY, 
"0");
+            Assertions.assertThat(readFieldMetadata.get("name"))
+                    .containsAllEntriesOf(fieldMetadata);
+            Assertions.assertThat(readFieldMetadata.get("name"))
+                    .containsEntry(FormatMetadataUtils.PARQUET_FIELD_ID_KEY, 
"1");
+        }
+    }
+
     @ParameterizedTest
     @ValueSource(booleans = {true, false})
     public void testEnableBloomFilter(boolean enabled) throws Exception {


Reply via email to