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

Abacn pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 8473d90e93f [Java IO] Add ArrowFlight IO connector (#37904)
8473d90e93f is described below

commit 8473d90e93fe41b08bd105e6a0c70b36b60fc39f
Author: Bruno Volpato <[email protected]>
AuthorDate: Tue Jul 28 15:44:38 2026 -0400

    [Java IO] Add ArrowFlight IO connector (#37904)
    
    * [Java IO] Add ArrowFlight IO connector
    
    Add a new IO connector for Apache Arrow Flight, enabling high-performance
    data transfer over gRPC using the Arrow columnar format.
    
    Includes read (BoundedSource) and write (DoFn with doPut) support with
    endpoint-level split parallelism and bearer token authentication.
    
    Fixes #20116
    
    * Add arrow-flight to javaioPreCommit and fix test issues
    
    - Register :sdks:java:io:arrow-flight in javaioPreCommit task 
(build.gradle.kts)
    - Add --add-opens JVM arg for Arrow native memory on JDK 17+
    - Make host() nullable at AutoValue level to fix factory method NPE
    - Eagerly materialize rows from Arrow buffers to prevent stale access
    - Move root.setRowCount() after vector population for correct ordering
    - Use AtomicInteger for thread-safe write record counting in tests
    
    * [Java IO] Harden ArrowFlightIO writes
    
    * [Java IO] Address ArrowFlightIO review feedback
---
 CHANGES.md                                         |   1 +
 build.gradle.kts                                   |   1 +
 .../org/apache/beam/gradle/BeamModulePlugin.groovy |   1 +
 .../beam/sdk/extensions/arrow/ArrowConversion.java |  61 ++
 .../sdk/extensions/arrow/ArrowConversionTest.java  |  33 +
 sdks/java/io/arrow-flight/build.gradle             |  44 ++
 .../beam/sdk/io/arrowflight/ArrowFlightIO.java     | 840 +++++++++++++++++++++
 .../beam/sdk/io/arrowflight/package-info.java      |  29 +
 .../beam/sdk/io/arrowflight/ArrowFlightIOTest.java | 330 ++++++++
 settings.gradle.kts                                |   1 +
 .../site/content/en/documentation/io/connectors.md |  16 +
 11 files changed, 1357 insertions(+)

diff --git a/CHANGES.md b/CHANGES.md
index 1bbde577e87..a1fdede9449 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -65,6 +65,7 @@
 ## I/Os
 
 * Support for X source added (Java/Python) 
([#X](https://github.com/apache/beam/issues/X)).
+* Add ArrowFlight IO (Java) 
([#20116](https://github.com/apache/beam/issues/20116)).
 
 ## New Features / Improvements
 
diff --git a/build.gradle.kts b/build.gradle.kts
index ba2f7aaed67..de278f9f283 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -339,6 +339,7 @@ tasks.register("javaPreCommit") {
 // a precommit task build multiple IOs (except those splitting into single 
jobs)
 tasks.register("javaioPreCommit") {
   dependsOn(":sdks:java:io:amqp:build")
+  dependsOn(":sdks:java:io:arrow-flight:build")
   // CassandraIO, HBaseIO and HCatalogIO do not support Java17+, test ran 
separately
   // dependsOn(":sdks:java:io:cassandra:build")
   dependsOn(":sdks:java:io:csv:build")
diff --git 
a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy 
b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy
index 70a22e39772..0bf3dc11e2d 100644
--- a/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy
+++ b/buildSrc/src/main/groovy/org/apache/beam/gradle/BeamModulePlugin.groovy
@@ -951,6 +951,7 @@ class BeamModulePlugin implements Plugin<Project> {
         arrow_vector                                : 
"org.apache.arrow:arrow-vector:$arrow_version",
         arrow_memory_core                           : 
"org.apache.arrow:arrow-memory-core:$arrow_version",
         arrow_memory_netty                          : 
"org.apache.arrow:arrow-memory-netty:$arrow_version",
+        arrow_flight_core                           : 
"org.apache.arrow:flight-core:$arrow_version",
       ],
       groovy: [
         groovy_all: "org.codehaus.groovy:groovy-all:2.4.13",
diff --git 
a/sdks/java/extensions/arrow/src/main/java/org/apache/beam/sdk/extensions/arrow/ArrowConversion.java
 
b/sdks/java/extensions/arrow/src/main/java/org/apache/beam/sdk/extensions/arrow/ArrowConversion.java
index bf7078abc58..f1850df2e95 100644
--- 
a/sdks/java/extensions/arrow/src/main/java/org/apache/beam/sdk/extensions/arrow/ArrowConversion.java
+++ 
b/sdks/java/extensions/arrow/src/main/java/org/apache/beam/sdk/extensions/arrow/ArrowConversion.java
@@ -22,6 +22,7 @@ import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Pr
 import java.io.IOException;
 import java.io.InputStream;
 import java.nio.channels.Channels;
+import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Optional;
@@ -35,6 +36,7 @@ import org.apache.arrow.vector.VectorSchemaRoot;
 import org.apache.arrow.vector.ipc.ReadChannel;
 import org.apache.arrow.vector.ipc.message.ArrowRecordBatch;
 import org.apache.arrow.vector.ipc.message.MessageSerializer;
+import org.apache.arrow.vector.types.FloatingPointPrecision;
 import org.apache.arrow.vector.types.TimeUnit;
 import org.apache.arrow.vector.types.pojo.ArrowType;
 import org.apache.arrow.vector.util.Text;
@@ -57,6 +59,57 @@ import org.joda.time.DateTimeZone;
  */
 public class ArrowConversion {
 
+  /** Get Arrow Field from Beam Field. */
+  private static org.apache.arrow.vector.types.pojo.Field toArrowField(Field 
field) {
+    FieldType beamFieldType = field.getType();
+    ArrowType arrowType;
+    // TODO: Support aggregate and logical Beam field types.
+    switch (beamFieldType.getTypeName()) {
+      case BYTE:
+        arrowType = new ArrowType.Int(8, true);
+        break;
+      case INT16:
+        arrowType = new ArrowType.Int(16, true);
+        break;
+      case INT32:
+        arrowType = new ArrowType.Int(32, true);
+        break;
+      case INT64:
+        arrowType = new ArrowType.Int(64, true);
+        break;
+      case FLOAT:
+        arrowType = new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE);
+        break;
+      case DOUBLE:
+        arrowType = new ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE);
+        break;
+      case STRING:
+        arrowType = ArrowType.Utf8.INSTANCE;
+        break;
+      case BOOLEAN:
+        arrowType = ArrowType.Bool.INSTANCE;
+        break;
+      case BYTES:
+        arrowType = ArrowType.Binary.INSTANCE;
+        break;
+      case DATETIME:
+        arrowType = new ArrowType.Timestamp(TimeUnit.MILLISECOND, "UTC");
+        break;
+      default:
+        throw new IllegalArgumentException(
+            String.format(
+                "Arrow schema conversion does not support Beam type '%s' for 
field '%s'.",
+                beamFieldType.getTypeName(), field.getName()));
+    }
+
+    org.apache.arrow.vector.types.pojo.FieldType arrowFieldType =
+        beamFieldType.getNullable()
+            ? org.apache.arrow.vector.types.pojo.FieldType.nullable(arrowType)
+            : 
org.apache.arrow.vector.types.pojo.FieldType.notNullable(arrowType);
+    return new org.apache.arrow.vector.types.pojo.Field(
+        field.getName(), arrowFieldType, Collections.emptyList());
+  }
+
   /** Get Beam Field from Arrow Field. */
   private static Field toBeamField(org.apache.arrow.vector.types.pojo.Field 
field) {
     FieldType beamFieldType = toFieldType(field.getFieldType(), 
field.getChildren());
@@ -546,6 +599,14 @@ public class ArrowConversion {
   /** Converts Arrow schema to Beam row schema. */
   public static class ArrowSchemaTranslator {
 
+    /** Converts a supported Beam row schema to an Arrow schema. */
+    public static org.apache.arrow.vector.types.pojo.Schema 
toArrowSchema(Schema schema) {
+      return new org.apache.arrow.vector.types.pojo.Schema(
+          schema.getFields().stream()
+              .map(ArrowConversion::toArrowField)
+              .collect(Collectors.toList()));
+    }
+
     public static Schema 
toBeamSchema(org.apache.arrow.vector.types.pojo.Schema schema) {
       return toBeamSchema(schema.getFields());
     }
diff --git 
a/sdks/java/extensions/arrow/src/test/java/org/apache/beam/sdk/extensions/arrow/ArrowConversionTest.java
 
b/sdks/java/extensions/arrow/src/test/java/org/apache/beam/sdk/extensions/arrow/ArrowConversionTest.java
index 8c297576650..be5c7120343 100644
--- 
a/sdks/java/extensions/arrow/src/test/java/org/apache/beam/sdk/extensions/arrow/ArrowConversionTest.java
+++ 
b/sdks/java/extensions/arrow/src/test/java/org/apache/beam/sdk/extensions/arrow/ArrowConversionTest.java
@@ -83,6 +83,39 @@ public class ArrowConversionTest {
     
assertThat(ArrowConversion.ArrowSchemaTranslator.toBeamSchema(arrowSchema), 
equalTo(expected));
   }
 
+  @Test
+  public void toArrowSchema_convertsSimpleBeamSchema() {
+    Schema beamSchema =
+        Schema.builder()
+            .addByteField("int8")
+            .addInt16Field("int16")
+            .addInt32Field("int32")
+            .addInt64Field("int64")
+            .addFloatField("float32")
+            .addDoubleField("float64")
+            .addNullableField("string", FieldType.STRING)
+            .addBooleanField("boolean")
+            .addByteArrayField("bytes")
+            .addDateTimeField("timestamp")
+            .build();
+
+    org.apache.arrow.vector.types.pojo.Schema expected =
+        new org.apache.arrow.vector.types.pojo.Schema(
+            ImmutableList.of(
+                field("int8", new ArrowType.Int(8, true)),
+                field("int16", new ArrowType.Int(16, true)),
+                field("int32", new ArrowType.Int(32, true)),
+                field("int64", new ArrowType.Int(64, true)),
+                field("float32", new 
ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE)),
+                field("float64", new 
ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE)),
+                field("string", true, ArrowType.Utf8.INSTANCE),
+                field("boolean", ArrowType.Bool.INSTANCE),
+                field("bytes", ArrowType.Binary.INSTANCE),
+                field("timestamp", new 
ArrowType.Timestamp(TimeUnit.MILLISECOND, "UTC"))));
+
+    
assertThat(ArrowConversion.ArrowSchemaTranslator.toArrowSchema(beamSchema), 
equalTo(expected));
+  }
+
   @Test
   public void rowIterator() {
     org.apache.arrow.vector.types.pojo.Schema schema =
diff --git a/sdks/java/io/arrow-flight/build.gradle 
b/sdks/java/io/arrow-flight/build.gradle
new file mode 100644
index 00000000000..6d7a6765f8c
--- /dev/null
+++ b/sdks/java/io/arrow-flight/build.gradle
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+plugins { id 'org.apache.beam.module' }
+applyJavaNature(automaticModuleName: 'org.apache.beam.sdk.io.arrowflight')
+
+description = "Apache Beam :: SDKs :: Java :: IO :: Arrow Flight"
+ext.summary = "IO to read and write data using Apache Arrow Flight RPC."
+
+dependencies {
+  implementation project(path: ":sdks:java:core", configuration: "shadow")
+  implementation project(path: ":sdks:java:extensions:arrow")
+  implementation library.java.joda_time
+  implementation library.java.slf4j_api
+  implementation library.java.vendored_guava_32_1_2_jre
+  implementation(library.java.arrow_flight_core)
+  implementation(library.java.arrow_memory_core)
+  implementation(library.java.arrow_vector)
+  testImplementation library.java.hamcrest
+  testImplementation library.java.junit
+  testImplementation(library.java.arrow_memory_netty)
+  testRuntimeOnly library.java.slf4j_simple
+  testRuntimeOnly project(path: ":runners:direct-java", configuration: 
"shadow")
+}
+
+test {
+  // Keep aligned with ArrowFlightIO runtime guidance for Java 17+.
+  jvmArgs '--add-opens=java.base/java.nio=ALL-UNNAMED'
+}
diff --git 
a/sdks/java/io/arrow-flight/src/main/java/org/apache/beam/sdk/io/arrowflight/ArrowFlightIO.java
 
b/sdks/java/io/arrow-flight/src/main/java/org/apache/beam/sdk/io/arrowflight/ArrowFlightIO.java
new file mode 100644
index 00000000000..f14e10631d6
--- /dev/null
+++ 
b/sdks/java/io/arrow-flight/src/main/java/org/apache/beam/sdk/io/arrowflight/ArrowFlightIO.java
@@ -0,0 +1,840 @@
+/*
+ * 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.beam.sdk.io.arrowflight;
+
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
+import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.auto.value.AutoValue;
+import java.io.IOException;
+import java.io.Serializable;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import org.apache.arrow.flight.AsyncPutListener;
+import org.apache.arrow.flight.CallOption;
+import org.apache.arrow.flight.FlightCallHeaders;
+import org.apache.arrow.flight.FlightClient;
+import org.apache.arrow.flight.FlightDescriptor;
+import org.apache.arrow.flight.FlightEndpoint;
+import org.apache.arrow.flight.FlightInfo;
+import org.apache.arrow.flight.FlightStream;
+import org.apache.arrow.flight.HeaderCallOption;
+import org.apache.arrow.flight.Location;
+import org.apache.arrow.flight.Ticket;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.BigIntVector;
+import org.apache.arrow.vector.BitVector;
+import org.apache.arrow.vector.FieldVector;
+import org.apache.arrow.vector.Float4Vector;
+import org.apache.arrow.vector.Float8Vector;
+import org.apache.arrow.vector.IntVector;
+import org.apache.arrow.vector.SmallIntVector;
+import org.apache.arrow.vector.TimeStampMilliTZVector;
+import org.apache.arrow.vector.TinyIntVector;
+import org.apache.arrow.vector.VarBinaryVector;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.beam.sdk.coders.Coder;
+import org.apache.beam.sdk.coders.RowCoder;
+import org.apache.beam.sdk.extensions.arrow.ArrowConversion;
+import org.apache.beam.sdk.io.BoundedSource;
+import org.apache.beam.sdk.metrics.Counter;
+import org.apache.beam.sdk.metrics.Metrics;
+import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.transforms.DoFn;
+import org.apache.beam.sdk.transforms.PTransform;
+import org.apache.beam.sdk.transforms.ParDo;
+import org.apache.beam.sdk.transforms.display.DisplayData;
+import org.apache.beam.sdk.values.PBegin;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.PDone;
+import org.apache.beam.sdk.values.Row;
+import org.checkerframework.checker.nullness.qual.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * IO to read and write data using <a 
href="https://arrow.apache.org/docs/format/Flight.html";>Apache
+ * Arrow Flight</a>.
+ *
+ * <p>Arrow Flight is an RPC framework for transferring Arrow-formatted data 
over gRPC.
+ *
+ * <h3>Reading from an Arrow Flight server</h3>
+ *
+ * <p>{@link ArrowFlightIO#read()} returns a bounded {@link PCollection} of 
{@link Row} elements.
+ * Each row is converted from Arrow record batches using the existing {@link 
ArrowConversion}
+ * extension.
+ *
+ * <pre>{@code
+ * PCollection<Row> rows = pipeline.apply(
+ *     ArrowFlightIO.read()
+ *         .withHost("localhost")
+ *         .withPort(47470)
+ *         .withCommand("SELECT * FROM my_table"));
+ * }</pre>
+ *
+ * <h3>Writing to an Arrow Flight server</h3>
+ *
+ * <p>{@link ArrowFlightIO#write()} accepts a {@link PCollection} of {@link 
Row} elements and
+ * streams them to a Flight server using {@code doPut}.
+ *
+ * <pre>{@code
+ * rows.apply(
+ *     ArrowFlightIO.write()
+ *         .withHost("localhost")
+ *         .withPort(47470)
+ *         .withDescriptor("my_table")
+ *         .withBatchSize(1024));
+ * }</pre>
+ *
+ * <h3>Java runtime configuration</h3>
+ *
+ * <p>On Java 17 or later, Arrow memory access requires {@code
+ * --add-opens=java.base/java.nio=ALL-UNNAMED} on JVMs executing this 
connector. Beam SDK containers
+ * enable it by default. For other environments, add it to the local or runner 
JVM and pass {@code
+ * --JdkAddOpenModules=java.base/java.nio=ALL-UNNAMED} to configure SDK 
harness JVMs. See the <a
+ * href="https://arrow.apache.org/docs/java/install.html";>Arrow Java 
installation guide</a>.
+ */
+public class ArrowFlightIO {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(ArrowFlightIO.class);
+
+  private ArrowFlightIO() {}
+
+  private static byte[] copyToken(byte[] token) {
+    return Arrays.copyOf(checkNotNull(token, "token"), token.length);
+  }
+
+  private static CallOption[] callOptions(byte @Nullable [] token) {
+    if (token == null) {
+      return new CallOption[0];
+    }
+    FlightCallHeaders headers = new FlightCallHeaders();
+    headers.insert("authorization", "Bearer " + new String(token, 
StandardCharsets.UTF_8));
+    return new CallOption[] {new HeaderCallOption(headers)};
+  }
+
+  private static void validateWriteSchema(Schema schema) {
+    try {
+      ArrowConversion.ArrowSchemaTranslator.toArrowSchema(schema);
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException("ArrowFlightIO.write(): " + 
e.getMessage(), e);
+    }
+  }
+
+  public static Read read() {
+    return new 
AutoValue_ArrowFlightIO_Read.Builder().setPort(47470).setUseTls(false).build();
+  }
+
+  public static Write write() {
+    return new AutoValue_ArrowFlightIO_Write.Builder()
+        .setPort(47470)
+        .setUseTls(false)
+        .setBatchSize(1024)
+        .build();
+  }
+
+  /**
+   * Creates a {@link FlightClient} from the given connection parameters.
+   *
+   * <p>The client uses a {@link RootAllocator} for Arrow memory management 
and connects to the
+   * specified host and port using either plaintext or TLS.
+   */
+  static FlightClient createClient(
+      BufferAllocator allocator, String host, int port, boolean useTls) {
+    Location location;
+    if (useTls) {
+      location = Location.forGrpcTls(host, port);
+    } else {
+      location = Location.forGrpcInsecure(host, port);
+    }
+    return FlightClient.builder(allocator, location).build();
+  }
+
+  /** A serializable wrapper around Flight endpoint information for use in 
BoundedSource splits. */
+  static class SerializableEndpoint implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    private final byte[] ticketBytes;
+    private final @Nullable String host;
+    private final int port;
+
+    SerializableEndpoint(byte[] ticketBytes, @Nullable String host, int port) {
+      this.ticketBytes = ticketBytes;
+      this.host = host;
+      this.port = port;
+    }
+
+    static SerializableEndpoint fromFlightEndpoint(
+        FlightEndpoint endpoint, String defaultHost, int defaultPort) {
+      byte[] ticket = endpoint.getTicket().getBytes();
+      List<Location> locations = endpoint.getLocations();
+      if (locations != null && !locations.isEmpty()) {
+        URI uri = locations.get(0).getUri();
+        return new SerializableEndpoint(ticket, uri.getHost(), uri.getPort());
+      }
+      return new SerializableEndpoint(ticket, defaultHost, defaultPort);
+    }
+
+    Ticket getTicket() {
+      return new Ticket(ticketBytes);
+    }
+
+    String getHost(String defaultHost) {
+      return host != null ? host : defaultHost;
+    }
+
+    int getPort(int defaultPort) {
+      return port > 0 ? port : defaultPort;
+    }
+  }
+
+  // ======================== READ ========================
+
+  @AutoValue
+  public abstract static class Read extends PTransform<PBegin, 
PCollection<Row>> {
+
+    abstract @Nullable String host();
+
+    abstract int port();
+
+    abstract boolean useTls();
+
+    abstract @Nullable String command();
+
+    @SuppressWarnings("mutable")
+    abstract byte @Nullable [] token();
+
+    abstract Builder builder();
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setHost(String host);
+
+      abstract Builder setPort(int port);
+
+      abstract Builder setUseTls(boolean useTls);
+
+      abstract Builder setCommand(String command);
+
+      abstract Builder setToken(byte[] token);
+
+      abstract Read build();
+    }
+
+    /** Sets the Flight server host. */
+    public Read withHost(String host) {
+      return builder().setHost(host).build();
+    }
+
+    /** Sets the Flight server port. */
+    public Read withPort(int port) {
+      return builder().setPort(port).build();
+    }
+
+    /** Enables TLS for the connection. */
+    public Read withUseTls(boolean useTls) {
+      return builder().setUseTls(useTls).build();
+    }
+
+    /** Sets the command (e.g., a SQL query or table name) to request from the 
Flight server. */
+    public Read withCommand(String command) {
+      return builder().setCommand(command).build();
+    }
+
+    /** Sets a bearer token for authentication. */
+    public Read withToken(byte[] token) {
+      return builder().setToken(copyToken(token)).build();
+    }
+
+    @Override
+    public PCollection<Row> expand(PBegin input) {
+      checkArgument(host() != null, "withHost() is required");
+      checkArgument(command() != null, "withCommand() is required");
+
+      Schema beamSchema;
+      try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE);
+          FlightClient client =
+              createClient(allocator, checkNotNull(host(), "host"), port(), 
useTls())) {
+        FlightInfo info =
+            client.getInfo(
+                FlightDescriptor.command(
+                    checkNotNull(command(), 
"command").getBytes(StandardCharsets.UTF_8)),
+                callOptions());
+        beamSchema = 
ArrowConversion.ArrowSchemaTranslator.toBeamSchema(info.getSchema());
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        throw new RuntimeException("Interrupted while fetching Flight schema", 
e);
+      }
+
+      return input
+          .apply(org.apache.beam.sdk.io.Read.from(new 
FlightBoundedSource(this, beamSchema)))
+          .setRowSchema(beamSchema);
+    }
+
+    CallOption[] callOptions() {
+      return ArrowFlightIO.callOptions(token());
+    }
+
+    @Override
+    public void populateDisplayData(DisplayData.Builder builder) {
+      super.populateDisplayData(builder);
+      builder.addIfNotNull(DisplayData.item("host", host()));
+      builder.add(DisplayData.item("port", port()));
+      builder.add(DisplayData.item("useTls", useTls()));
+      builder.addIfNotNull(DisplayData.item("command", command()));
+    }
+  }
+
+  /** A {@link BoundedSource} that reads rows from Arrow Flight endpoints. */
+  static class FlightBoundedSource extends BoundedSource<Row> {
+    private final Read spec;
+    private final Schema beamSchema;
+    private final @Nullable SerializableEndpoint endpoint;
+
+    FlightBoundedSource(Read spec, Schema beamSchema) {
+      this(spec, beamSchema, null);
+    }
+
+    FlightBoundedSource(Read spec, Schema beamSchema, @Nullable 
SerializableEndpoint endpoint) {
+      this.spec = spec;
+      this.beamSchema = beamSchema;
+      this.endpoint = endpoint;
+    }
+
+    @Override
+    public List<? extends BoundedSource<Row>> split(
+        long desiredBundleSizeBytes, PipelineOptions options) throws Exception 
{
+      if (endpoint != null) {
+        return Collections.singletonList(this);
+      }
+
+      List<BoundedSource<Row>> sources = new ArrayList<>();
+      try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE);
+          FlightClient client =
+              createClient(
+                  allocator, checkNotNull(spec.host(), "host"), spec.port(), 
spec.useTls())) {
+        FlightInfo info =
+            client.getInfo(
+                FlightDescriptor.command(
+                    checkNotNull(spec.command(), 
"command").getBytes(StandardCharsets.UTF_8)),
+                spec.callOptions());
+        for (FlightEndpoint fe : info.getEndpoints()) {
+          SerializableEndpoint se =
+              SerializableEndpoint.fromFlightEndpoint(
+                  fe, checkNotNull(spec.host(), "host"), spec.port());
+          sources.add(new FlightBoundedSource(spec, beamSchema, se));
+        }
+      }
+
+      if (sources.isEmpty()) {
+        sources.add(this);
+      }
+      return sources;
+    }
+
+    @Override
+    public long getEstimatedSizeBytes(PipelineOptions options) throws 
Exception {
+      if (endpoint != null) {
+        return -1;
+      }
+      try (BufferAllocator allocator = new RootAllocator(Long.MAX_VALUE);
+          FlightClient client =
+              createClient(
+                  allocator, checkNotNull(spec.host(), "host"), spec.port(), 
spec.useTls())) {
+        FlightInfo info =
+            client.getInfo(
+                FlightDescriptor.command(
+                    checkNotNull(spec.command(), 
"command").getBytes(StandardCharsets.UTF_8)),
+                spec.callOptions());
+        return info.getBytes();
+      }
+    }
+
+    @Override
+    public BoundedReader<Row> createReader(PipelineOptions options) {
+      return new FlightBoundedReader(this);
+    }
+
+    @Override
+    public void validate() {
+      checkArgument(spec.host() != null, "host is required");
+      checkArgument(spec.command() != null, "command is required");
+    }
+
+    @Override
+    public Coder<Row> getOutputCoder() {
+      return RowCoder.of(beamSchema);
+    }
+  }
+
+  /** Reader that streams Arrow record batches from a Flight endpoint and 
emits Beam Rows. */
+  @SuppressWarnings("initialization.fields.uninitialized")
+  static class FlightBoundedReader extends BoundedSource.BoundedReader<Row> {
+    private static final Counter RECORDS_READ = 
Metrics.counter(ArrowFlightIO.class, "recordsRead");
+
+    private final FlightBoundedSource source;
+    private transient BufferAllocator allocator;
+    private transient FlightClient client;
+    private transient FlightStream stream;
+    private transient Iterator<Row> currentBatchIterator;
+    private transient Row current;
+    private FlightBoundedSource currentSource;
+
+    FlightBoundedReader(FlightBoundedSource source) {
+      this.source = source;
+      this.currentSource = source;
+    }
+
+    @Override
+    public boolean start() throws IOException {
+      allocator = new RootAllocator(Long.MAX_VALUE);
+      Read spec = source.spec;
+
+      String defaultHost = checkNotNull(spec.host(), "host");
+      SerializableEndpoint endpoint = source.endpoint;
+      if (endpoint == null) {
+        try (FlightClient discoveryClient =
+            createClient(allocator, defaultHost, spec.port(), spec.useTls())) {
+          FlightInfo info =
+              discoveryClient.getInfo(
+                  FlightDescriptor.command(
+                      checkNotNull(spec.command(), 
"command").getBytes(StandardCharsets.UTF_8)),
+                  spec.callOptions());
+          List<FlightEndpoint> endpoints = info.getEndpoints();
+          if (endpoints.isEmpty()) {
+            return false;
+          }
+          endpoint =
+              SerializableEndpoint.fromFlightEndpoint(endpoints.get(0), 
defaultHost, spec.port());
+        } catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+          throw new IOException("Interrupted while discovering Flight 
endpoints", e);
+        }
+        currentSource = new FlightBoundedSource(spec, source.beamSchema, 
endpoint);
+      }
+
+      client =
+          createClient(
+              allocator,
+              endpoint.getHost(defaultHost),
+              endpoint.getPort(spec.port()),
+              spec.useTls());
+      stream = client.getStream(endpoint.getTicket(), spec.callOptions());
+      currentBatchIterator = Collections.emptyIterator();
+      return advance();
+    }
+
+    @Override
+    public boolean advance() throws IOException {
+      while (true) {
+        if (currentBatchIterator.hasNext()) {
+          current = currentBatchIterator.next();
+          RECORDS_READ.inc();
+          return true;
+        }
+        if (stream.next()) {
+          VectorSchemaRoot root = stream.getRoot();
+          if (root.getRowCount() > 0) {
+            Iterator<Row> lazyIterator =
+                ArrowConversion.rowsFromRecordBatch(source.beamSchema, root);
+            List<Row> materializedRows = new ArrayList<>();
+            while (lazyIterator.hasNext()) {
+              Row lazyRow = lazyIterator.next();
+              materializedRows.add(
+                  
Row.withSchema(source.beamSchema).addValues(lazyRow.getValues()).build());
+            }
+            currentBatchIterator = materializedRows.iterator();
+          }
+        } else {
+          return false;
+        }
+      }
+    }
+
+    @Override
+    public Row getCurrent() {
+      return current;
+    }
+
+    @Override
+    public void close() throws IOException {
+      try {
+        if (stream != null) {
+          stream.close();
+        }
+      } catch (Exception e) {
+        LOG.warn("Error closing FlightStream", e);
+      }
+      try {
+        if (client != null) {
+          client.close();
+        }
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        LOG.warn("Interrupted closing FlightClient", e);
+      }
+      try {
+        if (allocator != null) {
+          allocator.close();
+        }
+      } catch (Exception e) {
+        LOG.warn("Error closing BufferAllocator", e);
+      }
+    }
+
+    @Override
+    public BoundedSource<Row> getCurrentSource() {
+      return currentSource;
+    }
+  }
+
+  // ======================== WRITE ========================
+
+  // TODO: Return a write result with failed rows for dead-letter handling 
instead of PDone.
+  @AutoValue
+  public abstract static class Write extends PTransform<PCollection<Row>, 
PDone> {
+
+    abstract @Nullable String host();
+
+    abstract int port();
+
+    abstract boolean useTls();
+
+    abstract @Nullable String descriptor();
+
+    abstract int batchSize();
+
+    @SuppressWarnings("mutable")
+    abstract byte @Nullable [] token();
+
+    abstract Builder builder();
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setHost(String host);
+
+      abstract Builder setPort(int port);
+
+      abstract Builder setUseTls(boolean useTls);
+
+      abstract Builder setDescriptor(String descriptor);
+
+      abstract Builder setBatchSize(int batchSize);
+
+      abstract Builder setToken(byte[] token);
+
+      abstract Write build();
+    }
+
+    /** Sets the Flight server host. */
+    public Write withHost(String host) {
+      return builder().setHost(host).build();
+    }
+
+    /** Sets the Flight server port. */
+    public Write withPort(int port) {
+      return builder().setPort(port).build();
+    }
+
+    /** Enables TLS for the connection. */
+    public Write withUseTls(boolean useTls) {
+      return builder().setUseTls(useTls).build();
+    }
+
+    /** Sets the Flight descriptor (table name or path) for the write target. 
*/
+    public Write withDescriptor(String descriptor) {
+      return builder().setDescriptor(descriptor).build();
+    }
+
+    /** Sets the batch size for writing. Rows are buffered and flushed in 
batches. */
+    public Write withBatchSize(int batchSize) {
+      checkArgument(batchSize > 0, "batchSize must be positive");
+      return builder().setBatchSize(batchSize).build();
+    }
+
+    /** Sets a bearer token for authentication. */
+    public Write withToken(byte[] token) {
+      return builder().setToken(copyToken(token)).build();
+    }
+
+    CallOption[] callOptions() {
+      return ArrowFlightIO.callOptions(token());
+    }
+
+    @Override
+    public PDone expand(PCollection<Row> input) {
+      checkArgument(host() != null, "withHost() is required");
+      checkArgument(descriptor() != null, "withDescriptor() is required");
+      Schema inputSchema = checkNotNull(input.getSchema(), "input schema");
+      validateWriteSchema(inputSchema);
+
+      input.apply(ParDo.of(new FlightWriteFn(this, inputSchema)));
+      return PDone.in(input.getPipeline());
+    }
+
+    @Override
+    public void populateDisplayData(DisplayData.Builder builder) {
+      super.populateDisplayData(builder);
+      builder.addIfNotNull(DisplayData.item("host", host()));
+      builder.add(DisplayData.item("port", port()));
+      builder.add(DisplayData.item("useTls", useTls()));
+      builder.addIfNotNull(DisplayData.item("descriptor", descriptor()));
+      builder.add(DisplayData.item("batchSize", batchSize()));
+    }
+  }
+
+  /** DoFn that buffers Beam Rows and streams them as Arrow record batches to 
a Flight server. */
+  @SuppressWarnings("initialization.fields.uninitialized")
+  static class FlightWriteFn extends DoFn<Row, Void> {
+    private static final Counter RECORDS_WRITTEN =
+        Metrics.counter(ArrowFlightIO.class, "recordsWritten");
+    private static final Counter BATCHES_WRITTEN =
+        Metrics.counter(ArrowFlightIO.class, "batchesWritten");
+
+    private final Write spec;
+    private final Schema beamSchema;
+    private transient @Nullable BufferAllocator allocator;
+    private transient @Nullable FlightClient client;
+    private transient FlightClient.@Nullable ClientStreamListener listener;
+    private transient @Nullable VectorSchemaRoot root;
+    private transient List<Row> batch;
+
+    FlightWriteFn(Write spec, Schema beamSchema) {
+      this.spec = spec;
+      this.beamSchema = beamSchema;
+    }
+
+    @StartBundle
+    public void startBundle() {
+      batch = new ArrayList<>();
+    }
+
+    @ProcessElement
+    public void processElement(@Element Row row) {
+      checkArgument(
+          row.getSchema().equivalent(beamSchema),
+          "ArrowFlightIO.write() requires all rows to use the same schema.");
+      batch.add(row);
+      if (batch.size() >= spec.batchSize()) {
+        flush();
+      }
+    }
+
+    @FinishBundle
+    public void finishBundle() {
+      RuntimeException failure = null;
+      try {
+        flush();
+      } catch (RuntimeException e) {
+        failure = e;
+      }
+
+      try {
+        closeConnection();
+      } catch (RuntimeException e) {
+        if (failure == null) {
+          failure = e;
+        } else {
+          failure.addSuppressed(e);
+        }
+      }
+
+      if (failure != null) {
+        throw failure;
+      }
+    }
+
+    @Teardown
+    public void teardown() {
+      try {
+        closeConnection();
+      } catch (RuntimeException e) {
+        LOG.warn("Error closing Flight write connection during teardown", e);
+      }
+    }
+
+    private void ensureConnection() {
+      if (client == null) {
+        BufferAllocator currentAllocator = new RootAllocator(Long.MAX_VALUE);
+        allocator = currentAllocator;
+        FlightClient currentClient =
+            createClient(
+                currentAllocator, checkNotNull(spec.host(), "host"), 
spec.port(), spec.useTls());
+        client = currentClient;
+
+        org.apache.arrow.vector.types.pojo.Schema arrowSchema =
+            ArrowConversion.ArrowSchemaTranslator.toArrowSchema(beamSchema);
+        VectorSchemaRoot currentRoot = VectorSchemaRoot.create(arrowSchema, 
currentAllocator);
+        root = currentRoot;
+
+        FlightDescriptor descriptor =
+            FlightDescriptor.path(checkNotNull(spec.descriptor(), 
"descriptor"));
+        listener =
+            currentClient.startPut(
+                descriptor, currentRoot, new AsyncPutListener(), 
spec.callOptions());
+      }
+    }
+
+    @SuppressWarnings("nullness")
+    private void flush() {
+      if (batch == null || batch.isEmpty()) {
+        return;
+      }
+      ensureConnection();
+
+      for (int colIdx = 0; colIdx < beamSchema.getFieldCount(); colIdx++) {
+        FieldVector vector = root.getVector(colIdx);
+        vector.allocateNew();
+        Schema.Field field = beamSchema.getField(colIdx);
+        for (int rowIdx = 0; rowIdx < batch.size(); rowIdx++) {
+          Object value = batch.get(rowIdx).getValue(colIdx);
+          if (value == null) {
+            vector.setNull(rowIdx);
+          } else {
+            setVectorValue(vector, rowIdx, value, field.getType());
+          }
+        }
+        vector.setValueCount(batch.size());
+      }
+      root.setRowCount(batch.size());
+
+      listener.putNext();
+      RECORDS_WRITTEN.inc(batch.size());
+      BATCHES_WRITTEN.inc();
+      root.clear();
+      batch.clear();
+    }
+
+    @SuppressWarnings("nullness")
+    private void setVectorValue(
+        FieldVector vector, int index, Object value, Schema.FieldType type) {
+      switch (type.getTypeName()) {
+        case BYTE:
+          ((TinyIntVector) vector).setSafe(index, ((Number) 
value).byteValue());
+          break;
+        case INT16:
+          ((SmallIntVector) vector).setSafe(index, ((Number) 
value).shortValue());
+          break;
+        case INT32:
+          ((IntVector) vector).setSafe(index, ((Number) value).intValue());
+          break;
+        case INT64:
+          ((BigIntVector) vector).setSafe(index, ((Number) value).longValue());
+          break;
+        case FLOAT:
+          ((Float4Vector) vector).setSafe(index, ((Number) 
value).floatValue());
+          break;
+        case DOUBLE:
+          ((Float8Vector) vector).setSafe(index, ((Number) 
value).doubleValue());
+          break;
+        case BOOLEAN:
+          ((BitVector) vector).setSafe(index, ((Boolean) value) ? 1 : 0);
+          break;
+        case STRING:
+          ((VarCharVector) vector)
+              .setSafe(index, 
value.toString().getBytes(StandardCharsets.UTF_8));
+          break;
+        case BYTES:
+          ((VarBinaryVector) vector).setSafe(index, (byte[]) value);
+          break;
+        case DATETIME:
+          long millis;
+          if (value instanceof org.joda.time.ReadableInstant) {
+            millis = ((org.joda.time.ReadableInstant) value).getMillis();
+          } else {
+            millis = ((Number) value).longValue();
+          }
+          ((TimeStampMilliTZVector) vector).setSafe(index, millis);
+          break;
+        default:
+          throw new IllegalArgumentException(
+              "Unsupported Beam type for ArrowFlightIO.write(): " + 
type.getTypeName());
+      }
+    }
+
+    private void closeConnection() {
+      RuntimeException failure = null;
+      FlightClient.ClientStreamListener currentListener = listener;
+      listener = null;
+      try {
+        if (currentListener != null) {
+          currentListener.completed();
+          currentListener.getResult();
+        }
+      } catch (RuntimeException e) {
+        failure = e;
+      }
+
+      VectorSchemaRoot currentRoot = root;
+      root = null;
+      try {
+        if (currentRoot != null) {
+          currentRoot.close();
+        }
+      } catch (Exception e) {
+        if (failure == null) {
+          failure = new RuntimeException("Error closing VectorSchemaRoot", e);
+        } else {
+          failure.addSuppressed(e);
+        }
+      }
+
+      FlightClient currentClient = client;
+      client = null;
+      try {
+        if (currentClient != null) {
+          currentClient.close();
+        }
+      } catch (InterruptedException e) {
+        Thread.currentThread().interrupt();
+        RuntimeException closeFailure = new RuntimeException("Interrupted 
closing FlightClient", e);
+        if (failure == null) {
+          failure = closeFailure;
+        } else {
+          failure.addSuppressed(closeFailure);
+        }
+      }
+
+      BufferAllocator currentAllocator = allocator;
+      allocator = null;
+      try {
+        if (currentAllocator != null) {
+          currentAllocator.close();
+        }
+      } catch (Exception e) {
+        if (failure == null) {
+          failure = new RuntimeException("Error closing BufferAllocator", e);
+        } else {
+          failure.addSuppressed(e);
+        }
+      }
+
+      if (failure != null) {
+        throw failure;
+      }
+    }
+  }
+}
diff --git 
a/sdks/java/io/arrow-flight/src/main/java/org/apache/beam/sdk/io/arrowflight/package-info.java
 
b/sdks/java/io/arrow-flight/src/main/java/org/apache/beam/sdk/io/arrowflight/package-info.java
new file mode 100644
index 00000000000..6988db88c3a
--- /dev/null
+++ 
b/sdks/java/io/arrow-flight/src/main/java/org/apache/beam/sdk/io/arrowflight/package-info.java
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+
+/**
+ * I/O connector for <a 
href="https://arrow.apache.org/docs/format/Flight.html";>Apache Arrow
+ * Flight</a>.
+ *
+ * <p>Arrow Flight is a high-performance RPC framework for fast data transport 
using the Apache
+ * Arrow columnar format over gRPC. This connector enables Beam pipelines to 
read from and write to
+ * Arrow Flight-compatible data systems.
+ *
+ * @see org.apache.beam.sdk.io.arrowflight.ArrowFlightIO
+ */
+package org.apache.beam.sdk.io.arrowflight;
diff --git 
a/sdks/java/io/arrow-flight/src/test/java/org/apache/beam/sdk/io/arrowflight/ArrowFlightIOTest.java
 
b/sdks/java/io/arrow-flight/src/test/java/org/apache/beam/sdk/io/arrowflight/ArrowFlightIOTest.java
new file mode 100644
index 00000000000..7ef67b5f624
--- /dev/null
+++ 
b/sdks/java/io/arrow-flight/src/test/java/org/apache/beam/sdk/io/arrowflight/ArrowFlightIOTest.java
@@ -0,0 +1,330 @@
+/*
+ * 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.beam.sdk.io.arrowflight;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.arrow.flight.Action;
+import org.apache.arrow.flight.ActionType;
+import org.apache.arrow.flight.CallStatus;
+import org.apache.arrow.flight.Criteria;
+import org.apache.arrow.flight.FlightConstants;
+import org.apache.arrow.flight.FlightDescriptor;
+import org.apache.arrow.flight.FlightEndpoint;
+import org.apache.arrow.flight.FlightInfo;
+import org.apache.arrow.flight.FlightProducer;
+import org.apache.arrow.flight.FlightServer;
+import org.apache.arrow.flight.FlightStream;
+import org.apache.arrow.flight.Location;
+import org.apache.arrow.flight.PutResult;
+import org.apache.arrow.flight.Result;
+import org.apache.arrow.flight.ServerHeaderMiddleware;
+import org.apache.arrow.flight.Ticket;
+import org.apache.arrow.memory.BufferAllocator;
+import org.apache.arrow.memory.RootAllocator;
+import org.apache.arrow.vector.VarCharVector;
+import org.apache.arrow.vector.VectorSchemaRoot;
+import org.apache.arrow.vector.types.pojo.ArrowType;
+import org.apache.arrow.vector.types.pojo.Field;
+import org.apache.arrow.vector.types.pojo.FieldType;
+import org.apache.beam.sdk.Pipeline;
+import org.apache.beam.sdk.Pipeline.PipelineExecutionException;
+import org.apache.beam.sdk.io.BoundedSource;
+import org.apache.beam.sdk.options.PipelineOptions;
+import org.apache.beam.sdk.options.PipelineOptionsFactory;
+import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.testing.PAssert;
+import org.apache.beam.sdk.testing.TestPipeline;
+import org.apache.beam.sdk.transforms.Create;
+import org.apache.beam.sdk.values.PCollection;
+import org.apache.beam.sdk.values.Row;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link ArrowFlightIO}. */
+@RunWith(JUnit4.class)
+public class ArrowFlightIOTest {
+
+  @Rule public final TestPipeline pipeline = TestPipeline.create();
+
+  private BufferAllocator allocator;
+  private FlightServer server;
+  private TestFlightProducer producer;
+  private int port;
+
+  @Before
+  public void setUp() throws Exception {
+    allocator = new RootAllocator(Long.MAX_VALUE);
+    producer = new TestFlightProducer(allocator);
+
+    // Bind to any available port
+    Location location = Location.forGrpcInsecure("localhost", 0);
+    server = FlightServer.builder(allocator, location, producer).build();
+    server.start();
+
+    port = server.getPort();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    if (server != null) {
+      server.close();
+    }
+    if (allocator != null) {
+      allocator.close();
+    }
+  }
+
+  @Test
+  public void testRead() {
+    PCollection<Row> output =
+        pipeline.apply(
+            "Read from Flight",
+            
ArrowFlightIO.read().withHost("localhost").withPort(port).withCommand("test_query"));
+
+    Schema expectedSchema = Schema.builder().addStringField("name").build();
+    Row expectedRow1 = 
Row.withSchema(expectedSchema).addValue("Alice").build();
+    Row expectedRow2 = Row.withSchema(expectedSchema).addValue("Bob").build();
+
+    PAssert.that(output).containsInAnyOrder(expectedRow1, expectedRow2);
+
+    pipeline.run().waitUntilFinish();
+  }
+
+  @Test
+  public void testCurrentSourceDoesNotResplitAfterReadStarts() throws 
Exception {
+    producer.endpointCount = 2;
+    PipelineOptions options = PipelineOptionsFactory.create();
+    Schema schema = Schema.builder().addStringField("name").build();
+    ArrowFlightIO.Read read =
+        
ArrowFlightIO.read().withHost("localhost").withPort(port).withCommand("test_query");
+    ArrowFlightIO.FlightBoundedSource source = new 
ArrowFlightIO.FlightBoundedSource(read, schema);
+
+    assertEquals(2, source.split(0, options).size());
+    try (BoundedSource.BoundedReader<Row> reader = 
source.createReader(options)) {
+      assertTrue(reader.start());
+      assertEquals(1, reader.getCurrentSource().split(0, options).size());
+    }
+  }
+
+  @Test
+  public void testWrite() throws Exception {
+    Schema expectedSchema = Schema.builder().addStringField("name").build();
+    Row row1 = Row.withSchema(expectedSchema).addValue("Charlie").build();
+    Row row2 = Row.withSchema(expectedSchema).addValue("Dave").build();
+
+    pipeline
+        .apply(Create.of(row1, row2).withRowSchema(expectedSchema))
+        .apply(
+            "Write to Flight",
+            ArrowFlightIO.write()
+                .withHost("localhost")
+                .withPort(port)
+                .withDescriptor("test_table"));
+
+    pipeline.run().waitUntilFinish();
+
+    assertEquals(2, producer.writtenRecords.get());
+  }
+
+  @Test
+  public void testWriteWithToken() throws Exception {
+    producer.requiredAuthorizationHeader = "Bearer test-token";
+
+    Schema expectedSchema = Schema.builder().addStringField("name").build();
+    Row row = Row.withSchema(expectedSchema).addValue("Charlie").build();
+
+    pipeline
+        .apply(Create.of(row).withRowSchema(expectedSchema))
+        .apply(
+            "Write to Flight with Token",
+            ArrowFlightIO.write()
+                .withHost("localhost")
+                .withPort(port)
+                .withDescriptor("test_table")
+                .withToken("test-token".getBytes(StandardCharsets.UTF_8)));
+
+    pipeline.run().waitUntilFinish();
+
+    assertEquals("Bearer test-token", producer.lastAuthorizationHeader.get());
+    assertEquals(1, producer.writtenRecords.get());
+  }
+
+  @Test
+  public void testWritePropagatesServerErrors() {
+    producer.failWrites = true;
+
+    Schema expectedSchema = Schema.builder().addStringField("name").build();
+    Row row = Row.withSchema(expectedSchema).addValue("Charlie").build();
+
+    pipeline
+        .apply(Create.of(row).withRowSchema(expectedSchema))
+        .apply(
+            "Write to Failing Flight Server",
+            ArrowFlightIO.write()
+                .withHost("localhost")
+                .withPort(port)
+                .withDescriptor("test_table"));
+
+    PipelineExecutionException exception =
+        assertThrows(PipelineExecutionException.class, () -> 
pipeline.run().waitUntilFinish());
+    assertThat(exception.getMessage(), containsString("Rejected write"));
+  }
+
+  @Test
+  public void testWriteRejectsUnsupportedSchema() {
+    Schema unsupportedSchema =
+        Schema.builder().addArrayField("names", 
Schema.FieldType.STRING).build();
+    Row row =
+        
Row.withSchema(unsupportedSchema).addArray(Collections.singletonList("Charlie")).build();
+    Pipeline testPipeline = Pipeline.create();
+
+    IllegalArgumentException exception =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                testPipeline
+                    .apply(Create.of(row).withRowSchema(unsupportedSchema))
+                    .apply(
+                        ArrowFlightIO.write()
+                            .withHost("localhost")
+                            .withPort(port)
+                            .withDescriptor("test_table")));
+
+    assertThat(exception.getMessage(), containsString("does not support Beam 
type 'ARRAY'"));
+  }
+
+  /** A simple FlightProducer that returns predefined data for reads and 
counts writes. */
+  private static class TestFlightProducer implements FlightProducer {
+
+    private final BufferAllocator allocator;
+    final AtomicInteger writtenRecords = new AtomicInteger();
+    final AtomicReference<String> lastAuthorizationHeader = new 
AtomicReference<>();
+    volatile int endpointCount = 1;
+    volatile boolean failWrites;
+    volatile String requiredAuthorizationHeader;
+
+    TestFlightProducer(BufferAllocator allocator) {
+      this.allocator = allocator;
+    }
+
+    @Override
+    public void getStream(CallContext context, Ticket ticket, 
ServerStreamListener listener) {
+      org.apache.arrow.vector.types.pojo.Schema schema =
+          new org.apache.arrow.vector.types.pojo.Schema(
+              Collections.singletonList(
+                  new Field(
+                      "name", FieldType.nullable(new ArrowType.Utf8()), 
Collections.emptyList())));
+
+      try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) 
{
+        listener.start(root);
+
+        VarCharVector vector = (VarCharVector) root.getVector("name");
+        vector.allocateNew();
+        vector.setSafe(0, "Alice".getBytes(StandardCharsets.UTF_8));
+        vector.setSafe(1, "Bob".getBytes(StandardCharsets.UTF_8));
+        vector.setValueCount(2);
+        root.setRowCount(2);
+
+        listener.putNext();
+        listener.completed();
+      } catch (Exception e) {
+        listener.error(e);
+      }
+    }
+
+    @Override
+    public void listFlights(
+        CallContext context, Criteria criteria, StreamListener<FlightInfo> 
listener) {
+      listener.onCompleted();
+    }
+
+    @Override
+    public FlightInfo getFlightInfo(CallContext context, FlightDescriptor 
descriptor) {
+      org.apache.arrow.vector.types.pojo.Schema schema =
+          new org.apache.arrow.vector.types.pojo.Schema(
+              Collections.singletonList(
+                  new Field(
+                      "name", FieldType.nullable(new ArrowType.Utf8()), 
Collections.emptyList())));
+      List<FlightEndpoint> endpoints = new ArrayList<>();
+      for (int i = 0; i < endpointCount; i++) {
+        endpoints.add(
+            new FlightEndpoint(
+                new Ticket(descriptor.getCommand()), 
Location.forGrpcInsecure("localhost", 0)));
+      }
+      return new FlightInfo(schema, descriptor, endpoints, -1, -1);
+    }
+
+    @Override
+    public Runnable acceptPut(
+        CallContext context, FlightStream flightStream, 
StreamListener<PutResult> ackStream) {
+      ServerHeaderMiddleware headerMiddleware = 
context.getMiddleware(FlightConstants.HEADER_KEY);
+      lastAuthorizationHeader.set(
+          headerMiddleware == null ? null : 
headerMiddleware.headers().get("authorization"));
+
+      return () -> {
+        try {
+          if (requiredAuthorizationHeader != null
+              && 
!requiredAuthorizationHeader.equals(lastAuthorizationHeader.get())) {
+            ackStream.onError(
+                CallStatus.UNAUTHENTICATED
+                    .withDescription("Missing or invalid authorization header")
+                    .toRuntimeException());
+            return;
+          }
+          while (flightStream.next()) {
+            VectorSchemaRoot root = flightStream.getRoot();
+            writtenRecords.addAndGet(root.getRowCount());
+          }
+          if (failWrites) {
+            ackStream.onError(
+                CallStatus.INTERNAL.withDescription("Rejected 
write").toRuntimeException());
+            return;
+          }
+          ackStream.onCompleted();
+        } catch (Exception e) {
+          ackStream.onError(e);
+        }
+      };
+    }
+
+    @Override
+    public void doAction(CallContext context, Action action, 
StreamListener<Result> listener) {
+      listener.onCompleted();
+    }
+
+    @Override
+    public void listActions(CallContext context, StreamListener<ActionType> 
listener) {
+      listener.onCompleted();
+    }
+  }
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8a116eeb394..a7cdfc70515 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -222,6 +222,7 @@ include(":sdks:java:harness:jmh")
 include(":sdks:java:io:amazon-web-services2")
 include(":sdks:java:io:amazon-web-services2:expansion-service")
 include(":sdks:java:io:amqp")
+include(":sdks:java:io:arrow-flight")
 include(":sdks:java:io:azure")
 include(":sdks:java:io:azure-cosmos")
 include(":sdks:java:io:cassandra")
diff --git a/website/www/site/content/en/documentation/io/connectors.md 
b/website/www/site/content/en/documentation/io/connectors.md
index 92c4c55220d..242a255ba82 100644
--- a/website/www/site/content/en/documentation/io/connectors.md
+++ b/website/www/site/content/en/documentation/io/connectors.md
@@ -882,6 +882,22 @@ This table provides a consolidated, at-a-glance overview 
of the available built-
     <td class="absent">✘</td>
     <td class="absent">✘</td>
   </tr>
+  <tr>
+    <td>ArrowFlightIO</td>
+    <td class="present">✔</td>
+    <td class="present">✔</td>
+    <td class="present">
+      ✔
+      <a 
href="https://beam.apache.org/releases/javadoc/current/org/apache/beam/sdk/io/arrowflight/ArrowFlightIO.html";>native</a>
+    </td>
+    <td>Not available</td>
+    <td>Not available</td>
+    <td>Not available</td>
+    <td>Not available</td>
+    <td class="present">✔</td>
+    <td class="present">✔</td>
+    <td class="absent">✘</td>
+  </tr>
   <tr>
     <td>DatabaseIO</td>
     <td class="present">✔</td>

Reply via email to