Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/trigger_files/IO_Iceberg_Integration_Tests.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"comment": "Modify this file in a trivial way to cause this test suite to run.",
"modification": 4
"modification": 5
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,20 @@
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;
Expand All @@ -75,8 +64,6 @@
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;
Expand Down Expand Up @@ -111,7 +98,6 @@
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;
Expand Down Expand Up @@ -251,15 +237,15 @@ public PCollectionRowTuple expand(PCollection<String> input) {
* <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.
Expand All @@ -274,8 +260,7 @@ static class ConvertToDataFile extends DoFn<String, SerializableDataFile> {
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
Expand Down Expand Up @@ -329,50 +314,33 @@ private static class ProcessResult {

@Setup
public void setup() {
executor = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
tasks = new BoundedAsyncTasks<>(THREAD_POOL_SIZE, MAX_IN_FLIGHT_TASKS);
Comment thread
claudevdm marked this conversation as resolved.
}

/** 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,
@Timestamp Instant timestamp,
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) {
Expand All @@ -398,18 +366,16 @@ private void outputResult(ProcessResult result, MultiOutputReceiver output) {

@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);
Comment thread
claudevdm marked this conversation as resolved.
numErrorFiles.inc();
} else if (result.dataFile != null) {
context.output(DATA_FILES, result.dataFile, result.timestamp, result.window);
}
numErrorFiles.inc(numErrors);
}

private Callable<ProcessResult> createProcessTask(
Expand Down Expand Up @@ -449,7 +415,7 @@ private Callable<ProcessResult> createProcessTask(
@Nullable ParquetMetadata parquetFooter = null;
if (format.equals(FileFormat.PARQUET)) {
try {
parquetFooter = readParquetFooter(filePath);
parquetFooter = ParquetFooters.read(filePath);
Comment thread
claudevdm marked this conversation as resolved.
} catch (Exception e) {
return errorResult(filePath, errorMessage(e), timestamp, window, paneInfo);
}
Expand Down Expand Up @@ -561,7 +527,7 @@ private static org.apache.iceberg.Schema getSchema(String filePath, FileFormat f
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);
}

Expand Down Expand Up @@ -872,12 +838,6 @@ public static Metrics getFileMetrics(
}
}

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.
Expand Down Expand Up @@ -911,15 +871,6 @@ static ParquetMetadata getFooterWithTypeIds(
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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Comment thread
claudevdm marked this conversation as resolved.
}
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());
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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();
Comment thread
claudevdm marked this conversation as resolved.
}
}

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);
}
}
Loading
Loading