Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
d42ad5f
First version of the Iceberg sink for Apache Beam
Fokko Dec 21, 2020
cd8f687
Merge branch 'master' of https://github.com/apache/incubator-iceberg …
Fokko Dec 23, 2020
98e1004
Some docs
Fokko Dec 23, 2020
0320353
Emit the Snapshot
Fokko Dec 26, 2020
480016a
Make the RAT checker happy
Fokko Dec 26, 2020
085068a
Add streaming test as well
Fokko Dec 28, 2020
f6d97ef
Cleanup of the tests
Fokko Dec 30, 2020
3580531
Merge branch 'master' of https://github.com/apache/incubator-iceberg …
Fokko Jan 2, 2021
c4ad6d7
Cleanup the test a bit
Fokko Jan 3, 2021
f62941f
Refactor the code
Fokko Jan 4, 2021
b0ddc18
Merge branch 'master' of https://github.com/apache/incubator-iceberg …
Fokko Jan 5, 2021
a92df28
Cleanup and make write() public
Fokko Jan 7, 2021
c6b417f
Merge branch 'master' of https://github.com/apache/incubator-iceberg …
Fokko Jan 7, 2021
7e77a3b
Allow to pass configuration
Jan 11, 2021
8e26fe2
Merge branch 'master' of https://github.com/apache/iceberg into fd-be…
Jan 11, 2021
099ed36
Add a nice builder
Jan 12, 2021
32a4bec
Merge branch 'master' of https://github.com/apache/iceberg into fd-be…
Jan 12, 2021
98433c5
Enable tests in the CI
Jan 12, 2021
e0adcb1
Fix the voilations
Jan 12, 2021
4105b5e
Merge branch 'master' of https://github.com/apache/iceberg into fd-be…
Jan 14, 2021
b57def4
Give it some nice names
Jan 14, 2021
a006045
The window will fire even if the list is empty
Jan 15, 2021
3a99c74
Merge branch 'master' of https://github.com/apache/iceberg into fd-be…
Jan 15, 2021
45a3716
Always return the current snapshot
Jan 15, 2021
b5ba1ac
Move to the Iceberg writers
Fokko Jan 21, 2021
6fdb60e
Merge branch 'fd-beam-sink' of github.com:Fokko/incubator-iceberg int…
Fokko Jan 21, 2021
d5ae150
Merge branch 'master' of https://github.com/apache/incubator-iceberg …
Fokko Jan 21, 2021
a6116b2
Move to the Iceberg writer
Fokko Jan 21, 2021
a11eb04
Merge branch 'master' of https://github.com/apache/incubator-iceberg …
Fokko Jan 21, 2021
76016be
Add more tests
Fokko Jan 24, 2021
7c88e68
Merge branch 'master' of https://github.com/apache/incubator-iceberg …
Fokko Jan 24, 2021
70f4733
Revert unrelated changes
Fokko Jan 24, 2021
a2ab6e3
Bump to Beam 0.27
Fokko Jan 24, 2021
2d7ce2e
Fix the checkstyle voilations
Fokko Jan 24, 2021
3399000
Revert unrelated changes
Fokko Jan 25, 2021
9e5f8f1
Restore the builder
Fokko Jan 25, 2021
e5499b9
Fix test
Fokko Jan 25, 2021
5f8badd
Merge branch 'master' of https://github.com/apache/iceberg into fd-be…
Jan 26, 2021
cefb84a
Make the API a bit more flexible
Jan 27, 2021
49b4340
Add testcases
Jan 28, 2021
edcb3e7
Merge branch 'master' of https://github.com/apache/iceberg into fd-be…
Jan 28, 2021
9bb635b
Clean up the code
Jan 28, 2021
48f9f4c
Merge branch 'master' of https://github.com/apache/iceberg into fd-be…
Jan 28, 2021
c6e239e
Add checks
Jan 28, 2021
f5c0167
Add missing start()
Jan 28, 2021
76dbb26
Add Rat exclusion
Jan 28, 2021
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
3 changes: 2 additions & 1 deletion api/src/main/java/org/apache/iceberg/catalog/Namespace.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@

package org.apache.iceberg.catalog;

import java.io.Serializable;
import java.util.Arrays;
import org.apache.iceberg.relocated.com.google.common.base.Joiner;

/**
* A namespace in a {@link Catalog}.
*/
public class Namespace {
Comment thread
Fokko marked this conversation as resolved.
public class Namespace implements Serializable {
private static final Namespace EMPTY_NAMESPACE = new Namespace(new String[] {});
private static final Joiner DOT = Joiner.on('.');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

package org.apache.iceberg.catalog;

import java.io.Serializable;
import java.util.Arrays;
import java.util.Objects;
import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
Expand All @@ -28,7 +29,7 @@
/**
* Identifies a table in iceberg catalog.
*/
public class TableIdentifier {
public class TableIdentifier implements Serializable {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some of these classes where they are implementing Serializable, should we consider adding a serialVersionUID?

I have many users (specifically for Flink) that encounter subtle issues when some Serializable class does not specify serialVersionUID and the compiler changes the assigned seriallVersionUID. Very often, these users need to allow some or all of their jobs existing state to be discarded when this happens.

I'm not sure if TableIdentifier gets placed into state anywhere ourselves, but for classes that are part of the public api is there an argument against including them?

Additionally, does anybody have any arguments for or against including serial version uid on the interfaces which are also extending serializable? For example, DataFile stands out to me in particular as things that could potentially cause issue. I could imagine that something along the lines of Class<DataFile> clazz = DataFile.class could potentially get serialized and cause issues when the interface changes.


private static final Splitter DOT = Splitter.on('.');

Expand Down
195 changes: 195 additions & 0 deletions beam/src/main/java/org/apache/iceberg/beam/IcebergIO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* 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.iceberg.beam;

import org.apache.beam.sdk.io.Compression;
import org.apache.beam.sdk.io.FileIO;
import org.apache.beam.sdk.io.WriteFilesResult;
import org.apache.beam.sdk.transforms.Combine;
import org.apache.beam.sdk.transforms.windowing.BoundedWindow;
import org.apache.beam.sdk.transforms.windowing.GlobalWindow;
import org.apache.beam.sdk.transforms.windowing.IntervalWindow;
import org.apache.beam.sdk.transforms.windowing.PaneInfo;
import org.apache.beam.sdk.values.KV;
import org.apache.beam.sdk.values.PCollection;
import org.apache.hadoop.conf.Configuration;
import org.apache.iceberg.AppendFiles;
import org.apache.iceberg.DataFiles;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.Table;
import org.apache.iceberg.catalog.TableIdentifier;
import org.apache.iceberg.exceptions.NoSuchTableException;
import org.apache.iceberg.hive.HiveCatalog;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;

import java.io.Serializable;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;


public class IcebergIO {

private IcebergIO() {
}

static PCollection<Snapshot> write(TableIdentifier table,
Schema schema,
String hiveMetastoreUrl,
WriteFilesResult<Void> resultFiles) {
PCollection<KV<Void, String>> filenames = resultFiles
.getPerDestinationOutputFilenames();

FileCombiner combiner = new FileCombiner(table, schema, hiveMetastoreUrl);

Combine.Globally<KV<Void, String>, Snapshot> combined = Combine.globally(combiner).withoutDefaults();

return filenames.apply(combined);
}

/**
* Defines a custom {@link FileIO.Write.FileNaming} which will use the prefix and suffix supplied to create
* a name based on the window, pane, number of shards, shard index, and compression. Removes
* window when in the {@link GlobalWindow} and pane info when it is the only firing of the pane.
*/
public static class HadoopCompatibleFilenamePolicy implements FileIO.Write.FileNaming, Serializable {
private final String suffix;

public HadoopCompatibleFilenamePolicy(String suffix) {
this.suffix = suffix;
}

@Override
public String getFilename(BoundedWindow window, PaneInfo pane, int numShards, int shardIndex, Compression compression) {
// Replace the HH:mm:ss with HH-mm-ss, so we don't use any semicolon's
final String format = "yyyy-MM-dd'T'HH-mm-ss";
final DateTimeFormatter formatter = DateTimeFormat.forPattern(format);

final StringBuilder res = new StringBuilder();
if (window instanceof IntervalWindow) {
if (res.length() > 0) {
res.append("-");
}
final IntervalWindow iw = (IntervalWindow) window;
res.append(iw.start().toString(formatter)).append("-").append(iw.end().toString(formatter));
}
boolean isOnlyFiring = pane.isFirst() && pane.isLast();
if (!isOnlyFiring) {
if (res.length() > 0) {
res.append("-");
}
res.append(pane.getIndex());
}
if (res.length() > 0) {
res.append("-");
}
final String numShardsStr = String.valueOf(numShards);
// A trillion shards per window per pane ought to be enough for everybody.
final DecimalFormat df =
new DecimalFormat("000000000000".substring(0, Math.max(5, numShardsStr.length())));
res.append(df.format(shardIndex)).append("-of-").append(df.format(numShards));
res.append(this.suffix);
res.append(compression.getSuggestedSuffix());
return res.toString();
}
}


private static class FileCombiner extends Combine.CombineFn<KV<Void, String>, List<String>, Snapshot> {
private final TableIdentifier tableIdentifier;
private final Schema schema;
private final String hiveMetastoreUrl;

FileCombiner(TableIdentifier table, Schema schema, String hiveMetastoreUrl) {
this.tableIdentifier = table;
this.schema = schema;
this.hiveMetastoreUrl = hiveMetastoreUrl;
}

@Override
public List<String> createAccumulator() {
return new ArrayList<>();
}

@Override
public List<String> addInput(List<String> mutableAccumulator, KV<Void, String> input) {
mutableAccumulator.add(input.getValue());
return mutableAccumulator;
}

@Override
public List<String> mergeAccumulators(Iterable<List<String>> accumulators) {
Iterator<List<String>> itr = accumulators.iterator();

if(itr.hasNext()) {
List<String> first = itr.next();

while(itr.hasNext()) {
first.addAll(itr.next());
}

return first;
} else {
return new ArrayList<>();
}
}

@Override
public Snapshot extractOutput(List<String> accumulator) {
try (HiveCatalog catalog = new HiveCatalog(
HiveCatalog.DEFAULT_NAME,
this.hiveMetastoreUrl,
1,
new Configuration()
)) {
Table table;
try {
table = catalog.loadTable(this.tableIdentifier);
} catch (NoSuchTableException e) {
// If it doesn't exist, we just create the table
table = catalog.createTable(this.tableIdentifier, schema);
}

// In case the schema has changed
if (table.schema() != this.schema) {
Comment thread
Fokko marked this conversation as resolved.
Outdated
table.updateSchema().unionByNameWith(this.schema).commit();
Comment thread
Fokko marked this conversation as resolved.
Outdated
}

// Append the new files
final AppendFiles app = table.newAppend();
// We need to get the statistics, not easy to get them through Beam

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a PR to aggregate the stats while writing an Avro file. You would get them for free using an Iceberg-based file writer instead of the generic Avro one. You'd also get correct record counts and file sizes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing out. I'll track PR https://github.com/apache/iceberg/pull/1963/files. I think we have to make a decision here. I do like the fact that we fully re-use the writers from Beam, so we don't have to maintain that part.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Iceberg provides common write classes in the core module that we extend for Flink and Spark object models, so if you do go with Iceberg writers, it would probably not be too much code. For example, take a look at the SparkPartitionedWriter.

Using Iceberg writers would help with stats as well as writing delete files if you later want to support CDC or other row-level use cases.

But, one challenge is integrating those writers with an object model. Spark and Flink have row classes that are directly supported in the integration. It looks like you're using Avro generics for Avro, and using an object model specific to a data format is going to be a little difficult because Iceberg needs additional metadata, like field IDs. It sounds difficult to make sure that each format writer is producing the right information for Iceberg tables.

@Fokko Fokko Jan 3, 2021

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avro is a first-class citizen in Beam, this is more or less the equivalent of the InternalRow of Spark. We could replace the AvroIO with an IcebergIO, but this feels like reinventing the wheel to me. I've noticed that there is quite some effort in the writers for Beam. For example, tuning the number and size of the output buffers kind of stuff. I would like to reuse this logic.

Apart from the writers themselves. We still need to current logic of doing the commits. So, I think we can keep this open for now.

for (String filename : accumulator) {
app.appendFile(DataFiles.builder(table.spec())
.withPath(filename)
.withFileSizeInBytes(1024)
.withRecordCount(100)
.build());
}
app.commit();

return table.currentSnapshot();
}
}
}

}
166 changes: 166 additions & 0 deletions beam/src/test/java/org/apache/iceberg/beam/IcebergIOTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Licensed 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.iceberg.beam;

import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
import org.apache.beam.sdk.Pipeline;
import org.apache.beam.sdk.coders.AvroCoder;
import org.apache.beam.sdk.coders.StringUtf8Coder;
import org.apache.beam.sdk.io.AvroIO;
import org.apache.beam.sdk.io.FileIO;
import org.apache.beam.sdk.io.WriteFilesResult;
import org.apache.beam.sdk.options.PipelineOptions;
import org.apache.beam.sdk.options.PipelineOptionsFactory;
import org.apache.beam.sdk.testing.TestPipeline;
import org.apache.beam.sdk.testing.TestStream;
import org.apache.beam.sdk.transforms.Create;
import org.apache.beam.sdk.transforms.DoFn;
import org.apache.beam.sdk.transforms.ParDo;
import org.apache.beam.sdk.transforms.windowing.FixedWindows;
import org.apache.beam.sdk.transforms.windowing.Window;
import org.apache.beam.sdk.values.PCollection;
import org.apache.beam.sdk.values.TimestampedValue;
import org.apache.iceberg.Snapshot;
import org.apache.iceberg.avro.AvroSchemaUtil;
import org.apache.iceberg.catalog.TableIdentifier;
import org.joda.time.Duration;
import org.joda.time.Instant;
import org.junit.Rule;
import org.junit.Test;

import java.util.Arrays;
import java.util.List;

public class IcebergIOTest {
private static final List<String> SENTENCES =
Arrays.asList(
"Beam window 1 1",
"Beam window 1 2",
"Beam window 1 3",
"Beam window 1 4",
"Beam window 2 1",
"Beam window 2 2");
private static final Instant START_TIME = new Instant(0);
private static final Duration WINDOW_DURATION = Duration.standardMinutes(1);

@Rule
public final transient TestPipeline pipeline = TestPipeline.create();
final String hiveMetastoreUrl = "thrift://localhost:9083/default";

private static final PipelineOptions options = TestPipeline.testingPipelineOptions();

private static final String stringSchema = "{\n" +
"\t\"type\": \"record\",\n" +
"\t\"name\": \"Word\",\n" +
"\t\"fields\": [{\n" +
"\t\t\"name\": \"word\",\n" +
"\t\t\"type\": [\"null\", \"string\"],\n" +
"\t\t\"default\": null\n" +
"\t}]\n" +
"}";

final Schema avroSchema = new Schema.Parser().parse(stringSchema);

public static class StringToGenericRecord extends DoFn<String, GenericRecord> {
private final Schema schema;

public StringToGenericRecord() {
schema = new Schema.Parser().parse(stringSchema);
}

@ProcessElement
public void processElement(@Element String word, OutputReceiver<GenericRecord> out) {
GenericRecord record = new GenericData.Record(schema);
record.put("word", word);
out.output(record);
}
}

@Test
public void testWriteFilesBatch() {
final PipelineOptions options = PipelineOptionsFactory.create();
final Pipeline p = Pipeline.create(options);

p.getCoderRegistry().registerCoderForClass(GenericRecord.class, AvroCoder.of(avroSchema));

PCollection<String> lines = p.apply(Create.of(SENTENCES)).setCoder(StringUtf8Coder.of());

PCollection<GenericRecord> records = lines.apply(ParDo.of(new StringToGenericRecord()));

final String hiveMetastoreUrl = "thrift://localhost:9083/default";
FileIO.Write<Void, GenericRecord> avroFileIO = FileIO.<GenericRecord>write()
.via(AvroIO.sink(avroSchema))
.to("/tmp/fokko/")
.withSuffix(".avro");

WriteFilesResult<Void> filesWritten = records.apply(avroFileIO);
org.apache.iceberg.Schema icebergSchema = AvroSchemaUtil.toIceberg(avroSchema);
TableIdentifier name = TableIdentifier.of("default", "test_batch");

IcebergIO.write(name, icebergSchema, hiveMetastoreUrl, filesWritten);

p.run();
}

@Test
public void testWriteFilesStreaming() {
pipeline.getCoderRegistry().registerCoderForClass(GenericRecord.class, AvroCoder.of(avroSchema));

// We should see four commits in the log
TestStream<String> stringsStream =
TestStream.create(StringUtf8Coder.of())
.advanceWatermarkTo(START_TIME)
.addElements(event(SENTENCES.get(0), 2L))
.advanceWatermarkTo(START_TIME.plus(Duration.standardSeconds(60L)))
.addElements(event(SENTENCES.get(1), 62L))
.advanceWatermarkTo(START_TIME.plus(Duration.standardSeconds(120L)))
.addElements(event(SENTENCES.get(2), 122L))
.advanceWatermarkTo(START_TIME.plus(Duration.standardSeconds(180L)))
.addElements(event(SENTENCES.get(3), 182L))
.advanceWatermarkToInfinity();

PCollection<GenericRecord> recordsStream = pipeline
.apply(stringsStream)
.setCoder(StringUtf8Coder.of())
.apply(ParDo.of(new StringToGenericRecord()));

FileIO.Write.FileNaming naming = new IcebergIO.HadoopCompatibleFilenamePolicy(".avro");

FileIO.Write<Void, GenericRecord> avroFileIO = FileIO.<GenericRecord>write()
.via(AvroIO.sink(avroSchema))
.to("/tmp/fokko/")
.withNumShards(1)
.withNaming(naming);

// Write the record
WriteFilesResult<Void> filesWritten = recordsStream
.apply(Window.into(FixedWindows.of(WINDOW_DURATION)))
.apply(avroFileIO);

org.apache.iceberg.Schema icebergSchema = AvroSchemaUtil.toIceberg(avroSchema);
TableIdentifier name = TableIdentifier.of("default", "test_streaming5");

PCollection<Snapshot> snapshots = IcebergIO.write(name, icebergSchema, hiveMetastoreUrl, filesWritten);

//PAssert.that(snapshots).containsInAnyOrder(new ArrayList<>());
pipeline.run(options).waitUntilFinish();
}

private TimestampedValue<String> event(String word, Long timestamp) {
return TimestampedValue.of(word, START_TIME.plus(new Duration(timestamp)));
}
}
Loading