-
Notifications
You must be signed in to change notification settings - Fork 3.4k
First version of the Iceberg sink for Apache Beam #1972
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
d42ad5f
cd8f687
98e1004
0320353
480016a
085068a
f6d97ef
3580531
c4ad6d7
f62941f
b0ddc18
a92df28
c6b417f
7e77a3b
8e26fe2
099ed36
32a4bec
98433c5
e0adcb1
4105b5e
b57def4
a006045
3a99c74
45a3716
b5ba1ac
6fdb60e
d5ae150
a6116b2
a11eb04
76016be
7c88e68
70f4733
a2ab6e3
2d7ce2e
3399000
9e5f8f1
e5499b9
5f8badd
cefb84a
49b4340
edcb3e7
9bb635b
48f9f4c
c6e239e
f5c0167
76dbb26
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -28,7 +29,7 @@ | |
| /** | ||
| * Identifies a table in iceberg catalog. | ||
| */ | ||
| public class TableIdentifier { | ||
| public class TableIdentifier implements Serializable { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I have many users (specifically for Flink) that encounter subtle issues when some Serializable class does not specify I'm not sure if Additionally, does anybody have any arguments for or against including serial version uid on the interfaces which are also extending serializable? For example, |
||
|
|
||
| private static final Splitter DOT = Splitter.on('.'); | ||
|
|
||
|
|
||
| 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) { | ||
|
Fokko marked this conversation as resolved.
Outdated
|
||
| table.updateSchema().unionByNameWith(this.schema).commit(); | ||
|
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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| } | ||
| 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))); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.