This is an automated email from the ASF dual-hosted git repository.
claudevdm 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 34a00c7781f AddFiles: extract bounded async task plumbing and Parquet
footer reads (#39896)
34a00c7781f is described below
commit 34a00c7781ff8333de79f6554747e039ea8f137e
Author: claudevdm <[email protected]>
AuthorDate: Thu Aug 27 14:28:35 2026 -0400
AddFiles: extract bounded async task plumbing and Parquet footer reads
(#39896)
* AddFiles: extract bounded async task plumbing and Parquet footer reads
ConvertToDataFile ran its per-file work (footer read, metrics, partition
resolution) on a private thread pool.
This change moves that queue into BoundedAsyncTasks and the footer read
into ParquetFooters so the upcoming schema pre-pass (a second DoFn that
reads every Parquet footer) can share both instead of copying them.
* trigger tests
* ParquetFooterTest
---------
Co-authored-by: Claude <[email protected]>
---
.../IO_Iceberg_Integration_Tests.json | 2 +-
.../org/apache/beam/sdk/io/iceberg/AddFiles.java | 103 +++-------
.../beam/sdk/io/iceberg/BoundedAsyncTasks.java | 113 +++++++++++
.../apache/beam/sdk/io/iceberg/ParquetFooters.java | 51 +++++
.../apache/beam/sdk/io/iceberg/AddFilesTest.java | 4 +-
.../beam/sdk/io/iceberg/BoundedAsyncTasksTest.java | 220 +++++++++++++++++++++
.../beam/sdk/io/iceberg/ParquetFootersTest.java | 113 +++++++++++
7 files changed, 527 insertions(+), 79 deletions(-)
diff --git a/.github/trigger_files/IO_Iceberg_Integration_Tests.json
b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
index 34a6e02150e..5d04b2c0a8c 100644
--- a/.github/trigger_files/IO_Iceberg_Integration_Tests.json
+++ b/.github/trigger_files/IO_Iceberg_Integration_Tests.json
@@ -1,4 +1,4 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to
run.",
- "modification": 4
+ "modification": 5
}
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
index 103df541e50..4645be9bfde 100644
---
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/AddFiles.java
@@ -27,31 +27,20 @@ import static
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Pr
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.ByteBuffer;
-import java.nio.channels.SeekableByteChannel;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
-import java.util.Iterator;
-import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.Future;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.beam.sdk.coders.KvCoder;
import org.apache.beam.sdk.coders.VarIntCoder;
import org.apache.beam.sdk.coders.VarLongCoder;
-import org.apache.beam.sdk.io.Compression;
-import org.apache.beam.sdk.io.FileSystems;
-import org.apache.beam.sdk.io.fs.ResourceId;
-import org.apache.beam.sdk.io.parquet.ParquetIO.ReadFiles.BeamParquetInputFile;
import org.apache.beam.sdk.metrics.Counter;
import org.apache.beam.sdk.schemas.Schema;
import org.apache.beam.sdk.schemas.SchemaCoder;
@@ -75,8 +64,6 @@ import org.apache.beam.sdk.values.TupleTag;
import org.apache.beam.sdk.values.TupleTagList;
import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Strings;
-import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
-import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Lists;
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.hash.Hasher;
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.hash.Hashing;
import org.apache.iceberg.AppendFiles;
@@ -111,7 +98,6 @@ import org.apache.iceberg.parquet.ParquetUtil;
import org.apache.iceberg.transforms.Transform;
import org.apache.iceberg.types.Conversions;
import org.apache.iceberg.types.Type;
-import org.apache.parquet.hadoop.ParquetFileReader;
import org.apache.parquet.hadoop.metadata.FileMetaData;
import org.apache.parquet.hadoop.metadata.ParquetMetadata;
import org.apache.parquet.schema.MessageType;
@@ -251,15 +237,15 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
* <p><b>Asynchronous Bundle Processing:</b> Because file I/O, catalog
lookups, and metadata
* inference can be highly latency-bound, this DoFn implements an
asynchronous processing pattern
* to maximize throughput. By default, Beam processes elements in a bundle
sequentially. To avoid
- * bottlenecking the pipeline, we use an internal {@link ExecutorService} to
process multiple
+ * bottlenecking the pipeline, we use an internal {@link BoundedAsyncTasks}
to process multiple
* files concurrently within a single DoFn instance.
*
* <p><b>Lifecycle & Thread Safety:</b>
*
* <ul>
* <li><b>{@link ProcessElement}:</b> Submits the heavy lifting (format
inference, metrics
- * collection, and partition resolution) to a background thread pool
and stores the
- * resulting {@link Future}.
+ * collection, and partition resolution) to a background thread pool
and emits results as
+ * they complete.
* <li><b>{@link FinishBundle}:</b> Blocks and awaits the completion of
all futures in the
* current bundle. It safely emits the successfully parsed {@link
DataFile}s, or error rows,
* back to the runner on the main thread, as {@link
MultiOutputReceiver} is not thread-safe.
@@ -274,8 +260,7 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
private final @Nullable List<String> partitionFields;
private final @Nullable List<String> sortFields;
private final @Nullable Map<String, String> tableProps;
- private transient @MonotonicNonNull ExecutorService executor;
- private transient @MonotonicNonNull LinkedList<Future<ProcessResult>>
activeTasks;
+ private transient @MonotonicNonNull BoundedAsyncTasks<ProcessResult> tasks;
private transient volatile @MonotonicNonNull Table table;
// Number of parallel threads processing incoming files
@@ -329,21 +314,23 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
@Setup
public void setup() {
- executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
+ tasks = new BoundedAsyncTasks<>(THREAD_POOL_SIZE, MAX_IN_FLIGHT_TASKS);
+ }
+
+ /** Clears anything left behind if the runner reuses this instance after a
failed bundle. */
+ @StartBundle
+ public void startBundle() {
+
+ checkStateNotNull(tasks).cancelAll();
}
@Teardown
public void teardown() {
- if (executor != null) {
- executor.shutdownNow();
+ if (tasks != null) {
+ tasks.shutdown();
}
}
- @StartBundle
- public void startBundle() {
- activeTasks = Lists.newLinkedList();
- }
-
@ProcessElement
public void process(
@Element String filePath,
@@ -351,28 +338,9 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
BoundedWindow window,
PaneInfo paneInfo,
MultiOutputReceiver output)
- throws IOException, InterruptedException, ExecutionException {
- LinkedList<Future<ProcessResult>> activeTasks =
checkStateNotNull(this.activeTasks);
-
- // start draining finished tasks, but don't block
- Iterator<Future<ProcessResult>> iterator = activeTasks.iterator();
- while (iterator.hasNext()) {
- Future<ProcessResult> future = iterator.next();
- if (future.isDone()) {
- outputResult(future.get(), output);
- iterator.remove();
- }
- }
-
- // if we have too many active tasks, wait until some finish
- while (activeTasks.size() >= MAX_IN_FLIGHT_TASKS) {
- Future<ProcessResult> oldestTask = activeTasks.removeFirst();
- outputResult(oldestTask.get(), output); // .get() blocks until the
task completes
- }
-
- // create a new task for the current element and add to queue
+ throws Exception {
Callable<ProcessResult> task = createProcessTask(filePath, timestamp,
window, paneInfo);
- activeTasks.add(checkStateNotNull(executor).submit(task));
+ checkStateNotNull(tasks).submit(task, result -> outputResult(result,
output));
}
private void outputResult(ProcessResult result, MultiOutputReceiver
output) {
@@ -398,18 +366,16 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
@FinishBundle
public void finishBundle(FinishBundleContext context) throws Exception {
- // Block and wait for threads to finish their work
- int numErrors = 0;
- for (Future<ProcessResult> future : checkStateNotNull(activeTasks)) {
- ProcessResult result = future.get();
- if (result.errorRow != null) {
- context.output(ERRORS, result.errorRow, result.timestamp,
result.window);
- numErrors++;
- } else if (result.dataFile != null) {
- context.output(DATA_FILES, result.dataFile, result.timestamp,
result.window);
- }
+ checkStateNotNull(tasks).awaitAll(result -> outputAtFinish(result,
context));
+ }
+
+ private static void outputAtFinish(ProcessResult result,
FinishBundleContext context) {
+ if (result.errorRow != null) {
+ context.output(ERRORS, result.errorRow, result.timestamp,
result.window);
+ numErrorFiles.inc();
+ } else if (result.dataFile != null) {
+ context.output(DATA_FILES, result.dataFile, result.timestamp,
result.window);
}
- numErrorFiles.inc(numErrors);
}
private Callable<ProcessResult> createProcessTask(
@@ -449,7 +415,7 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
@Nullable ParquetMetadata parquetFooter = null;
if (format.equals(FileFormat.PARQUET)) {
try {
- parquetFooter = readParquetFooter(filePath);
+ parquetFooter = ParquetFooters.read(filePath);
} catch (Exception e) {
return errorResult(filePath, errorMessage(e), timestamp, window,
paneInfo);
}
@@ -561,7 +527,7 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
throws IOException {
Preconditions.checkArgument(
format.equals(FileFormat.PARQUET), "Table creation is only supported
for Parquet files.");
- MessageType messageType =
readParquetFooter(filePath).getFileMetaData().getSchema();
+ MessageType messageType =
ParquetFooters.read(filePath).getFileMetaData().getSchema();
return ParquetSchemaUtil.convert(messageType);
}
@@ -872,12 +838,6 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
}
}
- static ParquetMetadata readParquetFooter(String filePath) throws IOException
{
- try (ParquetFileReader reader =
ParquetFileReader.open(getParquetInputFile(filePath))) {
- return reader.getFooter();
- }
- }
-
/**
* Some exceptions carry a null message (bare EOFException, NPE); the
error-routing path must
* never throw on one.
@@ -911,15 +871,6 @@ public class AddFiles extends
PTransform<PCollection<String>, PCollectionRowTupl
return new ParquetMetadata(newFileMeta, footer.getBlocks());
}
- static org.apache.parquet.io.InputFile getParquetInputFile(String filePath)
throws IOException {
- ResourceId resourceId =
-
Iterables.getOnlyElement(FileSystems.match(filePath).metadata()).resourceId();
- Compression compression =
Compression.detect(checkStateNotNull(resourceId.getFilename()));
- SeekableByteChannel channel =
- (SeekableByteChannel)
compression.readDecompressed(FileSystems.open(resourceId));
- return new BeamParquetInputFile(channel);
- }
-
static class UnknownFormatException extends IllegalArgumentException {}
static class UnknownPartitionException extends IllegalStateException {
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/BoundedAsyncTasks.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/BoundedAsyncTasks.java
new file mode 100644
index 00000000000..3349c9e07fe
--- /dev/null
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/BoundedAsyncTasks.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.beam.sdk.io.iceberg;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.function.Consumer;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.util.concurrent.ThreadFactoryBuilder;
+
+/**
+ * Runs tasks on a fixed thread pool while bounding how many are in flight.
Results are handed to
+ * {@code onDone} on the caller's thread (from {@link #submit} and {@link
#awaitAll}), so a DoFn can
+ * emit them without a thread-safe output.
+ */
+class BoundedAsyncTasks<T> {
+ private final ExecutorService executor;
+ private final int maxInFlight;
+ private final Deque<Future<T>> active = new ArrayDeque<>();
+
+ BoundedAsyncTasks(int threads, int maxInFlight) {
+ Preconditions.checkArgument(threads > 0, "threads must be positive, got:
%s", threads);
+ Preconditions.checkArgument(
+ maxInFlight > 0, "maxInFlight must be positive, got: %s", maxInFlight);
+ this.executor =
+ Executors.newFixedThreadPool(
+ threads,
+ new ThreadFactoryBuilder()
+ .setDaemon(true)
+ .setNameFormat("iceberg-async-task-%d")
+ .build());
+ this.maxInFlight = maxInFlight;
+ }
+
+ /**
+ * Submits a task, first delivering any finished results. Blocks while
{@code maxInFlight} tasks
+ * are outstanding. If a task failed, its exception is rethrown and every
other outstanding task
+ * is cancelled.
+ */
+ void submit(Callable<T> task, Consumer<T> onDone) throws Exception {
+ try {
+ drainFinished(onDone);
+ while (active.size() >= maxInFlight) {
+ Future<T> oldest = active.removeFirst();
+ onDone.accept(oldest.get()); // blocks until the oldest task completes
+ }
+ active.add(executor.submit(task));
+ } catch (Exception e) {
+ cancelAll();
+ throw e;
+ }
+ }
+
+ /**
+ * Delivers every outstanding result. Finished tasks drained during
execution may have been
+ * delivered out of submission order; remaining tasks are delivered in queue
order. The queue is
+ * empty afterwards.
+ */
+ void awaitAll(Consumer<T> onDone) throws Exception {
+ try {
+ while (!active.isEmpty()) {
+ Future<T> oldest = active.removeFirst();
+ onDone.accept(oldest.get());
+ }
+ } finally {
+ cancelAll();
+ }
+ }
+
+ /** Cancels and forgets every outstanding task. Results already delivered
are unaffected. */
+ void cancelAll() {
+ for (Future<T> future : active) {
+ future.cancel(true);
+ }
+ active.clear();
+ }
+
+ void shutdown() {
+ cancelAll();
+ executor.shutdownNow();
+ }
+
+ private void drainFinished(Consumer<T> onDone) throws Exception {
+ Iterator<Future<T>> iterator = active.iterator();
+ while (iterator.hasNext()) {
+ Future<T> future = iterator.next();
+ if (future.isDone()) {
+ iterator.remove();
+ onDone.accept(future.get());
+ }
+ }
+ }
+}
diff --git
a/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ParquetFooters.java
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ParquetFooters.java
new file mode 100644
index 00000000000..3ea62ac6efc
--- /dev/null
+++
b/sdks/java/io/iceberg/src/main/java/org/apache/beam/sdk/io/iceberg/ParquetFooters.java
@@ -0,0 +1,51 @@
+/*
+ * 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.iceberg;
+
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
+
+import java.io.IOException;
+import java.nio.channels.SeekableByteChannel;
+import org.apache.beam.sdk.io.Compression;
+import org.apache.beam.sdk.io.FileSystems;
+import org.apache.beam.sdk.io.fs.ResourceId;
+import org.apache.beam.sdk.io.parquet.ParquetIO.ReadFiles.BeamParquetInputFile;
+import
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
+import org.apache.parquet.hadoop.ParquetFileReader;
+import org.apache.parquet.hadoop.metadata.ParquetMetadata;
+import org.apache.parquet.io.InputFile;
+
+/** Reads Parquet footers through Beam's {@link FileSystems}, so no table or
FileIO is needed. */
+final class ParquetFooters {
+ private ParquetFooters() {}
+
+ static ParquetMetadata read(String filePath) throws IOException {
+ try (ParquetFileReader reader =
ParquetFileReader.open(inputFile(filePath))) {
+ return reader.getFooter();
+ }
+ }
+
+ private static InputFile inputFile(String filePath) throws IOException {
+ ResourceId resourceId =
+
Iterables.getOnlyElement(FileSystems.match(filePath).metadata()).resourceId();
+ Compression compression =
Compression.detect(checkStateNotNull(resourceId.getFilename()));
+ SeekableByteChannel channel =
+ (SeekableByteChannel)
compression.readDecompressed(FileSystems.open(resourceId));
+ return new BeamParquetInputFile(channel);
+ }
+}
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
index 40c038cc7f5..96da59f4505 100644
---
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/AddFilesTest.java
@@ -807,7 +807,7 @@ public class AddFilesTest {
writer.close();
InputFile file = table.io().newInputFile(fileName);
- ParquetMetadata footer = AddFiles.readParquetFooter(fileName);
+ ParquetMetadata footer = ParquetFooters.read(fileName);
Metrics metrics =
AddFiles.getFileMetrics(
file, FileFormat.PARQUET, metricsConfig,
MappingUtil.create(icebergSchema), footer);
@@ -873,7 +873,7 @@ public class AddFilesTest {
writer.close();
InputFile file = table.io().newInputFile(fileName);
- ParquetMetadata footer = AddFiles.readParquetFooter(fileName);
+ ParquetMetadata footer = ParquetFooters.read(fileName);
Metrics metrics =
AddFiles.getFileMetrics(
file, FileFormat.PARQUET, metricsConfig,
MappingUtil.create(icebergSchema), footer);
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/BoundedAsyncTasksTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/BoundedAsyncTasksTest.java
new file mode 100644
index 00000000000..09471c1f414
--- /dev/null
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/BoundedAsyncTasksTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.iceberg;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class BoundedAsyncTasksTest {
+ private final BoundedAsyncTasks<String> tasks = new BoundedAsyncTasks<>(2,
4);
+
+ @After
+ public void teardown() {
+ tasks.shutdown();
+ }
+
+ @Test
+ public void testConstructorValidatesArguments() {
+ assertThrows(IllegalArgumentException.class, () -> new
BoundedAsyncTasks<>(0, 4));
+ assertThrows(IllegalArgumentException.class, () -> new
BoundedAsyncTasks<>(2, 0));
+ }
+
+ @Test
+ public void testAllResultsAreDelivered() throws Exception {
+ List<String> delivered = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ String value = "t" + i;
+ tasks.submit(() -> value, delivered::add);
+ }
+ tasks.awaitAll(delivered::add);
+ List<String> expected = new ArrayList<>();
+ for (int i = 0; i < 10; i++) {
+ expected.add("t" + i);
+ }
+ assertThat(delivered, containsInAnyOrder(expected.toArray(new String[0])));
+ }
+
+ @Test
+ public void testOutOfOrderCompletionDrainsFinishedTasks() throws Exception {
+ CountDownLatch slowTaskHold = new CountDownLatch(1);
+ CountDownLatch fastTaskDone = new CountDownLatch(1);
+ List<String> delivered = new ArrayList<>();
+
+ // Task 0: slow, waiting on latch
+ tasks.submit(
+ () -> {
+ slowTaskHold.await();
+ return "slow";
+ },
+ delivered::add);
+
+ // Task 1: fast, signals when completed
+ tasks.submit(
+ () -> {
+ fastTaskDone.countDown();
+ return "fast";
+ },
+ delivered::add);
+
+ // Wait until fast task has finished running in the background
+ fastTaskDone.await();
+
+ // Trigger a drain by submitting another task
+ tasks.submit(() -> "noop", delivered::add);
+
+ // "fast" should have been drained while "slow" is still blocked
+ assertTrue(delivered.contains("fast"));
+ assertFalse(delivered.contains("slow"));
+
+ slowTaskHold.countDown();
+ tasks.awaitAll(delivered::add);
+ assertTrue(delivered.contains("slow"));
+ }
+
+ @Test
+ public void testSubmitBlocksAtMaxInFlight() throws Exception {
+ CountDownLatch release = new CountDownLatch(1);
+ CountDownLatch fifthExecuted = new CountDownLatch(1);
+ CountDownLatch fifthReturned = new CountDownLatch(1);
+ List<String> delivered = Collections.synchronizedList(new ArrayList<>());
+
+ for (int i = 0; i < 4; i++) {
+ tasks.submit(
+ () -> {
+ release.await();
+ return "blocked";
+ },
+ delivered::add);
+ }
+
+ // The fifth submit must block because 4 tasks are already outstanding.
+ Thread fifthSubmitter =
+ new Thread(
+ () -> {
+ try {
+ tasks.submit(
+ () -> {
+ fifthExecuted.countDown();
+ return "fifth";
+ },
+ delivered::add);
+ fifthReturned.countDown();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ });
+ fifthSubmitter.start();
+
+ // Verify submit() is blocked and has not completed while 4 tasks are in
flight
+ assertFalse(
+ "submit() must block while maxInFlight tasks are outstanding",
+ fifthReturned.await(50, TimeUnit.MILLISECONDS));
+ assertEquals(
+ "fifth task must not execute before oldest task completes", 1,
fifthExecuted.getCount());
+
+ // Release the blocked tasks; fifth submit should unblock and complete
+ release.countDown();
+ fifthReturned.await();
+ fifthExecuted.await();
+ fifthSubmitter.join();
+
+ tasks.awaitAll(delivered::add);
+ assertEquals(5, delivered.size());
+ assertTrue(delivered.contains("fifth"));
+ }
+
+ @Test
+ public void testAwaitAllFailureCancelsRemainingTasks() throws Exception {
+ CountDownLatch release = new CountDownLatch(1);
+ List<String> delivered = new ArrayList<>();
+ tasks.submit(
+ () -> {
+ release.await();
+ return "slow";
+ },
+ delivered::add);
+ tasks.submit(
+ () -> {
+ release.await();
+ throw new IllegalStateException("boom");
+ },
+ delivered::add);
+ // Submit a trailing task that would remain in the queue if awaitAll does
not clean up on
+ // failure
+ tasks.submit(() -> "trailing", delivered::add);
+
+ // awaitAll delivers in order: the slow task first, then the failure
propagates.
+ release.countDown();
+ ExecutionException failure =
+ assertThrows(ExecutionException.class, () ->
tasks.awaitAll(delivered::add));
+ assertTrue(failure.getCause() instanceof IllegalStateException);
+ assertEquals(Arrays.asList("slow"), delivered);
+
+ // Subsequent submissions on this instance should only see their own
results, not "trailing"
+ List<String> freshBatch = new ArrayList<>();
+ tasks.submit(() -> "fresh", freshBatch::add);
+ tasks.awaitAll(freshBatch::add);
+ assertEquals(Arrays.asList("fresh"), freshBatch);
+ }
+
+ @Test
+ public void testFailureOnSubmitCancelsOutstandingTasks() throws Exception {
+ CountDownLatch release = new CountDownLatch(1);
+ List<String> delivered = new ArrayList<>();
+ // The failure surfaces from whichever submit first drains the failed task.
+ boolean failed = false;
+ try {
+ tasks.submit(
+ () -> {
+ throw new IllegalStateException("boom");
+ },
+ delivered::add);
+ for (int i = 0; i < 5; i++) {
+ tasks.submit(
+ () -> {
+ release.await();
+ return "never";
+ },
+ delivered::add);
+ }
+ } catch (ExecutionException e) {
+ failed = true;
+ }
+ assertTrue(failed);
+ release.countDown();
+ tasks.awaitAll(delivered::add);
+ assertTrue(delivered.isEmpty());
+ }
+}
diff --git
a/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ParquetFootersTest.java
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ParquetFootersTest.java
new file mode 100644
index 00000000000..631adfb6431
--- /dev/null
+++
b/sdks/java/io/iceberg/src/test/java/org/apache/beam/sdk/io/iceberg/ParquetFootersTest.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.beam.sdk.io.iceberg;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.data.GenericRecord;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.data.parquet.GenericParquetWriter;
+import org.apache.iceberg.io.DataWriter;
+import org.apache.iceberg.parquet.Parquet;
+import org.apache.iceberg.types.Types;
+import org.apache.parquet.hadoop.metadata.ParquetMetadata;
+import org.apache.parquet.schema.MessageType;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ParquetFootersTest {
+ @Rule public TemporaryFolder temp = new TemporaryFolder();
+
+ private static final Schema SCHEMA =
+ new Schema(
+ Types.NestedField.required(1, "id", Types.IntegerType.get()),
+ Types.NestedField.optional(2, "name", Types.StringType.get()));
+
+ @Test
+ public void testReadsSchemaAndRowCount() throws IOException {
+ String path = writeParquet("data.parquet", 3);
+
+ ParquetMetadata footer = ParquetFooters.read(path);
+
+ MessageType schema = footer.getFileMetaData().getSchema();
+ assertEquals(2, schema.getFieldCount());
+ assertEquals("id", schema.getFieldName(0));
+ assertEquals("name", schema.getFieldName(1));
+ long rows = 0;
+ for (org.apache.parquet.hadoop.metadata.BlockMetaData block :
footer.getBlocks()) {
+ rows += block.getRowCount();
+ }
+ assertEquals(3, rows);
+ }
+
+ @Test
+ public void testMissingFileThrowsFileNotFound() {
+ String path = new File(temp.getRoot(),
"missing.parquet").getAbsolutePath();
+ assertThrows(FileNotFoundException.class, () -> ParquetFooters.read(path));
+ }
+
+ @Test
+ public void testEmptyFileThrows() throws IOException {
+ File file = temp.newFile("empty.parquet");
+ assertThrows(RuntimeException.class, () ->
ParquetFooters.read(file.getAbsolutePath()));
+ }
+
+ @Test
+ public void testNonParquetFileThrows() throws IOException {
+ File file = temp.newFile("garbage.parquet");
+ Files.write(file.toPath(), "this is not a parquet
file".getBytes(StandardCharsets.UTF_8));
+ assertThrows(RuntimeException.class, () ->
ParquetFooters.read(file.getAbsolutePath()));
+ }
+
+ @Test
+ public void testTruncatedFileThrows() throws IOException {
+ String path = writeParquet("full.parquet", 3);
+ byte[] bytes = Files.readAllBytes(new File(path).toPath());
+ File truncated = temp.newFile("truncated.parquet");
+ // Drop the trailing footer-length + magic bytes so the footer cannot be
located.
+ Files.write(truncated.toPath(), java.util.Arrays.copyOf(bytes,
bytes.length - 8));
+ assertThrows(RuntimeException.class, () ->
ParquetFooters.read(truncated.getAbsolutePath()));
+ }
+
+ private String writeParquet(String name, int rows) throws IOException {
+ String path = new File(temp.getRoot(), name).getAbsolutePath();
+ DataWriter<Record> writer =
+ Parquet.writeData(org.apache.iceberg.Files.localOutput(path))
+ .schema(SCHEMA)
+ .withSpec(PartitionSpec.unpartitioned())
+ .createWriterFunc(GenericParquetWriter::create)
+ .build();
+ for (int i = 0; i < rows; i++) {
+ writer.write(GenericRecord.create(SCHEMA).copy("id", i, "name", "n" +
i));
+ }
+ writer.close();
+ return path;
+ }
+}