From d794f4fbf9d8ce4c90507e4de36121ac1fc2fd4b Mon Sep 17 00:00:00 2001 From: qianchutao <72595723+qianchutao@users.noreply.github.com> Date: Fri, 6 May 2022 00:33:06 +0800 Subject: [PATCH 01/52] [MINOR] Optimize code logic (#5499) --- .../hudi/utilities/deltastreamer/HoodieDeltaStreamer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamer.java index 56124b82afc06..824c7375fa07a 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamer.java @@ -625,8 +625,8 @@ public DeltaSyncService(Config cfg, JavaSparkContext jssc, FileSystem fs, Config ValidationUtils.checkArgument(baseFileFormat.equals(cfg.baseFileFormat) || cfg.baseFileFormat == null, "Hoodie table's base file format is of type " + baseFileFormat + " but passed in CLI argument is " + cfg.baseFileFormat); - cfg.baseFileFormat = meta.getTableConfig().getBaseFileFormat().toString(); - this.cfg.baseFileFormat = cfg.baseFileFormat; + cfg.baseFileFormat = baseFileFormat; + this.cfg.baseFileFormat = baseFileFormat; } else { tableType = HoodieTableType.valueOf(cfg.tableType); if (cfg.baseFileFormat == null) { From abb4893b25df47328d01890f0d01fbc4e5d99135 Mon Sep 17 00:00:00 2001 From: guanziyue <30882822+guanziyue@users.noreply.github.com> Date: Fri, 6 May 2022 04:49:34 +0800 Subject: [PATCH 02/52] [HUDI-2875] Make HoodieParquetWriter Thread safe and memory executor exit gracefully (#4264) --- .../apache/hudi/io/HoodieConcatHandle.java | 3 ++ .../apache/hudi/io/HoodieCreateHandle.java | 3 ++ .../org/apache/hudi/io/HoodieMergeHandle.java | 3 ++ .../hudi/io/HoodieSortedMergeHandle.java | 3 ++ .../hudi/io/HoodieUnboundedCreateHandle.java | 3 ++ .../hudi/io/storage/HoodieParquetWriter.java | 10 +++++ .../action/commit/HoodieMergeHelper.java | 5 ++- .../execution/FlinkLazyInsertIterable.java | 1 + .../table/action/commit/FlinkMergeHelper.java | 5 ++- .../execution/JavaLazyInsertIterable.java | 1 + .../table/action/commit/JavaMergeHelper.java | 5 ++- .../execution/SparkLazyInsertIterable.java | 1 + .../OrcBootstrapMetadataHandler.java | 3 +- .../ParquetBootstrapMetadataHandler.java | 8 ++-- .../TestBoundedInMemoryExecutorInSpark.java | 45 +++++++++++++++++++ .../util/queue/BoundedInMemoryExecutor.java | 24 +++++++++- .../testutils/HoodieTestDataGenerator.java | 10 +++-- 17 files changed, 121 insertions(+), 12 deletions(-) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieConcatHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieConcatHandle.java index 022f600b5e078..ca245e0c391ba 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieConcatHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieConcatHandle.java @@ -35,6 +35,8 @@ import org.apache.log4j.LogManager; import org.apache.log4j.Logger; +import javax.annotation.concurrent.NotThreadSafe; + import java.io.IOException; import java.util.Collections; import java.util.Iterator; @@ -66,6 +68,7 @@ * Users should ensure there are no duplicates when "insert" operation is used and if the respective config is enabled. So, above scenario should not * happen and every batch should have new records to be inserted. Above example is for illustration purposes only. */ +@NotThreadSafe public class HoodieConcatHandle extends HoodieMergeHandle { private static final Logger LOG = LogManager.getLogger(HoodieConcatHandle.class); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieCreateHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieCreateHandle.java index 41d583668a933..43a8c12324136 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieCreateHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieCreateHandle.java @@ -42,12 +42,15 @@ import org.apache.log4j.LogManager; import org.apache.log4j.Logger; +import javax.annotation.concurrent.NotThreadSafe; + import java.io.IOException; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; +@NotThreadSafe public class HoodieCreateHandle extends HoodieWriteHandle { private static final Logger LOG = LogManager.getLogger(HoodieCreateHandle.class); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieMergeHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieMergeHandle.java index 3363571ddf0cb..2e2a894f5e96c 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieMergeHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieMergeHandle.java @@ -54,6 +54,8 @@ import org.apache.log4j.LogManager; import org.apache.log4j.Logger; +import javax.annotation.concurrent.NotThreadSafe; + import java.io.IOException; import java.util.Collections; import java.util.HashSet; @@ -91,6 +93,7 @@ * *

*/ +@NotThreadSafe public class HoodieMergeHandle extends HoodieWriteHandle { private static final Logger LOG = LogManager.getLogger(HoodieMergeHandle.class); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java index d6c1d1be40f36..7dce31a4c349b 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieSortedMergeHandle.java @@ -32,6 +32,8 @@ import org.apache.avro.generic.GenericRecord; +import javax.annotation.concurrent.NotThreadSafe; + import java.io.IOException; import java.util.Iterator; import java.util.List; @@ -45,6 +47,7 @@ * The implementation performs a merge-sort by comparing the key of the record being written to the list of * keys in newRecordKeys (sorted in-memory). */ +@NotThreadSafe public class HoodieSortedMergeHandle extends HoodieMergeHandle { private final Queue newRecordKeysSorted = new PriorityQueue<>(); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieUnboundedCreateHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieUnboundedCreateHandle.java index 9ab44d0f62f1b..ebbc7a5c28ea1 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieUnboundedCreateHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieUnboundedCreateHandle.java @@ -28,11 +28,14 @@ import org.apache.log4j.LogManager; import org.apache.log4j.Logger; +import javax.annotation.concurrent.NotThreadSafe; + /** * A HoodieCreateHandle which writes all data into a single file. *

* Please use this with caution. This can end up creating very large files if not used correctly. */ +@NotThreadSafe public class HoodieUnboundedCreateHandle extends HoodieCreateHandle { private static final Logger LOG = LogManager.getLogger(HoodieUnboundedCreateHandle.class); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieParquetWriter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieParquetWriter.java index 5b3c69ddf943e..095cacc144a9a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieParquetWriter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieParquetWriter.java @@ -30,13 +30,18 @@ import org.apache.parquet.hadoop.ParquetFileWriter; import org.apache.parquet.hadoop.ParquetWriter; +import javax.annotation.concurrent.NotThreadSafe; + import java.io.IOException; import java.util.concurrent.atomic.AtomicLong; /** * HoodieParquetWriter extends the ParquetWriter to help limit the size of underlying file. Provides a way to check if * the current file can take more records with the canWrite() + * + * ATTENTION: HoodieParquetWriter is not thread safe and developer should take care of the order of write and close */ +@NotThreadSafe public class HoodieParquetWriter extends ParquetWriter implements HoodieFileWriter { @@ -106,4 +111,9 @@ public void writeAvro(String key, IndexedRecord object) throws IOException { writeSupport.add(key); } } + + @Override + public void close() throws IOException { + super.close(); + } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java index 04dd29c63c5b4..3e2d8abdd7466 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/HoodieMergeHelper.java @@ -148,13 +148,16 @@ public void runMerge(HoodieTable>, HoodieData computeNext() { } finally { if (null != bufferedIteratorExecutor) { bufferedIteratorExecutor.shutdownNow(); + bufferedIteratorExecutor.awaitTermination(); } } } diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkMergeHelper.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkMergeHelper.java index 38d4e60f648ec..31312655251ab 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkMergeHelper.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/table/action/commit/FlinkMergeHelper.java @@ -102,13 +102,16 @@ public void runMerge(HoodieTable>, List, List } catch (Exception e) { throw new HoodieException(e); } finally { + // HUDI-2875: mergeHandle is not thread safe, we should totally terminate record inputting + // and executor firstly and then close mergeHandle. if (reader != null) { reader.close(); } - mergeHandle.close(); if (null != wrapper) { wrapper.shutdownNow(); + wrapper.awaitTermination(); } + mergeHandle.close(); } } } diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/execution/JavaLazyInsertIterable.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/execution/JavaLazyInsertIterable.java index f91dd5019a275..9821aedc875cd 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/execution/JavaLazyInsertIterable.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/execution/JavaLazyInsertIterable.java @@ -74,6 +74,7 @@ protected List computeNext() { } finally { if (null != bufferedIteratorExecutor) { bufferedIteratorExecutor.shutdownNow(); + bufferedIteratorExecutor.awaitTermination(); } } } diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaMergeHelper.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaMergeHelper.java index 7878d857761ea..46dd30a7cb773 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaMergeHelper.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaMergeHelper.java @@ -102,13 +102,16 @@ public void runMerge(HoodieTable>, List, List } catch (Exception e) { throw new HoodieException(e); } finally { + // HUDI-2875: mergeHandle is not thread safe, we should totally terminate record inputting + // and executor firstly and then close mergeHandle. if (reader != null) { reader.close(); } - mergeHandle.close(); if (null != wrapper) { wrapper.shutdownNow(); + wrapper.awaitTermination(); } + mergeHandle.close(); } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/SparkLazyInsertIterable.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/SparkLazyInsertIterable.java index a8a9e49c01c00..df5bd2d3f458c 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/SparkLazyInsertIterable.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/execution/SparkLazyInsertIterable.java @@ -95,6 +95,7 @@ protected List computeNext() { } finally { if (null != bufferedIteratorExecutor) { bufferedIteratorExecutor.shutdownNow(); + bufferedIteratorExecutor.awaitTermination(); } } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/OrcBootstrapMetadataHandler.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/OrcBootstrapMetadataHandler.java index e3d0e9b3c69d4..96ac794dcbc82 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/OrcBootstrapMetadataHandler.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/OrcBootstrapMetadataHandler.java @@ -80,10 +80,11 @@ void executeBootstrap(HoodieBootstrapHandle bootstrapHandle, Path so } catch (Exception e) { throw new HoodieException(e); } finally { - bootstrapHandle.close(); if (null != wrapper) { wrapper.shutdownNow(); + wrapper.awaitTermination(); } + bootstrapHandle.close(); } } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/ParquetBootstrapMetadataHandler.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/ParquetBootstrapMetadataHandler.java index d07ea771bc557..5f45629ba8023 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/ParquetBootstrapMetadataHandler.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/ParquetBootstrapMetadataHandler.java @@ -68,9 +68,9 @@ Schema getAvroSchema(Path sourceFilePath) throws IOException { void executeBootstrap(HoodieBootstrapHandle bootstrapHandle, Path sourceFilePath, KeyGeneratorInterface keyGenerator, String partitionPath, Schema avroSchema) throws Exception { BoundedInMemoryExecutor wrapper = null; + ParquetReader reader = + AvroParquetReader.builder(sourceFilePath).withConf(table.getHadoopConf()).build(); try { - ParquetReader reader = - AvroParquetReader.builder(sourceFilePath).withConf(table.getHadoopConf()).build(); wrapper = new BoundedInMemoryExecutor(config.getWriteBufferLimitBytes(), new ParquetReaderIterator(reader), new BootstrapRecordConsumer(bootstrapHandle), inp -> { String recKey = keyGenerator.getKey(inp).getRecordKey(); @@ -84,10 +84,12 @@ void executeBootstrap(HoodieBootstrapHandle bootstrapHandle, } catch (Exception e) { throw new HoodieException(e); } finally { - bootstrapHandle.close(); + reader.close(); if (null != wrapper) { wrapper.shutdownNow(); + wrapper.awaitTermination(); } + bootstrapHandle.close(); } } } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/execution/TestBoundedInMemoryExecutorInSpark.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/execution/TestBoundedInMemoryExecutorInSpark.java index 91f9cbc96e6ed..a714d60d0033a 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/execution/TestBoundedInMemoryExecutorInSpark.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/execution/TestBoundedInMemoryExecutorInSpark.java @@ -28,6 +28,7 @@ import org.apache.hudi.exception.HoodieException; import org.apache.hudi.testutils.HoodieClientTestHarness; +import org.apache.avro.generic.GenericRecord; import org.apache.avro.generic.IndexedRecord; import org.apache.spark.TaskContext; import org.apache.spark.TaskContext$; @@ -35,6 +36,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Iterator; import java.util.List; import scala.Tuple2; @@ -105,6 +107,7 @@ protected Integer getResult() { } finally { if (executor != null) { executor.shutdownNow(); + executor.awaitTermination(); } } } @@ -152,7 +155,49 @@ protected Integer getResult() { } finally { if (executor != null) { executor.shutdownNow(); + executor.awaitTermination(); } } } + + @Test + public void testExecutorTermination() { + HoodieWriteConfig hoodieWriteConfig = mock(HoodieWriteConfig.class); + when(hoodieWriteConfig.getWriteBufferLimitBytes()).thenReturn(1024); + Iterator unboundedRecordIter = new Iterator() { + @Override + public boolean hasNext() { + return true; + } + + @Override + public GenericRecord next() { + return dataGen.generateGenericRecord(); + } + }; + + BoundedInMemoryQueueConsumer, Integer> consumer = + new BoundedInMemoryQueueConsumer, Integer>() { + @Override + protected void consumeOneRecord(HoodieLazyInsertIterable.HoodieInsertValueGenResult record) { + } + + @Override + protected void finish() { + } + + @Override + protected Integer getResult() { + return 0; + } + }; + + BoundedInMemoryExecutor>, Integer> executor = + new BoundedInMemoryExecutor(hoodieWriteConfig.getWriteBufferLimitBytes(), unboundedRecordIter, + consumer, getTransformFunction(HoodieTestDataGenerator.AVRO_SCHEMA), + getPreExecuteRunnable()); + executor.shutdownNow(); + boolean terminatedGracefully = executor.awaitTermination(); + assertTrue(terminatedGracefully); + } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/queue/BoundedInMemoryExecutor.java b/hudi-common/src/main/java/org/apache/hudi/common/util/queue/BoundedInMemoryExecutor.java index d1e5e66083196..46ef5dc40caf8 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/util/queue/BoundedInMemoryExecutor.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/queue/BoundedInMemoryExecutor.java @@ -37,6 +37,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -48,7 +49,7 @@ public class BoundedInMemoryExecutor { private static final Logger LOG = LogManager.getLogger(BoundedInMemoryExecutor.class); - + private static final long TERMINATE_WAITING_TIME_SECS = 60L; // Executor service used for launching write thread. private final ExecutorService producerExecutorService; // Executor service used for launching read thread. @@ -168,6 +169,27 @@ public boolean isRemaining() { public void shutdownNow() { producerExecutorService.shutdownNow(); consumerExecutorService.shutdownNow(); + // close queue to force producer stop + queue.close(); + } + + public boolean awaitTermination() { + // if current thread has been interrupted before awaitTermination was called, we still give + // executor a chance to proceeding. So clear the interrupt flag and reset it if needed before return. + boolean interruptedBefore = Thread.interrupted(); + boolean producerTerminated = false; + boolean consumerTerminated = false; + try { + producerTerminated = producerExecutorService.awaitTermination(TERMINATE_WAITING_TIME_SECS, TimeUnit.SECONDS); + consumerTerminated = consumerExecutorService.awaitTermination(TERMINATE_WAITING_TIME_SECS, TimeUnit.SECONDS); + } catch (InterruptedException ie) { + // fail silently for any other interruption + } + // reset interrupt flag if needed + if (interruptedBefore) { + Thread.currentThread().interrupt(); + } + return producerTerminated && consumerTerminated; } public BoundedInMemoryQueue getQueue() { diff --git a/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestDataGenerator.java b/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestDataGenerator.java index cb4f5570743a6..e05d5f6f3e088 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestDataGenerator.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestDataGenerator.java @@ -860,12 +860,14 @@ public boolean deleteExistingKeyIfPresent(HoodieKey key) { return false; } + public GenericRecord generateGenericRecord() { + return generateGenericRecord(genPseudoRandomUUID(rand).toString(), "0", + genPseudoRandomUUID(rand).toString(), genPseudoRandomUUID(rand).toString(), rand.nextLong()); + } + public List generateGenericRecords(int numRecords) { List list = new ArrayList<>(); - IntStream.range(0, numRecords).forEach(i -> { - list.add(generateGenericRecord(genPseudoRandomUUID(rand).toString(), "0", - genPseudoRandomUUID(rand).toString(), genPseudoRandomUUID(rand).toString(), rand.nextLong())); - }); + IntStream.range(0, numRecords).forEach(i -> list.add(generateGenericRecord())); return list; } From 248b0591b031ef5d017bc4f710b9b0496e751c39 Mon Sep 17 00:00:00 2001 From: Jin Xing Date: Fri, 6 May 2022 15:29:47 +0800 Subject: [PATCH 03/52] [HUDI-4042] Support truncate-partition for Spark-3.2 (#5506) --- .../hudi/analysis/HoodieSpark3Analysis.scala | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark3Analysis.scala b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark3Analysis.scala index e20f934592e45..4c77733b144aa 100644 --- a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark3Analysis.scala +++ b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieSpark3Analysis.scala @@ -179,21 +179,22 @@ case class HoodieSpark3ResolveReferences(sparkSession: SparkSession) extends Rul case class HoodieSpark3PostAnalysisRule(sparkSession: SparkSession) extends Rule[LogicalPlan] { override def apply(plan: LogicalPlan): LogicalPlan = { plan match { - case ShowPartitions(child, specOpt, _) - if child.isInstanceOf[ResolvedTable] && - child.asInstanceOf[ResolvedTable].table.isInstanceOf[HoodieInternalV2Table] => - ShowHoodieTablePartitionsCommand(child.asInstanceOf[ResolvedTable].identifier.asTableIdentifier, specOpt.map(s => s.asInstanceOf[UnresolvedPartitionSpec].spec)) + case ShowPartitions(ResolvedTable(_, idt, _: HoodieInternalV2Table, _), specOpt, _) => + ShowHoodieTablePartitionsCommand( + idt.asTableIdentifier, specOpt.map(s => s.asInstanceOf[UnresolvedPartitionSpec].spec)) // Rewrite TruncateTableCommand to TruncateHoodieTableCommand - case TruncateTable(child) - if child.isInstanceOf[ResolvedTable] && - child.asInstanceOf[ResolvedTable].table.isInstanceOf[HoodieInternalV2Table] => - new TruncateHoodieTableCommand(child.asInstanceOf[ResolvedTable].identifier.asTableIdentifier, None) + case TruncateTable(ResolvedTable(_, idt, _: HoodieInternalV2Table, _)) => + TruncateHoodieTableCommand(idt.asTableIdentifier, None) - case DropPartitions(child, specs, ifExists, purge) - if child.resolved && child.isInstanceOf[ResolvedTable] && child.asInstanceOf[ResolvedTable].table.isInstanceOf[HoodieInternalV2Table] => + case TruncatePartition( + ResolvedTable(_, idt, _: HoodieInternalV2Table, _), + partitionSpec: UnresolvedPartitionSpec) => + TruncateHoodieTableCommand(idt.asTableIdentifier, Some(partitionSpec.spec)) + + case DropPartitions(ResolvedTable(_, idt, _: HoodieInternalV2Table, _), specs, ifExists, purge) => AlterHoodieTableDropPartitionCommand( - child.asInstanceOf[ResolvedTable].identifier.asTableIdentifier, + idt.asTableIdentifier, specs.seq.map(f => f.asInstanceOf[UnresolvedPartitionSpec]).map(s => s.spec), ifExists, purge, From c319ee9cea78544406e30dd36cc603fd0a0283db Mon Sep 17 00:00:00 2001 From: Raymond Xu <2701446+xushiyan@users.noreply.github.com> Date: Fri, 6 May 2022 05:52:06 -0700 Subject: [PATCH 04/52] [HUDI-4017] Improve spark sql coverage in CI (#5512) Add GitHub actions tasks to run spark sql UTs under spark 3.1 and 3.2. --- .github/workflows/bot.yml | 8 ++++++++ .../hudi/functional/TestSqlStatement.scala | 4 ++-- .../SpaceCurveOptimizeBenchmark.scala | 4 ++-- ...ase.scala => HoodieSparkSqlTestBase.scala} | 2 +- .../spark/sql/hudi/TestAlterTable.scala | 2 +- .../hudi/TestAlterTableDropPartition.scala | 2 +- .../spark/sql/hudi/TestCompactionTable.scala | 2 +- .../spark/sql/hudi/TestCreateTable.scala | 2 +- .../spark/sql/hudi/TestDeleteTable.scala | 2 +- .../apache/spark/sql/hudi/TestDropTable.scala | 2 +- .../sql/hudi/TestHoodieOptionConfig.scala | 20 +++---------------- .../spark/sql/hudi/TestInsertTable.scala | 2 +- .../sql/hudi/TestMergeIntoLogOnlyTable.scala | 2 +- .../spark/sql/hudi/TestMergeIntoTable.scala | 2 +- .../spark/sql/hudi/TestMergeIntoTable2.scala | 2 +- .../hudi/TestPartialUpdateForMergeInto.scala | 2 +- .../spark/sql/hudi/TestShowPartitions.scala | 2 +- .../apache/spark/sql/hudi/TestSpark3DDL.scala | 2 +- .../apache/spark/sql/hudi/TestSqlConf.scala | 2 +- .../spark/sql/hudi/TestTimeTravelTable.scala | 2 +- .../spark/sql/hudi/TestTruncateTable.scala | 2 +- .../spark/sql/hudi/TestUpdateTable.scala | 2 +- .../procedure/TestCallCommandParser.scala | 4 ++-- .../hudi/procedure/TestCallProcedure.scala | 4 ++-- .../procedure/TestClusteringProcedure.scala | 4 ++-- .../procedure/TestCompactionProcedure.scala | 4 ++-- .../procedure/TestSavepointsProcedure.scala | 4 ++-- 27 files changed, 43 insertions(+), 49 deletions(-) rename hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/{TestHoodieSqlBase.scala => HoodieSparkSqlTestBase.scala} (98%) diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml index 29702846b3d2d..b76a465d7128c 100644 --- a/.github/workflows/bot.yml +++ b/.github/workflows/bot.yml @@ -59,3 +59,11 @@ jobs: if: ${{ !endsWith(env.SPARK_PROFILE, '3.2') }} # skip test spark 3.2 before hadoop upgrade to 3.x run: mvn test -Punit-tests -D"$SCALA_PROFILE" -D"$SPARK_PROFILE" -D"$FLINK_PROFILE" -DfailIfNoTests=false -pl hudi-examples/hudi-examples-flink,hudi-examples/hudi-examples-java,hudi-examples/hudi-examples-spark + - name: Spark SQL Test + env: + SCALA_PROFILE: ${{ matrix.scalaProfile }} + SPARK_PROFILE: ${{ matrix.sparkProfile }} + FLINK_PROFILE: ${{ matrix.flinkProfile }} + if: ${{ !endsWith(env.SPARK_PROFILE, '2.4') }} # skip test spark 2.4 as it's covered by Azure CI + run: + mvn test -Punit-tests -D"$SCALA_PROFILE" -D"$SPARK_PROFILE" -D"$FLINK_PROFILE" '-Dtest=org.apache.spark.sql.hudi.Test*' -pl hudi-spark-datasource/hudi-spark diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSqlStatement.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSqlStatement.scala index c451b51ef77c6..f8a9cf5fb060f 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSqlStatement.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestSqlStatement.scala @@ -18,9 +18,9 @@ package org.apache.hudi.functional import org.apache.hudi.common.util.FileIOUtils -import org.apache.spark.sql.hudi.TestHoodieSqlBase +import org.apache.spark.sql.hudi.HoodieSparkSqlTestBase -class TestSqlStatement extends TestHoodieSqlBase { +class TestSqlStatement extends HoodieSparkSqlTestBase { val STATE_INIT = 0 val STATE_SKIP_COMMENT = 1 val STATE_FINISH_COMMENT = 2 diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/execution/benchmark/SpaceCurveOptimizeBenchmark.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/execution/benchmark/SpaceCurveOptimizeBenchmark.scala index d84fad4f2493c..273303fdae63d 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/execution/benchmark/SpaceCurveOptimizeBenchmark.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/execution/benchmark/SpaceCurveOptimizeBenchmark.scala @@ -23,7 +23,7 @@ import org.apache.hudi.ColumnStatsIndexHelper.buildColumnStatsTableFor import org.apache.hudi.config.HoodieClusteringConfig.LayoutOptimizationStrategy import org.apache.hudi.sort.SpaceCurveSortingHelper import org.apache.spark.sql.DataFrame -import org.apache.spark.sql.hudi.TestHoodieSqlBase +import org.apache.spark.sql.hudi.HoodieSparkSqlTestBase import org.apache.spark.sql.types.{IntegerType, StructField} import org.junit.jupiter.api.{Disabled, Tag, Test} @@ -31,7 +31,7 @@ import scala.collection.JavaConversions._ import scala.util.Random @Tag("functional") -object SpaceCurveOptimizeBenchmark extends TestHoodieSqlBase { +object SpaceCurveOptimizeBenchmark extends HoodieSparkSqlTestBase { def evalSkippingPercent(tableName: String, co1: String, co2: String, value1: Int, value2: Int): Unit= { val sourceTableDF = spark.sql(s"select * from ${tableName}") diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestHoodieSqlBase.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/HoodieSparkSqlTestBase.scala similarity index 98% rename from hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestHoodieSqlBase.scala rename to hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/HoodieSparkSqlTestBase.scala index d1f373db99e51..68fc6d7c41d89 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestHoodieSqlBase.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/HoodieSparkSqlTestBase.scala @@ -31,7 +31,7 @@ import org.scalatest.{BeforeAndAfterAll, FunSuite, Tag} import java.io.File import java.util.TimeZone -class TestHoodieSqlBase extends FunSuite with BeforeAndAfterAll { +class HoodieSparkSqlTestBase extends FunSuite with BeforeAndAfterAll { org.apache.log4j.Logger.getRootLogger.setLevel(Level.WARN) private lazy val sparkWareHouse = { diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestAlterTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestAlterTable.scala index 0f2cb547c2fe9..6d29ea3f4a13e 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestAlterTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestAlterTable.scala @@ -22,7 +22,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient import org.apache.spark.sql.catalyst.TableIdentifier import org.apache.spark.sql.types.{LongType, StructField, StructType} -class TestAlterTable extends TestHoodieSqlBase { +class TestAlterTable extends HoodieSparkSqlTestBase { test("Test Alter Table") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestAlterTableDropPartition.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestAlterTableDropPartition.scala index ecbbadeeb9a28..677f8632a7143 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestAlterTableDropPartition.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestAlterTableDropPartition.scala @@ -23,7 +23,7 @@ import org.apache.hudi.config.HoodieWriteConfig import org.apache.hudi.keygen.{ComplexKeyGenerator, SimpleKeyGenerator} import org.apache.spark.sql.SaveMode -class TestAlterTableDropPartition extends TestHoodieSqlBase { +class TestAlterTableDropPartition extends HoodieSparkSqlTestBase { test("Drop non-partitioned table") { val tableName = generateTableName diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCompactionTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCompactionTable.scala index 20238a6e4318d..0ef89fc5b9fe3 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCompactionTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCompactionTable.scala @@ -17,7 +17,7 @@ package org.apache.spark.sql.hudi -class TestCompactionTable extends TestHoodieSqlBase { +class TestCompactionTable extends HoodieSparkSqlTestBase { test("Test compaction table") { withTempDir {tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala index 6b8efb84e32f1..e7910fa115852 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala @@ -30,7 +30,7 @@ import org.apache.spark.sql.types._ import scala.collection.JavaConverters._ -class TestCreateTable extends TestHoodieSqlBase { +class TestCreateTable extends HoodieSparkSqlTestBase { test("Test Create Managed Hoodie Table") { val databaseName = "hudi_database" diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDeleteTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDeleteTable.scala index b2e888a5f3140..4c7c6269667ab 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDeleteTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDeleteTable.scala @@ -22,7 +22,7 @@ import org.apache.hudi.config.HoodieWriteConfig import org.apache.hudi.keygen.SimpleKeyGenerator import org.apache.spark.sql.SaveMode -class TestDeleteTable extends TestHoodieSqlBase { +class TestDeleteTable extends HoodieSparkSqlTestBase { test("Test Delete Table") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDropTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDropTable.scala index c53eb9127c887..ed43d37d0388e 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDropTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDropTable.scala @@ -17,7 +17,7 @@ package org.apache.spark.sql.hudi -class TestDropTable extends TestHoodieSqlBase { +class TestDropTable extends HoodieSparkSqlTestBase { test("Test Drop Table") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestHoodieOptionConfig.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestHoodieOptionConfig.scala index 4c0c60385104b..14c2245d5be36 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestHoodieOptionConfig.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestHoodieOptionConfig.scala @@ -19,27 +19,13 @@ package org.apache.spark.sql.hudi import org.apache.hudi.common.model.{DefaultHoodieRecordPayload, OverwriteWithLatestAvroPayload} import org.apache.hudi.common.table.HoodieTableConfig -import org.apache.hudi.testutils.HoodieClientTestBase - -import org.apache.spark.sql.SparkSession +import org.apache.hudi.testutils.SparkClientFunctionalTestHarness import org.apache.spark.sql.types._ - import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.{BeforeEach, Test} - +import org.junit.jupiter.api.Test import org.scalatest.Matchers.intercept -class TestHoodieOptionConfig extends HoodieClientTestBase { - - var spark: SparkSession = _ - - /** - * Setup method running before each test. - */ - @BeforeEach override def setUp() { - initSparkContexts() - spark = sqlContext.sparkSession - } +class TestHoodieOptionConfig extends SparkClientFunctionalTestHarness { @Test def testWithDefaultSqlOptions(): Unit = { diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestInsertTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestInsertTable.scala index 3141208db121e..ab75ef563f229 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestInsertTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestInsertTable.scala @@ -26,7 +26,7 @@ import org.apache.spark.sql.SaveMode import java.io.File -class TestInsertTable extends TestHoodieSqlBase { +class TestInsertTable extends HoodieSparkSqlTestBase { test("Test Insert Into") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoLogOnlyTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoLogOnlyTable.scala index 5139825f9428f..232b6bbb511c5 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoLogOnlyTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoLogOnlyTable.scala @@ -19,7 +19,7 @@ package org.apache.spark.sql.hudi import org.apache.hudi.testutils.DataSourceTestUtils -class TestMergeIntoLogOnlyTable extends TestHoodieSqlBase { +class TestMergeIntoLogOnlyTable extends HoodieSparkSqlTestBase { test("Test Query Log Only MOR Table") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoTable.scala index 28dee88e1f61e..992a442f4fda3 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoTable.scala @@ -20,7 +20,7 @@ package org.apache.spark.sql.hudi import org.apache.hudi.{DataSourceReadOptions, HoodieDataSourceHelpers} import org.apache.hudi.common.fs.FSUtils -class TestMergeIntoTable extends TestHoodieSqlBase { +class TestMergeIntoTable extends HoodieSparkSqlTestBase { test("Test MergeInto Basic") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoTable2.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoTable2.scala index 5041a543168bf..e162368dacc72 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoTable2.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestMergeIntoTable2.scala @@ -21,7 +21,7 @@ import org.apache.hudi.HoodieSparkUtils import org.apache.hudi.common.table.HoodieTableMetaClient import org.apache.spark.sql.Row -class TestMergeIntoTable2 extends TestHoodieSqlBase { +class TestMergeIntoTable2 extends HoodieSparkSqlTestBase { test("Test MergeInto for MOR table 2") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestPartialUpdateForMergeInto.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestPartialUpdateForMergeInto.scala index 2524d04ec81fb..1af7a162be185 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestPartialUpdateForMergeInto.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestPartialUpdateForMergeInto.scala @@ -17,7 +17,7 @@ package org.apache.spark.sql.hudi -class TestPartialUpdateForMergeInto extends TestHoodieSqlBase { +class TestPartialUpdateForMergeInto extends HoodieSparkSqlTestBase { test("Test Partial Update") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestShowPartitions.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestShowPartitions.scala index 868bfc43d57f1..369f3b341adce 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestShowPartitions.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestShowPartitions.scala @@ -19,7 +19,7 @@ package org.apache.spark.sql.hudi import org.apache.spark.sql.Row -class TestShowPartitions extends TestHoodieSqlBase { +class TestShowPartitions extends HoodieSparkSqlTestBase { test("Test Show Non Partitioned Table's Partitions") { val tableName = generateTableName diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestSpark3DDL.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestSpark3DDL.scala index 54163635984bf..15fed579bba41 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestSpark3DDL.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestSpark3DDL.scala @@ -27,7 +27,7 @@ import org.apache.spark.sql.{DataFrame, Row, SaveMode, SparkSession} import scala.collection.JavaConversions._ import scala.collection.JavaConverters._ -class TestSpark3DDL extends TestHoodieSqlBase { +class TestSpark3DDL extends HoodieSparkSqlTestBase { def createTestResult(tableName: String): Array[Row] = { spark.sql(s"select * from ${tableName} order by id") diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestSqlConf.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestSqlConf.scala index 1a8ac0e645899..ac3c49efdd713 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestSqlConf.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestSqlConf.scala @@ -28,7 +28,7 @@ import java.nio.file.{Files, Paths} import org.scalatest.BeforeAndAfter -class TestSqlConf extends TestHoodieSqlBase with BeforeAndAfter { +class TestSqlConf extends HoodieSparkSqlTestBase with BeforeAndAfter { def setEnv(key: String, value: String): String = { val field = System.getenv().getClass.getDeclaredField("m") diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestTimeTravelTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestTimeTravelTable.scala index 471ebd6107dcc..ce0f17c3f569c 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestTimeTravelTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestTimeTravelTable.scala @@ -20,7 +20,7 @@ package org.apache.spark.sql.hudi import org.apache.hudi.HoodieSparkUtils import org.apache.hudi.common.table.HoodieTableMetaClient -class TestTimeTravelTable extends TestHoodieSqlBase { +class TestTimeTravelTable extends HoodieSparkSqlTestBase { test("Test Insert and Update Record with time travel") { if (HoodieSparkUtils.gteqSpark3_2) { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestTruncateTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestTruncateTable.scala index a61d0f822cf45..5dd243079efb7 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestTruncateTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestTruncateTable.scala @@ -23,7 +23,7 @@ import org.apache.hudi.config.HoodieWriteConfig import org.apache.hudi.keygen.{ComplexKeyGenerator, SimpleKeyGenerator} import org.apache.spark.sql.SaveMode -class TestTruncateTable extends TestHoodieSqlBase { +class TestTruncateTable extends HoodieSparkSqlTestBase { test("Test Truncate non-partitioned Table") { Seq("cow", "mor").foreach { tableType => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestUpdateTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestUpdateTable.scala index 57c4a972960a9..8c709ab37a6e3 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestUpdateTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestUpdateTable.scala @@ -17,7 +17,7 @@ package org.apache.spark.sql.hudi -class TestUpdateTable extends TestHoodieSqlBase { +class TestUpdateTable extends HoodieSparkSqlTestBase { test("Test Update Table") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCallCommandParser.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCallCommandParser.scala index 87814763bf4d3..668fb544934dd 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCallCommandParser.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCallCommandParser.scala @@ -21,13 +21,13 @@ import com.google.common.collect.ImmutableList import org.apache.hudi.HoodieSparkUtils import org.apache.spark.sql.catalyst.expressions.Literal import org.apache.spark.sql.catalyst.plans.logical.{CallCommand, NamedArgument, PositionalArgument} -import org.apache.spark.sql.hudi.TestHoodieSqlBase +import org.apache.spark.sql.hudi.HoodieSparkSqlTestBase import org.apache.spark.sql.types.{DataType, DataTypes} import java.math.BigDecimal import scala.collection.JavaConverters -class TestCallCommandParser extends TestHoodieSqlBase { +class TestCallCommandParser extends HoodieSparkSqlTestBase { private val parser = spark.sessionState.sqlParser test("Test Call Produce with Positional Arguments") { diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCallProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCallProcedure.scala index bdf4cbe7ba0ff..f75569a1171f5 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCallProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCallProcedure.scala @@ -17,9 +17,9 @@ package org.apache.spark.sql.hudi.procedure -import org.apache.spark.sql.hudi.TestHoodieSqlBase +import org.apache.spark.sql.hudi.HoodieSparkSqlTestBase -class TestCallProcedure extends TestHoodieSqlBase { +class TestCallProcedure extends HoodieSparkSqlTestBase { test("Test Call show_commits Procedure") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringProcedure.scala index 6214117233467..f975651bd7527 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringProcedure.scala @@ -24,11 +24,11 @@ import org.apache.hudi.common.table.timeline.{HoodieActiveTimeline, HoodieTimeli import org.apache.hudi.common.util.{Option => HOption} import org.apache.hudi.{HoodieCLIUtils, HoodieDataSourceHelpers} -import org.apache.spark.sql.hudi.TestHoodieSqlBase +import org.apache.spark.sql.hudi.HoodieSparkSqlTestBase import scala.collection.JavaConverters.asScalaIteratorConverter -class TestClusteringProcedure extends TestHoodieSqlBase { +class TestClusteringProcedure extends HoodieSparkSqlTestBase { test("Test Call run_clustering Procedure By Table") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCompactionProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCompactionProcedure.scala index f6e6772d161b6..0f6f96f91196f 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCompactionProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCompactionProcedure.scala @@ -19,9 +19,9 @@ package org.apache.spark.sql.hudi.procedure -import org.apache.spark.sql.hudi.TestHoodieSqlBase +import org.apache.spark.sql.hudi.HoodieSparkSqlTestBase -class TestCompactionProcedure extends TestHoodieSqlBase { +class TestCompactionProcedure extends HoodieSparkSqlTestBase { test("Test Call run_compaction Procedure by Table") { withTempDir { tmp => diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestSavepointsProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestSavepointsProcedure.scala index 7d60ca018d32a..cfc5319c75641 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestSavepointsProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestSavepointsProcedure.scala @@ -17,9 +17,9 @@ package org.apache.spark.sql.hudi.procedure -import org.apache.spark.sql.hudi.TestHoodieSqlBase +import org.apache.spark.sql.hudi.HoodieSparkSqlTestBase -class TestSavepointsProcedure extends TestHoodieSqlBase { +class TestSavepointsProcedure extends HoodieSparkSqlTestBase { test("Test Call create_savepoints Procedure") { withTempDir { tmp => From 52fe1c9faeb83fe51b520e18d0c37b67ad3fcfe4 Mon Sep 17 00:00:00 2001 From: Sivabalan Narayanan Date: Fri, 6 May 2022 09:27:29 -0400 Subject: [PATCH 05/52] [HUDI-3675] Adding post write termination strategy to deltastreamer continuous mode (#5073) - Added a postWriteTerminationStrategy to deltastreamer continuous mode. One can enable by setting the appropriate termination strategy using DeltastreamerConfig.postWriteTerminationStrategyClass. If not, continuous mode is expected to run forever. - Added one concrete impl for termination strategy as NoNewDataTerminationStrategy which shuts down deltastreamer if there is no new data to consume from source for N consecutive rounds. --- .../deltastreamer/HoodieDeltaStreamer.java | 16 ++++++ .../NoNewDataTerminationStrategy.java | 56 +++++++++++++++++++ .../PostWriteTerminationStrategy.java | 39 +++++++++++++ .../TerminationStrategyUtils.java | 45 +++++++++++++++ .../functional/TestHoodieDeltaStreamer.java | 47 +++++++++++++++- .../utilities/sources/TestDataSource.java | 9 ++- 6 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/NoNewDataTerminationStrategy.java create mode 100644 hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/PostWriteTerminationStrategy.java create mode 100644 hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/TerminationStrategyUtils.java diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamer.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamer.java index 824c7375fa07a..7a688b50c7097 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamer.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/HoodieDeltaStreamer.java @@ -43,6 +43,7 @@ import org.apache.hudi.common.util.ClusteringUtils; import org.apache.hudi.common.util.CompactionUtils; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieClusteringConfig; @@ -403,6 +404,9 @@ public static class Config implements Serializable { + "https://spark.apache.org/docs/latest/job-scheduling.html") public Integer clusterSchedulingMinShare = 0; + @Parameter(names = {"--post-write-termination-strategy-class"}, description = "Post writer termination strategy class to gracefully shutdown deltastreamer in continuous mode") + public String postWriteTerminationStrategyClass = ""; + public boolean isAsyncCompactionEnabled() { return continuousMode && !forceDisableCompaction && HoodieTableType.MERGE_ON_READ.equals(HoodieTableType.valueOf(tableType)); @@ -603,6 +607,8 @@ public static class DeltaSyncService extends HoodieAsyncService { */ private transient DeltaSync deltaSync; + private final Option postWriteTerminationStrategy; + public DeltaSyncService(Config cfg, JavaSparkContext jssc, FileSystem fs, Configuration conf, Option properties) throws IOException { this.cfg = cfg; @@ -610,6 +616,8 @@ public DeltaSyncService(Config cfg, JavaSparkContext jssc, FileSystem fs, Config this.sparkSession = SparkSession.builder().config(jssc.getConf()).getOrCreate(); this.asyncCompactService = Option.empty(); this.asyncClusteringService = Option.empty(); + this.postWriteTerminationStrategy = StringUtils.isNullOrEmpty(cfg.postWriteTerminationStrategyClass) ? Option.empty() : + TerminationStrategyUtils.createPostWriteTerminationStrategy(properties.get(), cfg.postWriteTerminationStrategyClass); if (fs.exists(new Path(cfg.targetBasePath))) { HoodieTableMetaClient meta = @@ -695,6 +703,14 @@ protected Pair startService() { } } } + // check if deltastreamer need to be shutdown + if (postWriteTerminationStrategy.isPresent()) { + if (postWriteTerminationStrategy.get().shouldShutdown(scheduledCompactionInstantAndRDD.isPresent() ? Option.of(scheduledCompactionInstantAndRDD.get().getRight()) : + Option.empty())) { + error = true; + shutdown(false); + } + } long toSleepMs = cfg.minSyncIntervalSeconds * 1000 - (System.currentTimeMillis() - start); if (toSleepMs > 0) { LOG.info("Last sync ran less than min sync interval: " + cfg.minSyncIntervalSeconds + " s, sleep: " diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/NoNewDataTerminationStrategy.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/NoNewDataTerminationStrategy.java new file mode 100644 index 0000000000000..2701ce4bc3085 --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/NoNewDataTerminationStrategy.java @@ -0,0 +1,56 @@ +/* + * 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.hudi.utilities.deltastreamer; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.util.Option; + +import org.apache.log4j.LogManager; +import org.apache.log4j.Logger; +import org.apache.spark.api.java.JavaRDD; + +/** + * Post writer termination strategy for deltastreamer in continuous mode. This strategy is based on no new data for consecutive number of times. + */ +public class NoNewDataTerminationStrategy implements PostWriteTerminationStrategy { + + private static final Logger LOG = LogManager.getLogger(NoNewDataTerminationStrategy.class); + + public static final String MAX_ROUNDS_WITHOUT_NEW_DATA_TO_SHUTDOWN = "max.rounds.without.new.data.to.shutdown"; + public static final int DEFAULT_MAX_ROUNDS_WITHOUT_NEW_DATA_TO_SHUTDOWN = 3; + + private final int numTimesNoNewDataToShutdown; + private int numTimesNoNewData = 0; + + public NoNewDataTerminationStrategy(TypedProperties properties) { + numTimesNoNewDataToShutdown = properties.getInteger(MAX_ROUNDS_WITHOUT_NEW_DATA_TO_SHUTDOWN, DEFAULT_MAX_ROUNDS_WITHOUT_NEW_DATA_TO_SHUTDOWN); + } + + @Override + public boolean shouldShutdown(Option> writeStatuses) { + numTimesNoNewData = writeStatuses.isPresent() ? 0 : numTimesNoNewData + 1; + if (numTimesNoNewData >= numTimesNoNewDataToShutdown) { + LOG.info("Shutting down on continuous mode as there is no new data for " + numTimesNoNewData); + return true; + } + return false; + } +} diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/PostWriteTerminationStrategy.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/PostWriteTerminationStrategy.java new file mode 100644 index 0000000000000..61f55428f166a --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/PostWriteTerminationStrategy.java @@ -0,0 +1,39 @@ +/* + * 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.hudi.utilities.deltastreamer; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.util.Option; + +import org.apache.spark.api.java.JavaRDD; + +/** + * Post write termination strategy for deltastreamer in continuous mode. + */ +public interface PostWriteTerminationStrategy { + + /** + * Returns whether deltastreamer needs to be shutdown. + * @param writeStatuses optional pair of scheduled compaction instant and write statuses. + * @return true if deltastreamer has to be shutdown. false otherwise. + */ + boolean shouldShutdown(Option> writeStatuses); + +} diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/TerminationStrategyUtils.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/TerminationStrategyUtils.java new file mode 100644 index 0000000000000..1b046a0db0da2 --- /dev/null +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/TerminationStrategyUtils.java @@ -0,0 +1,45 @@ +/* + * 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.hudi.utilities.deltastreamer; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.ReflectionUtils; +import org.apache.hudi.common.util.StringUtils; +import org.apache.hudi.exception.HoodieException; + +public class TerminationStrategyUtils { + + /** + * Create a PostWriteTerminationStrategy class via reflection, + *
+ * if the class name of PostWriteTerminationStrategy is configured through the {@link HoodieDeltaStreamer.Config#postWriteTerminationStrategyClass}. + */ + public static Option createPostWriteTerminationStrategy(TypedProperties properties, String postWriteTerminationStrategyClass) + throws HoodieException { + try { + return StringUtils.isNullOrEmpty(postWriteTerminationStrategyClass) + ? Option.empty() : + Option.of((PostWriteTerminationStrategy) ReflectionUtils.loadClass(postWriteTerminationStrategyClass, properties)); + } catch (Throwable e) { + throw new HoodieException("Could not create PostWritTerminationStrategy class " + postWriteTerminationStrategyClass, e); + } + } +} diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java index 0576f6aaee88b..3eaec56cc2764 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java @@ -60,6 +60,7 @@ import org.apache.hudi.utilities.HoodieIndexer; import org.apache.hudi.utilities.deltastreamer.DeltaSync; import org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamer; +import org.apache.hudi.utilities.deltastreamer.NoNewDataTerminationStrategy; import org.apache.hudi.utilities.schema.FilebasedSchemaProvider; import org.apache.hudi.utilities.schema.SchemaProvider; import org.apache.hudi.utilities.schema.SparkAvroPostProcessor; @@ -738,18 +739,30 @@ public void testUpsertsCOWContinuousMode() throws Exception { testUpsertsContinuousMode(HoodieTableType.COPY_ON_WRITE, "continuous_cow"); } + @Test + public void testUpsertsCOWContinuousModeShutdownGracefully() throws Exception { + testUpsertsContinuousMode(HoodieTableType.COPY_ON_WRITE, "continuous_cow", true); + } + @Test public void testUpsertsMORContinuousMode() throws Exception { testUpsertsContinuousMode(HoodieTableType.MERGE_ON_READ, "continuous_mor"); } private void testUpsertsContinuousMode(HoodieTableType tableType, String tempDir) throws Exception { + testUpsertsContinuousMode(tableType, tempDir, false); + } + + private void testUpsertsContinuousMode(HoodieTableType tableType, String tempDir, boolean testShutdownGracefully) throws Exception { String tableBasePath = dfsBasePath + "/" + tempDir; // Keep it higher than batch-size to test continuous mode int totalRecords = 3000; // Initial bulk insert HoodieDeltaStreamer.Config cfg = TestHelpers.makeConfig(tableBasePath, WriteOperationType.UPSERT); cfg.continuousMode = true; + if (testShutdownGracefully) { + cfg.postWriteTerminationStrategyClass = NoNewDataTerminationStrategy.class.getName(); + } cfg.tableType = tableType.name(); cfg.configs.add(String.format("%s=%d", SourceConfigs.MAX_UNIQUE_RECORDS_PROP, totalRecords)); cfg.configs.add(String.format("%s=false", HoodieCompactionConfig.AUTO_CLEAN.key())); @@ -763,6 +776,9 @@ private void testUpsertsContinuousMode(HoodieTableType tableType, String tempDir } TestHelpers.assertRecordCount(totalRecords, tableBasePath, sqlContext); TestHelpers.assertDistanceCount(totalRecords, tableBasePath, sqlContext); + if (testShutdownGracefully) { + TestDataSource.returnEmptyBatch = true; + } return true; }); } @@ -781,8 +797,35 @@ static void deltaStreamerTestRunner(HoodieDeltaStreamer ds, HoodieDeltaStreamer. } }); TestHelpers.waitTillCondition(condition, dsFuture, 360); - ds.shutdownGracefully(); - dsFuture.get(); + if (cfg != null && !cfg.postWriteTerminationStrategyClass.isEmpty()) { + awaitDeltaStreamerShutdown(ds); + } else { + ds.shutdownGracefully(); + dsFuture.get(); + } + } + + static void awaitDeltaStreamerShutdown(HoodieDeltaStreamer ds) throws InterruptedException { + // await until deltastreamer shuts down on its own + boolean shutDownRequested = false; + int timeSoFar = 0; + while (!shutDownRequested) { + shutDownRequested = ds.getDeltaSyncService().isShutdownRequested(); + Thread.sleep(500); + timeSoFar += 500; + if (timeSoFar > (2 * 60 * 1000)) { + Assertions.fail("Deltastreamer should have shutdown by now"); + } + } + boolean shutdownComplete = false; + while (!shutdownComplete) { + shutdownComplete = ds.getDeltaSyncService().isShutdown(); + Thread.sleep(500); + timeSoFar += 500; + if (timeSoFar > (2 * 60 * 1000)) { + Assertions.fail("Deltastreamer should have shutdown by now"); + } + } } static void deltaStreamerTestRunner(HoodieDeltaStreamer ds, Function condition) throws Exception { diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestDataSource.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestDataSource.java index 1806d5c48b06d..a5a39dbe2d09e 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestDataSource.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestDataSource.java @@ -39,11 +39,14 @@ public class TestDataSource extends AbstractBaseTestSource { private static final Logger LOG = LogManager.getLogger(TestDataSource.class); + public static boolean returnEmptyBatch = false; + private static int counter = 0; public TestDataSource(TypedProperties props, JavaSparkContext sparkContext, SparkSession sparkSession, SchemaProvider schemaProvider) { super(props, sparkContext, sparkSession, schemaProvider); initDataGen(); + returnEmptyBatch = false; } @Override @@ -54,9 +57,13 @@ protected InputBatch> fetchNewData(Option lastChe LOG.info("Source Limit is set to " + sourceLimit); // No new data. - if (sourceLimit <= 0) { + if (sourceLimit <= 0 || returnEmptyBatch) { + LOG.warn("Return no new data from Test Data source " + counter + ", source limit " + sourceLimit); return new InputBatch<>(Option.empty(), lastCheckpointStr.orElse(null)); + } else { + LOG.warn("Returning valid data from Test Data source " + counter + ", source limit " + sourceLimit); } + counter++; List records = fetchNextBatch(props, (int) sourceLimit, instantTime, DEFAULT_PARTITION_NUM).collect(Collectors.toList()); From 9625d16937954a54420384b41f964e48cba8cc2f Mon Sep 17 00:00:00 2001 From: cxzl25 Date: Sat, 7 May 2022 15:39:14 +0800 Subject: [PATCH 06/52] [HUDI-3849] AvroDeserializer supports AVRO_REBASE_MODE_IN_READ configuration (#5287) --- .../spark/sql/avro/HoodieSpark3_2AvroDeserializer.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/avro/HoodieSpark3_2AvroDeserializer.scala b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/avro/HoodieSpark3_2AvroDeserializer.scala index 0275e2f635d3b..d839c73032cd4 100644 --- a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/avro/HoodieSpark3_2AvroDeserializer.scala +++ b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/avro/HoodieSpark3_2AvroDeserializer.scala @@ -18,13 +18,14 @@ package org.apache.spark.sql.avro import org.apache.avro.Schema -import org.apache.hudi.HoodieSparkUtils +import org.apache.spark.sql.internal.SQLConf import org.apache.spark.sql.types.DataType class HoodieSpark3_2AvroDeserializer(rootAvroType: Schema, rootCatalystType: DataType) extends HoodieAvroDeserializer { - private val avroDeserializer = new AvroDeserializer(rootAvroType, rootCatalystType, "EXCEPTION") + private val avroDeserializer = new AvroDeserializer(rootAvroType, rootCatalystType, + SQLConf.get.getConf(SQLConf.AVRO_REBASE_MODE_IN_READ)) def deserialize(data: Any): Option[Any] = avroDeserializer.deserialize(data) } From 80f99893a06b984da7332d7db05d6ac309810da0 Mon Sep 17 00:00:00 2001 From: BruceLin Date: Sat, 7 May 2022 20:03:18 +0800 Subject: [PATCH 07/52] [MINOR] Fixing class not found when using flink and enable metadata table (#5527) --- packaging/hudi-flink-bundle/pom.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/packaging/hudi-flink-bundle/pom.xml b/packaging/hudi-flink-bundle/pom.xml index a322daaabe9a1..903671d754c76 100644 --- a/packaging/hudi-flink-bundle/pom.xml +++ b/packaging/hudi-flink-bundle/pom.xml @@ -111,6 +111,7 @@ com.github.davidmoten:guava-mini com.github.davidmoten:hilbert-curve + com.github.ben-manes.caffeine:caffeine com.twitter:bijection-avro_${scala.binary.version} com.twitter:bijection-core_${scala.binary.version} io.dropwizard.metrics:metrics-core From 569a76a9a5389efb74ec88b81c616f8ead58df5c Mon Sep 17 00:00:00 2001 From: Sivabalan Narayanan Date: Sat, 7 May 2022 15:37:20 -0400 Subject: [PATCH 08/52] [MINOR] fixing flaky tests in deltastreamer tests (#5521) --- .../hudi/utilities/functional/HoodieDeltaStreamerTestBase.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/HoodieDeltaStreamerTestBase.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/HoodieDeltaStreamerTestBase.java index a9de85ce5ac9e..4ac6f73d880fb 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/HoodieDeltaStreamerTestBase.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/HoodieDeltaStreamerTestBase.java @@ -30,6 +30,7 @@ import org.apache.hudi.hive.HiveSyncConfig; import org.apache.hudi.hive.MultiPartKeysValueExtractor; import org.apache.hudi.utilities.schema.FilebasedSchemaProvider; +import org.apache.hudi.utilities.sources.TestParquetDFSSourceEmptyBatch; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; import org.apache.avro.Schema; @@ -191,6 +192,7 @@ protected static void writeCommonPropsToFile(FileSystem dfs, String dfsBasePath) @BeforeEach public void setup() throws Exception { super.setup(); + TestParquetDFSSourceEmptyBatch.returnEmptyBatch = false; } @AfterAll From 75eaa0bffe86306c5df7a52c42ee41b7c7dc24c1 Mon Sep 17 00:00:00 2001 From: guanziyue <30882822+guanziyue@users.noreply.github.com> Date: Mon, 9 May 2022 10:27:37 +0800 Subject: [PATCH 09/52] [HUDI-4055]refactor ratelimiter to avoid stack overflow (#5530) --- .../apache/hudi/common/util/RateLimiter.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/RateLimiter.java b/hudi-common/src/main/java/org/apache/hudi/common/util/RateLimiter.java index e156ccffdbb97..4915e454af215 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/util/RateLimiter.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/RateLimiter.java @@ -53,19 +53,22 @@ private RateLimiter(int permits, TimeUnit timePeriod) { } public boolean tryAcquire(int numPermits) { - if (numPermits > maxPermits) { - acquire(maxPermits); - return tryAcquire(numPermits - maxPermits); - } else { - return acquire(numPermits); + int remainingPermits = numPermits; + while (remainingPermits > 0) { + if (remainingPermits > maxPermits) { + acquire(maxPermits); + remainingPermits -= maxPermits; + } else { + return acquire(remainingPermits); + } } + return true; } public boolean acquire(int numOps) { try { - if (!semaphore.tryAcquire(numOps)) { + while (!semaphore.tryAcquire(numOps)) { Thread.sleep(WAIT_BEFORE_NEXT_ACQUIRE_PERMIT_IN_MS); - return acquire(numOps); } LOG.debug(String.format("acquire permits: %s, maxPremits: %s", numOps, maxPermits)); } catch (InterruptedException e) { From 4c708402758545c5097579d1a92e8d710ef1f61d Mon Sep 17 00:00:00 2001 From: ForwardXu Date: Mon, 9 May 2022 15:17:24 +0800 Subject: [PATCH 10/52] [MINOR] Fixing close for HoodieCatalog's test (#5531) * [MINOR] Fixing close for HoodieCatalog's test --- .../org/apache/hudi/table/catalog/TestHoodieCatalog.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieCatalog.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieCatalog.java index 3930e763fbaaa..8e23ef9d63bcb 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieCatalog.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/catalog/TestHoodieCatalog.java @@ -42,6 +42,7 @@ import org.apache.flink.table.catalog.exceptions.TableAlreadyExistException; import org.apache.flink.table.catalog.exceptions.TableNotExistException; import org.apache.flink.table.types.logical.LogicalTypeRoot; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -138,6 +139,13 @@ void beforeEach() { catalog.open(); } + @AfterEach + void afterEach() { + if (catalog != null) { + catalog.close(); + } + } + @Test public void testListDatabases() { List actual = catalog.listDatabases(); From 6b47ef6ed223e10a05b91acc3331cff2fa069d87 Mon Sep 17 00:00:00 2001 From: xicm <36392121+xicm@users.noreply.github.com> Date: Mon, 9 May 2022 16:35:50 +0800 Subject: [PATCH 11/52] =?UTF-8?q?[HUDI-4053]=20Flaky=20ITTestHoodieDataSou?= =?UTF-8?q?rce.testStreamWriteBatchReadOpti=E2=80=A6=20(#5526)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [HUDI-4053] Flaky ITTestHoodieDataSource.testStreamWriteBatchReadOptimized Co-authored-by: xicm --- .../apache/hudi/table/ITTestHoodieDataSource.java | 10 +++++++++- .../test/java/org/apache/hudi/utils/TestData.java | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java index 088ddb260dd5f..0c423df6b7bdb 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java @@ -240,7 +240,15 @@ void testStreamWriteBatchReadOptimized() { List rows = CollectionUtil.iterableToList( () -> streamTableEnv.sqlQuery("select * from t1").execute().collect()); - assertRowsEquals(rows, TestData.DATA_SET_SOURCE_INSERT); + + // the test is flaky based on whether the first compaction is pending when + // scheduling the 2nd compaction. + // see details in CompactionPlanOperator#scheduleCompaction. + if (rows.size() < TestData.DATA_SET_SOURCE_INSERT.size()) { + assertRowsEquals(rows, TestData.DATA_SET_SOURCE_INSERT_FIRST_COMMIT); + } else { + assertRowsEquals(rows, TestData.DATA_SET_SOURCE_INSERT); + } } @Test diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestData.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestData.java index c1e924056cfa2..61f1657c2c6ed 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestData.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestData.java @@ -164,6 +164,18 @@ public class TestData { TimestampData.fromEpochMillis(8000), StringData.fromString("par4")) ); + // data set of test_source.data first commit. + public static List DATA_SET_SOURCE_INSERT_FIRST_COMMIT = Arrays.asList( + insertRow(StringData.fromString("id1"), StringData.fromString("Danny"), 23, + TimestampData.fromEpochMillis(1000), StringData.fromString("par1")), + insertRow(StringData.fromString("id2"), StringData.fromString("Stephen"), 33, + TimestampData.fromEpochMillis(2000), StringData.fromString("par1")), + insertRow(StringData.fromString("id3"), StringData.fromString("Julian"), 53, + TimestampData.fromEpochMillis(3000), StringData.fromString("par2")), + insertRow(StringData.fromString("id4"), StringData.fromString("Fabian"), 31, + TimestampData.fromEpochMillis(4000), StringData.fromString("par2")) + ); + // data set of test_source.data latest commit. public static List DATA_SET_SOURCE_INSERT_LATEST_COMMIT = Arrays.asList( insertRow(StringData.fromString("id5"), StringData.fromString("Sophia"), 18, From 6285a239a35c5808ba2eea00193a51564c716ab6 Mon Sep 17 00:00:00 2001 From: Sivabalan Narayanan Date: Mon, 9 May 2022 12:40:22 -0400 Subject: [PATCH 12/52] [HUDI-3995] Making perf optimizations for bulk insert row writer path (#5462) - Avoid using udf for key generator for SimpleKeyGen and NonPartitionedKeyGen. - Fixed NonPartitioned Key generator to directly fetch record key from row rather than involving GenericRecord. - Other minor fixes around using static values instead of looking up hashmap. --- .../io/storage/row/HoodieRowCreateHandle.java | 14 +-- .../hudi/keygen/BuiltinKeyGenerator.java | 103 +++++------------- .../hudi/keygen/ComplexKeyGenerator.java | 8 +- .../hudi/keygen/GlobalDeleteKeyGenerator.java | 4 +- .../keygen/NonpartitionedKeyGenerator.java | 6 + .../hudi/keygen/RowKeyGeneratorHelper.java | 57 +++++----- .../hudi/keygen/SimpleKeyGenerator.java | 8 +- .../keygen/TimestampBasedKeyGenerator.java | 16 +-- .../hudi/keygen/TestRowGeneratorHelper.scala | 24 ++-- .../org/apache/hudi/avro/HoodieAvroUtils.java | 4 +- .../hudi/common/model/HoodieRecord.java | 6 +- .../common/table/HoodieTableMetaClient.java | 14 ++- .../hudi/HoodieDatasetBulkInsertHelper.java | 71 ++++++++---- .../BulkInsertDataInternalWriterHelper.java | 5 +- .../apache/hudi/HoodieSparkSqlWriter.scala | 3 +- .../TestHoodieDatasetBulkInsertHelper.java | 53 +++++++-- .../hudi/keygen/TestComplexKeyGenerator.java | 2 +- .../TestGlobalDeleteRecordGenerator.java | 4 +- .../TestNonpartitionedKeyGenerator.java | 2 +- .../hudi/keygen/TestSimpleKeyGenerator.java | 4 +- 20 files changed, 219 insertions(+), 189 deletions(-) diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java index ce3cd6f09768d..4db7eb26e64ba 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java @@ -68,8 +68,8 @@ public class HoodieRowCreateHandle implements Serializable { private final HoodieTimer currTimer; public HoodieRowCreateHandle(HoodieTable table, HoodieWriteConfig writeConfig, String partitionPath, String fileId, - String instantTime, int taskPartitionId, long taskId, long taskEpochId, - StructType structType) { + String instantTime, int taskPartitionId, long taskId, long taskEpochId, + StructType structType) { this.partitionPath = partitionPath; this.table = table; this.writeConfig = writeConfig; @@ -107,16 +107,15 @@ public HoodieRowCreateHandle(HoodieTable table, HoodieWriteConfig writeConfig, S /** * Writes an {@link InternalRow} to the underlying HoodieInternalRowFileWriter. Before writing, value for meta columns are computed as required * and wrapped in {@link HoodieInternalRow}. {@link HoodieInternalRow} is what gets written to HoodieInternalRowFileWriter. + * * @param record instance of {@link InternalRow} that needs to be written to the fileWriter. * @throws IOException */ public void write(InternalRow record) throws IOException { try { - String partitionPath = record.getUTF8String(HoodieRecord.HOODIE_META_COLUMNS_NAME_TO_POS.get( - HoodieRecord.PARTITION_PATH_METADATA_FIELD)).toString(); - String seqId = HoodieRecord.generateSequenceId(instantTime, taskPartitionId, SEQGEN.getAndIncrement()); - String recordKey = record.getUTF8String(HoodieRecord.HOODIE_META_COLUMNS_NAME_TO_POS.get( - HoodieRecord.RECORD_KEY_METADATA_FIELD)).toString(); + final String partitionPath = String.valueOf(record.getUTF8String(HoodieRecord.PARTITION_PATH_META_FIELD_POS)); + final String seqId = HoodieRecord.generateSequenceId(instantTime, taskPartitionId, SEQGEN.getAndIncrement()); + final String recordKey = String.valueOf(record.getUTF8String(HoodieRecord.RECORD_KEY_META_FIELD_POS)); HoodieInternalRow internalRow = new HoodieInternalRow(instantTime, seqId, recordKey, partitionPath, path.getName(), record); try { @@ -141,6 +140,7 @@ public boolean canWrite() { /** * Closes the {@link HoodieRowCreateHandle} and returns an instance of {@link HoodieInternalWriteStatus} containing the stats and * status of the writes to this handle. + * * @return the {@link HoodieInternalWriteStatus} containing the stats and status of the writes to this handle. * @throws IOException */ diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/BuiltinKeyGenerator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/BuiltinKeyGenerator.java index fe03f60ee816c..0642a85c5f6cd 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/BuiltinKeyGenerator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/BuiltinKeyGenerator.java @@ -18,25 +18,25 @@ package org.apache.hudi.keygen; -import org.apache.avro.generic.GenericRecord; import org.apache.hudi.ApiMaturityLevel; import org.apache.hudi.AvroConversionUtils; -import org.apache.hudi.HoodieSparkUtils; import org.apache.hudi.PublicAPIMethod; -import org.apache.hudi.client.utils.SparkRowSerDe; import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.exception.HoodieIOException; -import org.apache.hudi.exception.HoodieKeyException; + +import org.apache.avro.generic.GenericRecord; import org.apache.spark.sql.Row; import org.apache.spark.sql.catalyst.InternalRow; import org.apache.spark.sql.types.DataType; import org.apache.spark.sql.types.StructType; -import scala.Function1; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +import scala.Function1; /** * Base class for the built-in key generators. Contains methods structured for @@ -46,13 +46,12 @@ public abstract class BuiltinKeyGenerator extends BaseKeyGenerator implements Sp private static final String STRUCT_NAME = "hoodieRowTopLevelField"; private static final String NAMESPACE = "hoodieRow"; - private transient Function1 converterFn = null; - private SparkRowSerDe sparkRowSerDe; + private Function1 converterFn = null; + private final AtomicBoolean validatePartitionFields = new AtomicBoolean(false); protected StructType structType; - protected Map> recordKeyPositions = new HashMap<>(); - protected Map> partitionPathPositions = new HashMap<>(); - protected Map> partitionPathDataTypes = null; + protected Map, DataType>> recordKeySchemaInfo = new HashMap<>(); + protected Map, DataType>> partitionPathSchemaInfo = new HashMap<>(); protected BuiltinKeyGenerator(TypedProperties config) { super(config); @@ -60,6 +59,7 @@ protected BuiltinKeyGenerator(TypedProperties config) { /** * Fetch record key from {@link Row}. + * * @param row instance of {@link Row} from which record key is requested. * @return the record key of interest from {@link Row}. */ @@ -74,6 +74,7 @@ public String getRecordKey(Row row) { /** * Fetch partition path from {@link Row}. + * * @param row instance of {@link Row} from which partition path is requested * @return the partition path of interest from {@link Row}. */ @@ -97,87 +98,41 @@ public String getPartitionPath(Row row) { @PublicAPIMethod(maturity = ApiMaturityLevel.EVOLVING) public String getPartitionPath(InternalRow internalRow, StructType structType) { try { - initDeserializer(structType); - Row row = sparkRowSerDe.deserializeRow(internalRow); - return getPartitionPath(row); + buildFieldSchemaInfoIfNeeded(structType); + return RowKeyGeneratorHelper.getPartitionPathFromInternalRow(internalRow, getPartitionPathFields(), + hiveStylePartitioning, partitionPathSchemaInfo); } catch (Exception e) { throw new HoodieIOException("Conversion of InternalRow to Row failed with exception " + e); } } - private void initDeserializer(StructType structType) { - if (sparkRowSerDe == null) { - sparkRowSerDe = HoodieSparkUtils.getDeserializer(structType); - } - } - - void buildFieldPositionMapIfNeeded(StructType structType) { + void buildFieldSchemaInfoIfNeeded(StructType structType) { if (this.structType == null) { - // parse simple fields - getRecordKeyFields().stream() - .filter(f -> !(f.contains("."))) - .forEach(f -> { - if (structType.getFieldIndex(f).isDefined()) { - recordKeyPositions.put(f, Collections.singletonList((Integer) (structType.getFieldIndex(f).get()))); - } else { - throw new HoodieKeyException("recordKey value not found for field: \"" + f + "\""); - } - }); - // parse nested fields - getRecordKeyFields().stream() - .filter(f -> f.contains(".")) - .forEach(f -> recordKeyPositions.put(f, RowKeyGeneratorHelper.getNestedFieldIndices(structType, f, true))); - // parse simple fields + getRecordKeyFields() + .stream().filter(f -> !f.isEmpty()) + .forEach(f -> recordKeySchemaInfo.put(f, RowKeyGeneratorHelper.getFieldSchemaInfo(structType, f, true))); if (getPartitionPathFields() != null) { - getPartitionPathFields().stream().filter(f -> !f.isEmpty()).filter(f -> !(f.contains("."))) - .forEach(f -> { - if (structType.getFieldIndex(f).isDefined()) { - partitionPathPositions.put(f, - Collections.singletonList((Integer) (structType.getFieldIndex(f).get()))); - } else { - partitionPathPositions.put(f, Collections.singletonList(-1)); - } - }); - // parse nested fields - getPartitionPathFields().stream().filter(f -> !f.isEmpty()).filter(f -> f.contains(".")) - .forEach(f -> partitionPathPositions.put(f, - RowKeyGeneratorHelper.getNestedFieldIndices(structType, f, false))); + getPartitionPathFields().stream().filter(f -> !f.isEmpty()) + .forEach(f -> partitionPathSchemaInfo.put(f, RowKeyGeneratorHelper.getFieldSchemaInfo(structType, f, false))); } this.structType = structType; } } protected String getPartitionPathInternal(InternalRow row, StructType structType) { - buildFieldDataTypesMapIfNeeded(structType); + buildFieldSchemaInfoIfNeeded(structType); validatePartitionFieldsForInternalRow(); return RowKeyGeneratorHelper.getPartitionPathFromInternalRow(row, getPartitionPathFields(), - hiveStylePartitioning, partitionPathPositions, partitionPathDataTypes); + hiveStylePartitioning, partitionPathSchemaInfo); } protected void validatePartitionFieldsForInternalRow() { - partitionPathPositions.entrySet().forEach(entry -> { - if (entry.getValue().size() > 1) { - throw new IllegalArgumentException("Nested column for partitioning is not supported with disabling meta columns"); - } - }); - } - - void buildFieldDataTypesMapIfNeeded(StructType structType) { - buildFieldPositionMapIfNeeded(structType); - if (this.partitionPathDataTypes == null) { - this.partitionPathDataTypes = new HashMap<>(); - if (getPartitionPathFields() != null) { - // populating simple fields are good enough - getPartitionPathFields().stream().filter(f -> !f.isEmpty()).filter(f -> !(f.contains("."))) - .forEach(f -> { - if (structType.getFieldIndex(f).isDefined()) { - partitionPathDataTypes.put(f, - Collections.singletonList((structType.fields()[structType.fieldIndex(f)].dataType()))); - } else { - partitionPathDataTypes.put(f, Collections.singletonList(null)); - } - }); - } + if (!validatePartitionFields.getAndSet(true)) { + partitionPathSchemaInfo.values().forEach(entry -> { + if (entry.getKey().size() > 1) { + throw new IllegalArgumentException("Nested column for partitioning is not supported with disabling meta columns"); + } + }); } } } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/ComplexKeyGenerator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/ComplexKeyGenerator.java index 2e2167f9379f0..9ba3fb8760882 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/ComplexKeyGenerator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/ComplexKeyGenerator.java @@ -60,15 +60,15 @@ public String getPartitionPath(GenericRecord record) { @Override public String getRecordKey(Row row) { - buildFieldPositionMapIfNeeded(row.schema()); - return RowKeyGeneratorHelper.getRecordKeyFromRow(row, getRecordKeyFields(), recordKeyPositions, true); + buildFieldSchemaInfoIfNeeded(row.schema()); + return RowKeyGeneratorHelper.getRecordKeyFromRow(row, getRecordKeyFields(), recordKeySchemaInfo, true); } @Override public String getPartitionPath(Row row) { - buildFieldPositionMapIfNeeded(row.schema()); + buildFieldSchemaInfoIfNeeded(row.schema()); return RowKeyGeneratorHelper.getPartitionPathFromRow(row, getPartitionPathFields(), - hiveStylePartitioning, partitionPathPositions); + hiveStylePartitioning, partitionPathSchemaInfo); } @Override diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/GlobalDeleteKeyGenerator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/GlobalDeleteKeyGenerator.java index 391ea2c87c917..77eec748c7cb1 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/GlobalDeleteKeyGenerator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/GlobalDeleteKeyGenerator.java @@ -60,8 +60,8 @@ public List getPartitionPathFields() { @Override public String getRecordKey(Row row) { - buildFieldPositionMapIfNeeded(row.schema()); - return RowKeyGeneratorHelper.getRecordKeyFromRow(row, getRecordKeyFields(), recordKeyPositions, true); + buildFieldSchemaInfoIfNeeded(row.schema()); + return RowKeyGeneratorHelper.getRecordKeyFromRow(row, getRecordKeyFields(), recordKeySchemaInfo, true); } @Override diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/NonpartitionedKeyGenerator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/NonpartitionedKeyGenerator.java index 032c750f03240..dc8b253b0f1be 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/NonpartitionedKeyGenerator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/NonpartitionedKeyGenerator.java @@ -61,6 +61,12 @@ public List getPartitionPathFields() { return nonpartitionedAvroKeyGenerator.getPartitionPathFields(); } + @Override + public String getRecordKey(Row row) { + buildFieldSchemaInfoIfNeeded(row.schema()); + return RowKeyGeneratorHelper.getRecordKeyFromRow(row, getRecordKeyFields(), recordKeySchemaInfo, false); + } + @Override public String getPartitionPath(Row row) { return nonpartitionedAvroKeyGenerator.getEmptyPartition(); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/RowKeyGeneratorHelper.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/RowKeyGeneratorHelper.java index 6a28fbe9501a9..c0e10e6f9b775 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/RowKeyGeneratorHelper.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/RowKeyGeneratorHelper.java @@ -18,6 +18,7 @@ package org.apache.hudi.keygen; +import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.exception.HoodieKeyException; import org.apache.spark.sql.Row; @@ -52,17 +53,18 @@ public class RowKeyGeneratorHelper { /** * Generates record key for the corresponding {@link Row}. - * @param row instance of {@link Row} of interest - * @param recordKeyFields record key fields as a list + * + * @param row instance of {@link Row} of interest + * @param recordKeyFields record key fields as a list * @param recordKeyPositions record key positions for the corresponding record keys in {@code recordKeyFields} - * @param prefixFieldName {@code true} if field name need to be prefixed in the returned result. {@code false} otherwise. + * @param prefixFieldName {@code true} if field name need to be prefixed in the returned result. {@code false} otherwise. * @return the record key thus generated */ - public static String getRecordKeyFromRow(Row row, List recordKeyFields, Map> recordKeyPositions, boolean prefixFieldName) { + public static String getRecordKeyFromRow(Row row, List recordKeyFields, Map, DataType>> recordKeyPositions, boolean prefixFieldName) { AtomicBoolean keyIsNullOrEmpty = new AtomicBoolean(true); String toReturn = recordKeyFields.stream().map(field -> { String val = null; - List fieldPositions = recordKeyPositions.get(field); + List fieldPositions = recordKeyPositions.get(field).getKey(); if (fieldPositions.size() == 1) { // simple field Integer fieldPos = fieldPositions.get(0); if (row.isNullAt(fieldPos)) { @@ -76,7 +78,7 @@ public static String getRecordKeyFromRow(Row row, List recordKeyFields, } } } else { // nested fields - val = getNestedFieldVal(row, recordKeyPositions.get(field)).toString(); + val = getNestedFieldVal(row, recordKeyPositions.get(field).getKey()).toString(); if (!val.contains(NULL_RECORDKEY_PLACEHOLDER) && !val.contains(EMPTY_RECORDKEY_PLACEHOLDER)) { keyIsNullOrEmpty.set(false); } @@ -91,17 +93,18 @@ public static String getRecordKeyFromRow(Row row, List recordKeyFields, /** * Generates partition path for the corresponding {@link Row}. - * @param row instance of {@link Row} of interest - * @param partitionPathFields partition path fields as a list - * @param hiveStylePartitioning {@code true} if hive style partitioning is set. {@code false} otherwise + * + * @param row instance of {@link Row} of interest + * @param partitionPathFields partition path fields as a list + * @param hiveStylePartitioning {@code true} if hive style partitioning is set. {@code false} otherwise * @param partitionPathPositions partition path positions for the corresponding fields in {@code partitionPathFields} * @return the generated partition path for the row */ - public static String getPartitionPathFromRow(Row row, List partitionPathFields, boolean hiveStylePartitioning, Map> partitionPathPositions) { + public static String getPartitionPathFromRow(Row row, List partitionPathFields, boolean hiveStylePartitioning, Map, DataType>> partitionPathPositions) { return IntStream.range(0, partitionPathFields.size()).mapToObj(idx -> { String field = partitionPathFields.get(idx); String val = null; - List fieldPositions = partitionPathPositions.get(field); + List fieldPositions = partitionPathPositions.get(field).getKey(); if (fieldPositions.size() == 1) { // simple Integer fieldPos = fieldPositions.get(0); // for partition path, if field is not found, index will be set to -1 @@ -118,7 +121,7 @@ public static String getPartitionPathFromRow(Row row, List partitionPath val = field + "=" + val; } } else { // nested - Object data = getNestedFieldVal(row, partitionPathPositions.get(field)); + Object data = getNestedFieldVal(row, partitionPathPositions.get(field).getKey()); data = convertToTimestampIfInstant(data); if (data.toString().contains(NULL_RECORDKEY_PLACEHOLDER) || data.toString().contains(EMPTY_RECORDKEY_PLACEHOLDER)) { val = hiveStylePartitioning ? field + "=" + HUDI_DEFAULT_PARTITION_PATH : HUDI_DEFAULT_PARTITION_PATH; @@ -130,20 +133,20 @@ public static String getPartitionPathFromRow(Row row, List partitionPath }).collect(Collectors.joining(DEFAULT_PARTITION_PATH_SEPARATOR)); } - public static String getPartitionPathFromInternalRow(InternalRow row, List partitionPathFields, boolean hiveStylePartitioning, - Map> partitionPathPositions, - Map> partitionPathDataTypes) { + public static String getPartitionPathFromInternalRow(InternalRow internalRow, List partitionPathFields, boolean hiveStylePartitioning, + Map, DataType>> partitionPathPositions) { return IntStream.range(0, partitionPathFields.size()).mapToObj(idx -> { String field = partitionPathFields.get(idx); String val = null; - List fieldPositions = partitionPathPositions.get(field); + List fieldPositions = partitionPathPositions.get(field).getKey(); + DataType dataType = partitionPathPositions.get(field).getValue(); if (fieldPositions.size() == 1) { // simple Integer fieldPos = fieldPositions.get(0); // for partition path, if field is not found, index will be set to -1 - if (fieldPos == -1 || row.isNullAt(fieldPos)) { + if (fieldPos == -1 || internalRow.isNullAt(fieldPos)) { val = HUDI_DEFAULT_PARTITION_PATH; } else { - Object value = row.get(fieldPos, partitionPathDataTypes.get(field).get(0)); + Object value = internalRow.get(fieldPos, dataType); if (value == null || value.toString().isEmpty()) { val = HUDI_DEFAULT_PARTITION_PATH; } else { @@ -180,22 +183,22 @@ public static Object getFieldValFromInternalRow(InternalRow internalRow, /** * Fetch the field value located at the positions requested for. - * + *

* The fetching logic recursively goes into the nested field based on the position list to get the field value. * For example, given the row [4357686,key1,2020-03-21,pi,[val1,10]] with the following schema, which has the fourth * field as a nested field, and positions list as [4,0], - * + *

* 0 = "StructField(timestamp,LongType,false)" * 1 = "StructField(_row_key,StringType,false)" * 2 = "StructField(ts_ms,StringType,false)" * 3 = "StructField(pii_col,StringType,false)" * 4 = "StructField(nested_col,StructType(StructField(prop1,StringType,false), StructField(prop2,LongType,false)),false)" - * + *

* the logic fetches the value from field nested_col.prop1. * If any level of the nested field is null, {@link KeyGenUtils#NULL_RECORDKEY_PLACEHOLDER} is returned. * If the field value is an empty String, {@link KeyGenUtils#EMPTY_RECORDKEY_PLACEHOLDER} is returned. * - * @param row instance of {@link Row} of interest + * @param row instance of {@link Row} of interest * @param positions tree style positions where the leaf node need to be fetched and returned * @return the field value as per the positions requested for. */ @@ -234,13 +237,14 @@ public static Object getNestedFieldVal(Row row, List positions) { * @param structType schema of interest * @param field field of interest for which the positions are requested for * @param isRecordKey {@code true} if the field requested for is a record key. {@code false} in case of a partition path. - * @return the positions of the field as per the struct type. + * @return the positions of the field as per the struct type and the leaf field's datatype. */ - public static List getNestedFieldIndices(StructType structType, String field, boolean isRecordKey) { + public static Pair, DataType> getFieldSchemaInfo(StructType structType, String field, boolean isRecordKey) { String[] slices = field.split("\\."); List positions = new ArrayList<>(); int index = 0; int totalCount = slices.length; + DataType leafFieldDataType = null; while (index < totalCount) { String slice = slices[index]; Option curIndexOpt = structType.getFieldIndex(slice); @@ -258,6 +262,9 @@ public static List getNestedFieldIndices(StructType structType, String } } structType = (StructType) nestedField.dataType(); + } else { + // leaf node. + leafFieldDataType = nestedField.dataType(); } } else { if (isRecordKey) { @@ -269,7 +276,7 @@ public static List getNestedFieldIndices(StructType structType, String } index++; } - return positions; + return Pair.of(positions, leafFieldDataType); } private static Object convertToTimestampIfInstant(Object data) { diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/SimpleKeyGenerator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/SimpleKeyGenerator.java index b84a8abdcc796..2f139a61eace8 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/SimpleKeyGenerator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/SimpleKeyGenerator.java @@ -65,15 +65,15 @@ public String getPartitionPath(GenericRecord record) { @Override public String getRecordKey(Row row) { - buildFieldPositionMapIfNeeded(row.schema()); - return RowKeyGeneratorHelper.getRecordKeyFromRow(row, getRecordKeyFields(), recordKeyPositions, false); + buildFieldSchemaInfoIfNeeded(row.schema()); + return RowKeyGeneratorHelper.getRecordKeyFromRow(row, getRecordKeyFields(), recordKeySchemaInfo, false); } @Override public String getPartitionPath(Row row) { - buildFieldPositionMapIfNeeded(row.schema()); + buildFieldSchemaInfoIfNeeded(row.schema()); return RowKeyGeneratorHelper.getPartitionPathFromRow(row, getPartitionPathFields(), - hiveStylePartitioning, partitionPathPositions); + hiveStylePartitioning, partitionPathSchemaInfo); } @Override diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/TimestampBasedKeyGenerator.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/TimestampBasedKeyGenerator.java index e3a5a3310524b..004753f2461ae 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/TimestampBasedKeyGenerator.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/TimestampBasedKeyGenerator.java @@ -29,8 +29,8 @@ import java.io.IOException; -import static org.apache.hudi.keygen.KeyGenUtils.HUDI_DEFAULT_PARTITION_PATH; import static org.apache.hudi.keygen.KeyGenUtils.EMPTY_RECORDKEY_PLACEHOLDER; +import static org.apache.hudi.keygen.KeyGenUtils.HUDI_DEFAULT_PARTITION_PATH; import static org.apache.hudi.keygen.KeyGenUtils.NULL_RECORDKEY_PLACEHOLDER; /** @@ -61,24 +61,24 @@ public String getPartitionPath(GenericRecord record) { @Override public String getRecordKey(Row row) { - buildFieldPositionMapIfNeeded(row.schema()); - return RowKeyGeneratorHelper.getRecordKeyFromRow(row, getRecordKeyFields(), recordKeyPositions, false); + buildFieldSchemaInfoIfNeeded(row.schema()); + return RowKeyGeneratorHelper.getRecordKeyFromRow(row, getRecordKeyFields(), recordKeySchemaInfo, false); } @Override public String getPartitionPath(Row row) { - buildFieldPositionMapIfNeeded(row.schema()); - Object partitionPathFieldVal = RowKeyGeneratorHelper.getNestedFieldVal(row, partitionPathPositions.get(getPartitionPathFields().get(0))); + buildFieldSchemaInfoIfNeeded(row.schema()); + Object partitionPathFieldVal = RowKeyGeneratorHelper.getNestedFieldVal(row, partitionPathSchemaInfo.get(getPartitionPathFields().get(0)).getKey()); return getTimestampBasedPartitionPath(partitionPathFieldVal); } @Override public String getPartitionPath(InternalRow internalRow, StructType structType) { - buildFieldDataTypesMapIfNeeded(structType); + buildFieldSchemaInfoIfNeeded(structType); validatePartitionFieldsForInternalRow(); Object partitionPathFieldVal = RowKeyGeneratorHelper.getFieldValFromInternalRow(internalRow, - partitionPathPositions.get(getPartitionPathFields().get(0)).get(0), - partitionPathDataTypes.get(getPartitionPathFields().get(0)).get(0)); + partitionPathSchemaInfo.get(getPartitionPathFields().get(0)).getKey().get(0), + partitionPathSchemaInfo.get(getPartitionPathFields().get(0)).getValue()); return getTimestampBasedPartitionPath(partitionPathFieldVal); } diff --git a/hudi-client/hudi-spark-client/src/test/scala/org/apache/hudi/keygen/TestRowGeneratorHelper.scala b/hudi-client/hudi-spark-client/src/test/scala/org/apache/hudi/keygen/TestRowGeneratorHelper.scala index d4b89e1c9ec2e..cd55e381e2d7d 100644 --- a/hudi-client/hudi-spark-client/src/test/scala/org/apache/hudi/keygen/TestRowGeneratorHelper.scala +++ b/hudi-client/hudi-spark-client/src/test/scala/org/apache/hudi/keygen/TestRowGeneratorHelper.scala @@ -19,11 +19,9 @@ package org.apache.hudi.keygen import java.sql.Timestamp - import org.apache.spark.sql.Row - import org.apache.hudi.keygen.RowKeyGeneratorHelper._ - +import org.apache.spark.sql.types.{DataType, DataTypes} import org.junit.jupiter.api.{Assertions, Test} import scala.collection.JavaConverters._ @@ -36,7 +34,9 @@ class TestRowGeneratorHelper { /** single plain partition */ val row1 = Row.fromSeq(Seq(1, "z3", 10.0, "20220108")) val ptField1 = List("dt").asJava - val ptPos1 = Map("dt" -> List(new Integer(3)).asJava).asJava + val mapValue = org.apache.hudi.common.util.collection.Pair.of(List(new Integer(3)).asJava, DataTypes.LongType) + val ptPos1 = Map("dt" -> mapValue).asJava + Assertions.assertEquals("20220108", getPartitionPathFromRow(row1, ptField1, false, ptPos1)) Assertions.assertEquals("dt=20220108", @@ -45,9 +45,9 @@ class TestRowGeneratorHelper { /** multiple plain partitions */ val row2 = Row.fromSeq(Seq(1, "z3", 10.0, "2022", "01", "08")) val ptField2 = List("year", "month", "day").asJava - val ptPos2 = Map("year" -> List(new Integer(3)).asJava, - "month" -> List(new Integer(4)).asJava, - "day" -> List(new Integer(5)).asJava + val ptPos2 = Map("year" -> org.apache.hudi.common.util.collection.Pair.of(List(new Integer(3)).asJava, DataTypes.StringType), + "month" -> org.apache.hudi.common.util.collection.Pair.of(List(new Integer(4)).asJava, DataTypes.StringType), + "day" -> org.apache.hudi.common.util.collection.Pair.of(List(new Integer(5)).asJava, DataTypes.StringType) ).asJava Assertions.assertEquals("2022/01/08", getPartitionPathFromRow(row2, ptField2, false, ptPos2)) @@ -58,8 +58,8 @@ class TestRowGeneratorHelper { val timestamp = Timestamp.valueOf("2020-01-08 10:00:00") val instant = timestamp.toInstant val ptField3 = List("event", "event_time").asJava - val ptPos3 = Map("event" -> List(new Integer(3)).asJava, - "event_time" -> List(new Integer(4)).asJava + val ptPos3 = Map("event" -> org.apache.hudi.common.util.collection.Pair.of(List(new Integer(3)).asJava, DataTypes.StringType), + "event_time" -> org.apache.hudi.common.util.collection.Pair.of(List(new Integer(4)).asJava, DataTypes.TimestampType) ).asJava // with timeStamp type @@ -79,7 +79,7 @@ class TestRowGeneratorHelper { /** mixed case with plain and nested partitions */ val nestedRow4 = Row.fromSeq(Seq(instant, "ad")) val ptField4 = List("event_time").asJava - val ptPos4 = Map("event_time" -> List(new Integer(3), new Integer(0)).asJava).asJava + val ptPos4 = Map("event_time" -> org.apache.hudi.common.util.collection.Pair.of(List(new Integer(3), new Integer(0)).asJava, DataTypes.TimestampType)).asJava // with instant type val row4 = Row.fromSeq(Seq(1, "z3", 10.0, nestedRow4, "click")) Assertions.assertEquals("2020-01-08 10:00:00.0", @@ -90,8 +90,8 @@ class TestRowGeneratorHelper { val nestedRow5 = Row.fromSeq(Seq(timestamp, "ad")) val ptField5 = List("event", "event_time").asJava val ptPos5 = Map( - "event_time" -> List(new Integer(3), new Integer(0)).asJava, - "event" -> List(new Integer(4)).asJava + "event_time" -> org.apache.hudi.common.util.collection.Pair.of(List(new Integer(3), new Integer(0)).asJava, DataTypes.TimestampType), + "event" -> org.apache.hudi.common.util.collection.Pair.of(List(new Integer(4)).asJava, DataTypes.StringType) ).asJava val row5 = Row.fromSeq(Seq(1, "z3", 10.0, nestedRow5, "click")) Assertions.assertEquals("click/2020-01-08 10:00:00.0", diff --git a/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java b/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java index f69d5683d1cfb..e2b586964ef4e 100644 --- a/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java @@ -400,7 +400,7 @@ public static GenericRecord rewriteRecordWithMetadata(GenericRecord genericRecor copyOldValueOrSetDefault(genericRecord, newRecord, f); } // do not preserve FILENAME_METADATA_FIELD - newRecord.put(HoodieRecord.FILENAME_METADATA_FIELD_POS, fileName); + newRecord.put(HoodieRecord.FILENAME_META_FIELD_POS, fileName); if (!GenericData.get().validate(newSchema, newRecord)) { throw new SchemaCompatibilityException( "Unable to validate the rewritten record " + genericRecord + " against schema " + newSchema); @@ -412,7 +412,7 @@ public static GenericRecord rewriteRecordWithMetadata(GenericRecord genericRecor public static GenericRecord rewriteEvolutionRecordWithMetadata(GenericRecord genericRecord, Schema newSchema, String fileName) { GenericRecord newRecord = HoodieAvroUtils.rewriteRecordWithNewSchema(genericRecord, newSchema, new HashMap<>()); // do not preserve FILENAME_METADATA_FIELD - newRecord.put(HoodieRecord.FILENAME_METADATA_FIELD_POS, fileName); + newRecord.put(HoodieRecord.FILENAME_META_FIELD_POS, fileName); return newRecord; } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieRecord.java b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieRecord.java index 0f21ae1bef185..e504b7b87dd9b 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieRecord.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieRecord.java @@ -42,8 +42,6 @@ public abstract class HoodieRecord implements Serializable { public static final String OPERATION_METADATA_FIELD = "_hoodie_operation"; public static final String HOODIE_IS_DELETED = "_hoodie_is_deleted"; - public static int FILENAME_METADATA_FIELD_POS = 4; - public static final List HOODIE_META_COLUMNS = CollectionUtils.createImmutableList(COMMIT_TIME_METADATA_FIELD, COMMIT_SEQNO_METADATA_FIELD, RECORD_KEY_METADATA_FIELD, PARTITION_PATH_METADATA_FIELD, FILENAME_METADATA_FIELD); @@ -59,6 +57,10 @@ public abstract class HoodieRecord implements Serializable { IntStream.range(0, HOODIE_META_COLUMNS.size()).mapToObj(idx -> Pair.of(HOODIE_META_COLUMNS.get(idx), idx)) .collect(Collectors.toMap(Pair::getKey, Pair::getValue)); + public static int RECORD_KEY_META_FIELD_POS = HOODIE_META_COLUMNS_NAME_TO_POS.get(RECORD_KEY_METADATA_FIELD); + public static int PARTITION_PATH_META_FIELD_POS = HOODIE_META_COLUMNS_NAME_TO_POS.get(PARTITION_PATH_METADATA_FIELD); + public static int FILENAME_META_FIELD_POS = HOODIE_META_COLUMNS_NAME_TO_POS.get(FILENAME_METADATA_FIELD); + /** * Identifies the record across the table. */ diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java index 251a990d87c04..6b10a62820e32 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java @@ -384,12 +384,14 @@ public void validateTableProperties(Properties properties) { throw new HoodieException(HoodieTableConfig.POPULATE_META_FIELDS.key() + " already disabled for the table. Can't be re-enabled back"); } - // Meta fields can be disabled only when {@code SimpleKeyGenerator} is used - if (!getTableConfig().populateMetaFields() - && !properties.getProperty(HoodieTableConfig.KEY_GENERATOR_CLASS_NAME.key(), "org.apache.hudi.keygen.SimpleKeyGenerator") - .equals("org.apache.hudi.keygen.SimpleKeyGenerator")) { - throw new HoodieException("Only simple key generator is supported when meta fields are disabled. KeyGenerator used : " - + properties.getProperty(HoodieTableConfig.KEY_GENERATOR_CLASS_NAME.key())); + // meta fields can be disabled only with SimpleKeyGenerator, NonPartitioned and ComplexKeyGen. + if (!getTableConfig().populateMetaFields()) { + String keyGenClass = properties.getProperty(HoodieTableConfig.KEY_GENERATOR_CLASS_NAME.key(), "org.apache.hudi.keygen.SimpleKeyGenerator"); + if (!keyGenClass.equals("org.apache.hudi.keygen.SimpleKeyGenerator") && !keyGenClass.equals("org.apache.hudi.keygen.NonpartitionedKeyGenerator") + && !keyGenClass.equals("org.apache.hudi.keygen.ComplexKeyGenerator")) { + throw new HoodieException("Only simple, non partitioned and complex key generator is supported when meta fields are disabled. KeyGenerator used : " + + properties.getProperty(HoodieTableConfig.KEY_GENERATOR_CLASS_NAME.key())); + } } } diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/HoodieDatasetBulkInsertHelper.java b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/HoodieDatasetBulkInsertHelper.java index b3acf444adb88..0ed7b23c7471a 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/HoodieDatasetBulkInsertHelper.java +++ b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/HoodieDatasetBulkInsertHelper.java @@ -22,7 +22,11 @@ import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.common.util.ReflectionUtils; import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.hive.NonPartitionedExtractor; import org.apache.hudi.keygen.BuiltinKeyGenerator; +import org.apache.hudi.keygen.ComplexKeyGenerator; +import org.apache.hudi.keygen.SimpleKeyGenerator; +import org.apache.hudi.keygen.constant.KeyGeneratorOptions; import org.apache.hudi.table.BulkInsertPartitioner; import org.apache.log4j.LogManager; @@ -57,18 +61,18 @@ public class HoodieDatasetBulkInsertHelper { /** * Prepares input hoodie spark dataset for bulk insert. It does the following steps. - * 1. Uses KeyGenerator to generate hoodie record keys and partition path. - * 2. Add hoodie columns to input spark dataset. - * 3. Reorders input dataset columns so that hoodie columns appear in the beginning. - * 4. Sorts input dataset by hoodie partition path and record key + * 1. Uses KeyGenerator to generate hoodie record keys and partition path. + * 2. Add hoodie columns to input spark dataset. + * 3. Reorders input dataset columns so that hoodie columns appear in the beginning. + * 4. Sorts input dataset by hoodie partition path and record key * * @param sqlContext SQL Context - * @param config Hoodie Write Config - * @param rows Spark Input dataset + * @param config Hoodie Write Config + * @param rows Spark Input dataset * @return hoodie dataset which is ready for bulk insert. */ public static Dataset prepareHoodieDatasetForBulkInsert(SQLContext sqlContext, - HoodieWriteConfig config, Dataset rows, String structName, String recordNamespace, + HoodieWriteConfig config, Dataset rows, String structName, String recordNamespace, BulkInsertPartitioner> bulkInsertPartitionerRows, boolean isGlobalIndex, boolean dropPartitionColumns) { List originalFields = @@ -77,27 +81,46 @@ public static Dataset prepareHoodieDatasetForBulkInsert(SQLContext sqlConte TypedProperties properties = new TypedProperties(); properties.putAll(config.getProps()); String keyGeneratorClass = properties.getString(DataSourceWriteOptions.KEYGENERATOR_CLASS_NAME().key()); + String recordKeyFields = properties.getString(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key()); + String partitionPathFields = properties.containsKey(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key()) + ? properties.getString(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key()) : ""; BuiltinKeyGenerator keyGenerator = (BuiltinKeyGenerator) ReflectionUtils.loadClass(keyGeneratorClass, properties); - String tableName = properties.getString(HoodieWriteConfig.TBL_NAME.key()); - String recordKeyUdfFn = RECORD_KEY_UDF_FN + tableName; - String partitionPathUdfFn = PARTITION_PATH_UDF_FN + tableName; - sqlContext.udf().register(recordKeyUdfFn, (UDF1) keyGenerator::getRecordKey, DataTypes.StringType); - sqlContext.udf().register(partitionPathUdfFn, (UDF1) keyGenerator::getPartitionPath, DataTypes.StringType); - - final Dataset rowDatasetWithRecordKeys = rows.withColumn(HoodieRecord.RECORD_KEY_METADATA_FIELD, - callUDF(recordKeyUdfFn, org.apache.spark.sql.functions.struct( - JavaConverters.collectionAsScalaIterableConverter(originalFields).asScala().toSeq()))); - - final Dataset rowDatasetWithRecordKeysAndPartitionPath = - rowDatasetWithRecordKeys.withColumn(HoodieRecord.PARTITION_PATH_METADATA_FIELD, - callUDF(partitionPathUdfFn, - org.apache.spark.sql.functions.struct( - JavaConverters.collectionAsScalaIterableConverter(originalFields).asScala().toSeq()))); + + Dataset rowDatasetWithRecordKeysAndPartitionPath; + if (keyGeneratorClass.equals(NonPartitionedExtractor.class.getName())) { + // for non partitioned, set partition path to empty. + rowDatasetWithRecordKeysAndPartitionPath = rows.withColumn(HoodieRecord.RECORD_KEY_METADATA_FIELD, functions.col(recordKeyFields)) + .withColumn(HoodieRecord.PARTITION_PATH_METADATA_FIELD, functions.lit("").cast(DataTypes.StringType)); + } else if (keyGeneratorClass.equals(SimpleKeyGenerator.class.getName()) + || (keyGeneratorClass.equals(ComplexKeyGenerator.class.getName()) && !recordKeyFields.contains(",") && !partitionPathFields.contains(",") + && (!partitionPathFields.contains("timestamp")))) { // incase of ComplexKeyGen, check partition path type. + // simple fields for both record key and partition path: can directly use withColumn + String partitionPathField = keyGeneratorClass.equals(SimpleKeyGenerator.class.getName()) ? partitionPathFields : + partitionPathFields.substring(partitionPathFields.indexOf(":") + 1); + rowDatasetWithRecordKeysAndPartitionPath = rows.withColumn(HoodieRecord.RECORD_KEY_METADATA_FIELD, functions.col(recordKeyFields).cast(DataTypes.StringType)) + .withColumn(HoodieRecord.PARTITION_PATH_METADATA_FIELD, functions.col(partitionPathField).cast(DataTypes.StringType)); + } else { + // use udf + String tableName = properties.getString(HoodieWriteConfig.TBL_NAME.key()); + String recordKeyUdfFn = RECORD_KEY_UDF_FN + tableName; + String partitionPathUdfFn = PARTITION_PATH_UDF_FN + tableName; + sqlContext.udf().register(recordKeyUdfFn, (UDF1) keyGenerator::getRecordKey, DataTypes.StringType); + sqlContext.udf().register(partitionPathUdfFn, (UDF1) keyGenerator::getPartitionPath, DataTypes.StringType); + + final Dataset rowDatasetWithRecordKeys = rows.withColumn(HoodieRecord.RECORD_KEY_METADATA_FIELD, + callUDF(recordKeyUdfFn, org.apache.spark.sql.functions.struct( + JavaConverters.collectionAsScalaIterableConverter(originalFields).asScala().toSeq()))); + rowDatasetWithRecordKeysAndPartitionPath = + rowDatasetWithRecordKeys.withColumn(HoodieRecord.PARTITION_PATH_METADATA_FIELD, + callUDF(partitionPathUdfFn, + org.apache.spark.sql.functions.struct( + JavaConverters.collectionAsScalaIterableConverter(originalFields).asScala().toSeq()))); + } // Add other empty hoodie fields which will be populated before writing to parquet. Dataset rowDatasetWithHoodieColumns = rowDatasetWithRecordKeysAndPartitionPath.withColumn(HoodieRecord.COMMIT_TIME_METADATA_FIELD, - functions.lit("").cast(DataTypes.StringType)) + functions.lit("").cast(DataTypes.StringType)) .withColumn(HoodieRecord.COMMIT_SEQNO_METADATA_FIELD, functions.lit("").cast(DataTypes.StringType)) .withColumn(HoodieRecord.FILENAME_METADATA_FIELD, @@ -106,7 +129,7 @@ public static Dataset prepareHoodieDatasetForBulkInsert(SQLContext sqlConte Dataset processedDf = rowDatasetWithHoodieColumns; if (dropPartitionColumns) { String partitionColumns = String.join(",", keyGenerator.getPartitionPathFields()); - for (String partitionField: keyGenerator.getPartitionPathFields()) { + for (String partitionField : keyGenerator.getPartitionPathFields()) { originalFields.remove(new Column(partitionField)); } processedDf = rowDatasetWithHoodieColumns.drop(partitionColumns); diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/internal/BulkInsertDataInternalWriterHelper.java b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/internal/BulkInsertDataInternalWriterHelper.java index 823de99fc3590..9a793c4227936 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/internal/BulkInsertDataInternalWriterHelper.java +++ b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/hudi/internal/BulkInsertDataInternalWriterHelper.java @@ -25,8 +25,8 @@ import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.exception.HoodieIOException; -import org.apache.hudi.io.storage.row.HoodieRowCreateHandleWithoutMetaFields; import org.apache.hudi.io.storage.row.HoodieRowCreateHandle; +import org.apache.hudi.io.storage.row.HoodieRowCreateHandleWithoutMetaFields; import org.apache.hudi.keygen.BuiltinKeyGenerator; import org.apache.hudi.keygen.NonpartitionedKeyGenerator; import org.apache.hudi.keygen.SimpleKeyGenerator; @@ -123,8 +123,7 @@ public void write(InternalRow record) throws IOException { try { String partitionPath = null; if (populateMetaFields) { // usual path where meta fields are pre populated in prep step. - partitionPath = record.getUTF8String( - HoodieRecord.HOODIE_META_COLUMNS_NAME_TO_POS.get(HoodieRecord.PARTITION_PATH_METADATA_FIELD)).toString(); + partitionPath = String.valueOf(record.getUTF8String(HoodieRecord.PARTITION_PATH_META_FIELD_POS)); } else { // if meta columns are disabled. if (!keyGeneratorOpt.isPresent()) { // NoPartitionerKeyGen partitionPath = ""; diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala index 4423874ab8e8c..89b2ecc1a9357 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala @@ -564,8 +564,7 @@ object HoodieSparkSqlWriter { throw new HoodieException("Bulk insert using row writer is not supported with current Spark version." + " To use row writer please switch to spark 2 or spark 3") } - val hoodieConfig = HoodieWriterUtils.convertMapToHoodieConfig(params) - val syncHiveSuccess = metaSync(sqlContext.sparkSession, hoodieConfig, basePath, df.schema) + val syncHiveSuccess = metaSync(sqlContext.sparkSession, writeConfig, basePath, df.schema) (syncHiveSuccess, common.util.Option.ofNullable(instantTime)) } diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieDatasetBulkInsertHelper.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieDatasetBulkInsertHelper.java index 9185d09aad1d0..6b617ca208185 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieDatasetBulkInsertHelper.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestHoodieDatasetBulkInsertHelper.java @@ -24,6 +24,9 @@ import org.apache.hudi.common.util.FileIOUtils; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.execution.bulkinsert.NonSortPartitionerWithRows; +import org.apache.hudi.keygen.ComplexKeyGenerator; +import org.apache.hudi.keygen.NonpartitionedKeyGenerator; +import org.apache.hudi.keygen.SimpleKeyGenerator; import org.apache.hudi.testutils.DataSourceTestUtils; import org.apache.hudi.testutils.HoodieClientTestBase; @@ -94,20 +97,36 @@ private void init() throws IOException { public void testBulkInsertHelperConcurrently() { IntStream.range(0, 2).parallel().forEach(i -> { if (i % 2 == 0) { - testBulkInsertHelperFor("_row_key"); + testBulkInsertHelperFor(SimpleKeyGenerator.class.getName(), "_row_key"); } else { - testBulkInsertHelperFor("ts"); + testBulkInsertHelperFor(SimpleKeyGenerator.class.getName(), "ts"); } }); } - @Test - public void testBulkInsertHelper() { - testBulkInsertHelperFor("_row_key"); + private static Stream provideKeyGenArgs() { + return Stream.of( + Arguments.of(SimpleKeyGenerator.class.getName()), + Arguments.of(ComplexKeyGenerator.class.getName()), + Arguments.of(NonpartitionedKeyGenerator.class.getName())); } - private void testBulkInsertHelperFor(String recordKey) { - HoodieWriteConfig config = getConfigBuilder(schemaStr).withProps(getPropsAllSet(recordKey)).combineInput(false, false).build(); + @ParameterizedTest + @MethodSource("provideKeyGenArgs") + public void testBulkInsertHelper(String keyGenClass) { + testBulkInsertHelperFor(keyGenClass, "_row_key"); + } + + private void testBulkInsertHelperFor(String keyGenClass, String recordKey) { + Map props = null; + if (keyGenClass.equals(SimpleKeyGenerator.class.getName())) { + props = getPropsAllSet(recordKey); + } else if (keyGenClass.equals(ComplexKeyGenerator.class.getName())) { + props = getPropsForComplexKeyGen(recordKey); + } else { // NonPartitioned key gen + props = getPropsForNonPartitionedKeyGen(recordKey); + } + HoodieWriteConfig config = getConfigBuilder(schemaStr).withProps(props).combineInput(false, false).build(); List rows = DataSourceTestUtils.generateRandomRows(10); Dataset dataset = sqlContext.createDataFrame(rows, structType); Dataset result = HoodieDatasetBulkInsertHelper.prepareHoodieDatasetForBulkInsert(sqlContext, config, dataset, "testStructName", @@ -121,9 +140,10 @@ private void testBulkInsertHelperFor(String recordKey) { assertTrue(resultSchema.fieldIndex(entry.getKey()) == entry.getValue()); } + boolean isNonPartitioned = keyGenClass.equals(NonpartitionedKeyGenerator.class.getName()); result.toJavaRDD().foreach(entry -> { assertTrue(entry.get(resultSchema.fieldIndex(HoodieRecord.RECORD_KEY_METADATA_FIELD)).equals(entry.getAs(recordKey).toString())); - assertTrue(entry.get(resultSchema.fieldIndex(HoodieRecord.PARTITION_PATH_METADATA_FIELD)).equals(entry.getAs("partition"))); + assertTrue(entry.get(resultSchema.fieldIndex(HoodieRecord.PARTITION_PATH_METADATA_FIELD)).equals(isNonPartitioned ? "" : entry.getAs("partition"))); assertTrue(entry.get(resultSchema.fieldIndex(HoodieRecord.COMMIT_SEQNO_METADATA_FIELD)).equals("")); assertTrue(entry.get(resultSchema.fieldIndex(HoodieRecord.COMMIT_TIME_METADATA_FIELD)).equals("")); assertTrue(entry.get(resultSchema.fieldIndex(HoodieRecord.FILENAME_METADATA_FIELD)).equals("")); @@ -253,6 +273,23 @@ private Map getProps(String recordKey, boolean setAll, boolean s return props; } + private Map getPropsForComplexKeyGen(String recordKey) { + Map props = new HashMap<>(); + props.put(DataSourceWriteOptions.KEYGENERATOR_CLASS_NAME().key(), ComplexKeyGenerator.class.getName()); + props.put(DataSourceWriteOptions.RECORDKEY_FIELD().key(), recordKey); + props.put(DataSourceWriteOptions.PARTITIONPATH_FIELD().key(), "simple:partition"); + props.put(HoodieWriteConfig.TBL_NAME.key(), recordKey + "_table"); + return props; + } + + private Map getPropsForNonPartitionedKeyGen(String recordKey) { + Map props = new HashMap<>(); + props.put(DataSourceWriteOptions.KEYGENERATOR_CLASS_NAME().key(), NonpartitionedKeyGenerator.class.getName()); + props.put(DataSourceWriteOptions.RECORDKEY_FIELD().key(), recordKey); + props.put(HoodieWriteConfig.TBL_NAME.key(), recordKey + "_table"); + return props; + } + @Test public void testNoPropsSet() { HoodieWriteConfig config = getConfigBuilder(schemaStr).build(); diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestComplexKeyGenerator.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestComplexKeyGenerator.java index 735277d959ee4..6719c2a3d6d23 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestComplexKeyGenerator.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestComplexKeyGenerator.java @@ -83,7 +83,7 @@ public void testNullRecordKeyFields() { public void testWrongRecordKeyField() { ComplexKeyGenerator keyGenerator = new ComplexKeyGenerator(getWrongRecordKeyFieldProps()); Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.getRecordKey(getRecord())); - Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldPositionMapIfNeeded(KeyGeneratorTestUtilities.structType)); + Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldSchemaInfoIfNeeded(KeyGeneratorTestUtilities.structType)); } @Test diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestGlobalDeleteRecordGenerator.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestGlobalDeleteRecordGenerator.java index 3bd6a60c4c1ea..f6c4c8a8b58cd 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestGlobalDeleteRecordGenerator.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestGlobalDeleteRecordGenerator.java @@ -68,7 +68,7 @@ public void testNullRecordKeyFields() { public void testWrongRecordKeyField() { GlobalDeleteKeyGenerator keyGenerator = new GlobalDeleteKeyGenerator(getWrongRecordKeyFieldProps()); Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.getRecordKey(getRecord())); - Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldPositionMapIfNeeded(KeyGeneratorTestUtilities.structType)); + Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldSchemaInfoIfNeeded(KeyGeneratorTestUtilities.structType)); } @Test @@ -78,7 +78,7 @@ public void testHappyFlow() { HoodieKey key = keyGenerator.getKey(record); Assertions.assertEquals(key.getRecordKey(), "_row_key:key1,pii_col:pi"); Assertions.assertEquals(key.getPartitionPath(), ""); - keyGenerator.buildFieldPositionMapIfNeeded(KeyGeneratorTestUtilities.structType); + keyGenerator.buildFieldSchemaInfoIfNeeded(KeyGeneratorTestUtilities.structType); Row row = KeyGeneratorTestUtilities.getRow(record); Assertions.assertEquals(keyGenerator.getRecordKey(row), "_row_key:key1,pii_col:pi"); Assertions.assertEquals(keyGenerator.getPartitionPath(row), ""); diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestNonpartitionedKeyGenerator.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestNonpartitionedKeyGenerator.java index 297b077794d56..75d9b7da74bc8 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestNonpartitionedKeyGenerator.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestNonpartitionedKeyGenerator.java @@ -94,7 +94,7 @@ public void testNullPartitionPathFields() { public void testWrongRecordKeyField() { NonpartitionedKeyGenerator keyGenerator = new NonpartitionedKeyGenerator(getWrongRecordKeyFieldProps()); Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.getRecordKey(getRecord())); - Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldPositionMapIfNeeded(KeyGeneratorTestUtilities.structType)); + Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldSchemaInfoIfNeeded(KeyGeneratorTestUtilities.structType)); } @Test diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestSimpleKeyGenerator.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestSimpleKeyGenerator.java index 7dea9e414e693..17cff3505ebef 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestSimpleKeyGenerator.java +++ b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/keygen/TestSimpleKeyGenerator.java @@ -100,7 +100,7 @@ public void testNullRecordKeyFields() { public void testWrongRecordKeyField() { SimpleKeyGenerator keyGenerator = new SimpleKeyGenerator(getWrongRecordKeyFieldProps()); Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.getRecordKey(getRecord())); - Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldPositionMapIfNeeded(KeyGeneratorTestUtilities.structType)); + Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldSchemaInfoIfNeeded(KeyGeneratorTestUtilities.structType)); } @Test @@ -116,7 +116,7 @@ public void testWrongPartitionPathField() { public void testComplexRecordKeyField() { SimpleKeyGenerator keyGenerator = new SimpleKeyGenerator(getComplexRecordKeyProp()); Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.getRecordKey(getRecord())); - Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldPositionMapIfNeeded(KeyGeneratorTestUtilities.structType)); + Assertions.assertThrows(HoodieKeyException.class, () -> keyGenerator.buildFieldSchemaInfoIfNeeded(KeyGeneratorTestUtilities.structType)); } @Test From 6fd21d0f1043d0a06b93332d86e63d7b708fcbe8 Mon Sep 17 00:00:00 2001 From: aliceyyan <104287562+aliceyyan@users.noreply.github.com> Date: Tue, 10 May 2022 10:25:13 +0800 Subject: [PATCH 13/52] =?UTF-8?q?[HUDI-4044]=20When=20reading=20data=20fro?= =?UTF-8?q?m=20flink-hudi=20to=20external=20storage,=20the=20=E2=80=A6=20(?= =?UTF-8?q?#5516)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: aliceyyan --- .../apache/hudi/source/IncrementalInputSplits.java | 2 +- .../org/apache/hudi/table/HoodieTableSource.java | 3 ++- .../table/format/mor/MergeOnReadInputSplit.java | 13 ++++++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/IncrementalInputSplits.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/IncrementalInputSplits.java index 02e0e253cf577..94eeefcd36df3 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/IncrementalInputSplits.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/IncrementalInputSplits.java @@ -226,7 +226,7 @@ public Result inputSplits( String basePath = fileSlice.getBaseFile().map(BaseFile::getPath).orElse(null); return new MergeOnReadInputSplit(cnt.getAndAdd(1), basePath, logPaths, endInstant, - metaClient.getBasePath(), maxCompactionMemoryInBytes, mergeType, instantRange); + metaClient.getBasePath(), maxCompactionMemoryInBytes, mergeType, instantRange, fileSlice.getFileId()); }).collect(Collectors.toList())) .flatMap(Collection::stream) .collect(Collectors.toList()); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java index d00eb3e3ec700..da4abf0a96e60 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java @@ -181,6 +181,7 @@ public DataStream produceDataStream(StreamExecutionEnvironment execEnv) OneInputStreamOperatorFactory factory = StreamReadOperator.factory((MergeOnReadInputFormat) inputFormat); SingleOutputStreamOperator source = execEnv.addSource(monitoringFunction, getSourceOperatorName("split_monitor")) .setParallelism(1) + .keyBy(inputSplit -> inputSplit.getFileId()) .transform("split_reader", typeInfo, factory) .setParallelism(conf.getInteger(FlinkOptions.READ_TASKS)); return new DataStreamSource<>(source); @@ -316,7 +317,7 @@ private List buildFileIndex() { .map(logFile -> logFile.getPath().toString()) .collect(Collectors.toList())); return new MergeOnReadInputSplit(cnt.getAndAdd(1), basePath, logPaths, latestCommit, - metaClient.getBasePath(), maxCompactionMemoryInBytes, mergeType, null); + metaClient.getBasePath(), maxCompactionMemoryInBytes, mergeType, null, fileSlice.getFileId()); }).collect(Collectors.toList())) .flatMap(Collection::stream) .collect(Collectors.toList()); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputSplit.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputSplit.java index 156622c303519..cde646e41f035 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputSplit.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputSplit.java @@ -43,6 +43,7 @@ public class MergeOnReadInputSplit implements InputSplit { private final long maxCompactionMemoryInBytes; private final String mergeType; private final Option instantRange; + private String fileId; // for streaming reader to record the consumed offset, // which is the start of next round reading. @@ -56,7 +57,8 @@ public MergeOnReadInputSplit( String tablePath, long maxCompactionMemoryInBytes, String mergeType, - @Nullable InstantRange instantRange) { + @Nullable InstantRange instantRange, + String fileId) { this.splitNum = splitNum; this.basePath = Option.ofNullable(basePath); this.logPaths = logPaths; @@ -65,6 +67,15 @@ public MergeOnReadInputSplit( this.maxCompactionMemoryInBytes = maxCompactionMemoryInBytes; this.mergeType = mergeType; this.instantRange = Option.ofNullable(instantRange); + this.fileId = fileId; + } + + public String getFileId() { + return fileId; + } + + public void setFileId(String fileId) { + this.fileId = fileId; } public Option getBasePath() { From 4258a715174e0a97271112148ab20ef9307e2bd8 Mon Sep 17 00:00:00 2001 From: Lanyuanxiaoyao Date: Wed, 11 May 2022 06:45:53 +0800 Subject: [PATCH 14/52] [HUDI-4003] Try to read all the log file to parse schema (#5473) --- .../common/table/TableSchemaResolver.java | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/TableSchemaResolver.java b/hudi-common/src/main/java/org/apache/hudi/common/table/TableSchemaResolver.java index f178a23eeec7a..b76f71161d320 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/TableSchemaResolver.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/TableSchemaResolver.java @@ -61,6 +61,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Iterator; import java.util.List; import static org.apache.hudi.avro.AvroSchemaUtils.appendFieldsToSchema; @@ -98,8 +99,8 @@ private MessageType getTableParquetSchemaFromDataFile() { // For COW table, the file has data written must be in parquet or orc format currently. if (instantAndCommitMetadata.isPresent()) { HoodieCommitMetadata commitMetadata = instantAndCommitMetadata.get().getRight(); - String filePath = commitMetadata.getFileIdAndFullPaths(metaClient.getBasePath()).values().stream().findAny().get(); - return readSchemaFromBaseFile(filePath); + Iterator filePaths = commitMetadata.getFileIdAndFullPaths(metaClient.getBasePath()).values().iterator(); + return fetchSchemaFromFiles(filePaths); } else { throw new IllegalArgumentException("Could not find any data file written for commit, " + "so could not get schema for table " + metaClient.getBasePath()); @@ -109,13 +110,8 @@ private MessageType getTableParquetSchemaFromDataFile() { // Determine the file format based on the file name, and then extract schema from it. if (instantAndCommitMetadata.isPresent()) { HoodieCommitMetadata commitMetadata = instantAndCommitMetadata.get().getRight(); - String filePath = commitMetadata.getFileIdAndFullPaths(metaClient.getBasePath()).values().stream().findAny().get(); - if (filePath.contains(HoodieFileFormat.HOODIE_LOG.getFileExtension())) { - // this is a log file - return readSchemaFromLogFile(new Path(filePath)); - } else { - return readSchemaFromBaseFile(filePath); - } + Iterator filePaths = commitMetadata.getFileIdAndFullPaths(metaClient.getBasePath()).values().iterator(); + return fetchSchemaFromFiles(filePaths); } else { throw new IllegalArgumentException("Could not find any data file written for commit, " + "so could not get schema for table " + metaClient.getBasePath()); @@ -129,6 +125,20 @@ private MessageType getTableParquetSchemaFromDataFile() { } } + private MessageType fetchSchemaFromFiles(Iterator filePaths) throws IOException { + MessageType type = null; + while (filePaths.hasNext() && type == null) { + String filePath = filePaths.next(); + if (filePath.contains(HoodieFileFormat.HOODIE_LOG.getFileExtension())) { + // this is a log file + type = readSchemaFromLogFile(new Path(filePath)); + } else { + type = readSchemaFromBaseFile(filePath); + } + } + return type; + } + private MessageType readSchemaFromBaseFile(String filePath) throws IOException { if (filePath.contains(HoodieFileFormat.PARQUET.getFileExtension())) { // this is a parquet file From 4a8589f22254851b55dfce06bd91db57659df8c9 Mon Sep 17 00:00:00 2001 From: Alexey Kudinkin Date: Wed, 11 May 2022 05:08:31 -0700 Subject: [PATCH 15/52] [HUDI-4038] Avoid calling `getDataSize` after every record written (#5497) - getDataSize has non-trivial overhead in the current ParquetWriter impl, requiring traversal of already composed Column Groups in memory. Instead we can sample these calls to getDataSize to amortize its cost. Co-authored-by: sivabalan --- .../org/apache/hudi/cli/SparkHelpers.scala | 5 +- ...iter.java => HoodieAvroParquetWriter.java} | 61 +++---------- .../io/storage/HoodieBaseParquetWriter.java | 87 +++++++++++++++++++ .../hudi/io/storage/HoodieFileWriter.java | 8 +- .../io/storage/HoodieFileWriterFactory.java | 2 +- .../hudi/io/storage/HoodieHFileWriter.java | 2 +- .../hudi/io/storage/HoodieOrcWriter.java | 2 +- .../testutils/HoodieWriteableTestTable.java | 9 +- .../TestJavaCopyOnWriteActionExecutor.java | 5 +- .../row/HoodieInternalRowParquetWriter.java | 33 +------ .../storage/TestHoodieFileWriterFactory.java | 2 +- .../commit/TestCopyOnWriteActionExecutor.java | 5 +- 12 files changed, 124 insertions(+), 97 deletions(-) rename hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/{HoodieParquetWriter.java => HoodieAvroParquetWriter.java} (51%) create mode 100644 hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieBaseParquetWriter.java diff --git a/hudi-cli/src/main/scala/org/apache/hudi/cli/SparkHelpers.scala b/hudi-cli/src/main/scala/org/apache/hudi/cli/SparkHelpers.scala index 3802bb46a0f5b..fbfc1d8ec902e 100644 --- a/hudi-cli/src/main/scala/org/apache/hudi/cli/SparkHelpers.scala +++ b/hudi-cli/src/main/scala/org/apache/hudi/cli/SparkHelpers.scala @@ -28,7 +28,7 @@ import org.apache.hudi.common.bloom.{BloomFilter, BloomFilterFactory} import org.apache.hudi.common.model.{HoodieFileFormat, HoodieRecord} import org.apache.hudi.common.util.BaseFileUtils import org.apache.hudi.config.{HoodieIndexConfig, HoodieStorageConfig} -import org.apache.hudi.io.storage.{HoodieAvroParquetConfig, HoodieParquetWriter} +import org.apache.hudi.io.storage.{HoodieAvroParquetConfig, HoodieAvroParquetWriter} import org.apache.parquet.avro.AvroSchemaConverter import org.apache.parquet.hadoop.metadata.CompressionCodecName import org.apache.spark.sql.{DataFrame, SQLContext} @@ -50,8 +50,7 @@ object SparkHelpers { // Add current classLoad for config, if not will throw classNotFound of 'HoodieWrapperFileSystem'. parquetConfig.getHadoopConf().setClassLoader(Thread.currentThread.getContextClassLoader) - val writer = new HoodieParquetWriter[HoodieJsonPayload, IndexedRecord](instantTime, destinationFile, parquetConfig, schema, new SparkTaskContextSupplier(), - true) + val writer = new HoodieAvroParquetWriter[IndexedRecord](destinationFile, parquetConfig, instantTime, new SparkTaskContextSupplier(), true) for (rec <- sourceRecords) { val key: String = rec.get(HoodieRecord.RECORD_KEY_METADATA_FIELD).toString if (!keysToSkip.contains(key)) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieParquetWriter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieAvroParquetWriter.java similarity index 51% rename from hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieParquetWriter.java rename to hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieAvroParquetWriter.java index 095cacc144a9a..6f7940d04d0f2 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieParquetWriter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieAvroParquetWriter.java @@ -18,22 +18,15 @@ package org.apache.hudi.io.storage; -import org.apache.avro.Schema; import org.apache.avro.generic.IndexedRecord; import org.apache.hadoop.fs.Path; import org.apache.hudi.avro.HoodieAvroWriteSupport; import org.apache.hudi.common.engine.TaskContextSupplier; -import org.apache.hudi.common.fs.FSUtils; -import org.apache.hudi.common.fs.HoodieWrapperFileSystem; import org.apache.hudi.common.model.HoodieKey; -import org.apache.hudi.common.model.HoodieRecordPayload; -import org.apache.parquet.hadoop.ParquetFileWriter; -import org.apache.parquet.hadoop.ParquetWriter; import javax.annotation.concurrent.NotThreadSafe; import java.io.IOException; -import java.util.concurrent.atomic.AtomicLong; /** * HoodieParquetWriter extends the ParquetWriter to help limit the size of underlying file. Provides a way to check if @@ -42,45 +35,24 @@ * ATTENTION: HoodieParquetWriter is not thread safe and developer should take care of the order of write and close */ @NotThreadSafe -public class HoodieParquetWriter - extends ParquetWriter implements HoodieFileWriter { +public class HoodieAvroParquetWriter + extends HoodieBaseParquetWriter + implements HoodieFileWriter { - private static AtomicLong recordIndex = new AtomicLong(1); - - private final Path file; - private final HoodieWrapperFileSystem fs; - private final long maxFileSize; - private final HoodieAvroWriteSupport writeSupport; + private final String fileName; private final String instantTime; private final TaskContextSupplier taskContextSupplier; private final boolean populateMetaFields; + private final HoodieAvroWriteSupport writeSupport; - public HoodieParquetWriter(String instantTime, - Path file, - HoodieAvroParquetConfig parquetConfig, - Schema schema, - TaskContextSupplier taskContextSupplier, - boolean populateMetaFields) throws IOException { - super(HoodieWrapperFileSystem.convertToHoodiePath(file, parquetConfig.getHadoopConf()), - ParquetFileWriter.Mode.CREATE, - parquetConfig.getWriteSupport(), - parquetConfig.getCompressionCodecName(), - parquetConfig.getBlockSize(), - parquetConfig.getPageSize(), - parquetConfig.getPageSize(), - parquetConfig.dictionaryEnabled(), - DEFAULT_IS_VALIDATING_ENABLED, - DEFAULT_WRITER_VERSION, - FSUtils.registerFileSystem(file, parquetConfig.getHadoopConf())); - this.file = HoodieWrapperFileSystem.convertToHoodiePath(file, parquetConfig.getHadoopConf()); - this.fs = - (HoodieWrapperFileSystem) this.file.getFileSystem(FSUtils.registerFileSystem(file, parquetConfig.getHadoopConf())); - // We cannot accurately measure the snappy compressed output file size. We are choosing a - // conservative 10% - // TODO - compute this compression ratio dynamically by looking at the bytes written to the - // stream and the actual file size reported by HDFS - this.maxFileSize = parquetConfig.getMaxFileSize() - + Math.round(parquetConfig.getMaxFileSize() * parquetConfig.getCompressionRatio()); + @SuppressWarnings({"unchecked", "rawtypes"}) + public HoodieAvroParquetWriter(Path file, + HoodieAvroParquetConfig parquetConfig, + String instantTime, + TaskContextSupplier taskContextSupplier, + boolean populateMetaFields) throws IOException { + super(file, (HoodieBaseParquetConfig) parquetConfig); + this.fileName = file.getName(); this.writeSupport = parquetConfig.getWriteSupport(); this.instantTime = instantTime; this.taskContextSupplier = taskContextSupplier; @@ -91,7 +63,7 @@ public HoodieParquetWriter(String instantTime, public void writeAvroWithMetadata(HoodieKey key, R avroRecord) throws IOException { if (populateMetaFields) { prepRecordWithMetadata(key, avroRecord, instantTime, - taskContextSupplier.getPartitionIdSupplier().get(), recordIndex, file.getName()); + taskContextSupplier.getPartitionIdSupplier().get(), getWrittenRecordCount(), fileName); super.write(avroRecord); writeSupport.add(key.getRecordKey()); } else { @@ -99,11 +71,6 @@ public void writeAvroWithMetadata(HoodieKey key, R avroRecord) throws IOExceptio } } - @Override - public boolean canWrite() { - return getDataSize() < maxFileSize; - } - @Override public void writeAvro(String key, IndexedRecord object) throws IOException { super.write(object); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieBaseParquetWriter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieBaseParquetWriter.java new file mode 100644 index 0000000000000..b4aa6de1bd577 --- /dev/null +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieBaseParquetWriter.java @@ -0,0 +1,87 @@ +/* + * 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.hudi.io.storage; + +import org.apache.hadoop.fs.Path; +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.fs.HoodieWrapperFileSystem; +import org.apache.parquet.hadoop.ParquetFileWriter; +import org.apache.parquet.hadoop.ParquetWriter; +import org.apache.parquet.hadoop.api.WriteSupport; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Base class of Hudi's custom {@link ParquetWriter} implementations + * + * @param target type of the object being written into Parquet files (for ex, + * {@code IndexedRecord}, {@code InternalRow}) + */ +public abstract class HoodieBaseParquetWriter extends ParquetWriter { + + private static final int WRITTEN_RECORDS_THRESHOLD_FOR_FILE_SIZE_CHECK = 1000; + + private final AtomicLong writtenRecordCount = new AtomicLong(0); + private final long maxFileSize; + private long lastCachedDataSize = -1; + + public HoodieBaseParquetWriter(Path file, + HoodieBaseParquetConfig> parquetConfig) throws IOException { + super(HoodieWrapperFileSystem.convertToHoodiePath(file, parquetConfig.getHadoopConf()), + ParquetFileWriter.Mode.CREATE, + parquetConfig.getWriteSupport(), + parquetConfig.getCompressionCodecName(), + parquetConfig.getBlockSize(), + parquetConfig.getPageSize(), + parquetConfig.getPageSize(), + parquetConfig.dictionaryEnabled(), + DEFAULT_IS_VALIDATING_ENABLED, + DEFAULT_WRITER_VERSION, + FSUtils.registerFileSystem(file, parquetConfig.getHadoopConf())); + + // We cannot accurately measure the snappy compressed output file size. We are choosing a + // conservative 10% + // TODO - compute this compression ratio dynamically by looking at the bytes written to the + // stream and the actual file size reported by HDFS + this.maxFileSize = parquetConfig.getMaxFileSize() + + Math.round(parquetConfig.getMaxFileSize() * parquetConfig.getCompressionRatio()); + } + + public boolean canWrite() { + // TODO we can actually do evaluation more accurately: + // if we cache last data size check, since we account for how many records + // were written we can accurately project avg record size, and therefore + // estimate how many more records we can write before cut off + if (lastCachedDataSize == -1 || getWrittenRecordCount() % WRITTEN_RECORDS_THRESHOLD_FOR_FILE_SIZE_CHECK == 0) { + lastCachedDataSize = getDataSize(); + } + return lastCachedDataSize < maxFileSize; + } + + @Override + public void write(R object) throws IOException { + super.write(object); + writtenRecordCount.incrementAndGet(); + } + + protected long getWrittenRecordCount() { + return writtenRecordCount.get(); + } +} diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieFileWriter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieFileWriter.java index 1d1dd5c9bae6d..cce59d3b6624a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieFileWriter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieFileWriter.java @@ -18,14 +18,12 @@ package org.apache.hudi.io.storage; -import java.util.concurrent.atomic.AtomicLong; import org.apache.avro.generic.GenericRecord; +import org.apache.avro.generic.IndexedRecord; import org.apache.hudi.avro.HoodieAvroUtils; import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.model.HoodieRecord; -import org.apache.avro.generic.IndexedRecord; - import java.io.IOException; public interface HoodieFileWriter { @@ -38,8 +36,8 @@ public interface HoodieFileWriter { void writeAvro(String key, R oldRecord) throws IOException; - default void prepRecordWithMetadata(HoodieKey key, R avroRecord, String instantTime, Integer partitionId, AtomicLong recordIndex, String fileName) { - String seqId = HoodieRecord.generateSequenceId(instantTime, partitionId, recordIndex.getAndIncrement()); + default void prepRecordWithMetadata(HoodieKey key, R avroRecord, String instantTime, Integer partitionId, long recordIndex, String fileName) { + String seqId = HoodieRecord.generateSequenceId(instantTime, partitionId, recordIndex); HoodieAvroUtils.addHoodieKeyToRecord((GenericRecord) avroRecord, key.getRecordKey(), key.getPartitionPath(), fileName); HoodieAvroUtils.addCommitMetadataToRecord((GenericRecord) avroRecord, instantTime, seqId); return; diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieFileWriterFactory.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieFileWriterFactory.java index 7d0c307dbfe53..ffdff25738ed7 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieFileWriterFactory.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieFileWriterFactory.java @@ -81,7 +81,7 @@ private static HoodieFi config.getParquetBlockSize(), config.getParquetPageSize(), config.getParquetMaxFileSize(), conf, config.getParquetCompressionRatio(), config.parquetDictionaryEnabled()); - return new HoodieParquetWriter<>(instantTime, path, parquetConfig, schema, taskContextSupplier, populateMetaFields); + return new HoodieAvroParquetWriter<>(path, parquetConfig, instantTime, taskContextSupplier, populateMetaFields); } static HoodieFileWriter newHFileFileWriter( diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieHFileWriter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieHFileWriter.java index 91f79cefa23d2..f065608b29bd5 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieHFileWriter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieHFileWriter.java @@ -113,7 +113,7 @@ public HoodieHFileWriter(String instantTime, Path file, HoodieHFileConfig hfileC public void writeAvroWithMetadata(HoodieKey key, R avroRecord) throws IOException { if (populateMetaFields) { prepRecordWithMetadata(key, avroRecord, instantTime, - taskContextSupplier.getPartitionIdSupplier().get(), recordIndex, file.getName()); + taskContextSupplier.getPartitionIdSupplier().get(), recordIndex.getAndIncrement(), file.getName()); writeAvro(key.getRecordKey(), avroRecord); } else { writeAvro(key.getRecordKey(), avroRecord); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieOrcWriter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieOrcWriter.java index 17d5ead3efb79..a532ac66c987c 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieOrcWriter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/storage/HoodieOrcWriter.java @@ -97,7 +97,7 @@ public HoodieOrcWriter(String instantTime, Path file, HoodieOrcConfig config, Sc @Override public void writeAvroWithMetadata(HoodieKey key, R avroRecord) throws IOException { prepRecordWithMetadata(key, avroRecord, instantTime, - taskContextSupplier.getPartitionIdSupplier().get(), RECORD_INDEX, file.getName()); + taskContextSupplier.getPartitionIdSupplier().get(), RECORD_INDEX.getAndIncrement(), file.getName()); writeAvro(key.getRecordKey(), avroRecord); } diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/HoodieWriteableTestTable.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/HoodieWriteableTestTable.java index 007ad290aadd9..6b847d4960fb6 100644 --- a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/HoodieWriteableTestTable.java +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/testutils/HoodieWriteableTestTable.java @@ -41,7 +41,7 @@ import org.apache.hudi.io.storage.HoodieAvroParquetConfig; import org.apache.hudi.io.storage.HoodieOrcConfig; import org.apache.hudi.io.storage.HoodieOrcWriter; -import org.apache.hudi.io.storage.HoodieParquetWriter; +import org.apache.hudi.io.storage.HoodieAvroParquetWriter; import org.apache.hudi.metadata.HoodieTableMetadataWriter; import org.apache.avro.Schema; @@ -113,10 +113,9 @@ public Path withInserts(String partition, String fileId, List reco HoodieAvroParquetConfig config = new HoodieAvroParquetConfig(writeSupport, CompressionCodecName.GZIP, ParquetWriter.DEFAULT_BLOCK_SIZE, ParquetWriter.DEFAULT_PAGE_SIZE, 120 * 1024 * 1024, new Configuration(), Double.parseDouble(HoodieStorageConfig.PARQUET_COMPRESSION_RATIO_FRACTION.defaultValue())); - try (HoodieParquetWriter writer = new HoodieParquetWriter( - currentInstantTime, - new Path(Paths.get(basePath, partition, fileName).toString()), - config, schema, contextSupplier, populateMetaFields)) { + try (HoodieAvroParquetWriter writer = new HoodieAvroParquetWriter<>( + new Path(Paths.get(basePath, partition, fileName).toString()), config, currentInstantTime, + contextSupplier, populateMetaFields)) { int seqId = 1; for (HoodieRecord record : records) { GenericRecord avroRecord = (GenericRecord) ((HoodieRecordPayload) record.getData()).getInsertValue(schema).get(); diff --git a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaCopyOnWriteActionExecutor.java b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaCopyOnWriteActionExecutor.java index 518414d614e8f..7b0c4dbdf2a96 100644 --- a/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaCopyOnWriteActionExecutor.java +++ b/hudi-client/hudi-java-client/src/test/java/org/apache/hudi/table/action/commit/TestJavaCopyOnWriteActionExecutor.java @@ -379,7 +379,7 @@ public void testFileSizeUpsertRecords() throws Exception { List records = new ArrayList<>(); // Approx 1150 records are written for block size of 64KB - for (int i = 0; i < 2000; i++) { + for (int i = 0; i < 2050; i++) { String recordStr = "{\"_row_key\":\"" + UUID.randomUUID().toString() + "\",\"time\":\"2016-01-31T03:16:41.415Z\",\"number\":" + i + "}"; RawTripTestPayload rowChange = new RawTripTestPayload(recordStr); @@ -402,7 +402,8 @@ public void testFileSizeUpsertRecords() throws Exception { counts++; } } - assertEquals(5, counts, "If the number of records are more than 1150, then there should be a new file"); + // we check canWrite only once every 1000 records. and so 2 files with 1000 records and 3rd file with 50 records. + assertEquals(3, counts, "If the number of records are more than 1150, then there should be a new file"); } @Test diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieInternalRowParquetWriter.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieInternalRowParquetWriter.java index 7e64d83879f05..5a0a60ea07500 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieInternalRowParquetWriter.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieInternalRowParquetWriter.java @@ -19,11 +19,7 @@ package org.apache.hudi.io.storage.row; import org.apache.hadoop.fs.Path; -import org.apache.hudi.common.fs.FSUtils; -import org.apache.hudi.common.fs.HoodieWrapperFileSystem; - -import org.apache.parquet.hadoop.ParquetFileWriter; -import org.apache.parquet.hadoop.ParquetWriter; +import org.apache.hudi.io.storage.HoodieBaseParquetWriter; import org.apache.spark.sql.catalyst.InternalRow; import java.io.IOException; @@ -31,32 +27,16 @@ /** * Parquet's impl of {@link HoodieInternalRowFileWriter} to write {@link InternalRow}s. */ -public class HoodieInternalRowParquetWriter extends ParquetWriter +public class HoodieInternalRowParquetWriter extends HoodieBaseParquetWriter implements HoodieInternalRowFileWriter { - private final Path file; - private final HoodieWrapperFileSystem fs; - private final long maxFileSize; private final HoodieRowParquetWriteSupport writeSupport; public HoodieInternalRowParquetWriter(Path file, HoodieRowParquetConfig parquetConfig) throws IOException { - super(HoodieWrapperFileSystem.convertToHoodiePath(file, parquetConfig.getHadoopConf()), - ParquetFileWriter.Mode.CREATE, parquetConfig.getWriteSupport(), parquetConfig.getCompressionCodecName(), - parquetConfig.getBlockSize(), parquetConfig.getPageSize(), parquetConfig.getPageSize(), - DEFAULT_IS_DICTIONARY_ENABLED, DEFAULT_IS_VALIDATING_ENABLED, - DEFAULT_WRITER_VERSION, FSUtils.registerFileSystem(file, parquetConfig.getHadoopConf())); - this.file = HoodieWrapperFileSystem.convertToHoodiePath(file, parquetConfig.getHadoopConf()); - this.fs = (HoodieWrapperFileSystem) this.file.getFileSystem(FSUtils.registerFileSystem(file, - parquetConfig.getHadoopConf())); - this.maxFileSize = parquetConfig.getMaxFileSize() - + Math.round(parquetConfig.getMaxFileSize() * parquetConfig.getCompressionRatio()); - this.writeSupport = parquetConfig.getWriteSupport(); - } + super(file, parquetConfig); - @Override - public boolean canWrite() { - return getDataSize() < maxFileSize; + this.writeSupport = parquetConfig.getWriteSupport(); } @Override @@ -69,9 +49,4 @@ public void writeRow(String key, InternalRow row) throws IOException { public void writeRow(InternalRow row) throws IOException { super.write(row); } - - @Override - public void close() throws IOException { - super.close(); - } } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/TestHoodieFileWriterFactory.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/TestHoodieFileWriterFactory.java index b7f34ab2b24d8..66016305d7ad3 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/TestHoodieFileWriterFactory.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/io/storage/TestHoodieFileWriterFactory.java @@ -49,7 +49,7 @@ public void testGetFileWriter() throws IOException { SparkTaskContextSupplier supplier = new SparkTaskContextSupplier(); HoodieFileWriter parquetWriter = HoodieFileWriterFactory.getFileWriter(instantTime, parquetPath, table, cfg, HoodieTestDataGenerator.AVRO_SCHEMA, supplier); - assertTrue(parquetWriter instanceof HoodieParquetWriter); + assertTrue(parquetWriter instanceof HoodieAvroParquetWriter); // hfile format. final Path hfilePath = new Path(basePath + "/partition/path/f1_1-0-1_000.hfile"); diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestCopyOnWriteActionExecutor.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestCopyOnWriteActionExecutor.java index 8114daa30f763..9574d35a65410 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestCopyOnWriteActionExecutor.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestCopyOnWriteActionExecutor.java @@ -419,7 +419,7 @@ public void testFileSizeUpsertRecords() throws Exception { List records = new ArrayList<>(); // Approx 1150 records are written for block size of 64KB - for (int i = 0; i < 2000; i++) { + for (int i = 0; i < 2050; i++) { String recordStr = "{\"_row_key\":\"" + UUID.randomUUID().toString() + "\",\"time\":\"2016-01-31T03:16:41.415Z\",\"number\":" + i + "}"; RawTripTestPayload rowChange = new RawTripTestPayload(recordStr); @@ -441,7 +441,8 @@ public void testFileSizeUpsertRecords() throws Exception { counts++; } } - assertEquals(5, counts, "If the number of records are more than 1150, then there should be a new file"); + // we check canWrite only once every 1000 records. and so 2 files with 1000 records and 3rd file with 50 records. + assertEquals(3, counts, "If the number of records are more than 1150, then there should be a new file"); } @Test From 7f0c1f3ddfe5e80453b0e34f7938824106a7e25e Mon Sep 17 00:00:00 2001 From: Jin Xing Date: Wed, 11 May 2022 22:28:58 +0800 Subject: [PATCH 16/52] [HUDI-4079] Supports showing table comment for hudi with spark3 (#5546) --- .../spark/sql/hudi/TestCreateTable.scala | 23 +++++++++++++++ .../sql/hudi/catalog/HoodieCatalog.scala | 28 ++++++++++--------- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala index e7910fa115852..69147272dabe0 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala @@ -18,6 +18,7 @@ package org.apache.spark.sql.hudi import org.apache.hudi.DataSourceWriteOptions._ +import org.apache.hudi.HoodieSparkUtils import org.apache.hudi.common.model.HoodieRecord import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient} import org.apache.hudi.config.HoodieWriteConfig @@ -641,4 +642,26 @@ class TestCreateTable extends HoodieSparkSqlTestBase { |""".stripMargin ) } + + if (HoodieSparkUtils.gteqSpark3_2) { + test("Test create table with comment") { + val tableName = generateTableName + spark.sql( + s""" + | create table $tableName ( + | id int, + | name string, + | price double, + | ts long + | ) using hudi + | comment "This is a simple hudi table" + | tblproperties ( + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + val shown = spark.sql(s"show create table $tableName").head.getString(0) + assertResult(true)(shown.contains("COMMENT 'This is a simple hudi table'")) + } + } } diff --git a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala index 82ea356215ca5..5f4572dcc9388 100644 --- a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala +++ b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala @@ -89,19 +89,21 @@ class HoodieCatalog extends DelegatingCatalogExtension } override def loadTable(ident: Identifier): Table = { - try { - super.loadTable(ident) match { - case v1: V1Table if sparkAdapter.isHoodieTable(v1.catalogTable) => - HoodieInternalV2Table( - spark, - v1.catalogTable.location.toString, - catalogTable = Some(v1.catalogTable), - tableIdentifier = Some(ident.toString)) - case o => o - } - } catch { - case e: Exception => - throw e + super.loadTable(ident) match { + case V1Table(catalogTable0) if sparkAdapter.isHoodieTable(catalogTable0) => + val catalogTable = catalogTable0.comment match { + case Some(v) => + val newProps = catalogTable0.properties + (TableCatalog.PROP_COMMENT -> v) + catalogTable0.copy(properties = newProps) + case _ => + catalogTable0 + } + HoodieInternalV2Table( + spark = spark, + path = catalogTable.location.toString, + catalogTable = Some(catalogTable), + tableIdentifier = Some(ident.toString)) + case o => o } } From b10ca7e69f43cf2f14b09d0a610102ab058b1511 Mon Sep 17 00:00:00 2001 From: Sivabalan Narayanan Date: Wed, 11 May 2022 16:02:54 -0400 Subject: [PATCH 17/52] [HUDI-4085] Fixing flakiness with parquet empty batch tests in TestHoodieDeltaStreamer (#5559) --- .../HoodieDeltaStreamerTestBase.java | 4 ++-- .../functional/TestHoodieDeltaStreamer.java | 22 ++++++++++++++----- .../TestParquetDFSSourceEmptyBatch.java | 14 ++++++++++-- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/HoodieDeltaStreamerTestBase.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/HoodieDeltaStreamerTestBase.java index 4ac6f73d880fb..1a1cf39dbfef6 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/HoodieDeltaStreamerTestBase.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/HoodieDeltaStreamerTestBase.java @@ -30,7 +30,7 @@ import org.apache.hudi.hive.HiveSyncConfig; import org.apache.hudi.hive.MultiPartKeysValueExtractor; import org.apache.hudi.utilities.schema.FilebasedSchemaProvider; -import org.apache.hudi.utilities.sources.TestParquetDFSSourceEmptyBatch; +import org.apache.hudi.utilities.sources.TestDataSource; import org.apache.hudi.utilities.testutils.UtilitiesTestBase; import org.apache.avro.Schema; @@ -192,7 +192,7 @@ protected static void writeCommonPropsToFile(FileSystem dfs, String dfsBasePath) @BeforeEach public void setup() throws Exception { super.setup(); - TestParquetDFSSourceEmptyBatch.returnEmptyBatch = false; + TestDataSource.returnEmptyBatch = false; } @AfterAll diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java index 3eaec56cc2764..2707e8392cb33 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java @@ -1509,9 +1509,13 @@ private static void prepareJsonKafkaDFSFiles(int numRecords, boolean createTopic testUtils.sendMessages(topicName, Helpers.jsonifyRecords(dataGenerator.generateInsertsAsPerSchema("000", numRecords, HoodieTestDataGenerator.TRIP_SCHEMA))); } - private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTransformer) throws IOException { + private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTransformer, String emptyBatchParam) throws IOException { prepareParquetDFSSource(useSchemaProvider, hasTransformer, "source.avsc", "target.avsc", - PROPS_FILENAME_TEST_PARQUET, PARQUET_SOURCE_ROOT, false); + PROPS_FILENAME_TEST_PARQUET, PARQUET_SOURCE_ROOT, false, "partition_path", emptyBatchParam); + } + + private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTransformer) throws IOException { + prepareParquetDFSSource(useSchemaProvider, hasTransformer, ""); } private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTransformer, String sourceSchemaFile, String targetSchemaFile, @@ -1520,9 +1524,15 @@ private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTrans "partition_path"); } + private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTransformer, String sourceSchemaFile, String targetSchemaFile, + String propsFileName, String parquetSourceRoot, boolean addCommonProps, String partitionPath) throws IOException { + prepareParquetDFSSource(useSchemaProvider, hasTransformer, sourceSchemaFile, targetSchemaFile, propsFileName, parquetSourceRoot, addCommonProps, + partitionPath, ""); + } + private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTransformer, String sourceSchemaFile, String targetSchemaFile, String propsFileName, String parquetSourceRoot, boolean addCommonProps, - String partitionPath) throws IOException { + String partitionPath, String emptyBatchParam) throws IOException { // Properties used for testing delta-streamer with Parquet source TypedProperties parquetProps = new TypedProperties(); @@ -1541,6 +1551,9 @@ private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTrans } } parquetProps.setProperty("hoodie.deltastreamer.source.dfs.root", parquetSourceRoot); + if (!StringUtils.isNullOrEmpty(emptyBatchParam)) { + parquetProps.setProperty(TestParquetDFSSourceEmptyBatch.RETURN_EMPTY_BATCH, emptyBatchParam); + } UtilitiesTestBase.Helpers.savePropsToDFS(parquetProps, dfs, dfsBasePath + "/" + propsFileName); } @@ -1549,7 +1562,7 @@ private void testParquetDFSSource(boolean useSchemaProvider, List transf } private void testParquetDFSSource(boolean useSchemaProvider, List transformerClassNames, boolean testEmptyBatch) throws Exception { - prepareParquetDFSSource(useSchemaProvider, transformerClassNames != null); + prepareParquetDFSSource(useSchemaProvider, transformerClassNames != null, testEmptyBatch ? "1" : ""); String tableBasePath = dfsBasePath + "/test_parquet_table" + testNum; HoodieDeltaStreamer deltaStreamer = new HoodieDeltaStreamer( TestHelpers.makeConfig(tableBasePath, WriteOperationType.INSERT, testEmptyBatch ? TestParquetDFSSourceEmptyBatch.class.getName() @@ -1563,7 +1576,6 @@ private void testParquetDFSSource(boolean useSchemaProvider, List transf if (testEmptyBatch) { prepareParquetDFSFiles(100, PARQUET_SOURCE_ROOT, "2.parquet", false, null, null); // parquet source to return empty batch - TestParquetDFSSourceEmptyBatch.returnEmptyBatch = true; deltaStreamer.sync(); // since we mimic'ed empty batch, total records should be same as first sync(). TestHelpers.assertRecordCount(PARQUET_NUM_RECORDS, tableBasePath, sqlContext); diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestParquetDFSSourceEmptyBatch.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestParquetDFSSourceEmptyBatch.java index 3129e91a9d3e0..11c3f4c8f95ee 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestParquetDFSSourceEmptyBatch.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/TestParquetDFSSourceEmptyBatch.java @@ -21,6 +21,7 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.utilities.schema.SchemaProvider; @@ -29,19 +30,28 @@ import org.apache.spark.sql.Row; import org.apache.spark.sql.SparkSession; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + public class TestParquetDFSSourceEmptyBatch extends ParquetDFSSource { - public static boolean returnEmptyBatch; + public static String RETURN_EMPTY_BATCH = "test.dfs.source.return.empty.batches.for"; + public static String DEFAULT_RETURN_EMPTY_BATCH = ""; + public List emptyBatches; + private int counter = 0; public TestParquetDFSSourceEmptyBatch(TypedProperties props, JavaSparkContext sparkContext, SparkSession sparkSession, SchemaProvider schemaProvider) { super(props, sparkContext, sparkSession, schemaProvider); + String[] emptyBatchesStr = props.getString(RETURN_EMPTY_BATCH, DEFAULT_RETURN_EMPTY_BATCH).split(","); + this.emptyBatches = Arrays.stream(emptyBatchesStr).filter(entry -> !StringUtils.isNullOrEmpty(entry)).map(entry -> Integer.parseInt(entry)).collect(Collectors.toList()); } @Override public Pair>, String> fetchNextBatch(Option lastCkptStr, long sourceLimit) { Pair>, String> toReturn = super.fetchNextBatch(lastCkptStr, sourceLimit); - if (returnEmptyBatch) { + if (emptyBatches.contains(counter++)) { return Pair.of(Option.empty(), toReturn.getRight()); } return toReturn; From ecd47e7aae39455262470410ab73c13c0287ad36 Mon Sep 17 00:00:00 2001 From: YueZhang <69956021+zhangyue19921010@users.noreply.github.com> Date: Thu, 12 May 2022 19:26:00 +0800 Subject: [PATCH 18/52] [HUDI-3963][Claim RFC number 53] Use Lock-Free Message Queue Improving Hoodie Writing Efficiency. (#5562) Co-authored-by: yuezhang --- rfc/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rfc/README.md b/rfc/README.md index 532f38fc5dce3..0ccf7b1bbe285 100644 --- a/rfc/README.md +++ b/rfc/README.md @@ -88,3 +88,5 @@ The list of all RFCs can be found here. | 50 | [Improve Timeline Server](./rfc-50/rfc-50.md) | `UNDER REVIEW` | | 51 | [Change Data Capture](./rfc-51/rfc-51.md) | `UNDER REVIEW` | | 52 | [Introduce Secondary Index to Improve HUDI Query Performance](./rfc-52/rfc-52.md) | `UNDER REVIEW` | +| 53 | [Use Lock-Free Message Queue Improving Hoodie Writing Efficiency](./rfc-53/rfc-53.md) | `UNDER REVIEW` | + From 0cec955fa26b2370a0ada56e6909a39cdd445047 Mon Sep 17 00:00:00 2001 From: Sivabalan Narayanan Date: Thu, 12 May 2022 21:01:55 -0400 Subject: [PATCH 19/52] [HUDI-4018][HUDI-4027] Adding integ test yamls for immutable use-cases. Added delete partition support to integ tests (#5501) - Added pure immutable test yamls to integ test framework. Added SparkBulkInsertNode as part of it. - Added delete_partition support to integ test framework using spark-datasource. - Added a single yaml to test all non core write operations (insert overwrite, insert overwrite table and delete partitions) - Added tests for 4 concurrent spark datasource writers (multi-writer tests). - Fixed readme w/ sample commands for multi-writer. --- .../deltastreamer-immutable-dataset.yaml | 53 +++++ .../deltastreamer-pure-bulk-inserts.yaml | 38 ++++ .../deltastreamer-pure-inserts.yaml | 38 ++++ .../config/test-suite/insert-overwrite.yaml | 3 +- .../config/test-suite/multi-writer-1-ds.yaml | 2 +- .../config/test-suite/multi-writer-1-sds.yaml | 52 +++++ .../config/test-suite/multi-writer-2-sds.yaml | 4 +- .../config/test-suite/multi-writer-3-sds.yaml | 52 +++++ .../config/test-suite/multi-writer-4-sds.yaml | 52 +++++ .../multi-writer-local-3.properties | 57 +++++ .../multi-writer-local-4.properties | 57 +++++ .../test-suite/spark-delete-partition.yaml | 57 +++++ .../test-suite/spark-immutable-dataset.yaml | 53 +++++ .../test-suite/spark-non-core-operations.yaml | 204 ++++++++++++++++++ .../test-suite/spark-pure-bulk-inserts.yaml | 38 ++++ .../config/test-suite/spark-pure-inserts.yaml | 38 ++++ hudi-integ-test/README.md | 72 +++++++ .../HoodieMultiWriterTestSuiteJob.java | 13 +- .../testsuite/configuration/DeltaConfig.java | 10 + .../dag/nodes/BaseValidateDatasetNode.java | 7 +- .../testsuite/generator/DeltaGenerator.java | 2 +- .../GenericRecordFullPayloadGenerator.java | 2 +- .../dag/nodes/SparkBulkInsertNode.scala | 39 +--- .../dag/nodes/SparkDeletePartitionNode.scala | 70 ++++++ .../apache/hudi/HoodieSparkSqlWriter.scala | 2 +- .../hudi/TestHoodieSparkSqlWriter.scala | 1 - 26 files changed, 970 insertions(+), 46 deletions(-) create mode 100644 docker/demo/config/test-suite/deltastreamer-immutable-dataset.yaml create mode 100644 docker/demo/config/test-suite/deltastreamer-pure-bulk-inserts.yaml create mode 100644 docker/demo/config/test-suite/deltastreamer-pure-inserts.yaml create mode 100644 docker/demo/config/test-suite/multi-writer-1-sds.yaml create mode 100644 docker/demo/config/test-suite/multi-writer-3-sds.yaml create mode 100644 docker/demo/config/test-suite/multi-writer-4-sds.yaml create mode 100644 docker/demo/config/test-suite/multi-writer-local-3.properties create mode 100644 docker/demo/config/test-suite/multi-writer-local-4.properties create mode 100644 docker/demo/config/test-suite/spark-delete-partition.yaml create mode 100644 docker/demo/config/test-suite/spark-immutable-dataset.yaml create mode 100644 docker/demo/config/test-suite/spark-non-core-operations.yaml create mode 100644 docker/demo/config/test-suite/spark-pure-bulk-inserts.yaml create mode 100644 docker/demo/config/test-suite/spark-pure-inserts.yaml create mode 100644 hudi-integ-test/src/main/scala/org/apache/hudi/integ/testsuite/dag/nodes/SparkDeletePartitionNode.scala diff --git a/docker/demo/config/test-suite/deltastreamer-immutable-dataset.yaml b/docker/demo/config/test-suite/deltastreamer-immutable-dataset.yaml new file mode 100644 index 0000000000000..4903e3650c144 --- /dev/null +++ b/docker/demo/config/test-suite/deltastreamer-immutable-dataset.yaml @@ -0,0 +1,53 @@ +# 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. +dag_name: deltastreamer-immutable-dataset.yaml +dag_rounds: 5 +dag_intermittent_delay_mins: 0 +dag_content: + first_bulk_insert: + config: + record_size: 200 + num_partitions_insert: 10 + repeat_count: 3 + num_records_insert: 5000 + type: BulkInsertNode + deps: none + first_validate: + config: + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_bulk_insert + first_insert: + config: + record_size: 200 + num_partitions_insert: 10 + repeat_count: 3 + num_records_insert: 5000 + type: InsertNode + deps: first_validate + second_validate: + config: + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_insert + last_validate: + config: + execute_itr_count: 5 + delete_input_data: true + type: ValidateAsyncOperations + deps: second_validate \ No newline at end of file diff --git a/docker/demo/config/test-suite/deltastreamer-pure-bulk-inserts.yaml b/docker/demo/config/test-suite/deltastreamer-pure-bulk-inserts.yaml new file mode 100644 index 0000000000000..d5342e22b1282 --- /dev/null +++ b/docker/demo/config/test-suite/deltastreamer-pure-bulk-inserts.yaml @@ -0,0 +1,38 @@ +# 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. +dag_name: deltastreamer-pure-bulk-inserts.yaml +dag_rounds: 10 +dag_intermittent_delay_mins: 0 +dag_content: + first_bulk_insert: + config: + record_size: 200 + num_partitions_insert: 10 + repeat_count: 3 + num_records_insert: 5000 + type: BulkInsertNode + deps: none + second_validate: + config: + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_bulk_insert + last_validate: + config: + execute_itr_count: 10 + type: ValidateAsyncOperations + deps: second_validate \ No newline at end of file diff --git a/docker/demo/config/test-suite/deltastreamer-pure-inserts.yaml b/docker/demo/config/test-suite/deltastreamer-pure-inserts.yaml new file mode 100644 index 0000000000000..3b209fe5fe016 --- /dev/null +++ b/docker/demo/config/test-suite/deltastreamer-pure-inserts.yaml @@ -0,0 +1,38 @@ +# 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. +dag_name: deltastreamer-pure-inserts.yaml +dag_rounds: 10 +dag_intermittent_delay_mins: 0 +dag_content: + first_insert: + config: + record_size: 200 + num_partitions_insert: 10 + repeat_count: 3 + num_records_insert: 5000 + type: InsertNode + deps: none + second_validate: + config: + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_insert + last_validate: + config: + execute_itr_count: 10 + type: ValidateAsyncOperations + deps: second_validate \ No newline at end of file diff --git a/docker/demo/config/test-suite/insert-overwrite.yaml b/docker/demo/config/test-suite/insert-overwrite.yaml index dc185d5938f6d..7e54cea6a910d 100644 --- a/docker/demo/config/test-suite/insert-overwrite.yaml +++ b/docker/demo/config/test-suite/insert-overwrite.yaml @@ -17,7 +17,6 @@ dag_name: simple-deltastreamer.yaml dag_rounds: 1 dag_intermittent_delay_mins: 1 dag_content: - first_insert: config: record_size: 1000 @@ -91,4 +90,4 @@ dag_content: validate_hive: false delete_input_data: false type: ValidateDatasetNode - deps: third_upsert + deps: third_upsert \ No newline at end of file diff --git a/docker/demo/config/test-suite/multi-writer-1-ds.yaml b/docker/demo/config/test-suite/multi-writer-1-ds.yaml index 3fe33b671dc39..3476d8075a6ed 100644 --- a/docker/demo/config/test-suite/multi-writer-1-ds.yaml +++ b/docker/demo/config/test-suite/multi-writer-1-ds.yaml @@ -14,7 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. dag_name: simple-deltastreamer.yaml -dag_rounds: 3 +dag_rounds: 6 dag_intermittent_delay_mins: 0 dag_content: first_insert: diff --git a/docker/demo/config/test-suite/multi-writer-1-sds.yaml b/docker/demo/config/test-suite/multi-writer-1-sds.yaml new file mode 100644 index 0000000000000..d60a8ba6d78a6 --- /dev/null +++ b/docker/demo/config/test-suite/multi-writer-1-sds.yaml @@ -0,0 +1,52 @@ +# 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. +dag_name: cow-spark-simple.yaml +dag_rounds: 6 +dag_intermittent_delay_mins: 0 +dag_content: + first_insert: + config: + record_size: 1000 + num_partitions_insert: 1 + repeat_count: 1 + num_records_insert: 100000 + start_partition: 1 + type: SparkInsertNode + deps: none + first_upsert: + config: + record_size: 1000 + num_partitions_insert: 1 + num_records_insert: 50000 + repeat_count: 1 + num_records_upsert: 50000 + num_partitions_upsert: 1 + start_partition: 1 + type: SparkUpsertNode + deps: first_insert + first_delete: + config: + num_partitions_delete: 0 + num_records_delete: 10000 + start_partition: 1 + type: SparkDeleteNode + deps: first_upsert + second_validate: + config: + validate_hive: false + delete_input_data: true + type: ValidateDatasetNode + deps: first_delete \ No newline at end of file diff --git a/docker/demo/config/test-suite/multi-writer-2-sds.yaml b/docker/demo/config/test-suite/multi-writer-2-sds.yaml index 9242dd26051ec..702065c672112 100644 --- a/docker/demo/config/test-suite/multi-writer-2-sds.yaml +++ b/docker/demo/config/test-suite/multi-writer-2-sds.yaml @@ -14,8 +14,8 @@ # See the License for the specific language governing permissions and # limitations under the License. dag_name: cow-spark-simple.yaml -dag_rounds: 3 -dag_intermittent_delay_mins: 0 +dag_rounds: 5 +dag_intermittent_delay_mins: 1 dag_content: first_insert: config: diff --git a/docker/demo/config/test-suite/multi-writer-3-sds.yaml b/docker/demo/config/test-suite/multi-writer-3-sds.yaml new file mode 100644 index 0000000000000..9ad21f467d50b --- /dev/null +++ b/docker/demo/config/test-suite/multi-writer-3-sds.yaml @@ -0,0 +1,52 @@ +# 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. +dag_name: cow-spark-simple.yaml +dag_rounds: 4 +dag_intermittent_delay_mins: 1 +dag_content: + first_insert: + config: + record_size: 1000 + num_partitions_insert: 1 + repeat_count: 1 + num_records_insert: 100000 + start_partition: 20 + type: SparkInsertNode + deps: none + first_upsert: + config: + record_size: 1000 + num_partitions_insert: 1 + num_records_insert: 50000 + repeat_count: 1 + num_records_upsert: 50000 + num_partitions_upsert: 1 + start_partition: 20 + type: SparkUpsertNode + deps: first_insert + first_delete: + config: + num_partitions_delete: 0 + num_records_delete: 10000 + start_partition: 20 + type: SparkDeleteNode + deps: first_upsert + second_validate: + config: + validate_hive: false + delete_input_data: true + type: ValidateDatasetNode + deps: first_delete \ No newline at end of file diff --git a/docker/demo/config/test-suite/multi-writer-4-sds.yaml b/docker/demo/config/test-suite/multi-writer-4-sds.yaml new file mode 100644 index 0000000000000..74dfa1cb4ba6a --- /dev/null +++ b/docker/demo/config/test-suite/multi-writer-4-sds.yaml @@ -0,0 +1,52 @@ +# 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. +dag_name: cow-spark-simple.yaml +dag_rounds: 4 +dag_intermittent_delay_mins: 1 +dag_content: + first_insert: + config: + record_size: 1000 + num_partitions_insert: 1 + repeat_count: 1 + num_records_insert: 100000 + start_partition: 30 + type: SparkInsertNode + deps: none + first_upsert: + config: + record_size: 1000 + num_partitions_insert: 1 + num_records_insert: 50000 + repeat_count: 1 + num_records_upsert: 50000 + num_partitions_upsert: 1 + start_partition: 30 + type: SparkUpsertNode + deps: first_insert + first_delete: + config: + num_partitions_delete: 0 + num_records_delete: 10000 + start_partition: 30 + type: SparkDeleteNode + deps: first_upsert + second_validate: + config: + validate_hive: false + delete_input_data: true + type: ValidateDatasetNode + deps: first_delete \ No newline at end of file diff --git a/docker/demo/config/test-suite/multi-writer-local-3.properties b/docker/demo/config/test-suite/multi-writer-local-3.properties new file mode 100644 index 0000000000000..48f0f0b1ace8b --- /dev/null +++ b/docker/demo/config/test-suite/multi-writer-local-3.properties @@ -0,0 +1,57 @@ + +# +# 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. +# + +hoodie.insert.shuffle.parallelism=2 +hoodie.upsert.shuffle.parallelism=2 +hoodie.bulkinsert.shuffle.parallelism=2 +hoodie.delete.shuffle.parallelism=2 + +hoodie.metadata.enable=false + +hoodie.deltastreamer.source.test.num_partitions=100 +hoodie.deltastreamer.source.test.datagen.use_rocksdb_for_storing_existing_keys=false +hoodie.deltastreamer.source.test.max_unique_records=100000000 +hoodie.embed.timeline.server=false +hoodie.deltastreamer.source.input.selector=org.apache.hudi.integ.testsuite.helpers.DFSTestSuitePathSelector + +hoodie.deltastreamer.source.input.selector=org.apache.hudi.integ.testsuite.helpers.DFSTestSuitePathSelector +hoodie.datasource.hive_sync.skip_ro_suffix=true + +hoodie.datasource.write.recordkey.field=_row_key +hoodie.datasource.write.keygenerator.class=org.apache.hudi.keygen.TimestampBasedKeyGenerator +hoodie.datasource.write.partitionpath.field=timestamp + +hoodie.write.concurrency.mode=optimistic_concurrency_control +hoodie.cleaner.policy.failed.writes=LAZY +hoodie.write.lock.provider=org.apache.hudi.client.transaction.lock.InProcessLockProvider + +hoodie.deltastreamer.source.dfs.root=/tmp/hudi/input3 +hoodie.deltastreamer.schemaprovider.target.schema.file=file:/tmp/source.avsc +hoodie.deltastreamer.schemaprovider.source.schema.file=file:/tmp/source.avsc +hoodie.deltastreamer.keygen.timebased.timestamp.type=UNIX_TIMESTAMP +hoodie.deltastreamer.keygen.timebased.output.dateformat=yyyy/MM/dd + +hoodie.datasource.hive_sync.jdbcurl=jdbc:hive2://hiveserver:10000/ +hoodie.datasource.hive_sync.database=testdb +hoodie.datasource.hive_sync.table=table1 +hoodie.datasource.hive_sync.assume_date_partitioning=false +hoodie.datasource.hive_sync.partition_fields=_hoodie_partition_path +hoodie.datasource.hive_sync.partition_extractor_class=org.apache.hudi.hive.SlashEncodedDayPartitionValueExtractor + diff --git a/docker/demo/config/test-suite/multi-writer-local-4.properties b/docker/demo/config/test-suite/multi-writer-local-4.properties new file mode 100644 index 0000000000000..4b5120928ccb1 --- /dev/null +++ b/docker/demo/config/test-suite/multi-writer-local-4.properties @@ -0,0 +1,57 @@ + +# +# 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. +# + +hoodie.insert.shuffle.parallelism=2 +hoodie.upsert.shuffle.parallelism=2 +hoodie.bulkinsert.shuffle.parallelism=2 +hoodie.delete.shuffle.parallelism=2 + +hoodie.metadata.enable=false + +hoodie.deltastreamer.source.test.num_partitions=100 +hoodie.deltastreamer.source.test.datagen.use_rocksdb_for_storing_existing_keys=false +hoodie.deltastreamer.source.test.max_unique_records=100000000 +hoodie.embed.timeline.server=false +hoodie.deltastreamer.source.input.selector=org.apache.hudi.integ.testsuite.helpers.DFSTestSuitePathSelector + +hoodie.deltastreamer.source.input.selector=org.apache.hudi.integ.testsuite.helpers.DFSTestSuitePathSelector +hoodie.datasource.hive_sync.skip_ro_suffix=true + +hoodie.datasource.write.recordkey.field=_row_key +hoodie.datasource.write.keygenerator.class=org.apache.hudi.keygen.TimestampBasedKeyGenerator +hoodie.datasource.write.partitionpath.field=timestamp + +hoodie.write.concurrency.mode=optimistic_concurrency_control +hoodie.cleaner.policy.failed.writes=LAZY +hoodie.write.lock.provider=org.apache.hudi.client.transaction.lock.InProcessLockProvider + +hoodie.deltastreamer.source.dfs.root=/tmp/hudi/input4 +hoodie.deltastreamer.schemaprovider.target.schema.file=file:/tmp/source.avsc +hoodie.deltastreamer.schemaprovider.source.schema.file=file:/tmp/source.avsc +hoodie.deltastreamer.keygen.timebased.timestamp.type=UNIX_TIMESTAMP +hoodie.deltastreamer.keygen.timebased.output.dateformat=yyyy/MM/dd + +hoodie.datasource.hive_sync.jdbcurl=jdbc:hive2://hiveserver:10000/ +hoodie.datasource.hive_sync.database=testdb +hoodie.datasource.hive_sync.table=table1 +hoodie.datasource.hive_sync.assume_date_partitioning=false +hoodie.datasource.hive_sync.partition_fields=_hoodie_partition_path +hoodie.datasource.hive_sync.partition_extractor_class=org.apache.hudi.hive.SlashEncodedDayPartitionValueExtractor + diff --git a/docker/demo/config/test-suite/spark-delete-partition.yaml b/docker/demo/config/test-suite/spark-delete-partition.yaml new file mode 100644 index 0000000000000..1d23fa7b0851c --- /dev/null +++ b/docker/demo/config/test-suite/spark-delete-partition.yaml @@ -0,0 +1,57 @@ +# 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. +dag_name: spark-delete-partition.yaml +dag_rounds: 1 +dag_intermittent_delay_mins: 1 +dag_content: + first_insert: + config: + record_size: 1000 + num_partitions_insert: 5 + repeat_count: 1 + num_records_insert: 10 + type: SparkInsertNode + deps: none + first_delete_partition: + config: + partitions_to_delete: "1970/01/01" + type: SparkDeletePartitionNode + deps: first_insert + second_validate: + config: + validate_full_data : true + input_partitions_to_skip_validate : "0" + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_delete_partition + second_insert: + config: + record_size: 1000 + num_partitions_insert: 5 + repeat_count: 1 + num_records_insert: 10 + start_partition: 2 + type: SparkInsertNode + deps: second_validate + third_validate: + config: + validate_full_data : true + input_partitions_to_skip_validate : "0" + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: second_insert \ No newline at end of file diff --git a/docker/demo/config/test-suite/spark-immutable-dataset.yaml b/docker/demo/config/test-suite/spark-immutable-dataset.yaml new file mode 100644 index 0000000000000..d6cbf1b244de5 --- /dev/null +++ b/docker/demo/config/test-suite/spark-immutable-dataset.yaml @@ -0,0 +1,53 @@ +# 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. +dag_name: spark-immutable-dataset.yaml +dag_rounds: 5 +dag_intermittent_delay_mins: 0 +dag_content: + first_bulk_insert: + config: + record_size: 200 + num_partitions_insert: 10 + repeat_count: 5 + num_records_insert: 5000 + type: SparkBulkInsertNode + deps: none + first_validate: + config: + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_bulk_insert + first_insert: + config: + record_size: 200 + num_partitions_insert: 10 + repeat_count: 5 + num_records_insert: 5000 + type: SparkInsertNode + deps: first_validate + second_validate: + config: + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_insert + last_validate: + config: + execute_itr_count: 5 + delete_input_data: true + type: ValidateAsyncOperations + deps: second_validate \ No newline at end of file diff --git a/docker/demo/config/test-suite/spark-non-core-operations.yaml b/docker/demo/config/test-suite/spark-non-core-operations.yaml new file mode 100644 index 0000000000000..f7189ce4587c8 --- /dev/null +++ b/docker/demo/config/test-suite/spark-non-core-operations.yaml @@ -0,0 +1,204 @@ +# 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. +dag_name: spark-non-core-operations.yaml +dag_rounds: 1 +dag_intermittent_delay_mins: 1 +dag_content: + first_insert: + config: + record_size: 1000 + num_partitions_insert: 10 + repeat_count: 1 + num_records_insert: 10000 + type: SparkInsertNode + deps: none + first_upsert: + config: + record_size: 1000 + num_partitions_insert: 10 + num_records_insert: 1000 + repeat_count: 1 + num_records_upsert: 8000 + num_partitions_upsert: 10 + type: SparkUpsertNode + deps: first_insert + second_insert: + config: + record_size: 1000 + num_partitions_insert: 10 + repeat_count: 1 + num_records_insert: 10000 + type: SparkInsertNode + deps: first_upsert + second_upsert: + config: + record_size: 1000 + num_partitions_insert: 10 + num_records_insert: 1000 + repeat_count: 1 + num_records_upsert: 8000 + num_partitions_upsert: 10 + type: SparkUpsertNode + deps: second_insert + first_insert_overwrite: + config: + record_size: 1000 + num_partitions_insert: 10 + repeat_count: 1 + num_records_insert: 10 + type: SparkInsertOverwriteNode + deps: second_upsert + delete_all_input_except_last: + config: + delete_input_data_except_latest: true + type: DeleteInputDatasetNode + deps: first_insert_overwrite + third_insert: + config: + record_size: 1000 + num_partitions_insert: 10 + repeat_count: 1 + num_records_insert: 10000 + type: SparkInsertNode + deps: delete_all_input_except_last + third_upsert: + config: + record_size: 1000 + num_partitions_insert: 10 + num_records_insert: 1000 + repeat_count: 1 + num_records_upsert: 8000 + num_partitions_upsert: 10 + type: SparkUpsertNode + deps: third_insert + second_validate: + config: + validate_full_data : true + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: third_upsert + fourth_insert: + config: + record_size: 1000 + num_partitions_insert: 10 + repeat_count: 1 + num_records_insert: 10000 + type: SparkInsertNode + deps: second_validate + fourth_upsert: + config: + record_size: 1000 + num_partitions_insert: 10 + num_records_insert: 1000 + repeat_count: 1 + num_records_upsert: 8000 + num_partitions_upsert: 10 + type: SparkUpsertNode + deps: fourth_insert + fifth_insert: + config: + record_size: 1000 + num_partitions_insert: 10 + repeat_count: 1 + num_records_insert: 10000 + type: SparkInsertNode + deps: fourth_upsert + fifth_upsert: + config: + record_size: 1000 + num_partitions_insert: 10 + num_records_insert: 1000 + repeat_count: 1 + num_records_upsert: 8000 + num_partitions_upsert: 10 + type: SparkUpsertNode + deps: fifth_insert + first_insert_overwrite_table: + config: + record_size: 1000 + repeat_count: 1 + num_records_insert: 10 + type: SparkInsertOverwriteTableNode + deps: fifth_upsert + second_delete_all_input_except_last: + config: + delete_input_data_except_latest: true + type: DeleteInputDatasetNode + deps: first_insert_overwrite_table + sixth_insert: + config: + record_size: 1000 + num_partitions_insert: 10 + repeat_count: 1 + num_records_insert: 10000 + type: SparkInsertNode + deps: second_delete_all_input_except_last + sixth_upsert: + config: + record_size: 1000 + num_partitions_insert: 10 + num_records_insert: 1000 + repeat_count: 1 + num_records_upsert: 8000 + num_partitions_upsert: 10 + type: SparkUpsertNode + deps: sixth_insert + third_validate: + config: + validate_full_data : true + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: sixth_upsert + seventh_insert: + config: + record_size: 1000 + num_partitions_insert: 5 + repeat_count: 1 + num_records_insert: 10 + type: SparkInsertNode + deps: third_validate + first_delete_partition: + config: + partitions_to_delete: "1970/01/01" + type: SparkDeletePartitionNode + deps: seventh_insert + fourth_validate: + config: + validate_full_data : true + input_partitions_to_skip_validate : "0" + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_delete_partition + eigth_insert: + config: + record_size: 1000 + num_partitions_insert: 5 + repeat_count: 1 + num_records_insert: 10 + start_partition: 2 + type: SparkInsertNode + deps: fourth_validate + fifth_validate: + config: + validate_full_data : true + input_partitions_to_skip_validate : "0" + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: eigth_insert \ No newline at end of file diff --git a/docker/demo/config/test-suite/spark-pure-bulk-inserts.yaml b/docker/demo/config/test-suite/spark-pure-bulk-inserts.yaml new file mode 100644 index 0000000000000..f82705cea3cec --- /dev/null +++ b/docker/demo/config/test-suite/spark-pure-bulk-inserts.yaml @@ -0,0 +1,38 @@ +# 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. +dag_name: spark-pure-bulk-inserts.yaml +dag_rounds: 5 +dag_intermittent_delay_mins: 0 +dag_content: + first_bulk_insert: + config: + record_size: 200 + num_partitions_insert: 10 + repeat_count: 4 + num_records_insert: 5000 + type: SparkBulkInsertNode + deps: none + second_validate: + config: + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_bulk_insert + last_validate: + config: + execute_itr_count: 5 + type: ValidateAsyncOperations + deps: second_validate \ No newline at end of file diff --git a/docker/demo/config/test-suite/spark-pure-inserts.yaml b/docker/demo/config/test-suite/spark-pure-inserts.yaml new file mode 100644 index 0000000000000..13482f988c70c --- /dev/null +++ b/docker/demo/config/test-suite/spark-pure-inserts.yaml @@ -0,0 +1,38 @@ +# 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. +dag_name: spark-pure-inserts.yaml +dag_rounds: 5 +dag_intermittent_delay_mins: 1 +dag_content: + first_insert: + config: + record_size: 200 + num_partitions_insert: 10 + repeat_count: 3 + num_records_insert: 5000 + type: SparkInsertNode + deps: none + second_validate: + config: + validate_hive: false + delete_input_data: false + type: ValidateDatasetNode + deps: first_insert + last_validate: + config: + execute_itr_count: 10 + type: ValidateAsyncOperations + deps: second_validate \ No newline at end of file diff --git a/hudi-integ-test/README.md b/hudi-integ-test/README.md index 6c1bad138cc18..5d26d03a20a89 100644 --- a/hudi-integ-test/README.md +++ b/hudi-integ-test/README.md @@ -522,6 +522,78 @@ Spark submit with the flag: --saferSchemaEvolution ``` +### Multi-writer tests +Integ test framework also supports multi-writer tests. + +#### Multi-writer tests with deltastreamer and a spark data source writer. + +Sample spark-submit command to test one delta streamer and a spark data source writer. +```shell +./bin/spark-submit --packages org.apache.spark:spark-avro_2.11:2.4.0 \ +--conf spark.task.cpus=3 --conf spark.executor.cores=3 \ +--conf spark.task.maxFailures=100 --conf spark.memory.fraction=0.4 \ +--conf spark.rdd.compress=true --conf spark.kryoserializer.buffer.max=2000m \ +--conf spark.serializer=org.apache.spark.serializer.KryoSerializer \ +--conf spark.memory.storageFraction=0.1 --conf spark.shuffle.service.enabled=true \ +--conf spark.sql.hive.convertMetastoreParquet=false --conf spark.driver.maxResultSize=12g \ +--conf spark.executor.heartbeatInterval=120s --conf spark.network.timeout=600s \ +--conf spark.yarn.max.executor.failures=10 \ +--conf spark.sql.catalogImplementation=hive \ +--class org.apache.hudi.integ.testsuite.HoodieMultiWriterTestSuiteJob \ +/packaging/hudi-integ-test-bundle/target/hudi-integ-test-bundle-0.12.0-SNAPSHOT.jar \ +--source-ordering-field test_suite_source_ordering_field \ +--use-deltastreamer \ +--target-base-path /tmp/hudi/output \ +--input-base-paths "/tmp/hudi/input1,/tmp/hudi/input2" \ +--target-table table1 \ +--props-paths "file:/docker/demo/config/test-suite/multi-writer-local-1.properties,file:/hudi/docker/demo/config/test-suite/multi-writer-local-2.properties" \ +--schemaprovider-class org.apache.hudi.integ.testsuite.schema.TestSuiteFileBasedSchemaProvider \ +--source-class org.apache.hudi.utilities.sources.AvroDFSSource \ +--input-file-size 125829120 \ +--workload-yaml-paths "file:/docker/demo/config/test-suite/multi-writer-1-ds.yaml,file:/docker/demo/config/test-suite/multi-writer-2-sds.yaml" \ +--workload-generator-classname org.apache.hudi.integ.testsuite.dag.WorkflowDagGenerator \ +--table-type COPY_ON_WRITE \ +--compact-scheduling-minshare 1 \ +--input-base-path "dummyValue" \ +--workload-yaml-path "dummyValue" \ +--props "dummyValue" \ +--use-hudi-data-to-generate-updates +``` + +#### Multi-writer tests with 4 concurrent spark data source writer. + +```shell +./bin/spark-submit --packages org.apache.spark:spark-avro_2.11:2.4.0 \ +--conf spark.task.cpus=3 --conf spark.executor.cores=3 \ +--conf spark.task.maxFailures=100 --conf spark.memory.fraction=0.4 \ +--conf spark.rdd.compress=true --conf spark.kryoserializer.buffer.max=2000m \ +--conf spark.serializer=org.apache.spark.serializer.KryoSerializer \ +--conf spark.memory.storageFraction=0.1 --conf spark.shuffle.service.enabled=true \ +--conf spark.sql.hive.convertMetastoreParquet=false --conf spark.driver.maxResultSize=12g \ +--conf spark.executor.heartbeatInterval=120s --conf spark.network.timeout=600s \ +--conf spark.yarn.max.executor.failures=10 --conf spark.sql.catalogImplementation=hive \ +--class org.apache.hudi.integ.testsuite.HoodieMultiWriterTestSuiteJob \ +/hudi-integ-test-bundle-0.12.0-SNAPSHOT.jar \ +--source-ordering-field test_suite_source_ordering_field \ +--use-deltastreamer \ +--target-base-path /tmp/hudi/output \ +--input-base-paths "/tmp/hudi/input1,/tmp/hudi/input2,/tmp/hudi/input3,/tmp/hudi/input4" \ +--target-table table1 \ +--props-paths "file:/multi-writer-local-1.properties,file:/multi-writer-local-2.properties,file:/multi-writer-local-3.properties,file:/multi-writer-local-4.properties" +--schemaprovider-class org.apache.hudi.integ.testsuite.schema.TestSuiteFileBasedSchemaProvider \ +--source-class org.apache.hudi.utilities.sources.AvroDFSSource \ +--input-file-size 125829120 \ +--workload-yaml-paths "file:/multi-writer-1-sds.yaml,file:/multi-writer-2-sds.yaml,file:/multi-writer-3-sds.yaml,file:/multi-writer-4-sds.yaml" \ +--workload-generator-classname org.apache.hudi.integ.testsuite.dag.WorkflowDagGenerator \ +--table-type COPY_ON_WRITE \ +--compact-scheduling-minshare 1 \ +--input-base-path "dummyValue" \ +--workload-yaml-path "dummyValue" \ +--props "dummyValue" \ +--use-hudi-data-to-generate-updates +``` + + ## Automated tests for N no of yamls in Local Docker environment Hudi provides a script to assist you in testing N no of yamls automatically. Checkout the script under diff --git a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/HoodieMultiWriterTestSuiteJob.java b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/HoodieMultiWriterTestSuiteJob.java index 6cff499825566..87d2f587597a0 100644 --- a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/HoodieMultiWriterTestSuiteJob.java +++ b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/HoodieMultiWriterTestSuiteJob.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Random; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -116,6 +117,7 @@ public static void main(String[] args) throws Exception { } ExecutorService executor = Executors.newFixedThreadPool(inputPaths.length); + Random random = new Random(); List testSuiteConfigList = new ArrayList<>(); int jobIndex = 0; @@ -131,11 +133,20 @@ public static void main(String[] args) throws Exception { AtomicBoolean jobFailed = new AtomicBoolean(false); AtomicInteger counter = new AtomicInteger(0); + List waitTimes = new ArrayList<>(); + for (int i = 0;i < jobIndex ;i++) { + if (i == 0) { + waitTimes.add(0L); + } else { + // every job after 1st, will start after 1 min + some delta. + waitTimes.add(60000L + random.nextInt(10000)); + } + } List> completableFutureList = new ArrayList<>(); testSuiteConfigList.forEach(hoodieTestSuiteConfig -> { try { // start each job at 20 seconds interval so that metaClient instantiation does not overstep - Thread.sleep(counter.get() * 20000); + Thread.sleep(waitTimes.get(counter.get())); LOG.info("Starting job " + hoodieTestSuiteConfig.toString()); } catch (InterruptedException e) { e.printStackTrace(); diff --git a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/configuration/DeltaConfig.java b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/configuration/DeltaConfig.java index 581cce954a53c..1578e86be47b6 100644 --- a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/configuration/DeltaConfig.java +++ b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/configuration/DeltaConfig.java @@ -101,6 +101,8 @@ public static class Config { private static String ENABLE_METADATA_VALIDATE = "enable_metadata_validate"; private static String VALIDATE_FULL_DATA = "validate_full_data"; private static String DELETE_INPUT_DATA_EXCEPT_LATEST = "delete_input_data_except_latest"; + private static String PARTITIONS_TO_DELETE = "partitions_to_delete"; + private static String INPUT_PARTITIONS_TO_SKIP_VALIDATE = "input_partitions_to_skip_validate"; // Spark SQL Create Table private static String TABLE_TYPE = "table_type"; @@ -203,6 +205,10 @@ public boolean isDisableIngest() { return Boolean.valueOf(configsMap.getOrDefault(DISABLE_INGEST, false).toString()); } + public String getPartitionsToDelete() { + return configsMap.getOrDefault(PARTITIONS_TO_DELETE, "").toString(); + } + public boolean getReinitContext() { return Boolean.valueOf(configsMap.getOrDefault(REINIT_CONTEXT, false).toString()); } @@ -223,6 +229,10 @@ public int validateOnceEveryIteration() { return Integer.valueOf(configsMap.getOrDefault(VALIDATE_ONCE_EVERY_ITR, 1).toString()); } + public String inputPartitonsToSkipWithValidate() { + return configsMap.getOrDefault(INPUT_PARTITIONS_TO_SKIP_VALIDATE, "").toString(); + } + public boolean isValidateFullData() { return Boolean.valueOf(configsMap.getOrDefault(VALIDATE_FULL_DATA, false).toString()); } diff --git a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/dag/nodes/BaseValidateDatasetNode.java b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/dag/nodes/BaseValidateDatasetNode.java index b5c661cb085f6..a0ebdc5754716 100644 --- a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/dag/nodes/BaseValidateDatasetNode.java +++ b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/dag/nodes/BaseValidateDatasetNode.java @@ -163,8 +163,13 @@ private Dataset getInputDf(ExecutionContext context, SparkSession session, // todo: fix hard coded fields from configs. // read input and resolve insert, updates, etc. Dataset inputDf = session.read().format("avro").load(inputPath); + Dataset trimmedDf = inputDf; + if (!config.inputPartitonsToSkipWithValidate().isEmpty()) { + trimmedDf = inputDf.filter("instr("+partitionPathField+", \'"+ config.inputPartitonsToSkipWithValidate() +"\') != 1"); + } + ExpressionEncoder encoder = getEncoder(inputDf.schema()); - return inputDf.groupByKey( + return trimmedDf.groupByKey( (MapFunction) value -> (partitionPathField.isEmpty() ? value.getAs(recordKeyField) : (value.getAs(partitionPathField) + "+" + value.getAs(recordKeyField))), Encoders.STRING()) .reduceGroups((ReduceFunction) (v1, v2) -> { diff --git a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/DeltaGenerator.java b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/DeltaGenerator.java index e7bc7b00a82a4..c30be2a2a5d2c 100644 --- a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/DeltaGenerator.java +++ b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/DeltaGenerator.java @@ -123,7 +123,7 @@ public JavaRDD generateInserts(Config operation) { int startPartition = operation.getStartPartition(); // Each spark partition below will generate records for a single partition given by the integer index. - List partitionIndexes = IntStream.rangeClosed(0 + startPartition, numPartitions + startPartition) + List partitionIndexes = IntStream.rangeClosed(0 + startPartition, numPartitions + startPartition - 1) .boxed().collect(Collectors.toList()); JavaRDD inputBatch = jsc.parallelize(partitionIndexes, numPartitions) diff --git a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/GenericRecordFullPayloadGenerator.java b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/GenericRecordFullPayloadGenerator.java index 59f02de0ac1a6..a936a81665116 100644 --- a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/GenericRecordFullPayloadGenerator.java +++ b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/generator/GenericRecordFullPayloadGenerator.java @@ -338,7 +338,7 @@ public boolean validate(GenericRecord record) { */ @VisibleForTesting public GenericRecord updateTimestamp(GenericRecord record, String fieldName) { - long delta = TimeUnit.SECONDS.convert((++partitionIndex % numDatePartitions) + startPartition, TimeUnit.DAYS); + long delta = TimeUnit.SECONDS.convert((partitionIndex++ % numDatePartitions) + startPartition, TimeUnit.DAYS); record.put(fieldName, delta); return record; } diff --git a/hudi-integ-test/src/main/scala/org/apache/hudi/integ/testsuite/dag/nodes/SparkBulkInsertNode.scala b/hudi-integ-test/src/main/scala/org/apache/hudi/integ/testsuite/dag/nodes/SparkBulkInsertNode.scala index ac254bea8dad0..b426f87071127 100644 --- a/hudi-integ-test/src/main/scala/org/apache/hudi/integ/testsuite/dag/nodes/SparkBulkInsertNode.scala +++ b/hudi-integ-test/src/main/scala/org/apache/hudi/integ/testsuite/dag/nodes/SparkBulkInsertNode.scala @@ -19,49 +19,18 @@ package org.apache.hudi.integ.testsuite.dag.nodes import org.apache.hudi.client.WriteStatus -import org.apache.hudi.config.HoodieWriteConfig import org.apache.hudi.integ.testsuite.configuration.DeltaConfig.Config -import org.apache.hudi.integ.testsuite.dag.ExecutionContext -import org.apache.hudi.{AvroConversionUtils, DataSourceWriteOptions} +import org.apache.hudi.DataSourceWriteOptions import org.apache.spark.rdd.RDD -import org.apache.spark.sql.SaveMode - -import scala.collection.JavaConverters._ /** * Spark datasource based bulk insert node * * @param dagNodeConfig DAG node configurations. */ -class SparkBulkInsertNode(dagNodeConfig: Config) extends DagNode[RDD[WriteStatus]] { - - config = dagNodeConfig +class SparkBulkInsertNode(dagNodeConfig: Config) extends SparkInsertNode(dagNodeConfig) { - /** - * Execute the {@link DagNode}. - * - * @param context The context needed for an execution of a node. - * @param curItrCount iteration count for executing the node. - * @throws Exception Thrown if the execution failed. - */ - override def execute(context: ExecutionContext, curItrCount: Int): Unit = { - if (!config.isDisableGenerate) { - context.getDeltaGenerator().writeRecords(context.getDeltaGenerator().generateInserts(config)).getValue().count() - } - val inputDF = AvroConversionUtils.createDataFrame(context.getWriterContext.getHoodieTestSuiteWriter.getNextBatch, - context.getWriterContext.getHoodieTestSuiteWriter.getSchema, - context.getWriterContext.getSparkSession) - val saveMode = if(curItrCount == 0) SaveMode.Overwrite else SaveMode.Append - inputDF.write.format("hudi") - .options(DataSourceWriteOptions.translateSqlOptions(context.getWriterContext.getProps.asScala.toMap)) - .option(DataSourceWriteOptions.TABLE_NAME.key(), context.getHoodieTestSuiteWriter.getCfg.targetTableName) - .option(DataSourceWriteOptions.TABLE_TYPE.key(), context.getHoodieTestSuiteWriter.getCfg.tableType) - .option(DataSourceWriteOptions.OPERATION.key(), DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL) - .option(DataSourceWriteOptions.ENABLE_ROW_WRITER.key(), String.valueOf(config.enableRowWriting())) - .option(DataSourceWriteOptions.COMMIT_METADATA_KEYPREFIX.key(), "deltastreamer.checkpoint.key") - .option("deltastreamer.checkpoint.key", context.getWriterContext.getHoodieTestSuiteWriter.getLastCheckpoint.orElse("")) - .option(HoodieWriteConfig.TBL_NAME.key(), context.getHoodieTestSuiteWriter.getCfg.targetTableName) - .mode(saveMode) - .save(context.getHoodieTestSuiteWriter.getWriteConfig.getBasePath) + override def getOperation(): String = { + DataSourceWriteOptions.BULK_INSERT_OPERATION_OPT_VAL } } diff --git a/hudi-integ-test/src/main/scala/org/apache/hudi/integ/testsuite/dag/nodes/SparkDeletePartitionNode.scala b/hudi-integ-test/src/main/scala/org/apache/hudi/integ/testsuite/dag/nodes/SparkDeletePartitionNode.scala new file mode 100644 index 0000000000000..9354deea28bb0 --- /dev/null +++ b/hudi-integ-test/src/main/scala/org/apache/hudi/integ/testsuite/dag/nodes/SparkDeletePartitionNode.scala @@ -0,0 +1,70 @@ +/* + * 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.hudi.integ.testsuite.dag.nodes + + +import org.apache.avro.Schema +import org.apache.hudi.client.WriteStatus +import org.apache.hudi.common.util.collection.Pair +import org.apache.hudi.config.HoodieWriteConfig +import org.apache.hudi.integ.testsuite.configuration.DeltaConfig.Config +import org.apache.hudi.integ.testsuite.dag.ExecutionContext +import org.apache.hudi.integ.testsuite.schema.SchemaUtils +import org.apache.hudi.integ.testsuite.writer.DeltaWriteStats +import org.apache.hudi.{AvroConversionUtils, DataSourceWriteOptions, HoodieSparkUtils} +import org.apache.log4j.LogManager +import org.apache.spark.api.java.JavaRDD +import org.apache.spark.rdd.RDD +import org.apache.spark.sql.SaveMode + +import scala.collection.JavaConverters._ + +/** + * Spark datasource based insert node + * + * @param dagNodeConfig DAG node configurations. + */ +class SparkDeletePartitionNode(dagNodeConfig: Config) extends DagNode[RDD[WriteStatus]] { + + private val log = LogManager.getLogger(getClass) + config = dagNodeConfig + + /** + * Execute the {@link DagNode}. + * + * @param context The context needed for an execution of a node. + * @param curItrCount iteration count for executing the node. + * @throws Exception Thrown if the execution failed. + */ + override def execute(context: ExecutionContext, curItrCount: Int): Unit = { + println("Generating input data for node {}", this.getName) + + context.getWriterContext.getSparkSession.emptyDataFrame.write.format("hudi") + .options(DataSourceWriteOptions.translateSqlOptions(context.getWriterContext.getProps.asScala.toMap)) + .option(DataSourceWriteOptions.PRECOMBINE_FIELD.key(), SchemaUtils.SOURCE_ORDERING_FIELD) + .option(DataSourceWriteOptions.TABLE_NAME.key, context.getHoodieTestSuiteWriter.getCfg.targetTableName) + .option(DataSourceWriteOptions.TABLE_TYPE.key, context.getHoodieTestSuiteWriter.getCfg.tableType) + .option(DataSourceWriteOptions.OPERATION.key, DataSourceWriteOptions.DELETE_PARTITION_OPERATION_OPT_VAL) + .option(HoodieWriteConfig.TBL_NAME.key, context.getHoodieTestSuiteWriter.getCfg.targetTableName) + .option(DataSourceWriteOptions.PARTITIONS_TO_DELETE.key, config.getPartitionsToDelete) + .mode(SaveMode.Append) + .save(context.getHoodieTestSuiteWriter.getWriteConfig.getBasePath) + } +} diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala index 89b2ecc1a9357..da2736e59bdda 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieSparkSqlWriter.scala @@ -212,7 +212,6 @@ object HoodieSparkSqlWriter { (writeStatuses, client) } case WriteOperationType.DELETE_PARTITION => { - val genericRecords = registerKryoClassesAndGetGenericRecords(tblName, sparkContext, df, reconcileSchema) if (!tableExists) { throw new HoodieException(s"hoodie table at $basePath does not exist") } @@ -222,6 +221,7 @@ object HoodieSparkSqlWriter { val partitionColsToDelete = parameters(DataSourceWriteOptions.PARTITIONS_TO_DELETE.key()).split(",") java.util.Arrays.asList(partitionColsToDelete: _*) } else { + val genericRecords = registerKryoClassesAndGetGenericRecords(tblName, sparkContext, df, reconcileSchema) genericRecords.map(gr => keyGenerator.getKey(gr).getPartitionPath).toJavaRDD().distinct().collect() } // Create a HoodieWriteClient & issue the delete. diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala index 111a46261c769..339dbb5c715ef 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala @@ -20,7 +20,6 @@ package org.apache.hudi import java.io.IOException import java.time.Instant import java.util.{Collections, Date, UUID} - import org.apache.commons.io.FileUtils import org.apache.hudi.DataSourceWriteOptions._ import org.apache.hudi.client.SparkRDDWriteClient From 701f8c039d1cd0104cff4d04f60b159c9e38d84e Mon Sep 17 00:00:00 2001 From: Bo Cui Date: Fri, 13 May 2022 09:50:11 +0800 Subject: [PATCH 20/52] [HUDI-3336][HUDI-FLINK]Support custom hadoop config for flink (#5528) * [HUDI-3336][HUDI-FLINK]Support custom hadoop config for flink --- .../hudi/configuration/FlinkOptions.java | 20 +++----- .../configuration/HadoopConfigurations.java | 48 +++++++++++++++++++ .../hudi/schema/FilebasedSchemaProvider.java | 6 ++- .../sink/bootstrap/BootstrapOperator.java | 3 +- .../sink/bulk/BulkInsertWriteFunction.java | 2 +- .../apache/hudi/sink/meta/CkpMetadata.java | 12 +++-- .../partitioner/BucketAssignFunction.java | 3 +- .../hudi/sink/utils/HiveSyncContext.java | 4 +- .../org/apache/hudi/source/FileIndex.java | 3 +- .../source/StreamReadMonitoringFunction.java | 3 +- .../apache/hudi/table/HoodieTableSource.java | 5 +- .../hudi/table/catalog/HoodieCatalog.java | 3 +- .../apache/hudi/table/format/FormatUtils.java | 12 ----- .../format/mor/MergeOnReadInputFormat.java | 6 +-- .../org/apache/hudi/util/FlinkTables.java | 4 +- .../org/apache/hudi/util/StreamerUtil.java | 25 +++------- .../hudi/util/ViewStorageProperties.java | 11 +++-- .../TestStreamWriteOperatorCoordinator.java | 5 +- .../hudi/sink/meta/TestCkpMetadata.java | 3 +- .../sink/partitioner/TestBucketAssigner.java | 3 +- .../apache/hudi/sink/utils/TestWriteBase.java | 3 +- .../hudi/source/TestStreamReadOperator.java | 3 +- .../hudi/table/format/TestInputFormat.java | 3 +- .../apache/hudi/utils/TestStreamerUtil.java | 2 +- .../java/org/apache/hudi/utils/TestUtils.java | 11 +++-- .../hudi/utils/TestViewStorageProperties.java | 10 ++-- 26 files changed, 126 insertions(+), 87 deletions(-) create mode 100644 hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java index c944f6a299144..729f0147b5940 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java @@ -709,25 +709,17 @@ private FlinkOptions() { // Prefix for Hoodie specific properties. private static final String PROPERTIES_PREFIX = "properties."; - /** - * Collects the config options that start with 'properties.' into a 'key'='value' list. - */ - public static Map getHoodieProperties(Map options) { - return getHoodiePropertiesWithPrefix(options, PROPERTIES_PREFIX); - } - /** * Collects the config options that start with specified prefix {@code prefix} into a 'key'='value' list. */ - public static Map getHoodiePropertiesWithPrefix(Map options, String prefix) { + public static Map getPropertiesWithPrefix(Map options, String prefix) { final Map hoodieProperties = new HashMap<>(); - - if (hasPropertyOptions(options)) { + if (hasPropertyOptions(options, prefix)) { options.keySet().stream() - .filter(key -> key.startsWith(PROPERTIES_PREFIX)) + .filter(key -> key.startsWith(prefix)) .forEach(key -> { final String value = options.get(key); - final String subKey = key.substring((prefix).length()); + final String subKey = key.substring(prefix.length()); hoodieProperties.put(subKey, value); }); } @@ -749,8 +741,8 @@ public static Configuration flatOptions(Configuration conf) { return fromMap(propsMap); } - private static boolean hasPropertyOptions(Map options) { - return options.keySet().stream().anyMatch(k -> k.startsWith(PROPERTIES_PREFIX)); + private static boolean hasPropertyOptions(Map options, String prefix) { + return options.keySet().stream().anyMatch(k -> k.startsWith(prefix)); } /** diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java new file mode 100644 index 0000000000000..7784e7caaae2a --- /dev/null +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java @@ -0,0 +1,48 @@ +/* + * 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.hudi.configuration; + +import org.apache.flink.configuration.Configuration; +import org.apache.hudi.util.FlinkClientUtil; + +import java.util.Map; + +public class HadoopConfigurations { + private static final String HADOOP_PREFIX = "hadoop."; + private static final String PARQUET_PREFIX = "parquet."; + + public static org.apache.hadoop.conf.Configuration getParquetConf( + org.apache.flink.configuration.Configuration options, + org.apache.hadoop.conf.Configuration hadoopConf) { + org.apache.hadoop.conf.Configuration copy = new org.apache.hadoop.conf.Configuration(hadoopConf); + Map parquetOptions = FlinkOptions.getPropertiesWithPrefix(options.toMap(), PARQUET_PREFIX); + parquetOptions.forEach((k, v) -> copy.set(PARQUET_PREFIX + k, v)); + return copy; + } + + /** + * Create a new hadoop configuration that is initialized with the given flink configuration. + */ + public static org.apache.hadoop.conf.Configuration getHadoopConf(Configuration conf) { + org.apache.hadoop.conf.Configuration hadoopConf = FlinkClientUtil.getHadoopConf(); + Map options = FlinkOptions.getPropertiesWithPrefix(conf.toMap(), HADOOP_PREFIX); + options.forEach((k, v) -> hadoopConf.set(k, v)); + return hadoopConf; + } +} diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/schema/FilebasedSchemaProvider.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/schema/FilebasedSchemaProvider.java index 1443a68cf0fc2..a349314b7a111 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/schema/FilebasedSchemaProvider.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/schema/FilebasedSchemaProvider.java @@ -21,6 +21,7 @@ import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.util.StreamerUtil; @@ -49,9 +50,10 @@ public static class Config { private Schema targetSchema; + @Deprecated public FilebasedSchemaProvider(TypedProperties props) { StreamerUtil.checkRequiredProperties(props, Collections.singletonList(Config.SOURCE_SCHEMA_FILE_PROP)); - FileSystem fs = FSUtils.getFs(props.getString(Config.SOURCE_SCHEMA_FILE_PROP), StreamerUtil.getHadoopConf()); + FileSystem fs = FSUtils.getFs(props.getString(Config.SOURCE_SCHEMA_FILE_PROP), HadoopConfigurations.getHadoopConf(new Configuration())); try { this.sourceSchema = new Schema.Parser().parse(fs.open(new Path(props.getString(Config.SOURCE_SCHEMA_FILE_PROP)))); if (props.containsKey(Config.TARGET_SCHEMA_FILE_PROP)) { @@ -65,7 +67,7 @@ public FilebasedSchemaProvider(TypedProperties props) { public FilebasedSchemaProvider(Configuration conf) { final String sourceSchemaPath = conf.getString(FlinkOptions.SOURCE_AVRO_SCHEMA_PATH); - final FileSystem fs = FSUtils.getFs(sourceSchemaPath, StreamerUtil.getHadoopConf()); + final FileSystem fs = FSUtils.getFs(sourceSchemaPath, HadoopConfigurations.getHadoopConf(conf)); try { this.sourceSchema = new Schema.Parser().parse(fs.open(new Path(sourceSchemaPath))); } catch (IOException ioe) { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/BootstrapOperator.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/BootstrapOperator.java index 1fc8d393be6a9..7c3f5a9329bb1 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/BootstrapOperator.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/BootstrapOperator.java @@ -34,6 +34,7 @@ import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.sink.bootstrap.aggregate.BootstrapAggFunction; import org.apache.hudi.sink.meta.CkpMetadata; @@ -122,7 +123,7 @@ public void initializeState(StateInitializationContext context) throws Exception } } - this.hadoopConf = StreamerUtil.getHadoopConf(); + this.hadoopConf = HadoopConfigurations.getHadoopConf(this.conf); this.writeConfig = StreamerUtil.getHoodieClientConfig(this.conf, true); this.hoodieTable = FlinkTables.createTable(writeConfig, hadoopConf, getRuntimeContext()); this.ckpMetadata = CkpMetadata.getInstance(hoodieTable.getMetaClient().getFs(), this.writeConfig.getBasePath()); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriteFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriteFunction.java index 6c8dcef0f3925..06d9fcd851c22 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriteFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/BulkInsertWriteFunction.java @@ -113,7 +113,7 @@ public BulkInsertWriteFunction(Configuration config, RowType rowType) { public void open(Configuration parameters) throws IOException { this.taskID = getRuntimeContext().getIndexOfThisSubtask(); this.writeClient = StreamerUtil.createWriteClient(this.config, getRuntimeContext()); - this.ckpMetadata = CkpMetadata.getInstance(config.getString(FlinkOptions.PATH)); + this.ckpMetadata = CkpMetadata.getInstance(config); this.initInstant = lastPendingInstant(); sendBootstrapEvent(); initWriterHelper(); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/meta/CkpMetadata.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/meta/CkpMetadata.java index ff1277d7b7e74..45a4e04bab285 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/meta/CkpMetadata.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/meta/CkpMetadata.java @@ -18,11 +18,13 @@ package org.apache.hudi.sink.meta; +import org.apache.flink.configuration.Configuration; import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.util.ValidationUtils; +import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.exception.HoodieException; -import org.apache.hudi.util.StreamerUtil; import org.apache.hadoop.fs.FileSystem; import org.apache.hadoop.fs.Path; @@ -70,8 +72,8 @@ public class CkpMetadata implements Serializable { private List messages; private List instantCache; - private CkpMetadata(String basePath) { - this(FSUtils.getFs(basePath, StreamerUtil.getHadoopConf()), basePath); + private CkpMetadata(Configuration config) { + this(FSUtils.getFs(config.getString(FlinkOptions.PATH), HadoopConfigurations.getHadoopConf(config)), config.getString(FlinkOptions.PATH)); } private CkpMetadata(FileSystem fs, String basePath) { @@ -196,8 +198,8 @@ public boolean isAborted(String instant) { // ------------------------------------------------------------------------- // Utilities // ------------------------------------------------------------------------- - public static CkpMetadata getInstance(String basePath) { - return new CkpMetadata(basePath); + public static CkpMetadata getInstance(Configuration config) { + return new CkpMetadata(config); } public static CkpMetadata getInstance(FileSystem fs, String basePath) { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java index c4b83bf51aace..89f89cf5c0a9f 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketAssignFunction.java @@ -31,6 +31,7 @@ import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.sink.bootstrap.IndexRecord; import org.apache.hudi.sink.utils.PayloadCreation; import org.apache.hudi.table.action.commit.BucketInfo; @@ -116,7 +117,7 @@ public void open(Configuration parameters) throws Exception { super.open(parameters); HoodieWriteConfig writeConfig = StreamerUtil.getHoodieClientConfig(this.conf, true); HoodieFlinkEngineContext context = new HoodieFlinkEngineContext( - new SerializableConfiguration(StreamerUtil.getHadoopConf()), + new SerializableConfiguration(HadoopConfigurations.getHadoopConf(this.conf)), new FlinkTaskContextSupplier(getRuntimeContext())); this.bucketAssigner = BucketAssigners.create( getRuntimeContext().getIndexOfThisSubtask(), diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/HiveSyncContext.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/HiveSyncContext.java index 536a0282fbcc4..bd837efc8737d 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/HiveSyncContext.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/HiveSyncContext.java @@ -22,11 +22,11 @@ import org.apache.hudi.aws.sync.AwsGlueCatalogSyncTool; import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.hive.HiveSyncConfig; import org.apache.hudi.hive.HiveSyncTool; import org.apache.hudi.hive.ddl.HiveSyncMode; import org.apache.hudi.table.format.FilePathUtils; -import org.apache.hudi.util.StreamerUtil; import org.apache.flink.configuration.Configuration; import org.apache.hadoop.fs.FileSystem; @@ -60,7 +60,7 @@ public HiveSyncTool hiveSyncTool() { public static HiveSyncContext create(Configuration conf) { HiveSyncConfig syncConfig = buildSyncConfig(conf); - org.apache.hadoop.conf.Configuration hadoopConf = StreamerUtil.getHadoopConf(); + org.apache.hadoop.conf.Configuration hadoopConf = HadoopConfigurations.getHadoopConf(conf); String path = conf.getString(FlinkOptions.PATH); FileSystem fs = FSUtils.getFs(path, hadoopConf); HiveConf hiveConf = new HiveConf(); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/FileIndex.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/FileIndex.java index 07383ef7fea5f..d7125b414352d 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/FileIndex.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/FileIndex.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.config.HoodieMetadataConfig; import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.util.StreamerUtil; import org.apache.flink.annotation.VisibleForTesting; @@ -54,7 +55,7 @@ public class FileIndex { private FileIndex(Path path, Configuration conf) { this.path = path; this.metadataConfig = metadataConfig(conf); - this.tableExists = StreamerUtil.tableExists(path.toString(), StreamerUtil.getHadoopConf()); + this.tableExists = StreamerUtil.tableExists(path.toString(), HadoopConfigurations.getHadoopConf(conf)); } public static FileIndex instance(Path path, Configuration conf) { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/StreamReadMonitoringFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/StreamReadMonitoringFunction.java index 8138e931e54e7..8bfde209360ac 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/StreamReadMonitoringFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/StreamReadMonitoringFunction.java @@ -21,6 +21,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.table.format.mor.MergeOnReadInputSplit; import org.apache.hudi.util.StreamerUtil; @@ -157,7 +158,7 @@ public void initializeState(FunctionInitializationContext context) throws Except @Override public void open(Configuration parameters) throws Exception { super.open(parameters); - this.hadoopConf = StreamerUtil.getHadoopConf(); + this.hadoopConf = HadoopConfigurations.getHadoopConf(parameters); } @Override diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java index da4abf0a96e60..bad592aa21d28 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java @@ -27,6 +27,7 @@ import org.apache.hudi.common.table.view.HoodieTableFileSystemView; import org.apache.hudi.common.util.Option; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.configuration.OptionsResolver; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.hadoop.HoodieROTablePathFilter; @@ -92,7 +93,7 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; -import static org.apache.hudi.table.format.FormatUtils.getParquetConf; +import static org.apache.hudi.configuration.HadoopConfigurations.getParquetConf; /** * Hoodie batch table source that always read the latest snapshot of the underneath table. @@ -155,7 +156,7 @@ public HoodieTableSource( : requiredPos; this.limit = limit == null ? NO_LIMIT_CONSTANT : limit; this.filters = filters == null ? Collections.emptyList() : filters; - this.hadoopConf = StreamerUtil.getHadoopConf(); + this.hadoopConf = HadoopConfigurations.getHadoopConf(conf); this.metaClient = StreamerUtil.metaClientForReader(conf, hadoopConf); this.maxCompactionMemoryInBytes = StreamerUtil.getMaxCompactionMemoryInBytes(conf); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/catalog/HoodieCatalog.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/catalog/HoodieCatalog.java index 3317967006101..956d61cc3c2a4 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/catalog/HoodieCatalog.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/catalog/HoodieCatalog.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.util.AvroSchemaConverter; import org.apache.hudi.util.StreamerUtil; @@ -93,7 +94,7 @@ public class HoodieCatalog extends AbstractCatalog { public HoodieCatalog(String name, Configuration options) { super(name, options.get(DEFAULT_DATABASE)); this.catalogPathStr = options.get(CATALOG_PATH); - this.hadoopConf = StreamerUtil.getHadoopConf(); + this.hadoopConf = HadoopConfigurations.getHadoopConf(options); this.tableCommonOptions = CatalogOptions.tableCommonOptions(options); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FormatUtils.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FormatUtils.java index fce9b75f764ea..478f94cb71f73 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FormatUtils.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/FormatUtils.java @@ -30,7 +30,6 @@ import org.apache.hudi.common.util.queue.BoundedInMemoryQueueProducer; import org.apache.hudi.common.util.queue.FunctionBasedQueueProducer; import org.apache.hudi.config.HoodieWriteConfig; -import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.hadoop.config.HoodieRealtimeConfig; import org.apache.hudi.table.format.mor.MergeOnReadInputSplit; import org.apache.hudi.util.StreamerUtil; @@ -49,7 +48,6 @@ import java.util.Iterator; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.function.Function; /** @@ -253,14 +251,4 @@ public static HoodieMergedLogRecordScanner logScanner( private static Boolean string2Boolean(String s) { return "true".equals(s.toLowerCase(Locale.ROOT)); } - - public static org.apache.hadoop.conf.Configuration getParquetConf( - org.apache.flink.configuration.Configuration options, - org.apache.hadoop.conf.Configuration hadoopConf) { - final String prefix = "parquet."; - org.apache.hadoop.conf.Configuration copy = new org.apache.hadoop.conf.Configuration(hadoopConf); - Map parquetOptions = FlinkOptions.getHoodiePropertiesWithPrefix(options.toMap(), prefix); - parquetOptions.forEach((k, v) -> copy.set(prefix + k, v)); - return copy; - } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputFormat.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputFormat.java index 202b14404aa35..4f2de3648ed56 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputFormat.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/format/mor/MergeOnReadInputFormat.java @@ -26,6 +26,7 @@ import org.apache.hudi.common.util.ClosableIterator; import org.apache.hudi.common.util.Option; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.configuration.OptionsResolver; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.keygen.KeyGenUtils; @@ -36,7 +37,6 @@ import org.apache.hudi.util.AvroToRowDataConverters; import org.apache.hudi.util.RowDataProjection; import org.apache.hudi.util.RowDataToAvroConverters; -import org.apache.hudi.util.StreamerUtil; import org.apache.hudi.util.StringToRowDataConverter; import org.apache.avro.Schema; @@ -167,7 +167,7 @@ public static Builder builder() { public void open(MergeOnReadInputSplit split) throws IOException { this.currentReadCount = 0L; this.closed = false; - this.hadoopConf = StreamerUtil.getHadoopConf(); + this.hadoopConf = HadoopConfigurations.getHadoopConf(this.conf); if (!(split.getLogPaths().isPresent() && split.getLogPaths().get().size() > 0)) { if (split.getInstantRange() != null) { // base file only with commit time filtering @@ -306,7 +306,7 @@ private ParquetColumnarRowSplitReader getReader(String path, int[] requiredPos) return ParquetSplitReaderUtil.genPartColumnarRowReader( this.conf.getBoolean(FlinkOptions.UTC_TIMEZONE), true, - FormatUtils.getParquetConf(this.conf, hadoopConf), + HadoopConfigurations.getParquetConf(this.conf, hadoopConf), fieldNames.toArray(new String[0]), fieldTypes.toArray(new DataType[0]), partObjects, diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/FlinkTables.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/FlinkTables.java index 6918a06b186b8..d440588b642e5 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/FlinkTables.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/FlinkTables.java @@ -27,7 +27,7 @@ import org.apache.flink.api.common.functions.RuntimeContext; import org.apache.flink.configuration.Configuration; -import static org.apache.hudi.util.StreamerUtil.getHadoopConf; +import static org.apache.hudi.configuration.HadoopConfigurations.getHadoopConf; import static org.apache.hudi.util.StreamerUtil.getHoodieClientConfig; /** @@ -44,7 +44,7 @@ private FlinkTables() { */ public static HoodieFlinkTable createTable(Configuration conf, RuntimeContext runtimeContext) { HoodieFlinkEngineContext context = new HoodieFlinkEngineContext( - new SerializableConfiguration(getHadoopConf()), + new SerializableConfiguration(getHadoopConf(conf)), new FlinkTaskContextSupplier(runtimeContext)); HoodieWriteConfig writeConfig = getHoodieClientConfig(conf, true); return HoodieFlinkTable.create(writeConfig, context); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/StreamerUtil.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/StreamerUtil.java index dfbe0efd67c70..b977dfd7c5343 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/StreamerUtil.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/StreamerUtil.java @@ -43,6 +43,7 @@ import org.apache.hudi.config.HoodieStorageConfig; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.configuration.OptionsResolver; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieIOException; @@ -101,7 +102,7 @@ public static TypedProperties getProps(FlinkStreamerConfig cfg) { return new TypedProperties(); } return readConfig( - getHadoopConf(), + HadoopConfigurations.getHadoopConf(cfg), new Path(cfg.propsFilePath), cfg.configs).getProps(); } @@ -140,11 +141,6 @@ public static DFSPropertiesConfiguration readConfig(org.apache.hadoop.conf.Confi return conf; } - // Keep the redundant to avoid too many modifications. - public static org.apache.hadoop.conf.Configuration getHadoopConf() { - return FlinkClientUtil.getHadoopConf(); - } - /** * Mainly used for tests. */ @@ -215,7 +211,7 @@ public static HoodieWriteConfig getHoodieClientConfig( HoodieWriteConfig writeConfig = builder.build(); if (loadFsViewStorageConfig) { // do not use the builder to give a change for recovering the original fs view storage config - FileSystemViewStorageConfig viewStorageConfig = ViewStorageProperties.loadFromProperties(conf.getString(FlinkOptions.PATH)); + FileSystemViewStorageConfig viewStorageConfig = ViewStorageProperties.loadFromProperties(conf.getString(FlinkOptions.PATH), conf); writeConfig.setViewStorageConfig(viewStorageConfig); } return writeConfig; @@ -255,7 +251,7 @@ public static void checkRequiredProperties(TypedProperties props, List c */ public static HoodieTableMetaClient initTableIfNotExists(Configuration conf) throws IOException { final String basePath = conf.getString(FlinkOptions.PATH); - final org.apache.hadoop.conf.Configuration hadoopConf = StreamerUtil.getHadoopConf(); + final org.apache.hadoop.conf.Configuration hadoopConf = HadoopConfigurations.getHadoopConf(conf); if (!tableExists(basePath, hadoopConf)) { HoodieTableMetaClient metaClient = HoodieTableMetaClient.withPropertyBuilder() .setTableCreateSchema(conf.getString(FlinkOptions.SOURCE_AVRO_SCHEMA)) @@ -348,18 +344,11 @@ public static HoodieTableMetaClient createMetaClient(String basePath, org.apache return HoodieTableMetaClient.builder().setBasePath(basePath).setConf(hadoopConf).build(); } - /** - * Creates the meta client. - */ - public static HoodieTableMetaClient createMetaClient(String basePath) { - return createMetaClient(basePath, FlinkClientUtil.getHadoopConf()); - } - /** * Creates the meta client. */ public static HoodieTableMetaClient createMetaClient(Configuration conf) { - return createMetaClient(conf.getString(FlinkOptions.PATH)); + return createMetaClient(conf.getString(FlinkOptions.PATH), HadoopConfigurations.getHadoopConf(conf)); } /** @@ -382,7 +371,7 @@ public static HoodieFlinkWriteClient createWriteClient(Configuration conf, Runti public static HoodieFlinkWriteClient createWriteClient(Configuration conf, RuntimeContext runtimeContext, boolean loadFsViewStorageConfig) { HoodieFlinkEngineContext context = new HoodieFlinkEngineContext( - new SerializableConfiguration(getHadoopConf()), + new SerializableConfiguration(HadoopConfigurations.getHadoopConf(conf)), new FlinkTaskContextSupplier(runtimeContext)); HoodieWriteConfig writeConfig = getHoodieClientConfig(conf, loadFsViewStorageConfig); @@ -410,7 +399,7 @@ public static HoodieFlinkWriteClient createWriteClient(Configuration conf) throw .withRemoteServerPort(viewStorageConfig.getRemoteViewServerPort()) .withRemoteTimelineClientTimeoutSecs(viewStorageConfig.getRemoteTimelineClientTimeoutSecs()) .build(); - ViewStorageProperties.createProperties(conf.getString(FlinkOptions.PATH), rebuilt); + ViewStorageProperties.createProperties(conf.getString(FlinkOptions.PATH), rebuilt, conf); return writeClient; } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ViewStorageProperties.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ViewStorageProperties.java index da55e27f0c03b..91662e47077c7 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ViewStorageProperties.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/util/ViewStorageProperties.java @@ -18,8 +18,10 @@ package org.apache.hudi.util; +import org.apache.flink.configuration.Configuration; import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.common.table.view.FileSystemViewStorageConfig; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.exception.HoodieIOException; import org.apache.hadoop.fs.FSDataInputStream; @@ -48,9 +50,10 @@ public class ViewStorageProperties { */ public static void createProperties( String basePath, - FileSystemViewStorageConfig config) throws IOException { + FileSystemViewStorageConfig config, + Configuration flinkConf) throws IOException { Path propertyPath = getPropertiesFilePath(basePath); - FileSystem fs = FSUtils.getFs(basePath, StreamerUtil.getHadoopConf()); + FileSystem fs = FSUtils.getFs(basePath, HadoopConfigurations.getHadoopConf(flinkConf)); fs.delete(propertyPath, false); try (FSDataOutputStream outputStream = fs.create(propertyPath)) { config.getProps().store(outputStream, @@ -61,10 +64,10 @@ public static void createProperties( /** * Read the {@link FileSystemViewStorageConfig} with given table base path. */ - public static FileSystemViewStorageConfig loadFromProperties(String basePath) { + public static FileSystemViewStorageConfig loadFromProperties(String basePath, Configuration conf) { Path propertyPath = getPropertiesFilePath(basePath); LOG.info("Loading filesystem view storage properties from " + propertyPath); - FileSystem fs = FSUtils.getFs(basePath, StreamerUtil.getHadoopConf()); + FileSystem fs = FSUtils.getFs(basePath, HadoopConfigurations.getHadoopConf(conf)); Properties props = new Properties(); try { try (FSDataInputStream inputStream = fs.open(propertyPath)) { diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java index 7a8aeff97b560..55885dcab5837 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java @@ -24,6 +24,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.metadata.HoodieTableMetadata; import org.apache.hudi.sink.event.WriteMetadataEvent; import org.apache.hudi.sink.utils.MockCoordinatorExecutor; @@ -103,7 +104,7 @@ void testInstantState() { @Test public void testTableInitialized() throws IOException { - final org.apache.hadoop.conf.Configuration hadoopConf = StreamerUtil.getHadoopConf(); + final org.apache.hadoop.conf.Configuration hadoopConf = HadoopConfigurations.getHadoopConf(new Configuration()); String basePath = tempFile.getAbsolutePath(); try (FileSystem fs = FSUtils.getFs(basePath, hadoopConf)) { assertTrue(fs.exists(new Path(basePath, HoodieTableMetaClient.METAFOLDER_NAME))); @@ -201,7 +202,7 @@ void testSyncMetadataTable() throws Exception { assertNotEquals("", instant); final String metadataTableBasePath = HoodieTableMetadata.getMetadataTableBasePath(tempFile.getAbsolutePath()); - HoodieTableMetaClient metadataTableMetaClient = StreamerUtil.createMetaClient(metadataTableBasePath); + HoodieTableMetaClient metadataTableMetaClient = StreamerUtil.createMetaClient(metadataTableBasePath, HadoopConfigurations.getHadoopConf(conf)); HoodieTimeline completedTimeline = metadataTableMetaClient.getActiveTimeline().filterCompletedInstants(); assertThat("One instant need to sync to metadata table", completedTimeline.getInstants().count(), is(1L)); assertThat(completedTimeline.lastInstant().get().getTimestamp(), is(HoodieTableMetadata.SOLO_COMMIT_TIMESTAMP)); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/meta/TestCkpMetadata.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/meta/TestCkpMetadata.java index c4eecd7e4941b..a6fb493b9bdda 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/meta/TestCkpMetadata.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/meta/TestCkpMetadata.java @@ -19,6 +19,7 @@ package org.apache.hudi.sink.meta; import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.util.StreamerUtil; import org.apache.hudi.utils.TestConfigurations; @@ -47,7 +48,7 @@ public class TestCkpMetadata { @BeforeEach public void beforeEach() throws Exception { String basePath = tempFile.getAbsolutePath(); - FileSystem fs = FSUtils.getFs(tempFile.getAbsolutePath(), StreamerUtil.getHadoopConf()); + FileSystem fs = FSUtils.getFs(tempFile.getAbsolutePath(), HadoopConfigurations.getHadoopConf(new Configuration())); Configuration conf = TestConfigurations.getDefaultConf(basePath); StreamerUtil.initTableIfNotExists(conf); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestBucketAssigner.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestBucketAssigner.java index 4f4b5499530cc..0748739064cf3 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestBucketAssigner.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/partitioner/TestBucketAssigner.java @@ -24,6 +24,7 @@ import org.apache.hudi.common.model.HoodieRecordLocation; import org.apache.hudi.config.HoodieCompactionConfig; import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.sink.partitioner.profile.WriteProfile; import org.apache.hudi.table.action.commit.BucketInfo; import org.apache.hudi.table.action.commit.BucketType; @@ -71,7 +72,7 @@ public void before() throws IOException { writeConfig = StreamerUtil.getHoodieClientConfig(conf); context = new HoodieFlinkEngineContext( - new SerializableConfiguration(StreamerUtil.getHadoopConf()), + new SerializableConfiguration(HadoopConfigurations.getHadoopConf(conf)), new FlinkTaskContextSupplier(null)); StreamerUtil.initTableIfNotExists(conf); } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestWriteBase.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestWriteBase.java index a03f870296db7..ba60ff9469d73 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestWriteBase.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/utils/TestWriteBase.java @@ -26,6 +26,7 @@ import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.configuration.OptionsResolver; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.sink.event.WriteMetadataEvent; @@ -345,7 +346,7 @@ public TestHarness checkWrittenData( } private void checkWrittenDataMor(File baseFile, Map expected, int partitions) throws Exception { - HoodieTableMetaClient metaClient = StreamerUtil.createMetaClient(basePath); + HoodieTableMetaClient metaClient = StreamerUtil.createMetaClient(basePath, HadoopConfigurations.getHadoopConf(conf)); Schema schema = new TableSchemaResolver(metaClient).getTableAvroSchema(); String latestInstant = lastCompleteInstant(); FileSystem fs = FSUtils.getFs(basePath, new org.apache.hadoop.conf.Configuration()); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestStreamReadOperator.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestStreamReadOperator.java index db45a75977f5e..9f2aba77c1105 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestStreamReadOperator.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/TestStreamReadOperator.java @@ -21,6 +21,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.table.format.mor.MergeOnReadInputFormat; import org.apache.hudi.table.format.mor.MergeOnReadInputSplit; @@ -239,7 +240,7 @@ private List generateSplits(StreamReadMonitoringFunction private OneInputStreamOperatorTestHarness createReader() throws Exception { final String basePath = tempFile.getAbsolutePath(); - final org.apache.hadoop.conf.Configuration hadoopConf = StreamerUtil.getHadoopConf(); + final org.apache.hadoop.conf.Configuration hadoopConf = HadoopConfigurations.getHadoopConf(new Configuration()); final HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder() .setConf(hadoopConf).setBasePath(basePath).build(); final List partitionKeys = Collections.singletonList("partition"); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/format/TestInputFormat.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/format/TestInputFormat.java index 6fbbab81fa4a6..8d2f3585cd942 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/format/TestInputFormat.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/format/TestInputFormat.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.table.HoodieTableSource; import org.apache.hudi.table.format.cow.CopyOnWriteInputFormat; import org.apache.hudi.table.format.mor.MergeOnReadInputFormat; @@ -400,7 +401,7 @@ void testReadIncrementally(HoodieTableType tableType) throws Exception { TestData.writeData(dataset, conf); } - HoodieTableMetaClient metaClient = StreamerUtil.createMetaClient(tempFile.getAbsolutePath()); + HoodieTableMetaClient metaClient = StreamerUtil.createMetaClient(tempFile.getAbsolutePath(), HadoopConfigurations.getHadoopConf(conf)); List commits = metaClient.getCommitsTimeline().filterCompletedInstants().getInstants() .map(HoodieInstant::getTimestamp).collect(Collectors.toList()); diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestStreamerUtil.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestStreamerUtil.java index 57297c50ee82b..43b59bdf9e8bc 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestStreamerUtil.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestStreamerUtil.java @@ -106,7 +106,7 @@ void testInstantTimeDiff() { void testDumpRemoteViewStorageConfig() throws IOException { Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); StreamerUtil.createWriteClient(conf); - FileSystemViewStorageConfig storageConfig = ViewStorageProperties.loadFromProperties(conf.getString(FlinkOptions.PATH)); + FileSystemViewStorageConfig storageConfig = ViewStorageProperties.loadFromProperties(conf.getString(FlinkOptions.PATH), new Configuration()); assertThat(storageConfig.getStorageType(), is(FileSystemViewStorageType.REMOTE_FIRST)); } } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestUtils.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestUtils.java index 466ccdfd01e72..c3aa9c25c61a2 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestUtils.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestUtils.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.source.StreamReadMonitoringFunction; import org.apache.hudi.table.format.mor.MergeOnReadInputSplit; import org.apache.hudi.util.StreamerUtil; @@ -39,19 +40,19 @@ public class TestUtils { public static String getLastPendingInstant(String basePath) { final HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder() - .setConf(StreamerUtil.getHadoopConf()).setBasePath(basePath).build(); + .setConf(HadoopConfigurations.getHadoopConf(new Configuration())).setBasePath(basePath).build(); return StreamerUtil.getLastPendingInstant(metaClient); } public static String getLastCompleteInstant(String basePath) { final HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder() - .setConf(StreamerUtil.getHadoopConf()).setBasePath(basePath).build(); + .setConf(HadoopConfigurations.getHadoopConf(new Configuration())).setBasePath(basePath).build(); return StreamerUtil.getLastCompletedInstant(metaClient); } public static String getLastDeltaCompleteInstant(String basePath) { final HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder() - .setConf(StreamerUtil.getHadoopConf()).setBasePath(basePath).build(); + .setConf(HadoopConfigurations.getHadoopConf(new Configuration())).setBasePath(basePath).build(); return metaClient.getCommitsTimeline().filterCompletedInstants() .filter(hoodieInstant -> hoodieInstant.getAction().equals(HoodieTimeline.DELTA_COMMIT_ACTION)) .lastInstant() @@ -61,7 +62,7 @@ public static String getLastDeltaCompleteInstant(String basePath) { public static String getFirstCompleteInstant(String basePath) { final HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder() - .setConf(StreamerUtil.getHadoopConf()).setBasePath(basePath).build(); + .setConf(HadoopConfigurations.getHadoopConf(new Configuration())).setBasePath(basePath).build(); return metaClient.getCommitsAndCompactionTimeline().filterCompletedInstants().firstInstant() .map(HoodieInstant::getTimestamp).orElse(null); } @@ -69,7 +70,7 @@ public static String getFirstCompleteInstant(String basePath) { @Nullable public static String getNthCompleteInstant(String basePath, int n, boolean isDelta) { final HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder() - .setConf(StreamerUtil.getHadoopConf()).setBasePath(basePath).build(); + .setConf(HadoopConfigurations.getHadoopConf(new Configuration())).setBasePath(basePath).build(); return metaClient.getActiveTimeline() .filterCompletedInstants() .filter(instant -> isDelta ? HoodieTimeline.DELTA_COMMIT_ACTION.equals(instant.getAction()) : HoodieTimeline.COMMIT_ACTION.equals(instant.getAction())) diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestViewStorageProperties.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestViewStorageProperties.java index f80760bf1fd85..121a1c6785f30 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestViewStorageProperties.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestViewStorageProperties.java @@ -18,6 +18,7 @@ package org.apache.hudi.utils; +import org.apache.flink.configuration.Configuration; import org.apache.hudi.common.table.view.FileSystemViewStorageConfig; import org.apache.hudi.common.table.view.FileSystemViewStorageType; import org.apache.hudi.util.ViewStorageProperties; @@ -45,11 +46,12 @@ void testReadWriteProperties() throws IOException { .withStorageType(FileSystemViewStorageType.SPILLABLE_DISK) .withRemoteServerHost("host1") .withRemoteServerPort(1234).build(); - ViewStorageProperties.createProperties(basePath, config); - ViewStorageProperties.createProperties(basePath, config); - ViewStorageProperties.createProperties(basePath, config); + Configuration flinkConfig = new Configuration(); + ViewStorageProperties.createProperties(basePath, config, flinkConfig); + ViewStorageProperties.createProperties(basePath, config, flinkConfig); + ViewStorageProperties.createProperties(basePath, config, flinkConfig); - FileSystemViewStorageConfig readConfig = ViewStorageProperties.loadFromProperties(basePath); + FileSystemViewStorageConfig readConfig = ViewStorageProperties.loadFromProperties(basePath, new Configuration()); assertThat(readConfig.getStorageType(), is(FileSystemViewStorageType.SPILLABLE_DISK)); assertThat(readConfig.getRemoteViewServerHost(), is("host1")); assertThat(readConfig.getRemoteViewServerPort(), is(1234)); From 8ad0bb97453ee2d625cf2b41374d781d186aef5d Mon Sep 17 00:00:00 2001 From: Xingcan Cui Date: Fri, 13 May 2022 00:20:40 -0400 Subject: [PATCH 21/52] [MINOR] Fix a NPE for Option (#5461) --- .../java/org/apache/hudi/aws/sync/AWSGlueCatalogSyncClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hudi-aws/src/main/java/org/apache/hudi/aws/sync/AWSGlueCatalogSyncClient.java b/hudi-aws/src/main/java/org/apache/hudi/aws/sync/AWSGlueCatalogSyncClient.java index 81c05ed132a35..e5a23a9a571cc 100644 --- a/hudi-aws/src/main/java/org/apache/hudi/aws/sync/AWSGlueCatalogSyncClient.java +++ b/hudi-aws/src/main/java/org/apache/hudi/aws/sync/AWSGlueCatalogSyncClient.java @@ -394,7 +394,7 @@ public void createDatabase(String databaseName) { public Option getLastCommitTimeSynced(String tableName) { try { Table table = getTable(awsGlue, databaseName, tableName); - return Option.of(table.getParameters().getOrDefault(HOODIE_LAST_COMMIT_TIME_SYNC, null)); + return Option.ofNullable(table.getParameters().get(HOODIE_LAST_COMMIT_TIME_SYNC)); } catch (Exception e) { throw new HoodieGlueSyncException("Fail to get last sync commit time for " + tableId(databaseName, tableName), e); } From 7fb436d3cf66748f32776cf3db2d943f97b52160 Mon Sep 17 00:00:00 2001 From: Bo Cui Date: Fri, 13 May 2022 14:32:48 +0800 Subject: [PATCH 22/52] =?UTF-8?q?[HUDI-4078][HUDI-FLINK]BootstrapOperator?= =?UTF-8?q?=20contains=20the=20pending=20compact=E2=80=A6=20(#5545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [HUDI-4078][HUDI-FLINK]BootstrapOperator contains the pending compaction files --- .../table/view/TableFileSystemView.java | 2 +- .../apache/hudi/common/util/Functions.java | 8 +++---- .../sink/bootstrap/BootstrapOperator.java | 2 +- .../hudi/sink/TestWriteCopyOnWrite.java | 6 ++++- .../hudi/sink/TestWriteMergeOnRead.java | 23 +++++++++++++++++++ .../java/org/apache/hudi/utils/TestData.java | 10 ++++---- 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/TableFileSystemView.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/TableFileSystemView.java index 7330286734a08..c32e2cabb1012 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/TableFileSystemView.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/TableFileSystemView.java @@ -124,7 +124,7 @@ Stream getLatestFileSlicesBeforeOrOn(String partitionPath, String max * @param maxInstantTime Max Instant Time * @return */ - public Stream getLatestMergedFileSlicesBeforeOrOn(String partitionPath, String maxInstantTime); + Stream getLatestMergedFileSlicesBeforeOrOn(String partitionPath, String maxInstantTime); /** * Stream all the latest file slices, in the given range. diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/Functions.java b/hudi-common/src/main/java/org/apache/hudi/common/util/Functions.java index 0b82f091402a0..728ac717e4cd5 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/util/Functions.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/Functions.java @@ -33,28 +33,28 @@ static Runnable noop() { /** * A function which has not any parameter. */ - public interface Function0 extends Serializable { + interface Function0 extends Serializable { R apply(); } /** * A function which contains only one parameter. */ - public interface Function1 extends Serializable { + interface Function1 extends Serializable { R apply(T1 val1); } /** * A function which contains two parameters. */ - public interface Function2 extends Serializable { + interface Function2 extends Serializable { R apply(T1 val1, T2 val2); } /** * A function which contains three parameters. */ - public interface Function3 extends Serializable { + interface Function3 extends Serializable { R apply(T1 val1, T2 val2, T3 val3); } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/BootstrapOperator.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/BootstrapOperator.java index 7c3f5a9329bb1..f0ef3bccb7fac 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/BootstrapOperator.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bootstrap/BootstrapOperator.java @@ -199,7 +199,7 @@ protected void loadRecords(String partitionPath) throws Exception { Schema schema = new TableSchemaResolver(this.hoodieTable.getMetaClient()).getTableAvroSchema(); List fileSlices = this.hoodieTable.getSliceView() - .getLatestFileSlicesBeforeOrOn(partitionPath, latestCommitTime.get().getTimestamp(), true) + .getLatestMergedFileSlicesBeforeOrOn(partitionPath, latestCommitTime.get().getTimestamp()) .collect(toList()); for (FileSlice fileSlice : fileSlices) { diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java index 4771a7a3455b0..403d0272b4e18 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteCopyOnWrite.java @@ -352,6 +352,10 @@ public void testIndexStateBootstrap() throws Exception { // reset the config option conf.setBoolean(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true); + validateIndexLoaded(); + } + + protected void validateIndexLoaded() throws Exception { preparePipeline(conf) .consume(TestData.DATA_SET_UPDATE_INSERT) .checkIndexLoaded( @@ -418,7 +422,7 @@ private TestHarness preparePipeline() throws Exception { return TestHarness.instance().preparePipeline(tempFile, conf); } - private TestHarness preparePipeline(Configuration conf) throws Exception { + protected TestHarness preparePipeline(Configuration conf) throws Exception { return TestHarness.instance().preparePipeline(tempFile, conf); } diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java index a35a0ac8d0b88..f2c0500f9555c 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestWriteMergeOnRead.java @@ -20,8 +20,10 @@ import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.configuration.FlinkOptions; +import org.apache.hudi.utils.TestData; import org.apache.flink.configuration.Configuration; +import org.junit.jupiter.api.Test; import java.util.HashMap; import java.util.Map; @@ -36,6 +38,27 @@ protected void setUp(Configuration conf) { conf.setBoolean(FlinkOptions.COMPACTION_ASYNC_ENABLED, false); } + @Test + public void testIndexStateBootstrapWithCompactionScheduled() throws Exception { + // sets up the delta commits as 1 to generate a new compaction plan. + conf.setInteger(FlinkOptions.COMPACTION_DELTA_COMMITS, 1); + // open the function and ingest data + preparePipeline(conf) + .consume(TestData.DATA_SET_INSERT) + .assertEmptyDataFiles() + .checkpoint(1) + .assertNextEvent() + .checkpointComplete(1) + .checkWrittenData(EXPECTED1, 4) + .end(); + + // reset config options + conf.removeConfig(FlinkOptions.COMPACTION_DELTA_COMMITS); + // sets up index bootstrap + conf.setBoolean(FlinkOptions.INDEX_BOOTSTRAP_ENABLED, true); + validateIndexLoaded(); + } + @Override public void testInsertClustering() { // insert clustering is only valid for cow table. diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestData.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestData.java index 61f1657c2c6ed..c31c2bbadae25 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestData.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/utils/TestData.java @@ -21,6 +21,7 @@ import org.apache.hudi.client.common.HoodieFlinkEngineContext; import org.apache.hudi.common.config.HoodieCommonConfig; import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.model.HoodieLogFile; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.log.HoodieMergedLogRecordScanner; import org.apache.hudi.common.testutils.HoodieTestUtils; @@ -641,10 +642,11 @@ public static void checkWrittenDataMOR( File[] dataFiles = partitionDir.listFiles(file -> file.getName().contains(".log.") && !file.getName().startsWith("..")); assertNotNull(dataFiles); - HoodieMergedLogRecordScanner scanner = getScanner( - fs, baseFile.getPath(), Arrays.stream(dataFiles).map(File::getAbsolutePath) - .sorted(Comparator.naturalOrder()).collect(Collectors.toList()), - schema, latestInstant); + List logPaths = Arrays.stream(dataFiles) + .sorted((f1, f2) -> HoodieLogFile.getLogFileComparator() + .compare(new HoodieLogFile(f1.getPath()), new HoodieLogFile(f2.getPath()))) + .map(File::getAbsolutePath).collect(Collectors.toList()); + HoodieMergedLogRecordScanner scanner = getScanner(fs, baseFile.getPath(), logPaths, schema, latestInstant); List readBuffer = scanner.getRecords().values().stream() .map(hoodieRecord -> { try { From a704e3740c7b45fa080ff5e57ff14d32530300a1 Mon Sep 17 00:00:00 2001 From: Bo Cui Date: Fri, 13 May 2022 19:52:55 +0800 Subject: [PATCH 23/52] [HUDI-3336][HUDI-FLINK]Support custom hadoop config for flink (#5574) * [HUDI-3336][HUDI-FLINK]Support custom hadoop config for flink --- .../org/apache/hudi/source/StreamReadMonitoringFunction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/StreamReadMonitoringFunction.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/StreamReadMonitoringFunction.java index 8bfde209360ac..012bd093818a5 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/StreamReadMonitoringFunction.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/StreamReadMonitoringFunction.java @@ -158,7 +158,7 @@ public void initializeState(FunctionInitializationContext context) throws Except @Override public void open(Configuration parameters) throws Exception { super.open(parameters); - this.hadoopConf = HadoopConfigurations.getHadoopConf(parameters); + this.hadoopConf = HadoopConfigurations.getHadoopConf(conf); } @Override From 5c4813f1011baaa00856f88c69873923baafdba4 Mon Sep 17 00:00:00 2001 From: Sivabalan Narayanan Date: Fri, 13 May 2022 08:26:47 -0400 Subject: [PATCH 24/52] [HUDI-4072] Fix NULL schema for empty batches in deltastreamer (#5543) --- .../utilities/deltastreamer/DeltaSync.java | 23 ++++++++++- .../hudi/utilities/sources/InputBatch.java | 3 +- .../functional/TestHoodieDeltaStreamer.java | 39 +++++++++++++------ 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/DeltaSync.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/DeltaSync.java index b086a6c9edbab..8f44b8b7d0b34 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/DeltaSync.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/DeltaSync.java @@ -37,6 +37,7 @@ import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.table.HoodieTableConfig; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.TableSchemaResolver; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; @@ -77,6 +78,7 @@ import com.codahale.metrics.Timer; import org.apache.avro.Schema; +import org.apache.avro.SchemaCompatibility; import org.apache.avro.generic.GenericRecord; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; @@ -794,7 +796,7 @@ private HoodieWriteConfig getHoodieClientConfig(Schema schema) { .withProps(props); if (schema != null) { - builder.withSchema(schema.toString()); + builder.withSchema(getSchemaForWriteConfig(schema).toString()); } HoodieWriteConfig config = builder.build(); @@ -829,6 +831,25 @@ private HoodieWriteConfig getHoodieClientConfig(Schema schema) { return config; } + private Schema getSchemaForWriteConfig(Schema targetSchema) { + Schema newWriteSchema = targetSchema; + try { + if (targetSchema != null) { + // check if targetSchema is equal to NULL schema + if (SchemaCompatibility.checkReaderWriterCompatibility(targetSchema, InputBatch.NULL_SCHEMA).getType() == SchemaCompatibility.SchemaCompatibilityType.COMPATIBLE + && SchemaCompatibility.checkReaderWriterCompatibility(InputBatch.NULL_SCHEMA, targetSchema).getType() == SchemaCompatibility.SchemaCompatibilityType.COMPATIBLE) { + // target schema is null. fetch schema from commit metadata and use it + HoodieTableMetaClient meta = HoodieTableMetaClient.builder().setConf(new Configuration(fs.getConf())).setBasePath(cfg.targetBasePath).setPayloadClassName(cfg.payloadClassName).build(); + TableSchemaResolver schemaResolver = new TableSchemaResolver(meta); + newWriteSchema = schemaResolver.getTableAvroSchema(false); + } + } + return newWriteSchema; + } catch (Exception e) { + throw new HoodieException("Failed to fetch schema from table."); + } + } + /** * Register Avro Schemas. * diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/InputBatch.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/InputBatch.java index b2f6f784ca98b..04e3a574dc5c0 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/InputBatch.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/InputBatch.java @@ -28,6 +28,7 @@ public class InputBatch { + public static final Schema NULL_SCHEMA = Schema.create(Schema.Type.NULL); private final Option batch; private final String checkpointForNextBatch; private final SchemaProvider schemaProvider; @@ -69,7 +70,7 @@ public NullSchemaProvider(TypedProperties props, JavaSparkContext jssc) { @Override public Schema getSourceSchema() { - return Schema.create(Schema.Type.NULL); + return NULL_SCHEMA; } } } diff --git a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java index 2707e8392cb33..ad94ada59b2fd 100644 --- a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java +++ b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestHoodieDeltaStreamer.java @@ -1518,12 +1518,6 @@ private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTrans prepareParquetDFSSource(useSchemaProvider, hasTransformer, ""); } - private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTransformer, String sourceSchemaFile, String targetSchemaFile, - String propsFileName, String parquetSourceRoot, boolean addCommonProps) throws IOException { - prepareParquetDFSSource(useSchemaProvider, hasTransformer, sourceSchemaFile, targetSchemaFile, propsFileName, parquetSourceRoot, addCommonProps, - "partition_path"); - } - private void prepareParquetDFSSource(boolean useSchemaProvider, boolean hasTransformer, String sourceSchemaFile, String targetSchemaFile, String propsFileName, String parquetSourceRoot, boolean addCommonProps, String partitionPath) throws IOException { prepareParquetDFSSource(useSchemaProvider, hasTransformer, sourceSchemaFile, targetSchemaFile, propsFileName, parquetSourceRoot, addCommonProps, @@ -1562,7 +1556,13 @@ private void testParquetDFSSource(boolean useSchemaProvider, List transf } private void testParquetDFSSource(boolean useSchemaProvider, List transformerClassNames, boolean testEmptyBatch) throws Exception { - prepareParquetDFSSource(useSchemaProvider, transformerClassNames != null, testEmptyBatch ? "1" : ""); + PARQUET_SOURCE_ROOT = dfsBasePath + "/parquetFilesDfs" + testNum; + int parquetRecordsCount = 10; + boolean hasTransformer = transformerClassNames != null && !transformerClassNames.isEmpty(); + prepareParquetDFSFiles(parquetRecordsCount, PARQUET_SOURCE_ROOT, FIRST_PARQUET_FILE_NAME, false, null, null); + prepareParquetDFSSource(useSchemaProvider, hasTransformer, "source.avsc", "target.avsc", PROPS_FILENAME_TEST_PARQUET, + PARQUET_SOURCE_ROOT, false, "partition_path", testEmptyBatch ? "1" : ""); + String tableBasePath = dfsBasePath + "/test_parquet_table" + testNum; HoodieDeltaStreamer deltaStreamer = new HoodieDeltaStreamer( TestHelpers.makeConfig(tableBasePath, WriteOperationType.INSERT, testEmptyBatch ? TestParquetDFSSourceEmptyBatch.class.getName() @@ -1570,21 +1570,38 @@ private void testParquetDFSSource(boolean useSchemaProvider, List transf transformerClassNames, PROPS_FILENAME_TEST_PARQUET, false, useSchemaProvider, 100000, false, null, null, "timestamp", null), jsc); deltaStreamer.sync(); - TestHelpers.assertRecordCount(PARQUET_NUM_RECORDS, tableBasePath, sqlContext); - testNum++; + TestHelpers.assertRecordCount(parquetRecordsCount, tableBasePath, sqlContext); if (testEmptyBatch) { prepareParquetDFSFiles(100, PARQUET_SOURCE_ROOT, "2.parquet", false, null, null); - // parquet source to return empty batch deltaStreamer.sync(); // since we mimic'ed empty batch, total records should be same as first sync(). - TestHelpers.assertRecordCount(PARQUET_NUM_RECORDS, tableBasePath, sqlContext); + TestHelpers.assertRecordCount(parquetRecordsCount, tableBasePath, sqlContext); HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder().setBasePath(tableBasePath).setConf(jsc.hadoopConfiguration()).build(); // validate table schema fetches valid schema from last but one commit. TableSchemaResolver tableSchemaResolver = new TableSchemaResolver(metaClient); assertNotEquals(tableSchemaResolver.getTableAvroSchema(), Schema.create(Schema.Type.NULL).toString()); } + + // proceed w/ non empty batch. + prepareParquetDFSFiles(100, PARQUET_SOURCE_ROOT, "3.parquet", false, null, null); + deltaStreamer.sync(); + TestHelpers.assertRecordCount(parquetRecordsCount + 100, tableBasePath, sqlContext); + // validate commit metadata for all completed commits to have valid schema in extra metadata. + HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder().setBasePath(tableBasePath).setConf(jsc.hadoopConfiguration()).build(); + metaClient.reloadActiveTimeline().getCommitsTimeline().filterCompletedInstants().getInstants().forEach(entry -> assertValidSchemaInCommitMetadata(entry, metaClient)); + testNum++; + } + + private void assertValidSchemaInCommitMetadata(HoodieInstant instant, HoodieTableMetaClient metaClient) { + try { + HoodieCommitMetadata commitMetadata = HoodieCommitMetadata + .fromBytes(metaClient.getActiveTimeline().getInstantDetails(instant).get(), HoodieCommitMetadata.class); + assertFalse(StringUtils.isNullOrEmpty(commitMetadata.getMetadata(HoodieCommitMetadata.SCHEMA_KEY))); + } catch (IOException ioException) { + throw new HoodieException("Failed to parse commit metadata for " + instant.toString()); + } } private void testORCDFSSource(boolean useSchemaProvider, List transformerClassNames) throws Exception { From 52e63b39d6189beb3b381944ed553bb0052b12c9 Mon Sep 17 00:00:00 2001 From: wqwl611 <67826098+wqwl611@users.noreply.github.com> Date: Sat, 14 May 2022 09:01:15 +0800 Subject: [PATCH 25/52] [HUDI-4097] add table info to jobStatus (#5529) Co-authored-by: wqwl611 --- .../org/apache/hudi/client/BaseHoodieWriteClient.java | 4 ++-- .../org/apache/hudi/client/CompactionAdminClient.java | 6 +++--- .../org/apache/hudi/client/HoodieTimelineArchiver.java | 2 +- .../main/java/org/apache/hudi/index/HoodieIndexUtils.java | 2 +- .../org/apache/hudi/index/bloom/HoodieBloomIndex.java | 4 ++-- .../hudi/metadata/HoodieBackedTableMetadataWriter.java | 2 +- .../src/main/java/org/apache/hudi/table/HoodieTable.java | 6 +++--- .../hudi/table/action/clean/CleanActionExecutor.java | 2 +- .../hudi/table/action/clean/CleanPlanActionExecutor.java | 4 ++-- .../apache/hudi/table/action/commit/BaseWriteHelper.java | 2 +- .../apache/hudi/table/action/compact/HoodieCompactor.java | 4 ++-- .../table/action/compact/RunCompactionActionExecutor.java | 2 +- .../action/compact/ScheduleCompactionActionExecutor.java | 2 +- .../hudi/table/action/rollback/BaseRollbackHelper.java | 4 ++-- .../action/rollback/ListingBasedRollbackStrategy.java | 2 +- .../table/action/savepoint/SavepointActionExecutor.java | 2 +- .../java/org/apache/hudi/table/marker/WriteMarkers.java | 2 +- .../org/apache/hudi/client/HoodieFlinkWriteClient.java | 4 ++-- .../hudi/table/action/commit/JavaUpsertPartitioner.java | 2 +- .../java/org/apache/hudi/client/SparkRDDWriteClient.java | 4 ++-- .../hudi/index/bloom/SparkHoodieBloomIndexHelper.java | 2 +- .../bootstrap/SparkBootstrapCommitActionExecutor.java | 2 +- .../action/commit/BaseSparkCommitActionExecutor.java | 8 ++++---- .../hudi/table/action/commit/UpsertPartitioner.java | 2 +- .../org/apache/hudi/utilities/HDFSParquetImporter.java | 2 +- .../org/apache/hudi/utilities/HoodieSnapshotCopier.java | 2 +- .../org/apache/hudi/utilities/HoodieSnapshotExporter.java | 2 +- 27 files changed, 41 insertions(+), 41 deletions(-) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java index 4b747d3a77c00..2f425acbc7f2b 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java @@ -334,7 +334,7 @@ protected void preCommit(HoodieInstant inflightInstant, HoodieCommitMetadata met * @param metadata instance of {@link HoodieCommitMetadata}. */ protected void writeTableMetadata(HoodieTable table, String instantTime, String actionType, HoodieCommitMetadata metadata) { - context.setJobStatus(this.getClass().getSimpleName(), "Committing to metadata table"); + context.setJobStatus(this.getClass().getSimpleName(), "Committing to metadata table: " + config.getTableName()); table.getMetadataWriter(instantTime).ifPresent(w -> ((HoodieTableMetadataWriter) w).update(metadata, instantTime, table.isTableServiceAction(actionType))); } @@ -1038,7 +1038,7 @@ public void dropIndex(List partitionTypes) { HoodieInstant ownerInstant = new HoodieInstant(true, HoodieTimeline.INDEXING_ACTION, dropInstant); this.txnManager.beginTransaction(Option.of(ownerInstant), Option.empty()); try { - context.setJobStatus(this.getClass().getSimpleName(), "Dropping partitions from metadata table"); + context.setJobStatus(this.getClass().getSimpleName(), "Dropping partitions from metadata table: " + config.getTableName()); table.getMetadataWriter(dropInstant).ifPresent(w -> { try { ((HoodieTableMetadataWriter) w).dropMetadataPartitions(partitionTypes); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CompactionAdminClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CompactionAdminClient.java index 40e8f85a3ac70..d006b52b3306a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CompactionAdminClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/CompactionAdminClient.java @@ -85,7 +85,7 @@ public List validateCompactionPlan(HoodieTableMetaClient met if (plan.getOperations() != null) { List ops = plan.getOperations().stream() .map(CompactionOperation::convertFromAvroRecordInstance).collect(Collectors.toList()); - context.setJobStatus(this.getClass().getSimpleName(), "Validate compaction operations"); + context.setJobStatus(this.getClass().getSimpleName(), "Validate compaction operations: " + config.getTableName()); return context.map(ops, op -> { try { return validateCompactionOperation(metaClient, compactionInstant, op, Option.of(fsView)); @@ -351,7 +351,7 @@ private List runRenamingOps(HoodieTableMetaClient metaClient, } else { LOG.info("The following compaction renaming operations needs to be performed to un-schedule"); if (!dryRun) { - context.setJobStatus(this.getClass().getSimpleName(), "Execute unschedule operations"); + context.setJobStatus(this.getClass().getSimpleName(), "Execute unschedule operations: " + config.getTableName()); return context.map(renameActions, lfPair -> { try { LOG.info("RENAME " + lfPair.getLeft().getPath() + " => " + lfPair.getRight().getPath()); @@ -394,7 +394,7 @@ public List> getRenamingActionsForUnschedulin "Number of Compaction Operations :" + plan.getOperations().size() + " for instant :" + compactionInstant); List ops = plan.getOperations().stream() .map(CompactionOperation::convertFromAvroRecordInstance).collect(Collectors.toList()); - context.setJobStatus(this.getClass().getSimpleName(), "Generate compaction unscheduling operations"); + context.setJobStatus(this.getClass().getSimpleName(), "Generate compaction unscheduling operations: " + config.getTableName()); return context.flatMap(ops, op -> { try { return getRenamingActionsForUnschedulingCompactionOperation(metaClient, compactionInstant, op, diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTimelineArchiver.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTimelineArchiver.java index 190a5fe1c6064..41bcf001a0b6d 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTimelineArchiver.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTimelineArchiver.java @@ -519,7 +519,7 @@ private boolean deleteArchivedInstants(List archivedInstants, Hoo new Path(metaClient.getMetaPath(), archivedInstant.getFileName()) ).map(Path::toString).collect(Collectors.toList()); - context.setJobStatus(this.getClass().getSimpleName(), "Delete archived instants"); + context.setJobStatus(this.getClass().getSimpleName(), "Delete archived instants: " + config.getTableName()); Map resultDeleteInstantFiles = deleteFilesParallelize(metaClient, instantFiles, context, false); for (Map.Entry result : resultDeleteInstantFiles.entrySet()) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java index b714c50334b4f..9b3dc8df0098a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndexUtils.java @@ -83,7 +83,7 @@ public static List getLatestBaseFilesForPartition( public static List> getLatestBaseFilesForAllPartitions(final List partitions, final HoodieEngineContext context, final HoodieTable hoodieTable) { - context.setJobStatus(HoodieIndexUtils.class.getSimpleName(), "Load latest base files from all partitions"); + context.setJobStatus(HoodieIndexUtils.class.getSimpleName(), "Load latest base files from all partitions: " + hoodieTable.getConfig().getTableName()); return context.flatMap(partitions, partitionPath -> { List> filteredFiles = getLatestBaseFilesForPartition(partitionPath, hoodieTable).stream() diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bloom/HoodieBloomIndex.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bloom/HoodieBloomIndex.java index aeaf78672680d..6545c642c4ccb 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bloom/HoodieBloomIndex.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bloom/HoodieBloomIndex.java @@ -167,7 +167,7 @@ List> loadColumnRangesFromFiles( .map(pair -> Pair.of(pair.getKey(), pair.getValue().getFileId())) .collect(toList()); - context.setJobStatus(this.getClass().getName(), "Obtain key ranges for file slices (range pruning=on)"); + context.setJobStatus(this.getClass().getName(), "Obtain key ranges for file slices (range pruning=on): " + config.getTableName()); return context.map(partitionPathFileIDList, pf -> { try { HoodieRangeInfoHandle rangeInfoHandle = new HoodieRangeInfoHandle(config, hoodieTable, pf); @@ -209,7 +209,7 @@ private List> getFileInfoForLatestBaseFiles( protected List> loadColumnRangesFromMetaIndex( List partitions, final HoodieEngineContext context, final HoodieTable hoodieTable) { // also obtain file ranges, if range pruning is enabled - context.setJobStatus(this.getClass().getName(), "Load meta index key ranges for file slices"); + context.setJobStatus(this.getClass().getName(), "Load meta index key ranges for file slices: " + config.getTableName()); final String keyField = hoodieTable.getMetaClient().getTableConfig().getRecordKeyFieldProp(); return context.flatMap(partitions, partitionName -> { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java index d080d14a69fad..f5a96fb676131 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/metadata/HoodieBackedTableMetadataWriter.java @@ -1047,7 +1047,7 @@ protected void cleanIfNecessary(BaseHoodieWriteClient writeClient, String instan private void initialCommit(String createInstantTime, List partitionTypes) { // List all partitions in the basePath of the containing dataset LOG.info("Initializing metadata table by using file listings in " + dataWriteConfig.getBasePath()); - engineContext.setJobStatus(this.getClass().getSimpleName(), "Initializing metadata table by listing files and partitions"); + engineContext.setJobStatus(this.getClass().getSimpleName(), "Initializing metadata table by listing files and partitions: " + dataWriteConfig.getTableName()); Map> partitionToRecordsMap = new HashMap<>(); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/HoodieTable.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/HoodieTable.java index f6f73f633ef5d..807865dae2416 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/HoodieTable.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/HoodieTable.java @@ -566,7 +566,7 @@ public void finalizeWrite(HoodieEngineContext context, String instantTs, List>> invalidFilesByPartition) { // Now delete partially written files - context.setJobStatus(this.getClass().getSimpleName(), "Delete invalid files generated during the write operation"); + context.setJobStatus(this.getClass().getSimpleName(), "Delete invalid files generated during the write operation: " + config.getTableName()); context.map(new ArrayList<>(invalidFilesByPartition.values()), partitionWithFileList -> { final FileSystem fileSystem = metaClient.getFs(); LOG.info("Deleting invalid data files=" + partitionWithFileList); @@ -642,7 +642,7 @@ protected void reconcileAgainstMarkers(HoodieEngineContext context, } // Now delete partially written files - context.setJobStatus(this.getClass().getSimpleName(), "Delete all partially written files"); + context.setJobStatus(this.getClass().getSimpleName(), "Delete all partially written files: " + config.getTableName()); deleteInvalidFilesByPartitions(context, invalidPathsByPartition); // Now ensure the deleted files disappear @@ -665,7 +665,7 @@ protected void reconcileAgainstMarkers(HoodieEngineContext context, */ private void waitForAllFiles(HoodieEngineContext context, Map>> groupByPartition, FileVisibility visibility) { // This will either ensure all files to be deleted are present. - context.setJobStatus(this.getClass().getSimpleName(), "Wait for all files to appear/disappear"); + context.setJobStatus(this.getClass().getSimpleName(), "Wait for all files to appear/disappear: " + config.getTableName()); boolean checkPassed = context.map(new ArrayList<>(groupByPartition.entrySet()), partitionWithFileList -> waitForCondition(partitionWithFileList.getKey(), partitionWithFileList.getValue().stream(), visibility), config.getFinalizeWriteParallelism()) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanActionExecutor.java index 2bb277b05b4f8..30ed27b39b77a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanActionExecutor.java @@ -132,7 +132,7 @@ List clean(HoodieEngineContext context, HoodieCleanerPlan clean config.getCleanerParallelism()); LOG.info("Using cleanerParallelism: " + cleanerParallelism); - context.setJobStatus(this.getClass().getSimpleName(), "Perform cleaning of partitions"); + context.setJobStatus(this.getClass().getSimpleName(), "Perform cleaning of partitions: " + config.getTableName()); Stream> filesToBeDeletedPerPartition = cleanerPlan.getFilePathsToBeDeletedPerPartition().entrySet().stream() diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanPlanActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanPlanActionExecutor.java index fb2df582bfe15..d8e51bcd1643e 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanPlanActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/clean/CleanPlanActionExecutor.java @@ -96,7 +96,7 @@ HoodieCleanerPlan requestClean(HoodieEngineContext context) { try { CleanPlanner planner = new CleanPlanner<>(context, table, config); Option earliestInstant = planner.getEarliestCommitToRetain(); - context.setJobStatus(this.getClass().getSimpleName(), "Obtaining list of partitions to be cleaned"); + context.setJobStatus(this.getClass().getSimpleName(), "Obtaining list of partitions to be cleaned: " + config.getTableName()); List partitionsToClean = planner.getPartitionPathsToClean(earliestInstant); if (partitionsToClean.isEmpty()) { @@ -107,7 +107,7 @@ HoodieCleanerPlan requestClean(HoodieEngineContext context) { int cleanerParallelism = Math.min(partitionsToClean.size(), config.getCleanerParallelism()); LOG.info("Using cleanerParallelism: " + cleanerParallelism); - context.setJobStatus(this.getClass().getSimpleName(), "Generating list of file slices to be cleaned"); + context.setJobStatus(this.getClass().getSimpleName(), "Generating list of file slices to be cleaned: " + config.getTableName()); Map>> cleanOpsWithPartitionMeta = context .map(partitionsToClean, partitionPathToClean -> Pair.of(partitionPathToClean, planner.getDeletePaths(partitionPathToClean)), cleanerParallelism) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseWriteHelper.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseWriteHelper.java index 6d5372b47297d..846afec7c1db3 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseWriteHelper.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseWriteHelper.java @@ -49,7 +49,7 @@ public HoodieWriteMetadata write(String instantTime, I taggedRecords = dedupedRecords; if (table.getIndex().requiresTagging(operationType)) { // perform index loop up to get existing location of records - context.setJobStatus(this.getClass().getSimpleName(), "Tagging"); + context.setJobStatus(this.getClass().getSimpleName(), "Tagging: " + table.getConfig().getTableName()); taggedRecords = tag(dedupedRecords, context, table); } Duration indexLookupDuration = Duration.between(lookupBegin, Instant.now()); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/HoodieCompactor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/HoodieCompactor.java index d548e07eac8a5..75954872aedd5 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/HoodieCompactor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/HoodieCompactor.java @@ -133,7 +133,7 @@ public HoodieData compact( .map(CompactionOperation::convertFromAvroRecordInstance).collect(toList()); LOG.info("Compactor compacting " + operations + " files"); - context.setJobStatus(this.getClass().getSimpleName(), "Compacting file slices"); + context.setJobStatus(this.getClass().getSimpleName(), "Compacting file slices: " + config.getTableName()); TaskContextSupplier taskContextSupplier = table.getTaskContextSupplier(); return context.parallelize(operations).map(operation -> compact( compactionHandler, metaClient, config, operation, compactionInstantTime, taskContextSupplier)) @@ -288,7 +288,7 @@ HoodieCompactionPlan generateCompactionPlan( SliceView fileSystemView = hoodieTable.getSliceView(); LOG.info("Compaction looking for files to compact in " + partitionPaths + " partitions"); - context.setJobStatus(this.getClass().getSimpleName(), "Looking for files to compact"); + context.setJobStatus(this.getClass().getSimpleName(), "Looking for files to compact: " + config.getTableName()); List operations = context.flatMap(partitionPaths, partitionPath -> fileSystemView .getLatestFileSlices(partitionPath) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/RunCompactionActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/RunCompactionActionExecutor.java index 24c0dbc80ed80..5c184e77dfaa2 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/RunCompactionActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/RunCompactionActionExecutor.java @@ -88,7 +88,7 @@ public HoodieWriteMetadata> execute() { context, compactionPlan, table, configCopy, instantTime, compactionHandler); compactor.maybePersist(statuses, config); - context.setJobStatus(this.getClass().getSimpleName(), "Preparing compaction metadata"); + context.setJobStatus(this.getClass().getSimpleName(), "Preparing compaction metadata: " + config.getTableName()); List updateStatusMap = statuses.map(WriteStatus::getStat).collectAsList(); HoodieCommitMetadata metadata = new HoodieCommitMetadata(true); for (HoodieWriteStat stat : updateStatusMap) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/ScheduleCompactionActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/ScheduleCompactionActionExecutor.java index d3cc5660bc70a..05fb7c0c92d1d 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/ScheduleCompactionActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/ScheduleCompactionActionExecutor.java @@ -119,7 +119,7 @@ private HoodieCompactionPlan scheduleCompaction() { .collect(Collectors.toSet()); // exclude files in pending clustering from compaction. fgInPendingCompactionAndClustering.addAll(fileSystemView.getFileGroupsInPendingClustering().map(Pair::getLeft).collect(Collectors.toSet())); - context.setJobStatus(this.getClass().getSimpleName(), "Compaction: generating compaction plan"); + context.setJobStatus(this.getClass().getSimpleName(), "Compaction: generating compaction plan: " + config.getTableName()); return compactor.generateCompactionPlan(context, table, config, instantTime, fgInPendingCompactionAndClustering); } catch (IOException e) { throw new HoodieCompactionException("Could not schedule compaction " + config.getBasePath(), e); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/BaseRollbackHelper.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/BaseRollbackHelper.java index 8475afe16eea0..8d5e767307d78 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/BaseRollbackHelper.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/BaseRollbackHelper.java @@ -72,7 +72,7 @@ public BaseRollbackHelper(HoodieTableMetaClient metaClient, HoodieWriteConfig co public List performRollback(HoodieEngineContext context, HoodieInstant instantToRollback, List rollbackRequests) { int parallelism = Math.max(Math.min(rollbackRequests.size(), config.getRollbackParallelism()), 1); - context.setJobStatus(this.getClass().getSimpleName(), "Perform rollback actions"); + context.setJobStatus(this.getClass().getSimpleName(), "Perform rollback actions: " + config.getTableName()); // If not for conversion to HoodieRollbackInternalRequests, code fails. Using avro model (HoodieRollbackRequest) within spark.parallelize // is failing with com.esotericsoftware.kryo.KryoException // stack trace: https://gist.github.com/nsivabalan/b6359e7d5038484f8043506c8bc9e1c8 @@ -88,7 +88,7 @@ public List performRollback(HoodieEngineContext context, Hoo public List collectRollbackStats(HoodieEngineContext context, HoodieInstant instantToRollback, List rollbackRequests) { int parallelism = Math.max(Math.min(rollbackRequests.size(), config.getRollbackParallelism()), 1); - context.setJobStatus(this.getClass().getSimpleName(), "Collect rollback stats for upgrade/downgrade"); + context.setJobStatus(this.getClass().getSimpleName(), "Collect rollback stats for upgrade/downgrade: " + config.getTableName()); // If not for conversion to HoodieRollbackInternalRequests, code fails. Using avro model (HoodieRollbackRequest) within spark.parallelize // is failing with com.esotericsoftware.kryo.KryoException // stack trace: https://gist.github.com/nsivabalan/b6359e7d5038484f8043506c8bc9e1c8 diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/ListingBasedRollbackStrategy.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/ListingBasedRollbackStrategy.java index e3159abad8de7..aa9e0b6583a24 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/ListingBasedRollbackStrategy.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/rollback/ListingBasedRollbackStrategy.java @@ -88,7 +88,7 @@ public List getRollbackRequests(HoodieInstant instantToRo FSUtils.getAllPartitionPaths(context, table.getMetaClient().getBasePath(), false, false); int numPartitions = Math.max(Math.min(partitionPaths.size(), config.getRollbackParallelism()), 1); - context.setJobStatus(this.getClass().getSimpleName(), "Creating Listing Rollback Plan"); + context.setJobStatus(this.getClass().getSimpleName(), "Creating Listing Rollback Plan: " + config.getTableName()); HoodieTableType tableType = table.getMetaClient().getTableType(); String baseFileExtension = getBaseFileExtension(metaClient); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/savepoint/SavepointActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/savepoint/SavepointActionExecutor.java index 134b238852cd3..7f408c1b8d24a 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/savepoint/SavepointActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/savepoint/SavepointActionExecutor.java @@ -84,7 +84,7 @@ public HoodieSavepointMetadata execute() { ValidationUtils.checkArgument(HoodieTimeline.compareTimestamps(instantTime, HoodieTimeline.GREATER_THAN_OR_EQUALS, lastCommitRetained), "Could not savepoint commit " + instantTime + " as this is beyond the lookup window " + lastCommitRetained); - context.setJobStatus(this.getClass().getSimpleName(), "Collecting latest files for savepoint " + instantTime); + context.setJobStatus(this.getClass().getSimpleName(), "Collecting latest files for savepoint " + instantTime + " " + table.getConfig().getTableName()); List partitions = FSUtils.getAllPartitionPaths(context, config.getMetadataConfig(), table.getMetaClient().getBasePath()); Map> latestFilesMap = context.mapToPair(partitions, partitionPath -> { // Scan all partitions files with this commit time diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/WriteMarkers.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/WriteMarkers.java index 3dacf1e1302c5..07428dd936469 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/WriteMarkers.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/marker/WriteMarkers.java @@ -84,7 +84,7 @@ public Option createIfNotExists(String partitionPath, String dataFileName, */ public void quietDeleteMarkerDir(HoodieEngineContext context, int parallelism) { try { - context.setJobStatus(this.getClass().getSimpleName(), "Deleting marker directory"); + context.setJobStatus(this.getClass().getSimpleName(), "Deleting marker directory: " + basePath); deleteMarkerDir(context, parallelism); } catch (Exception e) { LOG.warn("Error deleting marker directory for instant " + instantTime, e); diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java index 271ba95d941e8..524758a675cfc 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java @@ -356,7 +356,7 @@ public void completeCompaction( HoodieCommitMetadata metadata, HoodieTable table, String compactionCommitTime) { - this.context.setJobStatus(this.getClass().getSimpleName(), "Collect compaction write status and commit compaction"); + this.context.setJobStatus(this.getClass().getSimpleName(), "Collect compaction write status and commit compaction: " + config.getTableName()); List writeStats = metadata.getWriteStats(); final HoodieInstant compactionInstant = new HoodieInstant(HoodieInstant.State.INFLIGHT, HoodieTimeline.COMPACTION_ACTION, compactionCommitTime); try { @@ -508,7 +508,7 @@ public Map> getPartitionToReplacedFileIds( List partitionPaths = FSUtils.getAllPartitionPaths(context, config.getMetadataConfig(), table.getMetaClient().getBasePath()); if (partitionPaths != null && partitionPaths.size() > 0) { - context.setJobStatus(this.getClass().getSimpleName(), "Getting ExistingFileIds of all partitions"); + context.setJobStatus(this.getClass().getSimpleName(), "Getting ExistingFileIds of all partitions: " + config.getTableName()); partitionToExistingFileIds = partitionPaths.stream().parallel() .collect( Collectors.toMap( diff --git a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaUpsertPartitioner.java b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaUpsertPartitioner.java index deaf934cf5d03..fb19259b55591 100644 --- a/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaUpsertPartitioner.java +++ b/hudi-client/hudi-java-client/src/main/java/org/apache/hudi/table/action/commit/JavaUpsertPartitioner.java @@ -230,7 +230,7 @@ private Map> getSmallFilesForPartitions(List par } if (partitionPaths != null && partitionPaths.size() > 0) { - context.setJobStatus(this.getClass().getSimpleName(), "Getting small files from partitions"); + context.setJobStatus(this.getClass().getSimpleName(), "Getting small files from partitions: " + config.getTableName()); partitionSmallFilesMap = context.mapToPair(partitionPaths, partitionPath -> new ImmutablePair<>(partitionPath, getSmallFiles(partitionPath)), 0); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java index 7b0c8bbc8d25c..3b512f0bdc871 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java @@ -117,7 +117,7 @@ protected HoodieIndex createIndex(HoodieWriteConfig writeConfig) { @Override public boolean commit(String instantTime, JavaRDD writeStatuses, Option> extraMetadata, String commitActionType, Map> partitionToReplacedFileIds) { - context.setJobStatus(this.getClass().getSimpleName(), "Committing stats"); + context.setJobStatus(this.getClass().getSimpleName(), "Committing stats: " + config.getTableName()); List writeStats = writeStatuses.map(WriteStatus::getStat).collect(); return commitStats(instantTime, writeStats, extraMetadata, commitActionType, partitionToReplacedFileIds); } @@ -303,7 +303,7 @@ public void commitCompaction(String compactionInstantTime, HoodieCommitMetadata protected void completeCompaction(HoodieCommitMetadata metadata, HoodieTable table, String compactionCommitTime) { - this.context.setJobStatus(this.getClass().getSimpleName(), "Collect compaction write status and commit compaction"); + this.context.setJobStatus(this.getClass().getSimpleName(), "Collect compaction write status and commit compaction: " + config.getTableName()); List writeStats = metadata.getWriteStats(); final HoodieInstant compactionInstant = new HoodieInstant(HoodieInstant.State.INFLIGHT, HoodieTimeline.COMPACTION_ACTION, compactionCommitTime); try { diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/bloom/SparkHoodieBloomIndexHelper.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/bloom/SparkHoodieBloomIndexHelper.java index 9c2f37d56a509..c9fb895adc401 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/bloom/SparkHoodieBloomIndexHelper.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/bloom/SparkHoodieBloomIndexHelper.java @@ -126,7 +126,7 @@ private Map computeComparisonsPerFileGroup( if (config.getBloomIndexPruneByRanges()) { // we will just try exploding the input and then count to determine comparisons // FIX(vc): Only do sampling here and extrapolate? - context.setJobStatus(this.getClass().getSimpleName(), "Compute all comparisons needed between records and files"); + context.setJobStatus(this.getClass().getSimpleName(), "Compute all comparisons needed between records and files: " + config.getTableName()); fileToComparisons = fileComparisonsRDD.mapToPair(t -> t).countByKey(); } else { fileToComparisons = new HashMap<>(); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/SparkBootstrapCommitActionExecutor.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/SparkBootstrapCommitActionExecutor.java index 504da8a722810..4e488047d845e 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/SparkBootstrapCommitActionExecutor.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/bootstrap/SparkBootstrapCommitActionExecutor.java @@ -334,7 +334,7 @@ private HoodieData runMetadataBootstrap(List getMetadataHandler(config, table, partitionFsPair.getRight().getRight()).runMetadataBootstrap(partitionFsPair.getLeft(), partitionFsPair.getRight().getLeft(), keyGenerator)); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java index 205da82ac145d..f8e4b31ff687e 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/BaseSparkCommitActionExecutor.java @@ -113,7 +113,7 @@ public BaseSparkCommitActionExecutor(HoodieEngineContext context, } private HoodieData> clusteringHandleUpdate(HoodieData> inputRecords, Set fileGroupsInPendingClustering) { - context.setJobStatus(this.getClass().getSimpleName(), "Handling updates which are under clustering"); + context.setJobStatus(this.getClass().getSimpleName(), "Handling updates which are under clustering: " + config.getTableName()); UpdateStrategy>> updateStrategy = (UpdateStrategy>>) ReflectionUtils .loadClass(config.getClusteringUpdatesStrategyClass(), this.context, fileGroupsInPendingClustering); Pair>, Set> recordsAndPendingClusteringFileGroups = @@ -152,7 +152,7 @@ public HoodieWriteMetadata> execute(HoodieData> execute(HoodieData> inputRecordsWithClusteringUpdate = fileGroupsInPendingClustering.isEmpty() ? inputRecords : clusteringHandleUpdate(inputRecords, fileGroupsInPendingClustering); - context.setJobStatus(this.getClass().getSimpleName(), "Doing partition and writing data"); + context.setJobStatus(this.getClass().getSimpleName(), "Doing partition and writing data: " + config.getTableName()); HoodieData writeStatuses = mapPartitionsAsRDD(inputRecordsWithClusteringUpdate, partitioner); HoodieWriteMetadata> result = new HoodieWriteMetadata<>(); updateIndexAndCommitIfNeeded(writeStatuses, result); @@ -280,7 +280,7 @@ protected void setCommitMetadata(HoodieWriteMetadata> re @Override protected void commit(Option> extraMetadata, HoodieWriteMetadata> result) { - context.setJobStatus(this.getClass().getSimpleName(), "Commit write status collect"); + context.setJobStatus(this.getClass().getSimpleName(), "Commit write status collect: " + config.getTableName()); commit(extraMetadata, result, result.getWriteStatuses().map(WriteStatus::getStat).collectAsList()); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/UpsertPartitioner.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/UpsertPartitioner.java index c54c526253f0b..c2f5a43066d36 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/UpsertPartitioner.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/action/commit/UpsertPartitioner.java @@ -266,7 +266,7 @@ private Map> getSmallFilesForPartitions(List par } if (partitionPaths != null && partitionPaths.size() > 0) { - context.setJobStatus(this.getClass().getSimpleName(), "Getting small files from partitions"); + context.setJobStatus(this.getClass().getSimpleName(), "Getting small files from partitions: " + config.getTableName()); JavaRDD partitionPathRdds = jsc.parallelize(partitionPaths, partitionPaths.size()); partitionSmallFilesMap = partitionPathRdds.mapToPair((PairFunction>) partitionPath -> new Tuple2<>(partitionPath, getSmallFiles(partitionPath))).collectAsMap(); diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HDFSParquetImporter.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HDFSParquetImporter.java index b5d7dc4b107dd..a2e81a3371d10 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HDFSParquetImporter.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HDFSParquetImporter.java @@ -174,7 +174,7 @@ protected JavaRDD> buildHoodieRecordsForImport ParquetInputFormat.setReadSupportClass(job, (AvroReadSupport.class)); HoodieEngineContext context = new HoodieSparkEngineContext(jsc); - context.setJobStatus(this.getClass().getSimpleName(), "Build records for import"); + context.setJobStatus(this.getClass().getSimpleName(), "Build records for import: " + cfg.tableName); return jsc.newAPIHadoopFile(cfg.srcPath, ParquetInputFormat.class, Void.class, GenericRecord.class, job.getConfiguration()) // To reduce large number of tasks. diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotCopier.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotCopier.java index a2717a35617f3..402b380a00e08 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotCopier.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotCopier.java @@ -107,7 +107,7 @@ public void snapshot(JavaSparkContext jsc, String baseDir, final String outputDi fs.delete(new Path(outputDir), true); } - context.setJobStatus(this.getClass().getSimpleName(), "Creating a snapshot"); + context.setJobStatus(this.getClass().getSimpleName(), "Creating a snapshot: " + baseDir); List> filesToCopy = context.flatMap(partitions, partition -> { // Only take latest version files <= latestCommit. diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotExporter.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotExporter.java index 255393b232eb1..753765fb6a504 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotExporter.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/HoodieSnapshotExporter.java @@ -177,7 +177,7 @@ private void exportAsNonHudi(JavaSparkContext jsc, Config cfg, List part : ReflectionUtils.loadClass(cfg.outputPartitioner); HoodieEngineContext context = new HoodieSparkEngineContext(jsc); - context.setJobStatus(this.getClass().getSimpleName(), "Exporting as non-HUDI dataset"); + context.setJobStatus(this.getClass().getSimpleName(), "Exporting as non-HUDI dataset: " + cfg.targetOutputPath); final BaseFileOnlyView fsView = getBaseFileOnlyView(jsc, cfg); Iterator exportingFilePaths = jsc .parallelize(partitions, partitions.size()) From 6e16e719cd614329018cd34a7c57d342fe2fa376 Mon Sep 17 00:00:00 2001 From: xi chaomin <36392121+xicm@users.noreply.github.com> Date: Sat, 14 May 2022 19:37:31 +0800 Subject: [PATCH 26/52] [HUDI-3980] Suport kerberos hbase index (#5464) - Add configurations in HoodieHBaseIndexConfig.java to support kerberos hbase connection. Co-authored-by: xicm --- .../hudi/config/HoodieHBaseIndexConfig.java | 52 +++++++++++++++++++ .../apache/hudi/config/HoodieWriteConfig.java | 20 +++++++ .../index/hbase/SparkHoodieHBaseIndex.java | 27 ++++++++-- 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieHBaseIndexConfig.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieHBaseIndexConfig.java index 3d7e3a7941daa..2389aa7fc1e02 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieHBaseIndexConfig.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieHBaseIndexConfig.java @@ -157,6 +157,33 @@ public class HoodieHBaseIndexConfig extends HoodieConfig { .withDocumentation("When set to true, the rollback method will delete the last failed task index. " + "The default value is false. Because deleting the index will add extra load on the Hbase cluster for each rollback"); + public static final ConfigProperty SECURITY_AUTHENTICATION = ConfigProperty + .key("hoodie.index.hbase.security.authentication") + .defaultValue("simple") + .withDocumentation("Property to decide if the hbase cluster secure authentication is enabled or not. " + + "Possible values are 'simple' (no authentication), and 'kerberos'."); + + public static final ConfigProperty KERBEROS_USER_KEYTAB = ConfigProperty + .key("hoodie.index.hbase.kerberos.user.keytab") + .noDefaultValue() + .withDocumentation("File name of the kerberos keytab file for connecting to the hbase cluster."); + + public static final ConfigProperty KERBEROS_USER_PRINCIPAL = ConfigProperty + .key("hoodie.index.hbase.kerberos.user.principal") + .noDefaultValue() + .withDocumentation("The kerberos principal name for connecting to the hbase cluster."); + + public static final ConfigProperty REGIONSERVER_PRINCIPAL = ConfigProperty + .key("hoodie.index.hbase.regionserver.kerberos.principal") + .noDefaultValue() + .withDocumentation("The value of hbase.regionserver.kerberos.principal in hbase cluster."); + + public static final ConfigProperty MASTER_PRINCIPAL = ConfigProperty + .key("hoodie.index.hbase.master.kerberos.principal") + .noDefaultValue() + .withDocumentation("The value of hbase.master.kerberos.principal in hbase cluster."); + + /** * @deprecated Use {@link #ZKQUORUM} and its methods instead */ @@ -444,6 +471,31 @@ public Builder hbaseZkZnodeParent(String zkZnodeParent) { return this; } + public Builder hbaseSecurityAuthentication(String authentication) { + hBaseIndexConfig.setValue(SECURITY_AUTHENTICATION, authentication); + return this; + } + + public Builder hbaseKerberosUserKeytab(String keytab) { + hBaseIndexConfig.setValue(KERBEROS_USER_KEYTAB, keytab); + return this; + } + + public Builder hbaseKerberosUserPrincipal(String principal) { + hBaseIndexConfig.setValue(KERBEROS_USER_PRINCIPAL, principal); + return this; + } + + public Builder hbaseKerberosRegionserverPrincipal(String principal) { + hBaseIndexConfig.setValue(REGIONSERVER_PRINCIPAL, principal); + return this; + } + + public Builder hbaseKerberosMasterPrincipal(String principal) { + hBaseIndexConfig.setValue(MASTER_PRINCIPAL, principal); + return this; + } + /** *

* Method to set maximum QPS allowed per Region Server. This should be same across various jobs. This is intended to diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java index 7b49a7a466785..322c2e84e7e89 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java @@ -1488,6 +1488,26 @@ public boolean getHBaseIndexShouldComputeQPSDynamically() { return getBoolean(HoodieHBaseIndexConfig.COMPUTE_QPS_DYNAMICALLY); } + public String getHBaseIndexSecurityAuthentication() { + return getString(HoodieHBaseIndexConfig.SECURITY_AUTHENTICATION); + } + + public String getHBaseIndexKerberosUserKeytab() { + return getString(HoodieHBaseIndexConfig.KERBEROS_USER_KEYTAB); + } + + public String getHBaseIndexKerberosUserPrincipal() { + return getString(HoodieHBaseIndexConfig.KERBEROS_USER_PRINCIPAL); + } + + public String getHBaseIndexRegionserverPrincipal() { + return getString(HoodieHBaseIndexConfig.REGIONSERVER_PRINCIPAL); + } + + public String getHBaseIndexMasterPrincipal() { + return getString(HoodieHBaseIndexConfig.MASTER_PRINCIPAL); + } + public int getHBaseIndexDesiredPutsTime() { return getInt(HoodieHBaseIndexConfig.DESIRED_PUTS_TIME_IN_SECONDS); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/hbase/SparkHoodieHBaseIndex.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/hbase/SparkHoodieHBaseIndex.java index fc73a0aed7d70..f841117d5c3a1 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/hbase/SparkHoodieHBaseIndex.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/hbase/SparkHoodieHBaseIndex.java @@ -42,7 +42,6 @@ import org.apache.hudi.exception.HoodieIndexException; import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.table.HoodieTable; - import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.hbase.HBaseConfiguration; import org.apache.hadoop.hbase.HRegionLocation; @@ -60,10 +59,12 @@ import org.apache.hadoop.hbase.client.ResultScanner; import org.apache.hadoop.hbase.client.Scan; import org.apache.hadoop.hbase.util.Bytes; +import org.apache.hadoop.security.UserGroupInformation; import org.apache.log4j.LogManager; import org.apache.log4j.Logger; import org.apache.spark.Partitioner; import org.apache.spark.SparkConf; +import org.apache.spark.SparkFiles; import org.apache.spark.api.java.JavaPairRDD; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; @@ -72,6 +73,7 @@ import java.io.IOException; import java.io.Serializable; +import java.security.PrivilegedExceptionAction; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; @@ -150,9 +152,28 @@ private Connection getHBaseConnection() { } String port = String.valueOf(config.getHbaseZkPort()); hbaseConfig.set("hbase.zookeeper.property.clientPort", port); + try { - return ConnectionFactory.createConnection(hbaseConfig); - } catch (IOException e) { + String authentication = config.getHBaseIndexSecurityAuthentication(); + if (authentication.equals("kerberos")) { + hbaseConfig.set("hbase.security.authentication", "kerberos"); + hbaseConfig.set("hadoop.security.authentication", "kerberos"); + hbaseConfig.set("hbase.security.authorization", "true"); + hbaseConfig.set("hbase.regionserver.kerberos.principal", config.getHBaseIndexRegionserverPrincipal()); + hbaseConfig.set("hbase.master.kerberos.principal", config.getHBaseIndexMasterPrincipal()); + + String principal = config.getHBaseIndexKerberosUserPrincipal(); + String keytab = SparkFiles.get(config.getHBaseIndexKerberosUserKeytab()); + + UserGroupInformation.setConfiguration(hbaseConfig); + UserGroupInformation ugi = UserGroupInformation.loginUserFromKeytabAndReturnUGI(principal, keytab); + return ugi.doAs((PrivilegedExceptionAction) () -> + (Connection) ConnectionFactory.createConnection(hbaseConfig) + ); + } else { + return ConnectionFactory.createConnection(hbaseConfig); + } + } catch (IOException | InterruptedException e) { throw new HoodieDependentSystemUnavailableException(HoodieDependentSystemUnavailableException.HBASE, quorum + ":" + port, e); } From 75f847691f0bdaf226d4713a8cb8c7639cffd5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=8F=AF=E4=BC=A6?= Date: Mon, 16 May 2022 09:50:29 +0800 Subject: [PATCH 27/52] [HUDI-4001] Filter the properties should not be used when create table for Spark SQL (#5495) --- .../catalyst/catalog/HoodieCatalogTable.scala | 3 + .../spark/sql/hudi/ProvidesHoodieConfig.scala | 3 +- .../command/CreateHoodieTableCommand.scala | 6 +- .../CreateHoodieTableAsSelectCommand.scala | 23 +++- .../spark/sql/hudi/TestCreateTable.scala | 103 +++++++++++++++++- 5 files changed, 127 insertions(+), 11 deletions(-) diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/HoodieCatalogTable.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/HoodieCatalogTable.scala index 7ee8f6ad569b2..76cea362a3b53 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/HoodieCatalogTable.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/HoodieCatalogTable.scala @@ -18,6 +18,7 @@ package org.apache.spark.sql.catalyst.catalog import org.apache.hudi.AvroConversionUtils +import org.apache.hudi.DataSourceWriteOptions.OPERATION import org.apache.hudi.HoodieWriterUtils._ import org.apache.hudi.common.config.DFSPropertiesConfiguration import org.apache.hudi.common.model.HoodieTableType @@ -321,6 +322,8 @@ class HoodieCatalogTable(val spark: SparkSession, val table: CatalogTable) exten } object HoodieCatalogTable { + // The properties should not be used when create table + val needFilterProps: List[String] = List(HoodieTableConfig.DATABASE_NAME.key, HoodieTableConfig.NAME.key, OPERATION.key) def apply(sparkSession: SparkSession, tableIdentifier: TableIdentifier): HoodieCatalogTable = { val catalogTable = sparkSession.sessionState.catalog.getTableMetadata(tableIdentifier) diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/ProvidesHoodieConfig.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/ProvidesHoodieConfig.scala index 31fb0ad6cb0cf..131ebebe85a5a 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/ProvidesHoodieConfig.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/ProvidesHoodieConfig.scala @@ -255,8 +255,7 @@ trait ProvidesHoodieConfig extends Logging { val hoodieProps = getHoodieProps(catalogProperties, tableConfig, sparkSession.sqlContext.conf) val hiveSyncConfig = buildHiveSyncConfig(hoodieProps, hoodieCatalogTable) - // operation can not be overwrite - val options = hoodieCatalogTable.catalogProperties.-(OPERATION.key()) + val options = hoodieCatalogTable.catalogProperties withSparkConf(sparkSession, options) { Map( diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableCommand.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableCommand.scala index 195bf4153c998..9bf1d721525c3 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableCommand.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableCommand.scala @@ -26,6 +26,7 @@ import org.apache.hudi.hadoop.utils.HoodieInputFormatUtils import org.apache.hudi.{DataSourceWriteOptions, SparkAdapterSupport} import org.apache.spark.sql.catalyst.analysis.NoSuchDatabaseException import org.apache.spark.sql.catalyst.catalog._ +import org.apache.spark.sql.catalyst.catalog.HoodieCatalogTable.needFilterProps import org.apache.spark.sql.hive.HiveClientUtils import org.apache.spark.sql.hive.HiveExternalCatalog._ import org.apache.spark.sql.hudi.HoodieSqlCommonUtils.isEnableHive @@ -130,8 +131,9 @@ object CreateHoodieTableCommand { .copy(table = tableName, database = Some(newDatabaseName)) val partitionColumnNames = hoodieCatalogTable.partitionSchema.map(_.name) - // append pk, preCombineKey, type to the properties of table - val newTblProperties = hoodieCatalogTable.catalogProperties ++ HoodieOptionConfig.extractSqlOptions(properties) + // Remove some properties should not be used;append pk, preCombineKey, type to the properties of table + val newTblProperties = + hoodieCatalogTable.catalogProperties.--(needFilterProps) ++ HoodieOptionConfig.extractSqlOptions(properties) val newTable = table.copy( identifier = newTableIdentifier, storage = newStorage, diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala index 1d2cea10afa7d..66aeb850e49e7 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala @@ -23,7 +23,8 @@ import org.apache.hudi.DataSourceWriteOptions import org.apache.hudi.hive.HiveSyncConfig import org.apache.hudi.hive.util.ConfigUtils import org.apache.hudi.sql.InsertMode -import org.apache.spark.sql.catalyst.catalog.{CatalogTable, CatalogTableType, HoodieCatalogTable} +import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat, CatalogTable, CatalogTableType, HoodieCatalogTable} +import org.apache.spark.sql.catalyst.catalog.HoodieCatalogTable.needFilterProps import org.apache.spark.sql.catalyst.plans.QueryPlan import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project} import org.apache.spark.sql.hudi.HoodieSqlCommonUtils @@ -66,9 +67,21 @@ case class CreateHoodieTableAsSelectCommand( // ReOrder the query which move the partition columns to the last of the project list val reOrderedQuery = reOrderPartitionColumn(query, table.partitionColumnNames) - val tableWithSchema = table.copy(schema = reOrderedQuery.schema) + // Remove some properties should not be used + val newStorage = new CatalogStorageFormat( + table.storage.locationUri, + table.storage.inputFormat, + table.storage.outputFormat, + table.storage.serde, + table.storage.compressed, + table.storage.properties.--(needFilterProps)) + val newTable = table.copy( + storage = newStorage, + schema = reOrderedQuery.schema, + properties = table.properties.--(needFilterProps) + ) - val hoodieCatalogTable = HoodieCatalogTable(sparkSession, tableWithSchema) + val hoodieCatalogTable = HoodieCatalogTable(sparkSession, newTable) val tablePath = hoodieCatalogTable.tableLocation val hadoopConf = sparkSession.sessionState.newHadoopConf() assert(HoodieSqlCommonUtils.isEmptyPath(tablePath, hadoopConf), @@ -83,11 +96,11 @@ case class CreateHoodieTableAsSelectCommand( val options = Map( HiveSyncConfig.HIVE_CREATE_MANAGED_TABLE.key -> (table.tableType == CatalogTableType.MANAGED).toString, HiveSyncConfig.HIVE_TABLE_SERDE_PROPERTIES.key -> ConfigUtils.configToString(tblProperties.asJava), - HiveSyncConfig.HIVE_TABLE_PROPERTIES.key -> ConfigUtils.configToString(table.properties.asJava), + HiveSyncConfig.HIVE_TABLE_PROPERTIES.key -> ConfigUtils.configToString(newTable.properties.asJava), DataSourceWriteOptions.SQL_INSERT_MODE.key -> InsertMode.NON_STRICT.value(), DataSourceWriteOptions.SQL_ENABLE_BULK_INSERT.key -> "true" ) - val success = InsertIntoHoodieTableCommand.run(sparkSession, tableWithSchema, reOrderedQuery, Map.empty, + val success = InsertIntoHoodieTableCommand.run(sparkSession, newTable, reOrderedQuery, Map.empty, mode == SaveMode.Overwrite, refreshTable = false, extraOptions = options) if (success) { // If write success, create the table in catalog if it has not synced to the diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala index 69147272dabe0..5435aad05e88a 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestCreateTable.scala @@ -29,6 +29,8 @@ import org.apache.spark.sql.catalyst.TableIdentifier import org.apache.spark.sql.catalyst.catalog.CatalogTableType import org.apache.spark.sql.types._ +import org.junit.jupiter.api.Assertions.assertFalse + import scala.collection.JavaConverters._ class TestCreateTable extends HoodieSparkSqlTestBase { @@ -49,8 +51,11 @@ class TestCreateTable extends HoodieSparkSqlTestBase { | ts long | ) using hudi | tblproperties ( + | hoodie.database.name = "databaseName", + | hoodie.table.name = "tableName", | primaryKey = 'id', - | preCombineField = 'ts' + | preCombineField = 'ts', + | hoodie.datasource.write.operation = 'upsert' | ) """.stripMargin) val table = spark.sessionState.catalog.getTableMetadata(TableIdentifier(tableName)) @@ -65,6 +70,9 @@ class TestCreateTable extends HoodieSparkSqlTestBase { StructField("price", DoubleType), StructField("ts", LongType)) )(table.schema.fields) + assertFalse(table.properties.contains(HoodieTableConfig.DATABASE_NAME.key())) + assertFalse(table.properties.contains(HoodieTableConfig.NAME.key())) + assertFalse(table.properties.contains(OPERATION.key())) val tablePath = table.storage.properties("path") val metaClient = HoodieTableMetaClient.builder() @@ -73,6 +81,10 @@ class TestCreateTable extends HoodieSparkSqlTestBase { .build() val tableConfig = metaClient.getTableConfig assertResult(databaseName)(tableConfig.getDatabaseName) + assertResult(tableName)(tableConfig.getTableName) + assertFalse(tableConfig.contains(OPERATION.key())) + + spark.sql("use default") } test("Test Create Hoodie Table With Options") { @@ -88,8 +100,11 @@ class TestCreateTable extends HoodieSparkSqlTestBase { | ) using hudi | partitioned by (dt) | options ( + | hoodie.database.name = "databaseName", + | hoodie.table.name = "tableName", | primaryKey = 'id', - | preCombineField = 'ts' + | preCombineField = 'ts', + | hoodie.datasource.write.operation = 'upsert' | ) """.stripMargin) val table = spark.sessionState.catalog.getTableMetadata(TableIdentifier(tableName)) @@ -108,6 +123,9 @@ class TestCreateTable extends HoodieSparkSqlTestBase { StructField("ts", LongType), StructField("dt", StringType)) )(table.schema.fields) + assertFalse(table.properties.contains(HoodieTableConfig.DATABASE_NAME.key())) + assertFalse(table.properties.contains(HoodieTableConfig.NAME.key())) + assertFalse(table.properties.contains(OPERATION.key())) val tablePath = table.storage.properties("path") val metaClient = HoodieTableMetaClient.builder() @@ -120,6 +138,9 @@ class TestCreateTable extends HoodieSparkSqlTestBase { assertResult("id")(tableConfig(HoodieTableConfig.RECORDKEY_FIELDS.key)) assertResult("ts")(tableConfig(HoodieTableConfig.PRECOMBINE_FIELD.key)) assertResult(classOf[ComplexKeyGenerator].getCanonicalName)(tableConfig(HoodieTableConfig.KEY_GENERATOR_CLASS_NAME.key)) + assertResult("default")(tableConfig(HoodieTableConfig.DATABASE_NAME.key())) + assertResult(tableName)(tableConfig(HoodieTableConfig.NAME.key())) + assertFalse(tableConfig.contains(OPERATION.key())) } test("Test Create External Hoodie Table") { @@ -361,6 +382,84 @@ class TestCreateTable extends HoodieSparkSqlTestBase { } } + test("Test Create Table As Select With Tblproperties For Filter Props") { + Seq("cow", "mor").foreach { tableType => + val tableName = generateTableName + spark.sql( + s""" + | create table $tableName using hudi + | partitioned by (dt) + | tblproperties( + | hoodie.database.name = "databaseName", + | hoodie.table.name = "tableName", + | primaryKey = 'id', + | preCombineField = 'ts', + | hoodie.datasource.write.operation = 'upsert', + | type = '$tableType' + | ) + | AS + | select 1 as id, 'a1' as name, 10 as price, '2021-04-01' as dt, 1000 as ts + """.stripMargin + ) + checkAnswer(s"select id, name, price, dt from $tableName")( + Seq(1, "a1", 10, "2021-04-01") + ) + val table = spark.sessionState.catalog.getTableMetadata(TableIdentifier(tableName)) + assertFalse(table.properties.contains(HoodieTableConfig.DATABASE_NAME.key())) + assertFalse(table.properties.contains(HoodieTableConfig.NAME.key())) + assertFalse(table.properties.contains(OPERATION.key())) + + val tablePath = table.storage.properties("path") + val metaClient = HoodieTableMetaClient.builder() + .setBasePath(tablePath) + .setConf(spark.sessionState.newHadoopConf()) + .build() + val tableConfig = metaClient.getTableConfig.getProps.asScala.toMap + assertResult("default")(tableConfig(HoodieTableConfig.DATABASE_NAME.key())) + assertResult(tableName)(tableConfig(HoodieTableConfig.NAME.key())) + assertFalse(tableConfig.contains(OPERATION.key())) + } + } + + test("Test Create Table As Select With Options For Filter Props") { + Seq("cow", "mor").foreach { tableType => + val tableName = generateTableName + spark.sql( + s""" + | create table $tableName using hudi + | partitioned by (dt) + | options( + | hoodie.database.name = "databaseName", + | hoodie.table.name = "tableName", + | primaryKey = 'id', + | preCombineField = 'ts', + | hoodie.datasource.write.operation = 'upsert', + | type = '$tableType' + | ) + | AS + | select 1 as id, 'a1' as name, 10 as price, '2021-04-01' as dt, 1000 as ts + """.stripMargin + ) + checkAnswer(s"select id, name, price, dt from $tableName")( + Seq(1, "a1", 10, "2021-04-01") + ) + val table = spark.sessionState.catalog.getTableMetadata(TableIdentifier(tableName)) + assertFalse(table.properties.contains(HoodieTableConfig.DATABASE_NAME.key())) + assertFalse(table.properties.contains(HoodieTableConfig.NAME.key())) + assertFalse(table.properties.contains(OPERATION.key())) + + val tablePath = table.storage.properties("path") + val metaClient = HoodieTableMetaClient.builder() + .setBasePath(tablePath) + .setConf(spark.sessionState.newHadoopConf()) + .build() + val tableConfig = metaClient.getTableConfig.getProps.asScala.toMap + assertResult("default")(tableConfig(HoodieTableConfig.DATABASE_NAME.key())) + assertResult(tableName)(tableConfig(HoodieTableConfig.NAME.key())) + assertFalse(tableConfig.contains(OPERATION.key())) + } + } + test("Test Create Table As Select when 'spark.sql.datetime.java8API.enabled' enables") { try { // enable spark.sql.datetime.java8API.enabled From 1fded18dff5bae064479d52b4e44f9fcf5bbb1b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=B5=A9?= Date: Mon, 16 May 2022 09:51:24 +0800 Subject: [PATCH 28/52] fix hive sync no partition table error (#5585) --- .../java/org/apache/hudi/common/config/TypedProperties.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/TypedProperties.java b/hudi-common/src/main/java/org/apache/hudi/common/config/TypedProperties.java index 09671ba2a3577..08015f61b2e04 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/config/TypedProperties.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/TypedProperties.java @@ -18,6 +18,8 @@ package org.apache.hudi.common.config; +import org.apache.hudi.common.util.StringUtils; + import java.io.Serializable; import java.util.Arrays; import java.util.Enumeration; @@ -73,7 +75,7 @@ public List getStringList(String property, String delimiter, List !StringUtils.isNullOrEmpty(s)).collect(Collectors.toList()); } public int getInteger(String property) { From 61030d8e7a5a05e215efed672267ac163b0cbcf6 Mon Sep 17 00:00:00 2001 From: Yuwei XIAO Date: Mon, 16 May 2022 11:07:01 +0800 Subject: [PATCH 29/52] [HUDI-3123] consistent hashing index: basic write path (upsert/insert) (#4480) 1. basic write path(insert/upsert) implementation 2. adapt simple bucket index --- .../client/utils/LazyIterableIterator.java | 4 +- .../apache/hudi/config/HoodieIndexConfig.java | 46 +++- .../apache/hudi/config/HoodieWriteConfig.java | 4 + .../org/apache/hudi/index/HoodieIndex.java | 6 +- .../hudi/index/bucket/BucketIdentifier.java | 49 ++-- .../bucket/BucketIndexLocationMapper.java | 35 +++ .../bucket/ConsistentBucketIdentifier.java | 104 ++++++++ .../hudi/index/bucket/HoodieBucketIndex.java | 119 +++------ .../index/bucket/HoodieSimpleBucketIndex.java | 99 +++++++ .../apache/hudi/io/WriteHandleFactory.java | 3 +- .../commit/BaseCommitActionExecutor.java | 2 +- .../storage/HoodieConsistentBucketLayout.java | 68 +++++ .../table/storage/HoodieDefaultLayout.java | 7 +- .../table/storage/HoodieLayoutFactory.java | 9 +- ...out.java => HoodieSimpleBucketLayout.java} | 32 +-- .../table/storage/HoodieStorageLayout.java | 2 +- .../index/bucket/TestBucketIdentifier.java | 122 +++++++++ .../TestConsistentBucketIdIdentifier.java | 79 ++++++ .../hudi/client/SparkRDDWriteClient.java | 2 +- .../org/apache/hudi/data/HoodieJavaRDD.java | 5 + .../hudi/index/SparkHoodieIndexFactory.java | 16 +- .../HoodieSparkConsistentBucketIndex.java | 210 +++++++++++++++ .../functional/TestConsistentBucketIndex.java | 250 ++++++++++++++++++ .../client/functional/TestHoodieIndex.java | 3 + .../hudi/index/TestHoodieIndexConfigs.java | 14 +- ....java => TestHoodieSimpleBucketIndex.java} | 17 +- .../commit/TestCopyOnWriteActionExecutor.java | 5 +- .../apache/hudi/common/data/HoodieData.java | 9 + .../apache/hudi/common/data/HoodieList.java | 5 + .../org/apache/hudi/common/fs/FSUtils.java | 4 + .../common/model/ConsistentHashingNode.java | 78 ++++++ .../common/model/HoodieCommitMetadata.java | 23 +- .../HoodieConsistentHashingMetadata.java | 142 ++++++++++ .../model/HoodieReplaceCommitMetadata.java | 17 +- .../model/HoodieRollingStatMetadata.java | 4 +- .../common/table/HoodieTableMetaClient.java | 8 + .../apache/hudi/common/util/JsonUtils.java | 38 +++ .../apache/hudi/common/util/hash/HashID.java | 9 + .../TestHoodieConsistentHashingMetadata.java | 31 +++ .../testutils/HoodieCommonTestHarness.java | 4 + .../index/bucket/TestBucketIdentifier.java | 67 ----- 41 files changed, 1512 insertions(+), 239 deletions(-) create mode 100644 hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIndexLocationMapper.java create mode 100644 hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/ConsistentBucketIdentifier.java create mode 100644 hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieSimpleBucketIndex.java create mode 100644 hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieConsistentBucketLayout.java rename hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/{HoodieBucketLayout.java => HoodieSimpleBucketLayout.java} (71%) create mode 100644 hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestBucketIdentifier.java create mode 100644 hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestConsistentBucketIdIdentifier.java create mode 100644 hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/bucket/HoodieSparkConsistentBucketIndex.java create mode 100644 hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestConsistentBucketIndex.java rename hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bucket/{TestHoodieBucketIndex.java => TestHoodieSimpleBucketIndex.java} (91%) create mode 100644 hudi-common/src/main/java/org/apache/hudi/common/model/ConsistentHashingNode.java create mode 100644 hudi-common/src/main/java/org/apache/hudi/common/model/HoodieConsistentHashingMetadata.java create mode 100644 hudi-common/src/main/java/org/apache/hudi/common/util/JsonUtils.java create mode 100644 hudi-common/src/test/java/org/apache/hudi/common/model/TestHoodieConsistentHashingMetadata.java delete mode 100644 hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/index/bucket/TestBucketIdentifier.java diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/LazyIterableIterator.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/LazyIterableIterator.java index 020944e7ab9b1..ad54f8c0a0992 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/LazyIterableIterator.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/utils/LazyIterableIterator.java @@ -45,7 +45,7 @@ public LazyIterableIterator(Iterator in) { /** * Called once, before any elements are processed. */ - protected abstract void start(); + protected void start() {} /** * Block computation to be overwritten by sub classes. @@ -55,7 +55,7 @@ public LazyIterableIterator(Iterator in) { /** * Called once, after all elements are processed. */ - protected abstract void end(); + protected void end() {} ////////////////// // iterable implementation diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieIndexConfig.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieIndexConfig.java index 7c1f7e00e7fb1..dbd45b9738285 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieIndexConfig.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieIndexConfig.java @@ -216,19 +216,40 @@ public class HoodieIndexConfig extends HoodieConfig { /** * ***** Bucket Index Configs ***** * Bucket Index is targeted to locate the record fast by hash in big data scenarios. - * The current implementation is a basic version, so there are some constraints: - * 1. Unsupported operation: bulk insert, cluster and so on. - * 2. Bucket num change requires rewriting the partition. - * 3. Predict the table size and future data growth well to set a reasonable bucket num. - * 4. A bucket size is recommended less than 3GB and avoid bing too small. - * more details and progress see [HUDI-3039]. - */ - // Bucket num equals file groups num in each partition. - // Bucket num can be set according to partition size and file group size. + * A bucket size is recommended less than 3GB to avoid being too small. + * For more details and progress, see [HUDI-3039]. + */ + + /** + * Bucket Index Engine Type: implementation of bucket index + * + * SIMPLE: + * 0. Check `HoodieSimpleBucketLayout` for its supported operations. + * 1. Bucket num is fixed and requires rewriting the partition if we want to change it. + * + * CONSISTENT_HASHING: + * 0. Check `HoodieConsistentBucketLayout` for its supported operations. + * 1. Bucket num will auto-adjust by running clustering (still in progress) + */ + public static final ConfigProperty BUCKET_INDEX_ENGINE_TYPE = ConfigProperty + .key("hoodie.index.bucket.engine") + .defaultValue("SIMPLE") + .sinceVersion("0.11.0") + .withDocumentation("Type of bucket index engine to use. Default is SIMPLE bucket index, with fixed number of bucket." + + "Possible options are [SIMPLE | CONSISTENT_HASHING]." + + "Consistent hashing supports dynamic resizing of the number of bucket, solving potential data skew and file size " + + "issues of the SIMPLE hashing engine."); + + /** + * Bucket num equals file groups num in each partition. + * Bucket num can be set according to partition size and file group size. + * + * In dynamic bucket index cases (e.g., using CONSISTENT_HASHING), this config of number of bucket serves as a initial bucket size + */ public static final ConfigProperty BUCKET_INDEX_NUM_BUCKETS = ConfigProperty .key("hoodie.bucket.index.num.buckets") .defaultValue(256) - .withDocumentation("Only applies if index type is BUCKET_INDEX. Determine the number of buckets in the hudi table, " + .withDocumentation("Only applies if index type is BUCKET. Determine the number of buckets in the hudi table, " + "and each partition is divided to N buckets."); public static final ConfigProperty BUCKET_INDEX_HASH_FIELD = ConfigProperty @@ -463,6 +484,11 @@ public Builder withIndexType(HoodieIndex.IndexType indexType) { return this; } + public Builder withBucketIndexEngineType(HoodieIndex.BucketIndexEngineType bucketType) { + hoodieIndexConfig.setValue(BUCKET_INDEX_ENGINE_TYPE, bucketType.name()); + return this; + } + public Builder withIndexClass(String indexClass) { hoodieIndexConfig.setValue(INDEX_CLASS_NAME, indexClass); return this; diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java index 322c2e84e7e89..3eeb99044b29d 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java @@ -1428,6 +1428,10 @@ public String getIndexClass() { return getString(HoodieIndexConfig.INDEX_CLASS_NAME); } + public HoodieIndex.BucketIndexEngineType getBucketIndexEngineType() { + return HoodieIndex.BucketIndexEngineType.valueOf(getString(HoodieIndexConfig.BUCKET_INDEX_ENGINE_TYPE)); + } + public int getBloomFilterNumEntries() { return getInt(HoodieIndexConfig.BLOOM_FILTER_NUM_ENTRIES_VALUE); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndex.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndex.java index 922371c4a0f45..1182c45c72479 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndex.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/HoodieIndex.java @@ -121,7 +121,7 @@ public abstract HoodieData updateLocation( public abstract boolean isImplicitWithStorage(); /** - * If the `getCustomizedPartitioner` returns a partitioner, it has to be true. + * To indicate if a operation type requires location tagging before writing */ @PublicAPIMethod(maturity = ApiMaturityLevel.EVOLVING) public boolean requiresTagging(WriteOperationType operationType) { @@ -143,4 +143,8 @@ public void close() { public enum IndexType { HBASE, INMEMORY, BLOOM, GLOBAL_BLOOM, SIMPLE, GLOBAL_SIMPLE, BUCKET, FLINK_STATE } + + public enum BucketIndexEngineType { + SIMPLE, CONSISTENT_HASHING + } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java index 1a07c4063f358..1f233b429789d 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java @@ -22,6 +22,7 @@ import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.model.HoodieRecord; +import java.io.Serializable; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -29,8 +30,8 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; -public class BucketIdentifier { - // compatible with the spark bucket name +public class BucketIdentifier implements Serializable { + // Compatible with the spark bucket name private static final Pattern BUCKET_NAME = Pattern.compile(".*_(\\d+)(?:\\..*)?$"); public static int getBucketId(HoodieRecord record, String indexKeyFields, int numBuckets) { @@ -38,27 +39,41 @@ public static int getBucketId(HoodieRecord record, String indexKeyFields, int nu } public static int getBucketId(HoodieKey hoodieKey, String indexKeyFields, int numBuckets) { - return getBucketId(hoodieKey.getRecordKey(), indexKeyFields, numBuckets); + return (getHashKeys(hoodieKey, indexKeyFields).hashCode() & Integer.MAX_VALUE) % numBuckets; + } + + public static int getBucketId(HoodieKey hoodieKey, List indexKeyFields, int numBuckets) { + return (getHashKeys(hoodieKey.getRecordKey(), indexKeyFields).hashCode() & Integer.MAX_VALUE) % numBuckets; } public static int getBucketId(String recordKey, String indexKeyFields, int numBuckets) { - List hashKeyFields; - if (!recordKey.contains(":")) { - hashKeyFields = Collections.singletonList(recordKey); - } else { - Map recordKeyPairs = Arrays.stream(recordKey.split(",")) - .map(p -> p.split(":")) - .collect(Collectors.toMap(p -> p[0], p -> p[1])); - hashKeyFields = Arrays.stream(indexKeyFields.split(",")) - .map(f -> recordKeyPairs.get(f)) - .collect(Collectors.toList()); - } - return (hashKeyFields.hashCode() & Integer.MAX_VALUE) % numBuckets; + return getBucketId(getHashKeys(recordKey, indexKeyFields), numBuckets); } - // only for test public static int getBucketId(List hashKeyFields, int numBuckets) { - return hashKeyFields.hashCode() % numBuckets; + return (hashKeyFields.hashCode() & Integer.MAX_VALUE) % numBuckets; + } + + public static List getHashKeys(HoodieKey hoodieKey, String indexKeyFields) { + return getHashKeys(hoodieKey.getRecordKey(), indexKeyFields); + } + + protected static List getHashKeys(String recordKey, String indexKeyFields) { + return !recordKey.contains(":") ? Collections.singletonList(recordKey) : + getHashKeysUsingIndexFields(recordKey, Arrays.asList(indexKeyFields.split(","))); + } + + protected static List getHashKeys(String recordKey, List indexKeyFields) { + return !recordKey.contains(":") ? Collections.singletonList(recordKey) : + getHashKeysUsingIndexFields(recordKey, indexKeyFields); + } + + private static List getHashKeysUsingIndexFields(String recordKey, List indexKeyFields) { + Map recordKeyPairs = Arrays.stream(recordKey.split(",")) + .map(p -> p.split(":")) + .collect(Collectors.toMap(p -> p[0], p -> p[1])); + return indexKeyFields.stream() + .map(f -> recordKeyPairs.get(f)).collect(Collectors.toList()); } public static String partitionBucketIdStr(String partition, int bucketId) { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIndexLocationMapper.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIndexLocationMapper.java new file mode 100644 index 0000000000000..4955087333a25 --- /dev/null +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIndexLocationMapper.java @@ -0,0 +1,35 @@ +/* + * 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.hudi.index.bucket; + +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecordLocation; +import org.apache.hudi.common.util.Option; + +import java.io.Serializable; + +public interface BucketIndexLocationMapper extends Serializable { + + /** + * Get record location given hoodie key and partition path + */ + Option getRecordLocation(HoodieKey key, String partitionPath); + +} diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/ConsistentBucketIdentifier.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/ConsistentBucketIdentifier.java new file mode 100644 index 0000000000000..c44a8a6ccfb0c --- /dev/null +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/ConsistentBucketIdentifier.java @@ -0,0 +1,104 @@ +/* + * 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.hudi.index.bucket; + +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.model.ConsistentHashingNode; +import org.apache.hudi.common.model.HoodieConsistentHashingMetadata; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.util.hash.HashID; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.SortedMap; +import java.util.TreeMap; + +public class ConsistentBucketIdentifier extends BucketIdentifier { + + /** + * Hashing metadata of a partition + */ + private final HoodieConsistentHashingMetadata metadata; + /** + * In-memory structure to speed up ring mapping (hashing value -> hashing node) + */ + private final TreeMap ring; + /** + * Mapping from fileId -> hashing node + */ + private final Map fileIdToBucket; + + public ConsistentBucketIdentifier(HoodieConsistentHashingMetadata metadata) { + this.metadata = metadata; + this.fileIdToBucket = new HashMap<>(); + this.ring = new TreeMap<>(); + initialize(); + } + + public Collection getNodes() { + return ring.values(); + } + + public HoodieConsistentHashingMetadata getMetadata() { + return metadata; + } + + public int getNumBuckets() { + return ring.size(); + } + + /** + * Get bucket of the given file group + * + * @param fileId the file group id. NOTE: not filePfx (i.e., uuid) + */ + public ConsistentHashingNode getBucketByFileId(String fileId) { + return fileIdToBucket.get(fileId); + } + + public ConsistentHashingNode getBucket(HoodieKey hoodieKey, List indexKeyFields) { + return getBucket(getHashKeys(hoodieKey.getRecordKey(), indexKeyFields)); + } + + protected ConsistentHashingNode getBucket(List hashKeys) { + int hashValue = HashID.getXXHash32(String.join("", hashKeys), 0); + return getBucket(hashValue & HoodieConsistentHashingMetadata.HASH_VALUE_MASK); + } + + protected ConsistentHashingNode getBucket(int hashValue) { + SortedMap tailMap = ring.tailMap(hashValue); + return tailMap.isEmpty() ? ring.firstEntry().getValue() : tailMap.get(tailMap.firstKey()); + } + + /** + * Initialize necessary data structure to facilitate bucket identifying. + * Specifically, we construct: + * - An in-memory tree (ring) to speed up range mapping searching. + * - A hash table (fileIdToBucket) to allow lookup of bucket using fileId. + */ + private void initialize() { + for (ConsistentHashingNode p : metadata.getNodes()) { + ring.put(p.getValue(), p); + // One bucket has only one file group, so append 0 directly + fileIdToBucket.put(FSUtils.createNewFileId(p.getFileIdPrefix(), 0), p); + } + } +} diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieBucketIndex.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieBucketIndex.java index a243eea767856..c3584d234a8e5 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieBucketIndex.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieBucketIndex.java @@ -26,9 +26,7 @@ import org.apache.hudi.common.model.HoodieRecordLocation; import org.apache.hudi.common.model.WriteOperationType; import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.config.HoodieWriteConfig; -import org.apache.hudi.exception.HoodieIOException; import org.apache.hudi.exception.HoodieIndexException; import org.apache.hudi.index.HoodieIndex; import org.apache.hudi.index.HoodieIndexUtils; @@ -37,28 +35,31 @@ import org.apache.log4j.LogManager; import org.apache.log4j.Logger; -import java.util.HashMap; -import java.util.Map; +import java.util.Arrays; +import java.util.List; /** * Hash indexing mechanism. */ -public class HoodieBucketIndex extends HoodieIndex { +public abstract class HoodieBucketIndex extends HoodieIndex { - private static final Logger LOG = LogManager.getLogger(HoodieBucketIndex.class); + private static final Logger LOG = LogManager.getLogger(HoodieBucketIndex.class); - private final int numBuckets; + protected final int numBuckets; + protected final List indexKeyFields; public HoodieBucketIndex(HoodieWriteConfig config) { super(config); - numBuckets = config.getBucketIndexNumBuckets(); - LOG.info("use bucket index, numBuckets=" + numBuckets); + + this.numBuckets = config.getBucketIndexNumBuckets(); + this.indexKeyFields = Arrays.asList(config.getBucketIndexHashField().split(",")); + LOG.info("Use bucket index, numBuckets = " + numBuckets + ", indexFields: " + indexKeyFields); } @Override public HoodieData updateLocation(HoodieData writeStatuses, - HoodieEngineContext context, - HoodieTable hoodieTable) + HoodieEngineContext context, + HoodieTable hoodieTable) throws HoodieIndexException { return writeStatuses; } @@ -68,62 +69,35 @@ public HoodieData> tagLocation( HoodieData> records, HoodieEngineContext context, HoodieTable hoodieTable) throws HoodieIndexException { - HoodieData> taggedRecords = records.mapPartitions(recordIter -> { - // partitionPath -> bucketId -> fileInfo - Map>> partitionPathFileIDList = new HashMap<>(); - return new LazyIterableIterator, HoodieRecord>(recordIter) { - - @Override - protected void start() { - - } - - @Override - protected HoodieRecord computeNext() { - HoodieRecord record = recordIter.next(); - int bucketId = BucketIdentifier.getBucketId(record, config.getBucketIndexHashField(), numBuckets); - String partitionPath = record.getPartitionPath(); - if (!partitionPathFileIDList.containsKey(partitionPath)) { - partitionPathFileIDList.put(partitionPath, loadPartitionBucketIdFileIdMapping(hoodieTable, partitionPath)); - } - if (partitionPathFileIDList.get(partitionPath).containsKey(bucketId)) { - Pair fileInfo = partitionPathFileIDList.get(partitionPath).get(bucketId); - return HoodieIndexUtils.getTaggedRecord(record, Option.of( - new HoodieRecordLocation(fileInfo.getRight(), fileInfo.getLeft()) - )); + // Initialize necessary information before tagging. e.g., hashing metadata + List partitions = records.map(HoodieRecord::getPartitionPath).distinct().collectAsList(); + LOG.info("Initializing hashing metadata for partitions: " + partitions); + BucketIndexLocationMapper mapper = getLocationMapper(hoodieTable, partitions); + + return records.mapPartitions(iterator -> + new LazyIterableIterator, HoodieRecord>(iterator) { + @Override + protected HoodieRecord computeNext() { + // TODO maybe batch the operation to improve performance + HoodieRecord record = inputItr.next(); + Option loc = mapper.getRecordLocation(record.getKey(), record.getPartitionPath()); + return HoodieIndexUtils.getTaggedRecord(record, loc); } - return record; - } - - @Override - protected void end() { - } - }; - }, true); - return taggedRecords; + ); } - private Map> loadPartitionBucketIdFileIdMapping( - HoodieTable hoodieTable, - String partition) { - // bucketId -> fileIds - Map> fileIDList = new HashMap<>(); - HoodieIndexUtils - .getLatestBaseFilesForPartition(partition, hoodieTable) - .forEach(file -> { - String fileId = file.getFileId(); - String commitTime = file.getCommitTime(); - int bucketId = BucketIdentifier.bucketIdFromFileId(fileId); - if (!fileIDList.containsKey(bucketId)) { - fileIDList.put(bucketId, Pair.of(fileId, commitTime)); - } else { - // check if bucket data is valid - throw new HoodieIOException("Find multiple files at partition path=" - + partition + " belongs to the same bucket id = " + bucketId); - } - }); - return fileIDList; + @Override + public boolean requiresTagging(WriteOperationType operationType) { + switch (operationType) { + case INSERT: + case INSERT_OVERWRITE: + case UPSERT: + case DELETE: + return true; + default: + return false; + } } @Override @@ -138,7 +112,7 @@ public boolean isGlobal() { @Override public boolean canIndexLogFiles() { - return false; + return true; } @Override @@ -146,19 +120,12 @@ public boolean isImplicitWithStorage() { return true; } - @Override - public boolean requiresTagging(WriteOperationType operationType) { - switch (operationType) { - case INSERT: - case INSERT_OVERWRITE: - case UPSERT: - return true; - default: - return false; - } - } - public int getNumBuckets() { return numBuckets; } + + /** + * Get a location mapper for the given table & partitionPath + */ + protected abstract BucketIndexLocationMapper getLocationMapper(HoodieTable table, List partitionPath); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieSimpleBucketIndex.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieSimpleBucketIndex.java new file mode 100644 index 0000000000000..92ac4f69b2c42 --- /dev/null +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/HoodieSimpleBucketIndex.java @@ -0,0 +1,99 @@ +/* + * 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.hudi.index.bucket; + +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecordLocation; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieIOException; +import org.apache.hudi.index.HoodieIndexUtils; +import org.apache.hudi.table.HoodieTable; + +import org.apache.log4j.LogManager; +import org.apache.log4j.Logger; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Simple bucket index implementation, with fixed bucket number. + */ +public class HoodieSimpleBucketIndex extends HoodieBucketIndex { + + private static final Logger LOG = LogManager.getLogger(HoodieSimpleBucketIndex.class); + + public HoodieSimpleBucketIndex(HoodieWriteConfig config) { + super(config); + } + + private Map loadPartitionBucketIdFileIdMapping( + HoodieTable hoodieTable, + String partition) { + // bucketId -> fileIds + Map bucketIdToFileIdMapping = new HashMap<>(); + hoodieTable.getMetaClient().reloadActiveTimeline(); + HoodieIndexUtils + .getLatestBaseFilesForPartition(partition, hoodieTable) + .forEach(file -> { + String fileId = file.getFileId(); + String commitTime = file.getCommitTime(); + int bucketId = BucketIdentifier.bucketIdFromFileId(fileId); + if (!bucketIdToFileIdMapping.containsKey(bucketId)) { + bucketIdToFileIdMapping.put(bucketId, new HoodieRecordLocation(commitTime, fileId)); + } else { + // Check if bucket data is valid + throw new HoodieIOException("Find multiple files at partition path=" + + partition + " belongs to the same bucket id = " + bucketId); + } + }); + return bucketIdToFileIdMapping; + } + + @Override + public boolean canIndexLogFiles() { + return false; + } + + @Override + protected BucketIndexLocationMapper getLocationMapper(HoodieTable table, List partitionPath) { + return new SimpleBucketIndexLocationMapper(table, partitionPath); + } + + public class SimpleBucketIndexLocationMapper implements BucketIndexLocationMapper { + + /** + * Mapping from partitionPath -> bucketId -> fileInfo + */ + private final Map> partitionPathFileIDList; + + public SimpleBucketIndexLocationMapper(HoodieTable table, List partitions) { + partitionPathFileIDList = partitions.stream().collect(Collectors.toMap(p -> p, p -> loadPartitionBucketIdFileIdMapping(table, p))); + } + + @Override + public Option getRecordLocation(HoodieKey key, String partitionPath) { + int bucketId = BucketIdentifier.getBucketId(key, indexKeyFields, numBuckets); + Map bucketIdToFileIdMapping = partitionPathFileIDList.get(partitionPath); + return Option.ofNullable(bucketIdToFileIdMapping.getOrDefault(bucketId, null)); + } + } +} diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/WriteHandleFactory.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/WriteHandleFactory.java index 36fae304d77f2..c267b5969d801 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/WriteHandleFactory.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/WriteHandleFactory.java @@ -19,6 +19,7 @@ package org.apache.hudi.io; import org.apache.hudi.common.engine.TaskContextSupplier; +import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.common.model.HoodieRecordPayload; import org.apache.hudi.config.HoodieWriteConfig; import org.apache.hudi.table.HoodieTable; @@ -32,6 +33,6 @@ public abstract HoodieWriteHandle create(HoodieWriteConfig config, S String partitionPath, String fileIdPrefix, TaskContextSupplier taskContextSupplier); protected String getNextFileId(String idPfx) { - return String.format("%s-%d", idPfx, numFilesWritten++); + return FSUtils.createNewFileId(idPfx, numFilesWritten++); } } \ No newline at end of file diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseCommitActionExecutor.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseCommitActionExecutor.java index fb07d35928d7c..31c8bbd6d30d2 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseCommitActionExecutor.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/commit/BaseCommitActionExecutor.java @@ -94,7 +94,7 @@ public BaseCommitActionExecutor(HoodieEngineContext context, HoodieWriteConfig c this.lastCompletedTxn = TransactionUtils.getLastCompletedTxnInstantAndMetadata(table.getMetaClient()); this.pendingInflightAndRequestedInstants = TransactionUtils.getInflightAndRequestedInstants(table.getMetaClient()); this.pendingInflightAndRequestedInstants.remove(instantTime); - if (table.getStorageLayout().doesNotSupport(operationType)) { + if (!table.getStorageLayout().writeOperationSupported(operationType)) { throw new UnsupportedOperationException("Executor " + this.getClass().getSimpleName() + " is not compatible with table layout " + table.getStorageLayout().getClass().getSimpleName()); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieConsistentBucketLayout.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieConsistentBucketLayout.java new file mode 100644 index 0000000000000..0ed2b9c939a7b --- /dev/null +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieConsistentBucketLayout.java @@ -0,0 +1,68 @@ +/* + * 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.hudi.table.storage; + +import org.apache.hudi.common.model.WriteOperationType; +import org.apache.hudi.common.util.CollectionUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieWriteConfig; + +import java.util.Set; + +/** + * Storage layout when using consistent hashing bucket index. + */ +public class HoodieConsistentBucketLayout extends HoodieStorageLayout { + public static final Set SUPPORTED_OPERATIONS = CollectionUtils.createImmutableSet( + WriteOperationType.INSERT, + WriteOperationType.INSERT_PREPPED, + WriteOperationType.UPSERT, + WriteOperationType.UPSERT_PREPPED, + WriteOperationType.INSERT_OVERWRITE, + WriteOperationType.DELETE, + WriteOperationType.COMPACT, + WriteOperationType.DELETE_PARTITION + ); + + public HoodieConsistentBucketLayout(HoodieWriteConfig config) { + super(config); + } + + /** + * Bucketing controls the number of file groups directly. + */ + @Override + public boolean determinesNumFileGroups() { + return true; + } + + /** + * Consistent hashing will tag all incoming records, so we could go ahead reusing an existing Partitioner + */ + @Override + public Option layoutPartitionerClass() { + return Option.empty(); + } + + @Override + public boolean writeOperationSupported(WriteOperationType operationType) { + return SUPPORTED_OPERATIONS.contains(operationType); + } + +} diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieDefaultLayout.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieDefaultLayout.java index 09d20707a4c85..28fe37c9b8fe0 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieDefaultLayout.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieDefaultLayout.java @@ -31,15 +31,18 @@ public HoodieDefaultLayout(HoodieWriteConfig config) { super(config); } + @Override public boolean determinesNumFileGroups() { return false; } + @Override public Option layoutPartitionerClass() { return Option.empty(); } - public boolean doesNotSupport(WriteOperationType operationType) { - return false; + @Override + public boolean writeOperationSupported(WriteOperationType operationType) { + return true; } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieLayoutFactory.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieLayoutFactory.java index e86d253df4bfa..e78c15b3a4b22 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieLayoutFactory.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieLayoutFactory.java @@ -30,7 +30,14 @@ public static HoodieStorageLayout createLayout(HoodieWriteConfig config) { case DEFAULT: return new HoodieDefaultLayout(config); case BUCKET: - return new HoodieBucketLayout(config); + switch (config.getBucketIndexEngineType()) { + case SIMPLE: + return new HoodieSimpleBucketLayout(config); + case CONSISTENT_HASHING: + return new HoodieConsistentBucketLayout(config); + default: + throw new HoodieNotSupportedException("Unknown bucket index engine type: " + config.getBucketIndexEngineType()); + } default: throw new HoodieNotSupportedException("Unknown layout type, set " + config.getLayoutType()); } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieBucketLayout.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieSimpleBucketLayout.java similarity index 71% rename from hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieBucketLayout.java rename to hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieSimpleBucketLayout.java index deefcfe6a621e..be048a23b058c 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieBucketLayout.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieSimpleBucketLayout.java @@ -19,31 +19,30 @@ package org.apache.hudi.table.storage; import org.apache.hudi.common.model.WriteOperationType; +import org.apache.hudi.common.util.CollectionUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.config.HoodieLayoutConfig; import org.apache.hudi.config.HoodieWriteConfig; -import java.util.HashSet; import java.util.Set; /** * Storage layout when using bucket index. Data distribution and files organization are in a specific way. */ -public class HoodieBucketLayout extends HoodieStorageLayout { +public class HoodieSimpleBucketLayout extends HoodieStorageLayout { - public static final Set SUPPORTED_OPERATIONS = new HashSet() {{ - add(WriteOperationType.INSERT); - add(WriteOperationType.INSERT_PREPPED); - add(WriteOperationType.UPSERT); - add(WriteOperationType.UPSERT_PREPPED); - add(WriteOperationType.INSERT_OVERWRITE); - add(WriteOperationType.DELETE); - add(WriteOperationType.COMPACT); - add(WriteOperationType.DELETE_PARTITION); - } - }; + public static final Set SUPPORTED_OPERATIONS = CollectionUtils.createImmutableSet( + WriteOperationType.INSERT, + WriteOperationType.INSERT_PREPPED, + WriteOperationType.UPSERT, + WriteOperationType.UPSERT_PREPPED, + WriteOperationType.INSERT_OVERWRITE, + WriteOperationType.DELETE, + WriteOperationType.COMPACT, + WriteOperationType.DELETE_PARTITION + ); - public HoodieBucketLayout(HoodieWriteConfig config) { + public HoodieSimpleBucketLayout(HoodieWriteConfig config) { super(config); } @@ -55,6 +54,7 @@ public boolean determinesNumFileGroups() { return true; } + @Override public Option layoutPartitionerClass() { return config.contains(HoodieLayoutConfig.LAYOUT_PARTITIONER_CLASS_NAME) ? Option.of(config.getString(HoodieLayoutConfig.LAYOUT_PARTITIONER_CLASS_NAME.key())) @@ -62,7 +62,7 @@ public Option layoutPartitionerClass() { } @Override - public boolean doesNotSupport(WriteOperationType operationType) { - return !SUPPORTED_OPERATIONS.contains(operationType); + public boolean writeOperationSupported(WriteOperationType operationType) { + return SUPPORTED_OPERATIONS.contains(operationType); } } diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieStorageLayout.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieStorageLayout.java index a0a4eab46304f..36be1a8bef6a8 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieStorageLayout.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/storage/HoodieStorageLayout.java @@ -48,7 +48,7 @@ public HoodieStorageLayout(HoodieWriteConfig config) { /** * Determines if the operation is supported by the layout. */ - public abstract boolean doesNotSupport(WriteOperationType operationType); + public abstract boolean writeOperationSupported(WriteOperationType operationType); public enum LayoutType { DEFAULT, BUCKET diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestBucketIdentifier.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestBucketIdentifier.java new file mode 100644 index 0000000000000..31f33890ad318 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestBucketIdentifier.java @@ -0,0 +1,122 @@ +/* + * 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.hudi.index.bucket; + +import org.apache.hudi.common.model.HoodieAvroRecord; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.keygen.KeyGenUtils; + +import org.apache.avro.Schema; +import org.apache.avro.generic.GenericData; +import org.apache.avro.generic.GenericRecord; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +public class TestBucketIdentifier { + + public static final String NESTED_COL_SCHEMA = "{\"type\":\"record\", \"name\":\"nested_col\",\"fields\": [" + + "{\"name\": \"prop1\",\"type\": \"string\"},{\"name\": \"prop2\", \"type\": \"long\"}]}"; + public static final String EXAMPLE_SCHEMA = "{\"type\": \"record\",\"name\": \"testrec\",\"fields\": [ " + + "{\"name\": \"timestamp\",\"type\": \"long\"},{\"name\": \"_row_key\", \"type\": \"string\"}," + + "{\"name\": \"ts_ms\", \"type\": \"string\"}," + + "{\"name\": \"pii_col\", \"type\": \"string\"}," + + "{\"name\": \"nested_col\",\"type\": " + + NESTED_COL_SCHEMA + "}" + + "]}"; + + public static GenericRecord getRecord() { + return getRecord(getNestedColRecord("val1", 10L)); + } + + public static GenericRecord getNestedColRecord(String prop1Value, Long prop2Value) { + GenericRecord nestedColRecord = new GenericData.Record(new Schema.Parser().parse(NESTED_COL_SCHEMA)); + nestedColRecord.put("prop1", prop1Value); + nestedColRecord.put("prop2", prop2Value); + return nestedColRecord; + } + + public static GenericRecord getRecord(GenericRecord nestedColRecord) { + GenericRecord record = new GenericData.Record(new Schema.Parser().parse(EXAMPLE_SCHEMA)); + record.put("timestamp", 4357686L); + record.put("_row_key", "key1"); + record.put("ts_ms", "2020-03-21"); + record.put("pii_col", "pi"); + record.put("nested_col", nestedColRecord); + return record; + } + + @Test + public void testBucketFileId() { + int[] ids = {0, 4, 8, 16, 32, 64, 128, 256, 512, 1000, 1024, 4096, 10000, 100000}; + for (int id : ids) { + String bucketIdStr = BucketIdentifier.bucketIdStr(id); + String fileId = BucketIdentifier.newBucketFileIdPrefix(bucketIdStr); + assert BucketIdentifier.bucketIdFromFileId(fileId) == id; + } + } + + @Test + public void testBucketIdWithSimpleRecordKey() { + String recordKeyField = "_row_key"; + String indexKeyField = "_row_key"; + GenericRecord record = getRecord(); + HoodieRecord hoodieRecord = new HoodieAvroRecord( + new HoodieKey(KeyGenUtils.getRecordKey(record, recordKeyField, false), ""), null); + int bucketId = BucketIdentifier.getBucketId(hoodieRecord, indexKeyField, 8); + assert bucketId == BucketIdentifier.getBucketId( + Arrays.asList(record.get(indexKeyField).toString()), 8); + } + + @Test + public void testBucketIdWithComplexRecordKey() { + List recordKeyField = Arrays.asList("_row_key", "ts_ms"); + String indexKeyField = "_row_key"; + GenericRecord record = getRecord(); + HoodieRecord hoodieRecord = new HoodieAvroRecord( + new HoodieKey(KeyGenUtils.getRecordKey(record, recordKeyField, false), ""), null); + int bucketId = BucketIdentifier.getBucketId(hoodieRecord, indexKeyField, 8); + assert bucketId == BucketIdentifier.getBucketId( + Arrays.asList(record.get(indexKeyField).toString()), 8); + } + + @Test + public void testGetHashKeys() { + BucketIdentifier identifier = new BucketIdentifier(); + List keys = identifier.getHashKeys(new HoodieKey("abc", "partition"), ""); + Assertions.assertEquals(1, keys.size()); + Assertions.assertEquals("abc", keys.get(0)); + + keys = identifier.getHashKeys(new HoodieKey("f1:abc", "partition"), "f1"); + Assertions.assertEquals(1, keys.size()); + Assertions.assertEquals("abc", keys.get(0)); + + keys = identifier.getHashKeys(new HoodieKey("f1:abc,f2:bcd", "partition"), "f2"); + Assertions.assertEquals(1, keys.size()); + Assertions.assertEquals("bcd", keys.get(0)); + + keys = identifier.getHashKeys(new HoodieKey("f1:abc,f2:bcd", "partition"), "f1,f2"); + Assertions.assertEquals(2, keys.size()); + Assertions.assertEquals("abc", keys.get(0)); + Assertions.assertEquals("bcd", keys.get(1)); + } +} diff --git a/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestConsistentBucketIdIdentifier.java b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestConsistentBucketIdIdentifier.java new file mode 100644 index 0000000000000..3ffe6ded188b8 --- /dev/null +++ b/hudi-client/hudi-client-common/src/test/java/org/apache/hudi/index/bucket/TestConsistentBucketIdIdentifier.java @@ -0,0 +1,79 @@ +/* + * 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.hudi.index.bucket; + +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.model.ConsistentHashingNode; +import org.apache.hudi.common.model.HoodieConsistentHashingMetadata; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.apache.hudi.common.model.HoodieConsistentHashingMetadata.HASH_VALUE_MASK; + +/** + * Unit test of consistent bucket identifier + */ +public class TestConsistentBucketIdIdentifier { + + @Test + public void testGetBucket() { + List nodes = Arrays.asList( + new ConsistentHashingNode(100, "0"), + new ConsistentHashingNode(0x2fffffff, "1"), + new ConsistentHashingNode(0x4fffffff, "2")); + HoodieConsistentHashingMetadata meta = new HoodieConsistentHashingMetadata((short) 0, "", "", 3, 0, nodes); + ConsistentBucketIdentifier identifier = new ConsistentBucketIdentifier(meta); + + Assertions.assertEquals(3, identifier.getNumBuckets()); + + // Get bucket by hash keys + Assertions.assertEquals(nodes.get(2), identifier.getBucket(Arrays.asList("Hudi"))); + Assertions.assertEquals(nodes.get(1), identifier.getBucket(Arrays.asList("bucket_index"))); + Assertions.assertEquals(nodes.get(1), identifier.getBucket(Arrays.asList("consistent_hashing"))); + Assertions.assertEquals(nodes.get(1), identifier.getBucket(Arrays.asList("bucket_index", "consistent_hashing"))); + int[] ref1 = {2, 2, 1, 1, 0, 1, 1, 1, 0, 1}; + int[] ref2 = {1, 0, 1, 0, 1, 1, 1, 0, 1, 2}; + for (int i = 0; i < 10; ++i) { + Assertions.assertEquals(nodes.get(ref1[i]), identifier.getBucket(Arrays.asList(Integer.toString(i)))); + Assertions.assertEquals(nodes.get(ref2[i]), identifier.getBucket(Arrays.asList(Integer.toString(i), Integer.toString(i + 1)))); + } + + // Get bucket by hash value + Assertions.assertEquals(nodes.get(0), identifier.getBucket(0)); + Assertions.assertEquals(nodes.get(0), identifier.getBucket(50)); + Assertions.assertEquals(nodes.get(0), identifier.getBucket(100)); + Assertions.assertEquals(nodes.get(1), identifier.getBucket(101)); + Assertions.assertEquals(nodes.get(1), identifier.getBucket(0x1fffffff)); + Assertions.assertEquals(nodes.get(1), identifier.getBucket(0x2fffffff)); + Assertions.assertEquals(nodes.get(2), identifier.getBucket(0x40000000)); + Assertions.assertEquals(nodes.get(2), identifier.getBucket(0x40000001)); + Assertions.assertEquals(nodes.get(2), identifier.getBucket(0x4fffffff)); + Assertions.assertEquals(nodes.get(0), identifier.getBucket(0x50000000)); + Assertions.assertEquals(nodes.get(0), identifier.getBucket(HASH_VALUE_MASK)); + + // Get bucket by file id + Assertions.assertEquals(nodes.get(0), identifier.getBucketByFileId(FSUtils.createNewFileId("0", 0))); + Assertions.assertEquals(nodes.get(1), identifier.getBucketByFileId(FSUtils.createNewFileId("1", 0))); + Assertions.assertEquals(nodes.get(2), identifier.getBucketByFileId(FSUtils.createNewFileId("2", 0))); + } +} diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java index 3b512f0bdc871..df82e75db92ca 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java @@ -179,7 +179,7 @@ public JavaRDD insert(JavaRDD> records, String inst initTable(WriteOperationType.INSERT, Option.ofNullable(instantTime)); table.validateInsertSchema(); preWrite(instantTime, WriteOperationType.INSERT, table.getMetaClient()); - HoodieWriteMetadata> result = table.insert(context,instantTime, HoodieJavaRDD.of(records)); + HoodieWriteMetadata> result = table.insert(context, instantTime, HoodieJavaRDD.of(records)); HoodieWriteMetadata> resultRDD = result.clone(HoodieJavaRDD.getJavaRDD(result.getWriteStatuses())); return postWrite(resultRDD, instantTime, table); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/data/HoodieJavaRDD.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/data/HoodieJavaRDD.java index 66edf607f84dd..0843dfc3c9920 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/data/HoodieJavaRDD.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/data/HoodieJavaRDD.java @@ -112,6 +112,11 @@ public HoodieData mapPartitions(SerializableFunction, Iterato return HoodieJavaRDD.of(rddData.mapPartitions(func::apply, preservesPartitioning)); } + @Override + public HoodieData mapPartitions(SerializableFunction, Iterator> func) { + return HoodieJavaRDD.of(rddData.mapPartitions(func::apply)); + } + @Override public HoodieData flatMap(SerializableFunction> func) { return HoodieJavaRDD.of(rddData.flatMap(e -> func.apply(e))); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkHoodieIndexFactory.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkHoodieIndexFactory.java index d1f40dca484c5..4525490c8d168 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkHoodieIndexFactory.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/SparkHoodieIndexFactory.java @@ -28,7 +28,8 @@ import org.apache.hudi.index.bloom.HoodieBloomIndex; import org.apache.hudi.index.bloom.HoodieGlobalBloomIndex; import org.apache.hudi.index.bloom.SparkHoodieBloomIndexHelper; -import org.apache.hudi.index.bucket.HoodieBucketIndex; +import org.apache.hudi.index.bucket.HoodieSimpleBucketIndex; +import org.apache.hudi.index.bucket.HoodieSparkConsistentBucketIndex; import org.apache.hudi.index.hbase.SparkHoodieHBaseIndex; import org.apache.hudi.index.inmemory.HoodieInMemoryHashIndex; import org.apache.hudi.index.simple.HoodieGlobalSimpleIndex; @@ -56,8 +57,6 @@ public static HoodieIndex createIndex(HoodieWriteConfig config) { return new SparkHoodieHBaseIndex(config); case INMEMORY: return new HoodieInMemoryHashIndex(config); - case BUCKET: - return new HoodieBucketIndex(config); case BLOOM: return new HoodieBloomIndex(config, SparkHoodieBloomIndexHelper.getInstance()); case GLOBAL_BLOOM: @@ -66,6 +65,15 @@ public static HoodieIndex createIndex(HoodieWriteConfig config) { return new HoodieSimpleIndex(config, getKeyGeneratorForSimpleIndex(config)); case GLOBAL_SIMPLE: return new HoodieGlobalSimpleIndex(config, getKeyGeneratorForSimpleIndex(config)); + case BUCKET: + switch (config.getBucketIndexEngineType()) { + case SIMPLE: + return new HoodieSimpleBucketIndex(config); + case CONSISTENT_HASHING: + return new HoodieSparkConsistentBucketIndex(config); + default: + throw new HoodieIndexException("Unknown bucket index engine type: " + config.getBucketIndexEngineType()); + } default: throw new HoodieIndexException("Index type unspecified, set " + config.getIndexType()); } @@ -90,6 +98,8 @@ public static boolean isGlobalIndex(HoodieWriteConfig config) { return false; case GLOBAL_SIMPLE: return true; + case BUCKET: + return false; default: return createIndex(config).isGlobal(); } diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/bucket/HoodieSparkConsistentBucketIndex.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/bucket/HoodieSparkConsistentBucketIndex.java new file mode 100644 index 0000000000000..ca6bf0fc7d990 --- /dev/null +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/index/bucket/HoodieSparkConsistentBucketIndex.java @@ -0,0 +1,210 @@ +/* + * 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.hudi.index.bucket; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.data.HoodieData; +import org.apache.hudi.common.engine.HoodieEngineContext; +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.fs.HoodieWrapperFileSystem; +import org.apache.hudi.common.model.ConsistentHashingNode; +import org.apache.hudi.common.model.HoodieConsistentHashingMetadata; +import org.apache.hudi.common.model.HoodieKey; +import org.apache.hudi.common.model.HoodieRecordLocation; +import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.util.FileIOUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; +import org.apache.hudi.common.util.ValidationUtils; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.exception.HoodieIndexException; +import org.apache.hudi.table.HoodieTable; + +import org.apache.hadoop.fs.FSDataOutputStream; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.fs.Path; +import org.apache.log4j.LogManager; +import org.apache.log4j.Logger; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Consistent hashing bucket index implementation, with auto-adjust bucket number. + * NOTE: bucket resizing is triggered by clustering. + */ +public class HoodieSparkConsistentBucketIndex extends HoodieBucketIndex { + + private static final Logger LOG = LogManager.getLogger(HoodieSparkConsistentBucketIndex.class); + + public HoodieSparkConsistentBucketIndex(HoodieWriteConfig config) { + super(config); + } + + @Override + public HoodieData updateLocation(HoodieData writeStatuses, + HoodieEngineContext context, + HoodieTable hoodieTable) + throws HoodieIndexException { + return writeStatuses; + } + + /** + * Do nothing. + * A failed write may create a hashing metadata for a partition. In this case, we still do nothing when rolling back + * the failed write. Because the hashing metadata created by a writer must have 00000000000000 timestamp and can be viewed + * as the initialization of a partition rather than as a part of the failed write. + */ + @Override + public boolean rollbackCommit(String instantTime) { + return true; + } + + @Override + protected BucketIndexLocationMapper getLocationMapper(HoodieTable table, List partitionPath) { + return new ConsistentBucketIndexLocationMapper(table, partitionPath); + } + + /** + * Load hashing metadata of the given partition, if it is not existed, create a new one (also persist it into storage) + * + * @param table hoodie table + * @param partition table partition + * @return Consistent hashing metadata + */ + public HoodieConsistentHashingMetadata loadOrCreateMetadata(HoodieTable table, String partition) { + HoodieConsistentHashingMetadata metadata = loadMetadata(table, partition); + if (metadata != null) { + return metadata; + } + + // There is no metadata, so try to create a new one and save it. + metadata = new HoodieConsistentHashingMetadata(partition, numBuckets); + if (saveMetadata(table, metadata, false)) { + return metadata; + } + + // The creation failed, so try load metadata again. Concurrent creation of metadata should have succeeded. + // Note: the consistent problem of cloud storage is handled internal in the HoodieWrapperFileSystem, i.e., ConsistentGuard + metadata = loadMetadata(table, partition); + ValidationUtils.checkState(metadata != null, "Failed to load or create metadata, partition: " + partition); + return metadata; + } + + /** + * Load hashing metadata of the given partition, if it is not existed, return null + * + * @param table hoodie table + * @param partition table partition + * @return Consistent hashing metadata or null if it does not exist + */ + public static HoodieConsistentHashingMetadata loadMetadata(HoodieTable table, String partition) { + Path metadataPath = FSUtils.getPartitionPath(table.getMetaClient().getHashingMetadataPath(), partition); + + try { + if (!table.getMetaClient().getFs().exists(metadataPath)) { + return null; + } + FileStatus[] metaFiles = table.getMetaClient().getFs().listStatus(metadataPath); + final HoodieTimeline completedCommits = table.getMetaClient().getActiveTimeline().getCommitTimeline().filterCompletedInstants(); + Predicate metaFilePredicate = fileStatus -> { + String filename = fileStatus.getPath().getName(); + if (!filename.contains(HoodieConsistentHashingMetadata.HASHING_METADATA_FILE_SUFFIX)) { + return false; + } + String timestamp = HoodieConsistentHashingMetadata.getTimestampFromFile(filename); + return completedCommits.containsInstant(timestamp) || timestamp.equals(HoodieTimeline.INIT_INSTANT_TS); + }; + + // Get a valid hashing metadata with the largest (latest) timestamp + FileStatus metaFile = Arrays.stream(metaFiles).filter(metaFilePredicate) + .max(Comparator.comparing(a -> a.getPath().getName())).orElse(null); + + if (metaFile == null) { + return null; + } + + byte[] content = FileIOUtils.readAsByteArray(table.getMetaClient().getFs().open(metaFile.getPath())); + return HoodieConsistentHashingMetadata.fromBytes(content); + } catch (IOException e) { + LOG.error("Error when loading hashing metadata, partition: " + partition, e); + throw new HoodieIndexException("Error while loading hashing metadata", e); + } + } + + /** + * Save metadata into storage + * + * @param table hoodie table + * @param metadata hashing metadata to be saved + * @param overwrite whether to overwrite existing metadata + * @return true if the metadata is saved successfully + */ + private static boolean saveMetadata(HoodieTable table, HoodieConsistentHashingMetadata metadata, boolean overwrite) { + HoodieWrapperFileSystem fs = table.getMetaClient().getFs(); + Path dir = FSUtils.getPartitionPath(table.getMetaClient().getHashingMetadataPath(), metadata.getPartitionPath()); + Path fullPath = new Path(dir, metadata.getFilename()); + try (FSDataOutputStream fsOut = fs.create(fullPath, overwrite)) { + byte[] bytes = metadata.toBytes(); + fsOut.write(bytes); + fsOut.close(); + return true; + } catch (IOException e) { + LOG.warn("Failed to update bucket metadata: " + metadata, e); + } + return false; + } + + public class ConsistentBucketIndexLocationMapper implements BucketIndexLocationMapper { + + /** + * Mapping from partitionPath -> bucket identifier + */ + private final Map partitionToIdentifier; + + public ConsistentBucketIndexLocationMapper(HoodieTable table, List partitions) { + // TODO maybe parallel + partitionToIdentifier = partitions.stream().collect(Collectors.toMap(p -> p, p -> { + HoodieConsistentHashingMetadata metadata = loadOrCreateMetadata(table, p); + return new ConsistentBucketIdentifier(metadata); + })); + } + + @Override + public Option getRecordLocation(HoodieKey key, String partitionPath) { + ConsistentHashingNode node = partitionToIdentifier.get(partitionPath).getBucket(key, indexKeyFields); + if (!StringUtils.isNullOrEmpty(node.getFileIdPrefix())) { + /** + * Dynamic Bucket Index doesn't need the instant time of the latest file group. + * We add suffix 0 here to the file uuid, following the naming convention, i.e., fileId = [uuid]_[numWrites] + */ + return Option.of(new HoodieRecordLocation(null, FSUtils.createNewFileId(node.getFileIdPrefix(), 0))); + } + + LOG.error("Consistent hashing node has no file group, partition: " + partitionPath + ", meta: " + + partitionToIdentifier.get(partitionPath).getMetadata().getFilename() + ", record_key: " + key.toString()); + throw new HoodieIndexException("Failed to getBucket as hashing node has no file group"); + } + } +} diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestConsistentBucketIndex.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestConsistentBucketIndex.java new file mode 100644 index 0000000000000..e0bc22f70d231 --- /dev/null +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestConsistentBucketIndex.java @@ -0,0 +1,250 @@ +/* + * 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.hudi.client.functional; + +import org.apache.hudi.client.WriteStatus; +import org.apache.hudi.common.fs.ConsistencyGuardConfig; +import org.apache.hudi.common.model.HoodieFileFormat; +import org.apache.hudi.common.model.HoodieRecord; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.view.FileSystemViewStorageConfig; +import org.apache.hudi.common.table.view.FileSystemViewStorageType; +import org.apache.hudi.common.testutils.HoodieTestDataGenerator; +import org.apache.hudi.common.testutils.HoodieTestUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.config.HoodieCompactionConfig; +import org.apache.hudi.config.HoodieIndexConfig; +import org.apache.hudi.config.HoodieStorageConfig; +import org.apache.hudi.config.HoodieWriteConfig; +import org.apache.hudi.hadoop.HoodieParquetInputFormat; +import org.apache.hudi.hadoop.RealtimeFileStatus; +import org.apache.hudi.hadoop.realtime.HoodieParquetRealtimeInputFormat; +import org.apache.hudi.hadoop.utils.HoodieInputFormatUtils; +import org.apache.hudi.index.HoodieIndex; +import org.apache.hudi.keygen.constant.KeyGeneratorOptions; +import org.apache.hudi.table.HoodieSparkTable; +import org.apache.hudi.table.HoodieTable; +import org.apache.hudi.testutils.HoodieClientTestHarness; +import org.apache.hudi.testutils.HoodieMergeOnReadTestUtils; +import org.apache.hudi.testutils.MetadataMergeWriteStatus; + +import org.apache.avro.generic.GenericRecord; +import org.apache.hadoop.fs.FileStatus; +import org.apache.hadoop.mapred.FileInputFormat; +import org.apache.hadoop.mapred.JobConf; +import org.apache.spark.api.java.JavaRDD; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.Properties; +import java.util.Random; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Test consistent hashing index + */ +@Tag("functional") +public class TestConsistentBucketIndex extends HoodieClientTestHarness { + + private final Random random = new Random(1); + private HoodieIndex index; + private HoodieWriteConfig config; + + private static Stream configParams() { + // preserveMetaField, partitioned + Object[][] data = new Object[][] { + {true, false}, + {false, false}, + {true, true}, + {false, true}, + }; + return Stream.of(data).map(Arguments::of); + } + + private void setUp(boolean populateMetaFields, boolean partitioned) throws Exception { + initPath(); + initSparkContexts(); + if (partitioned) { + initTestDataGenerator(); + } else { + initTestDataGenerator(new String[] {""}); + } + initFileSystem(); + Properties props = populateMetaFields ? new Properties() : getPropertiesForKeyGen(); + props.setProperty(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), "_row_key"); + metaClient = HoodieTestUtils.init(hadoopConf, basePath, HoodieTableType.MERGE_ON_READ, props); + config = getConfigBuilder() + .withProperties(props) + .withIndexConfig(HoodieIndexConfig.newBuilder() + .fromProperties(props) + .withIndexType(HoodieIndex.IndexType.BUCKET) + .withIndexKeyField("_row_key") + .withBucketIndexEngineType(HoodieIndex.BucketIndexEngineType.CONSISTENT_HASHING) + .build()) + .withAutoCommit(false) + .build(); + writeClient = getHoodieWriteClient(config); + index = writeClient.getIndex(); + } + + @AfterEach + public void tearDown() throws IOException { + cleanupResources(); + } + + /** + * Test bucket index tagging (always tag regardless of the write status) + * Test bucket index tagging consistency, two tagging result should be same + * + * @param populateMetaFields + * @param partitioned + * @throws Exception + */ + @ParameterizedTest + @MethodSource("configParams") + public void testTagLocation(boolean populateMetaFields, boolean partitioned) throws Exception { + setUp(populateMetaFields, partitioned); + String newCommitTime = "001"; + int totalRecords = 20 + random.nextInt(20); + List records = dataGen.generateInserts(newCommitTime, totalRecords); + JavaRDD writeRecords = jsc.parallelize(records, 2); + + metaClient = HoodieTableMetaClient.reload(metaClient); + HoodieTable hoodieTable = HoodieSparkTable.create(config, context, metaClient); + + // The records should be tagged anyway, even though it is the first time doing tagging + List taggedRecord = tagLocation(index, writeRecords, hoodieTable).collect(); + Assertions.assertTrue(taggedRecord.stream().allMatch(r -> r.isCurrentLocationKnown())); + + // Tag again, the records should get the same location (hashing metadata has been persisted after the first tagging) + List taggedRecord2 = tagLocation(index, writeRecords, hoodieTable).collect(); + for (HoodieRecord ref : taggedRecord) { + for (HoodieRecord record : taggedRecord2) { + if (ref.getRecordKey().equals(record.getRecordKey())) { + Assertions.assertEquals(ref.getCurrentLocation(), record.getCurrentLocation()); + break; + } + } + } + } + + @ParameterizedTest + @MethodSource("configParams") + public void testWriteData(boolean populateMetaFields, boolean partitioned) throws Exception { + setUp(populateMetaFields, partitioned); + String newCommitTime = "001"; + int totalRecords = 20 + random.nextInt(20); + List records = dataGen.generateInserts(newCommitTime, totalRecords); + JavaRDD writeRecords = jsc.parallelize(records, 2); + + metaClient = HoodieTableMetaClient.reload(metaClient); + + // Insert totalRecords records + writeClient.startCommitWithTime(newCommitTime); + List writeStatues = writeClient.upsert(writeRecords, newCommitTime).collect(); + org.apache.hudi.testutils.Assertions.assertNoWriteErrors(writeStatues); + boolean success = writeClient.commitStats(newCommitTime, writeStatues.stream() + .map(WriteStatus::getStat) + .collect(Collectors.toList()), Option.empty(), metaClient.getCommitActionType()); + Assertions.assertTrue(success); + metaClient = HoodieTableMetaClient.reload(metaClient); + // The number of distinct fileId should be the same as total log file numbers + Assertions.assertEquals(writeStatues.stream().map(WriteStatus::getFileId).distinct().count(), + Arrays.stream(dataGen.getPartitionPaths()).mapToInt(p -> Objects.requireNonNull(listStatus(p, true)).length).sum()); + Assertions.assertEquals(totalRecords, readRecords(dataGen.getPartitionPaths(), populateMetaFields).size()); + + // Upsert the same set of records, the number of records should be same + newCommitTime = "002"; + writeClient.startCommitWithTime(newCommitTime); + writeStatues = writeClient.upsert(writeRecords, newCommitTime).collect(); + org.apache.hudi.testutils.Assertions.assertNoWriteErrors(writeStatues); + success = writeClient.commitStats(newCommitTime, writeStatues.stream() + .map(WriteStatus::getStat) + .collect(Collectors.toList()), Option.empty(), metaClient.getCommitActionType()); + Assertions.assertTrue(success); + // The number of log file should double after this insertion + long numberOfLogFiles = Arrays.stream(dataGen.getPartitionPaths()) + .mapToInt(p -> { + return Arrays.stream(listStatus(p, true)).mapToInt(fs -> + fs instanceof RealtimeFileStatus ? ((RealtimeFileStatus) fs).getDeltaLogFiles().size() : 1).sum(); + }).sum(); + Assertions.assertEquals(writeStatues.stream().map(WriteStatus::getFileId).distinct().count() * 2, numberOfLogFiles); + // The record number should remain same because of deduplication + Assertions.assertEquals(totalRecords, readRecords(dataGen.getPartitionPaths(), populateMetaFields).size()); + + metaClient = HoodieTableMetaClient.reload(metaClient); + + // Upsert new set of records, and validate the total number of records + newCommitTime = "003"; + records = dataGen.generateInserts(newCommitTime, totalRecords); + writeRecords = jsc.parallelize(records, 2); + writeClient.startCommitWithTime(newCommitTime); + writeStatues = writeClient.upsert(writeRecords, newCommitTime).collect(); + org.apache.hudi.testutils.Assertions.assertNoWriteErrors(writeStatues); + success = writeClient.commitStats(newCommitTime, writeStatues.stream().map(WriteStatus::getStat).collect(Collectors.toList()), + Option.empty(), metaClient.getCommitActionType()); + Assertions.assertTrue(success); + Assertions.assertEquals(totalRecords * 2, readRecords(dataGen.getPartitionPaths(), populateMetaFields).size()); + } + + private List readRecords(String[] partitions, boolean populateMetaFields) { + return HoodieMergeOnReadTestUtils.getRecordsUsingInputFormat(hadoopConf, + Arrays.stream(partitions).map(p -> Paths.get(basePath, p).toString()).collect(Collectors.toList()), + basePath, new JobConf(hadoopConf), true, populateMetaFields); + } + + private FileStatus[] listStatus(String p, boolean realtime) { + JobConf jobConf = new JobConf(hadoopConf); + FileInputFormat.setInputPaths(jobConf, Paths.get(basePath, p).toString()); + FileInputFormat format = HoodieInputFormatUtils.getInputFormat(HoodieFileFormat.PARQUET, realtime, jobConf); + try { + if (realtime) { + return ((HoodieParquetRealtimeInputFormat) format).listStatus(jobConf); + } else { + return ((HoodieParquetInputFormat) format).listStatus(jobConf); + } + } catch (IOException e) { + e.printStackTrace(); + return null; + } + } + + private HoodieWriteConfig.Builder getConfigBuilder() { + return HoodieWriteConfig.newBuilder().withPath(basePath).withSchema(HoodieTestDataGenerator.TRIP_EXAMPLE_SCHEMA) + .withParallelism(2, 2).withBulkInsertParallelism(2).withFinalizeWriteParallelism(2).withDeleteParallelism(2) + .withWriteStatusClass(MetadataMergeWriteStatus.class) + .withConsistencyGuardConfig(ConsistencyGuardConfig.newBuilder().withConsistencyCheckEnabled(true).build()) + .withCompactionConfig(HoodieCompactionConfig.newBuilder().compactionSmallFileSize(1024 * 1024).build()) + .withStorageConfig(HoodieStorageConfig.newBuilder().hfileMaxFileSize(1024 * 1024).parquetMaxFileSize(1024 * 1024).build()) + .forTable("test-trip-table") + .withEmbeddedTimelineServerEnabled(true).withFileSystemViewConfig(FileSystemViewStorageConfig.newBuilder() + .withStorageType(FileSystemViewStorageType.EMBEDDED_KV_STORE).build()); + } +} diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieIndex.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieIndex.java index 024cf1ff50acc..8cbb74e6f5e03 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieIndex.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/client/functional/TestHoodieIndex.java @@ -131,6 +131,9 @@ private void setUp(IndexType indexType, boolean populateMetaFields, boolean roll HoodieIndexConfig.Builder indexBuilder = HoodieIndexConfig.newBuilder().withIndexType(indexType) .fromProperties(populateMetaFields ? new Properties() : getPropertiesForKeyGen()) .withIndexType(indexType); + if (indexType == IndexType.BUCKET) { + indexBuilder.withBucketIndexEngineType(HoodieIndex.BucketIndexEngineType.SIMPLE); + } config = getConfigBuilder() .withProperties(populateMetaFields ? new Properties() : getPropertiesForKeyGen()) .withRollbackUsingMarkers(rollbackUsingMarkers) diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/TestHoodieIndexConfigs.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/TestHoodieIndexConfigs.java index 171403eb03847..b843546799479 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/TestHoodieIndexConfigs.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/TestHoodieIndexConfigs.java @@ -26,7 +26,8 @@ import org.apache.hudi.index.HoodieIndex.IndexType; import org.apache.hudi.index.bloom.HoodieBloomIndex; import org.apache.hudi.index.bloom.HoodieGlobalBloomIndex; -import org.apache.hudi.index.bucket.HoodieBucketIndex; +import org.apache.hudi.index.bucket.HoodieSimpleBucketIndex; +import org.apache.hudi.index.bucket.HoodieSparkConsistentBucketIndex; import org.apache.hudi.index.hbase.SparkHoodieHBaseIndex; import org.apache.hudi.index.inmemory.HoodieInMemoryHashIndex; import org.apache.hudi.index.simple.HoodieSimpleIndex; @@ -88,8 +89,15 @@ public void testCreateIndex(IndexType indexType) { break; case BUCKET: config = clientConfigBuilder.withPath(basePath) - .withIndexConfig(indexConfigBuilder.withIndexType(IndexType.BUCKET).build()).build(); - assertTrue(SparkHoodieIndexFactory.createIndex(config) instanceof HoodieBucketIndex); + .withIndexConfig(indexConfigBuilder.withIndexType(IndexType.BUCKET) + .withBucketIndexEngineType(HoodieIndex.BucketIndexEngineType.SIMPLE).build()).build(); + assertTrue(SparkHoodieIndexFactory.createIndex(config) instanceof HoodieSimpleBucketIndex); + + config = clientConfigBuilder.withPath(basePath) + .withIndexConfig(indexConfigBuilder.withIndexType(IndexType.BUCKET) + .withBucketIndexEngineType(HoodieIndex.BucketIndexEngineType.CONSISTENT_HASHING).build()) + .build(); + assertTrue(SparkHoodieIndexFactory.createIndex(config) instanceof HoodieSparkConsistentBucketIndex); break; default: // no -op. just for checkstyle errors diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bucket/TestHoodieBucketIndex.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bucket/TestHoodieSimpleBucketIndex.java similarity index 91% rename from hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bucket/TestHoodieBucketIndex.java rename to hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bucket/TestHoodieSimpleBucketIndex.java index 2b3765948bb63..c8b877cecad11 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bucket/TestHoodieBucketIndex.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/index/bucket/TestHoodieSimpleBucketIndex.java @@ -52,10 +52,10 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -public class TestHoodieBucketIndex extends HoodieClientTestHarness { +public class TestHoodieSimpleBucketIndex extends HoodieClientTestHarness { - private static final Logger LOG = LogManager.getLogger(TestHoodieBucketIndex.class); - private static final Schema SCHEMA = getSchemaFromResource(TestHoodieBucketIndex.class, "/exampleSchema.avsc", true); + private static final Logger LOG = LogManager.getLogger(TestHoodieSimpleBucketIndex.class); + private static final Schema SCHEMA = getSchemaFromResource(TestHoodieSimpleBucketIndex.class, "/exampleSchema.avsc", true); private static final int NUM_BUCKET = 8; @BeforeEach @@ -78,11 +78,15 @@ public void testBucketIndexValidityCheck() { props.setProperty(HoodieIndexConfig.BUCKET_INDEX_HASH_FIELD.key(), "_row_key"); assertThrows(HoodieIndexException.class, () -> { HoodieIndexConfig.newBuilder().fromProperties(props) - .withIndexType(HoodieIndex.IndexType.BUCKET).withBucketNum("8").build(); + .withIndexType(HoodieIndex.IndexType.BUCKET) + .withBucketIndexEngineType(HoodieIndex.BucketIndexEngineType.SIMPLE) + .withBucketNum("8").build(); }); props.setProperty(HoodieIndexConfig.BUCKET_INDEX_HASH_FIELD.key(), "uuid"); HoodieIndexConfig.newBuilder().fromProperties(props) - .withIndexType(HoodieIndex.IndexType.BUCKET).withBucketNum("8").build(); + .withIndexType(HoodieIndex.IndexType.BUCKET) + .withBucketIndexEngineType(HoodieIndex.BucketIndexEngineType.SIMPLE) + .withBucketNum("8").build(); } @Test @@ -110,7 +114,7 @@ public void testTagLocation() throws Exception { HoodieWriteConfig config = makeConfig(); HoodieTable table = HoodieSparkTable.create(config, context, metaClient); - HoodieBucketIndex bucketIndex = new HoodieBucketIndex(config); + HoodieSimpleBucketIndex bucketIndex = new HoodieSimpleBucketIndex(config); HoodieData> taggedRecordRDD = bucketIndex.tagLocation(HoodieJavaRDD.of(recordRDD), context, table); assertFalse(taggedRecordRDD.collectAsList().stream().anyMatch(r -> r.isCurrentLocationKnown())); @@ -133,6 +137,7 @@ private HoodieWriteConfig makeConfig() { return HoodieWriteConfig.newBuilder().withPath(basePath).withSchema(SCHEMA.toString()) .withIndexConfig(HoodieIndexConfig.newBuilder().fromProperties(props) .withIndexType(HoodieIndex.IndexType.BUCKET) + .withBucketIndexEngineType(HoodieIndex.BucketIndexEngineType.SIMPLE) .withIndexKeyField("_row_key") .withBucketNum(String.valueOf(NUM_BUCKET)).build()).build(); } diff --git a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestCopyOnWriteActionExecutor.java b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestCopyOnWriteActionExecutor.java index 9574d35a65410..30f7ad66543d1 100644 --- a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestCopyOnWriteActionExecutor.java +++ b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/table/action/commit/TestCopyOnWriteActionExecutor.java @@ -148,7 +148,10 @@ private Properties makeIndexConfig(HoodieIndex.IndexType indexType) { props.putAll(indexConfig.build().getProps()); if (indexType.equals(HoodieIndex.IndexType.BUCKET)) { props.setProperty(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), "_row_key"); - indexConfig.fromProperties(props).withIndexKeyField("_row_key").withBucketNum("1"); + indexConfig.fromProperties(props) + .withIndexKeyField("_row_key") + .withBucketNum("1") + .withBucketIndexEngineType(HoodieIndex.BucketIndexEngineType.SIMPLE); props.putAll(indexConfig.build().getProps()); props.putAll(HoodieLayoutConfig.newBuilder().fromProperties(props) .withLayoutType(HoodieStorageLayout.LayoutType.BUCKET.name()) diff --git a/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieData.java b/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieData.java index 4e8d2b7eceaee..4b391ecbab752 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieData.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieData.java @@ -77,6 +77,15 @@ public abstract class HoodieData implements Serializable { public abstract HoodieData mapPartitions( SerializableFunction, Iterator> func, boolean preservesPartitioning); + /** + * @param func serializable map function by taking a partition of objects + * and generating an iterator. + * @param output object type. + * @return {@link HoodieData} containing the result. Actual execution may be deferred. + */ + public abstract HoodieData mapPartitions( + SerializableFunction, Iterator> func); + /** * @param func serializable flatmap function. * @param output object type. diff --git a/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieList.java b/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieList.java index c23e712cf41ae..28ed2e282deb5 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieList.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/data/HoodieList.java @@ -99,6 +99,11 @@ public HoodieData map(SerializableFunction func) { @Override public HoodieData mapPartitions(SerializableFunction, Iterator> func, boolean preservesPartitioning) { + return mapPartitions(func); + } + + @Override + public HoodieData mapPartitions(SerializableFunction, Iterator> func) { List result = new ArrayList<>(); throwingMapWrapper(func).apply(listData.iterator()).forEachRemaining(result::add); return HoodieList.of(result); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java index 79badb48a5895..aa0cadf5b9354 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/fs/FSUtils.java @@ -348,6 +348,10 @@ public static String createNewFileIdPfx() { return UUID.randomUUID().toString(); } + public static String createNewFileId(String idPfx, int id) { + return String.format("%s-%d", idPfx, id); + } + /** * Get the file extension from the log file. */ diff --git a/hudi-common/src/main/java/org/apache/hudi/common/model/ConsistentHashingNode.java b/hudi-common/src/main/java/org/apache/hudi/common/model/ConsistentHashingNode.java new file mode 100644 index 0000000000000..262bb963223bb --- /dev/null +++ b/hudi-common/src/main/java/org/apache/hudi/common/model/ConsistentHashingNode.java @@ -0,0 +1,78 @@ +/* + * 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.hudi.common.model; + +import org.apache.hudi.common.util.JsonUtils; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.io.IOException; +import java.io.Serializable; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Used in consistent hashing index, representing nodes in the consistent hash ring. + * Record the end hash range value and its corresponding file group id. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class ConsistentHashingNode implements Serializable { + + private final int value; + private final String fileIdPrefix; + + @JsonCreator + public ConsistentHashingNode(@JsonProperty("value") int value, @JsonProperty("fileIdPrefix") String fileIdPrefix) { + this.value = value; + this.fileIdPrefix = fileIdPrefix; + } + + public static String toJsonString(List nodes) throws IOException { + return JsonUtils.getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(nodes); + } + + public static List fromJsonString(String json) throws Exception { + if (json == null || json.isEmpty()) { + return Collections.emptyList(); + } + + ConsistentHashingNode[] nodes = JsonUtils.getObjectMapper().readValue(json, ConsistentHashingNode[].class); + return Arrays.asList(nodes); + } + + public int getValue() { + return value; + } + + public String getFileIdPrefix() { + return fileIdPrefix; + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("ConsistentHashingNode{"); + sb.append("value=").append(value); + sb.append(", fileIdPfx='").append(fileIdPrefix).append('\''); + sb.append('}'); + return sb.toString(); + } +} \ No newline at end of file diff --git a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieCommitMetadata.java b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieCommitMetadata.java index 53ceb00409ac7..f5077dea859ae 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieCommitMetadata.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieCommitMetadata.java @@ -18,17 +18,15 @@ package org.apache.hudi.common.model; -import com.fasterxml.jackson.annotation.JsonAutoDetect; +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.util.JsonUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.Pair; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileStatus; import org.apache.hadoop.fs.Path; -import org.apache.hudi.common.fs.FSUtils; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.collection.Pair; import org.apache.log4j.LogManager; import org.apache.log4j.Logger; @@ -227,7 +225,7 @@ public String toJsonString() throws IOException { LOG.info("partition path is null for " + partitionToWriteStats.get(null)); partitionToWriteStats.remove(null); } - return getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(this); + return JsonUtils.getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(this); } public static T fromJsonString(String jsonStr, Class clazz) throws Exception { @@ -235,7 +233,7 @@ public static T fromJsonString(String jsonStr, Class clazz) throws Except // For empty commit file (no data or somethings bad happen). return clazz.newInstance(); } - return getObjectMapper().readValue(jsonStr, clazz); + return JsonUtils.getObjectMapper().readValue(jsonStr, clazz); } // Here the functions are named "fetch" instead of "get", to get avoid of the json conversion. @@ -457,13 +455,6 @@ public static T fromBytes(byte[] bytes, Class clazz) throws IOException { } } - protected static ObjectMapper getObjectMapper() { - ObjectMapper mapper = new ObjectMapper(); - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); - return mapper; - } - @Override public String toString() { return "HoodieCommitMetadata{" + "partitionToWriteStats=" + partitionToWriteStats diff --git a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieConsistentHashingMetadata.java b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieConsistentHashingMetadata.java new file mode 100644 index 0000000000000..46f115262745f --- /dev/null +++ b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieConsistentHashingMetadata.java @@ -0,0 +1,142 @@ +/* + * 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.hudi.common.model; + +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.util.JsonUtils; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.apache.log4j.LogManager; +import org.apache.log4j.Logger; + +import java.io.IOException; +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * All the metadata that is used for consistent hashing bucket index + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class HoodieConsistentHashingMetadata implements Serializable { + + private static final Logger LOG = LogManager.getLogger(HoodieConsistentHashingMetadata.class); + /** + * Upper-bound of the hash value + */ + public static final int HASH_VALUE_MASK = Integer.MAX_VALUE; + public static final String HASHING_METADATA_FILE_SUFFIX = ".hashing_meta"; + + private final short version; + private final String partitionPath; + private final String instant; + private final int numBuckets; + private final int seqNo; + private final List nodes; + + @JsonCreator + public HoodieConsistentHashingMetadata(@JsonProperty("version") short version, @JsonProperty("partitionPath") String partitionPath, + @JsonProperty("instant") String instant, @JsonProperty("numBuckets") int numBuckets, + @JsonProperty("seqNo") int seqNo, @JsonProperty("nodes") List nodes) { + this.version = version; + this.partitionPath = partitionPath; + this.instant = instant; + this.numBuckets = numBuckets; + this.seqNo = seqNo; + this.nodes = nodes; + } + + /** + * Construct default metadata with all bucket's file group uuid initialized + */ + public HoodieConsistentHashingMetadata(String partitionPath, int numBuckets) { + this((short) 0, partitionPath, HoodieTimeline.INIT_INSTANT_TS, numBuckets, 0, constructDefaultHashingNodes(numBuckets)); + } + + private static List constructDefaultHashingNodes(int numBuckets) { + long step = ((long) HASH_VALUE_MASK + numBuckets - 1) / numBuckets; + return IntStream.range(1, numBuckets + 1) + .mapToObj(i -> new ConsistentHashingNode((int) Math.min(step * i, HASH_VALUE_MASK), FSUtils.createNewFileIdPfx())).collect(Collectors.toList()); + } + + public short getVersion() { + return version; + } + + public String getPartitionPath() { + return partitionPath; + } + + public String getInstant() { + return instant; + } + + public int getNumBuckets() { + return numBuckets; + } + + public int getSeqNo() { + return seqNo; + } + + public List getNodes() { + return nodes; + } + + public String getFilename() { + return instant + HASHING_METADATA_FILE_SUFFIX; + } + + public byte[] toBytes() throws IOException { + return toJsonString().getBytes(StandardCharsets.UTF_8); + } + + public static HoodieConsistentHashingMetadata fromBytes(byte[] bytes) throws IOException { + try { + return fromJsonString(new String(bytes, StandardCharsets.UTF_8), HoodieConsistentHashingMetadata.class); + } catch (Exception e) { + throw new IOException("unable to read hashing metadata", e); + } + } + + private String toJsonString() throws IOException { + return JsonUtils.getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(this); + } + + protected static T fromJsonString(String jsonStr, Class clazz) throws Exception { + if (jsonStr == null || jsonStr.isEmpty()) { + // For empty commit file (no data or something bad happen). + return clazz.newInstance(); + } + return JsonUtils.getObjectMapper().readValue(jsonStr, clazz); + } + + /** + * Get instant time from the hashing metadata filename + * Pattern of the filename: .HASHING_METADATA_FILE_SUFFIX + */ + public static String getTimestampFromFile(String filename) { + return filename.split("\\.")[0]; + } +} diff --git a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieReplaceCommitMetadata.java b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieReplaceCommitMetadata.java index 7cc9ee3a0c146..2dd6cda47d3db 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieReplaceCommitMetadata.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieReplaceCommitMetadata.java @@ -18,11 +18,9 @@ package org.apache.hudi.common.model; -import com.fasterxml.jackson.annotation.JsonAutoDetect; +import org.apache.hudi.common.util.JsonUtils; + import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.log4j.LogManager; import org.apache.log4j.Logger; @@ -80,7 +78,7 @@ public String toJsonString() throws IOException { LOG.info("partition path is null for " + partitionToReplaceFileIds.get(null)); partitionToReplaceFileIds.remove(null); } - return getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(this); + return JsonUtils.getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(this); } public static T fromJsonString(String jsonStr, Class clazz) throws Exception { @@ -88,7 +86,7 @@ public static T fromJsonString(String jsonStr, Class clazz) throws Except // For empty commit file (no data or somethings bad happen). return clazz.newInstance(); } - return getObjectMapper().readValue(jsonStr, clazz); + return JsonUtils.getObjectMapper().readValue(jsonStr, clazz); } @Override @@ -124,13 +122,6 @@ public static T fromBytes(byte[] bytes, Class clazz) throws IOException { } } - protected static ObjectMapper getObjectMapper() { - ObjectMapper mapper = new ObjectMapper(); - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); - return mapper; - } - @Override public String toString() { return "HoodieReplaceMetadata{" + "partitionToWriteStats=" + partitionToWriteStats diff --git a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieRollingStatMetadata.java b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieRollingStatMetadata.java index a354092675e4f..0a5240ed55d83 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieRollingStatMetadata.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieRollingStatMetadata.java @@ -18,6 +18,8 @@ package org.apache.hudi.common.model; +import org.apache.hudi.common.util.JsonUtils; + import org.apache.log4j.LogManager; import org.apache.log4j.Logger; @@ -81,7 +83,7 @@ public String toJsonString() throws IOException { LOG.info("partition path is null for " + partitionToRollingStats.get(null)); partitionToRollingStats.remove(null); } - return HoodieCommitMetadata.getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(this); + return JsonUtils.getObjectMapper().writerWithDefaultPrettyPrinter().writeValueAsString(this); } public HoodieRollingStatMetadata merge(HoodieRollingStatMetadata rollingStatMetadata) { diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java index 6b10a62820e32..546ddf7a30671 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java @@ -85,6 +85,7 @@ public class HoodieTableMetaClient implements Serializable { public static final String BOOTSTRAP_INDEX_ROOT_FOLDER_PATH = AUXILIARYFOLDER_NAME + Path.SEPARATOR + ".bootstrap"; public static final String HEARTBEAT_FOLDER_NAME = METAFOLDER_NAME + Path.SEPARATOR + ".heartbeat"; public static final String METADATA_TABLE_FOLDER_PATH = METAFOLDER_NAME + Path.SEPARATOR + "metadata"; + public static final String HASHING_METADATA_FOLDER_NAME = ".bucket_index" + Path.SEPARATOR + "consistent_hashing_metadata"; public static final String BOOTSTRAP_INDEX_BY_PARTITION_FOLDER_PATH = BOOTSTRAP_INDEX_ROOT_FOLDER_PATH + Path.SEPARATOR + ".partitions"; public static final String BOOTSTRAP_INDEX_BY_FILE_ID_FOLDER_PATH = BOOTSTRAP_INDEX_ROOT_FOLDER_PATH + Path.SEPARATOR @@ -211,6 +212,13 @@ public String getSchemaFolderName() { return new Path(metaPath.get(), SCHEMA_FOLDER_NAME).toString(); } + /** + * @return Hashing metadata base path + */ + public String getHashingMetadataPath() { + return new Path(metaPath.get(), HASHING_METADATA_FOLDER_NAME).toString(); + } + /** * @return Temp Folder path */ diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/JsonUtils.java b/hudi-common/src/main/java/org/apache/hudi/common/util/JsonUtils.java new file mode 100644 index 0000000000000..d820bde178e13 --- /dev/null +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/JsonUtils.java @@ -0,0 +1,38 @@ +/* + * 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.hudi.common.util; + +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class JsonUtils { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + static { + MAPPER.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + MAPPER.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY); + } + + public static ObjectMapper getObjectMapper() { + return MAPPER; + } +} diff --git a/hudi-common/src/main/java/org/apache/hudi/common/util/hash/HashID.java b/hudi-common/src/main/java/org/apache/hudi/common/util/hash/HashID.java index c56d76097866b..ccb29dfbb580d 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/util/hash/HashID.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/util/hash/HashID.java @@ -106,6 +106,15 @@ public static byte[] hash(final byte[] messageBytes, final Size bits) { } } + public static int getXXHash32(final String message, int hashSeed) { + return getXXHash32(message.getBytes(StandardCharsets.UTF_8), hashSeed); + } + + public static int getXXHash32(final byte[] message, int hashSeed) { + XXHashFactory factory = XXHashFactory.fastestInstance(); + return factory.hash32().hash(message, 0, message.length, hashSeed); + } + private static byte[] getXXHash(final byte[] message, final Size bits) { XXHashFactory factory = XXHashFactory.fastestInstance(); switch (bits) { diff --git a/hudi-common/src/test/java/org/apache/hudi/common/model/TestHoodieConsistentHashingMetadata.java b/hudi-common/src/test/java/org/apache/hudi/common/model/TestHoodieConsistentHashingMetadata.java new file mode 100644 index 0000000000000..8aa2e65561c59 --- /dev/null +++ b/hudi-common/src/test/java/org/apache/hudi/common/model/TestHoodieConsistentHashingMetadata.java @@ -0,0 +1,31 @@ +/* + * 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.hudi.common.model; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class TestHoodieConsistentHashingMetadata { + + @Test + public void testGetTimestamp() { + Assertions.assertTrue(HoodieConsistentHashingMetadata.getTimestampFromFile("0000.hashing_metadata").equals("0000")); + Assertions.assertTrue(HoodieConsistentHashingMetadata.getTimestampFromFile("1234.hashing_metadata").equals("1234")); + } +} diff --git a/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieCommonTestHarness.java b/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieCommonTestHarness.java index 311c131d432c6..dc64856d3c76c 100644 --- a/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieCommonTestHarness.java +++ b/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieCommonTestHarness.java @@ -66,6 +66,10 @@ protected void initTestDataGenerator() { dataGen = new HoodieTestDataGenerator(); } + protected void initTestDataGenerator(String[] partitionPaths) { + dataGen = new HoodieTestDataGenerator(partitionPaths); + } + /** * Cleanups test data generator. * diff --git a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/index/bucket/TestBucketIdentifier.java b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/index/bucket/TestBucketIdentifier.java deleted file mode 100644 index 4491a74fa62ba..0000000000000 --- a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/index/bucket/TestBucketIdentifier.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * 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.hudi.index.bucket; - -import org.apache.hudi.common.model.HoodieAvroRecord; -import org.apache.hudi.common.model.HoodieKey; -import org.apache.hudi.common.model.HoodieRecord; -import org.apache.hudi.keygen.KeyGenUtils; -import org.apache.hudi.testutils.KeyGeneratorTestUtilities; - -import org.apache.avro.generic.GenericRecord; -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.List; - -public class TestBucketIdentifier { - - @Test - public void testBucketFileId() { - for (int i = 0; i < 1000; i++) { - String bucketId = BucketIdentifier.bucketIdStr(i); - String fileId = BucketIdentifier.newBucketFileIdPrefix(bucketId); - assert BucketIdentifier.bucketIdFromFileId(fileId) == i; - } - } - - @Test - public void testBucketIdWithSimpleRecordKey() { - String recordKeyField = "_row_key"; - String indexKeyField = "_row_key"; - GenericRecord record = KeyGeneratorTestUtilities.getRecord(); - HoodieRecord hoodieRecord = new HoodieAvroRecord( - new HoodieKey(KeyGenUtils.getRecordKey(record, recordKeyField, false), ""), null); - int bucketId = BucketIdentifier.getBucketId(hoodieRecord, indexKeyField, 8); - assert bucketId == BucketIdentifier.getBucketId( - Arrays.asList(record.get(indexKeyField).toString()), 8); - } - - @Test - public void testBucketIdWithComplexRecordKey() { - List recordKeyField = Arrays.asList("_row_key","ts_ms"); - String indexKeyField = "_row_key"; - GenericRecord record = KeyGeneratorTestUtilities.getRecord(); - HoodieRecord hoodieRecord = new HoodieAvroRecord( - new HoodieKey(KeyGenUtils.getRecordKey(record, recordKeyField, false), ""), null); - int bucketId = BucketIdentifier.getBucketId(hoodieRecord, indexKeyField, 8); - assert bucketId == BucketIdentifier.getBucketId( - Arrays.asList(record.get(indexKeyField).toString()), 8); - } -} From 43e08193ef712c19bf986c65a184991c6de960b6 Mon Sep 17 00:00:00 2001 From: Danny Chan Date: Mon, 16 May 2022 17:40:08 +0800 Subject: [PATCH 30/52] [HUDI-4098] Metadata table heartbeat for instant has expired, last heartbeat 0 (#5583) --- .../FlinkHoodieBackedTableMetadataWriter.java | 5 +++ .../TestStreamWriteOperatorCoordinator.java | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/metadata/FlinkHoodieBackedTableMetadataWriter.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/metadata/FlinkHoodieBackedTableMetadataWriter.java index 76774e9618d79..222ff78edc9fe 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/metadata/FlinkHoodieBackedTableMetadataWriter.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/metadata/FlinkHoodieBackedTableMetadataWriter.java @@ -138,6 +138,11 @@ protected void commit(String instantTime, Map statuses = preppedRecordList.size() > 0 diff --git a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java index 55885dcab5837..59a0580e56c5c 100644 --- a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java +++ b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/sink/TestStreamWriteOperatorCoordinator.java @@ -22,6 +22,8 @@ import org.apache.hudi.common.fs.FSUtils; import org.apache.hudi.common.model.HoodieWriteStat; import org.apache.hudi.common.table.HoodieTableMetaClient; +import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; +import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.configuration.HadoopConfigurations; @@ -253,6 +255,49 @@ void testSyncMetadataTable() throws Exception { assertThat(completedTimeline.nthFromLastInstant(1).get().getAction(), is(HoodieTimeline.COMMIT_ACTION)); } + @Test + void testSyncMetadataTableWithReusedInstant() throws Exception { + // reset + reset(); + // override the default configuration + Configuration conf = TestConfigurations.getDefaultConf(tempFile.getAbsolutePath()); + conf.setBoolean(FlinkOptions.METADATA_ENABLED, true); + OperatorCoordinator.Context context = new MockOperatorCoordinatorContext(new OperatorID(), 1); + coordinator = new StreamWriteOperatorCoordinator(conf, context); + coordinator.start(); + coordinator.setExecutor(new MockCoordinatorExecutor(context)); + + final WriteMetadataEvent event0 = WriteMetadataEvent.emptyBootstrap(0); + + coordinator.handleEventFromOperator(0, event0); + + String instant = coordinator.getInstant(); + assertNotEquals("", instant); + + final String metadataTableBasePath = HoodieTableMetadata.getMetadataTableBasePath(tempFile.getAbsolutePath()); + HoodieTableMetaClient metadataTableMetaClient = StreamerUtil.createMetaClient(metadataTableBasePath, HadoopConfigurations.getHadoopConf(conf)); + HoodieTimeline completedTimeline = metadataTableMetaClient.getActiveTimeline().filterCompletedInstants(); + assertThat("One instant need to sync to metadata table", completedTimeline.getInstants().count(), is(1L)); + assertThat(completedTimeline.lastInstant().get().getTimestamp(), is(HoodieTableMetadata.SOLO_COMMIT_TIMESTAMP)); + + // writes a normal commit + mockWriteWithMetadata(); + instant = coordinator.getInstant(); + // creates an inflight commit on the metadata timeline + metadataTableMetaClient.getActiveTimeline() + .createNewInstant(new HoodieInstant(HoodieInstant.State.REQUESTED, HoodieActiveTimeline.DELTA_COMMIT_ACTION, instant)); + metadataTableMetaClient.getActiveTimeline().transitionRequestedToInflight(HoodieActiveTimeline.DELTA_COMMIT_ACTION, instant); + metadataTableMetaClient.reloadActiveTimeline(); + + // write another commit with existing instant on the metadata timeline + instant = mockWriteWithMetadata(); + metadataTableMetaClient.reloadActiveTimeline(); + + completedTimeline = metadataTableMetaClient.getActiveTimeline().filterCompletedInstants(); + assertThat("One instant need to sync to metadata table", completedTimeline.getInstants().count(), is(3L)); + assertThat(completedTimeline.lastInstant().get().getTimestamp(), is(instant)); + } + // ------------------------------------------------------------------------- // Utilities // ------------------------------------------------------------------------- From a7a42e4490cc8a96b12b8ebbecb4b90d8e8ecbdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=8F=AF=E4=BC=A6?= Date: Mon, 16 May 2022 23:26:23 +0800 Subject: [PATCH 31/52] [HUDI-4103] [HUDI-4001] Filter the properties should not be used when create table for Spark SQL --- .../org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala index 5f4572dcc9388..2c5261a12f146 100644 --- a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala +++ b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala @@ -26,6 +26,7 @@ import org.apache.hudi.{DataSourceWriteOptions, SparkAdapterSupport} import org.apache.spark.sql.HoodieSpark3SqlUtils.convertTransforms import org.apache.spark.sql.catalyst.TableIdentifier import org.apache.spark.sql.catalyst.analysis.{NoSuchTableException, TableAlreadyExistsException, UnresolvedAttribute} +import org.apache.spark.sql.catalyst.catalog.HoodieCatalogTable.needFilterProps import org.apache.spark.sql.catalyst.catalog.{CatalogTable, CatalogTableType, CatalogUtils, HoodieCatalogTable} import org.apache.spark.sql.connector.catalog.CatalogV2Implicits.IdentifierHelper import org.apache.spark.sql.connector.catalog.TableChange.{AddColumn, ColumnChange, UpdateColumnComment, UpdateColumnType} @@ -215,7 +216,7 @@ class HoodieCatalog extends DelegatingCatalogExtension val loc = locUriOpt .orElse(existingTableOpt.flatMap(_.storage.locationUri)) .getOrElse(spark.sessionState.catalog.defaultTablePath(id)) - val storage = DataSource.buildStorageFormatFromOptions(writeOptions) + val storage = DataSource.buildStorageFormatFromOptions(writeOptions.--(needFilterProps)) .copy(locationUri = Option(loc)) val tableType = if (location.isDefined) CatalogTableType.EXTERNAL else CatalogTableType.MANAGED @@ -233,7 +234,7 @@ class HoodieCatalog extends DelegatingCatalogExtension provider = Option("hudi"), partitionColumnNames = newPartitionColumns, bucketSpec = newBucketSpec, - properties = tablePropertiesNew.asScala.toMap, + properties = tablePropertiesNew.asScala.toMap.--(needFilterProps), comment = commentOpt) val hoodieCatalogTable = HoodieCatalogTable(spark, tableDesc) From ad773b3d9622ebed9a8419eb5095aa6dbb8d08f0 Mon Sep 17 00:00:00 2001 From: Shawy Geng Date: Tue, 17 May 2022 09:47:10 +0800 Subject: [PATCH 32/52] [HUDI-3654] Preparations for hudi metastore. (#5572) * [HUDI-3654] Preparations for hudi metastore. Co-authored-by: gengxiaoyu --- .../apache/hudi/client/BaseHoodieClient.java | 3 +- .../hudi/client/HoodieTimelineArchiver.java | 3 + .../apache/hudi/config/HoodieWriteConfig.java | 10 ++ .../apache/hudi/table/HoodieSparkTable.java | 3 +- .../common/config/HoodieMetastoreConfig.java | 93 +++++++++++++++++++ .../common/table/HoodieTableMetaClient.java | 50 ++++++++-- .../table/timeline/HoodieActiveTimeline.java | 10 +- .../table/view/FileSystemViewManager.java | 14 +++ 8 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 hudi-common/src/main/java/org/apache/hudi/common/config/HoodieMetastoreConfig.java diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieClient.java index 3f208a0f86a09..b41747d83a85e 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieClient.java @@ -135,7 +135,8 @@ protected HoodieTableMetaClient createMetaClient(boolean loadActiveTimelineOnLoa return HoodieTableMetaClient.builder().setConf(hadoopConf).setBasePath(config.getBasePath()) .setLoadActiveTimelineOnLoad(loadActiveTimelineOnLoad).setConsistencyGuardConfig(config.getConsistencyGuardConfig()) .setLayoutVersion(Option.of(new TimelineLayoutVersion(config.getTimelineLayoutVersion()))) - .setFileSystemRetryConfig(config.getFileSystemRetryConfig()).build(); + .setFileSystemRetryConfig(config.getFileSystemRetryConfig()) + .setProperties(config.getProps()).build(); } public Option getTimelineServer() { diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTimelineArchiver.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTimelineArchiver.java index 41bcf001a0b6d..2974cc2ef6d6f 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTimelineArchiver.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTimelineArchiver.java @@ -459,6 +459,9 @@ private Stream getCommitInstantsToArchive() { private Stream getInstantsToArchive() { Stream instants = Stream.concat(getCleanInstantsToArchive(), getCommitInstantsToArchive()); + if (config.isMetastoreEnabled()) { + return Stream.empty(); + } // For archiving and cleaning instants, we need to include intermediate state files if they exist HoodieActiveTimeline rawActiveTimeline = new HoodieActiveTimeline(metaClient, false); diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java index 3eeb99044b29d..dd5c0bfd6ded3 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/config/HoodieWriteConfig.java @@ -28,6 +28,7 @@ import org.apache.hudi.common.config.HoodieCommonConfig; import org.apache.hudi.common.config.HoodieConfig; import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.config.HoodieMetastoreConfig; import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.engine.EngineType; import org.apache.hudi.common.fs.ConsistencyGuardConfig; @@ -495,6 +496,7 @@ public class HoodieWriteConfig extends HoodieConfig { private FileSystemViewStorageConfig viewStorageConfig; private HoodiePayloadConfig hoodiePayloadConfig; private HoodieMetadataConfig metadataConfig; + private HoodieMetastoreConfig metastoreConfig; private HoodieCommonConfig commonConfig; private EngineType engineType; @@ -886,6 +888,7 @@ protected HoodieWriteConfig(EngineType engineType, Properties props) { this.viewStorageConfig = clientSpecifiedViewStorageConfig; this.hoodiePayloadConfig = HoodiePayloadConfig.newBuilder().fromProperties(newProps).build(); this.metadataConfig = HoodieMetadataConfig.newBuilder().fromProperties(props).build(); + this.metastoreConfig = HoodieMetastoreConfig.newBuilder().fromProperties(props).build(); this.commonConfig = HoodieCommonConfig.newBuilder().fromProperties(props).build(); } @@ -2100,6 +2103,13 @@ public HoodieStorageLayout.LayoutType getLayoutType() { return HoodieStorageLayout.LayoutType.valueOf(getString(HoodieLayoutConfig.LAYOUT_TYPE)); } + /** + * Metastore configs. + */ + public boolean isMetastoreEnabled() { + return metastoreConfig.enableMetastore(); + } + public static class Builder { protected final HoodieWriteConfig writeConfig = new HoodieWriteConfig(); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/HoodieSparkTable.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/HoodieSparkTable.java index 71efe89a055e1..20e3bd4c14ac3 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/HoodieSparkTable.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/table/HoodieSparkTable.java @@ -63,7 +63,8 @@ public static HoodieSparkTable create(HoodieW HoodieTableMetaClient.builder().setConf(context.getHadoopConf().get()).setBasePath(config.getBasePath()) .setLoadActiveTimelineOnLoad(true).setConsistencyGuardConfig(config.getConsistencyGuardConfig()) .setLayoutVersion(Option.of(new TimelineLayoutVersion(config.getTimelineLayoutVersion()))) - .setFileSystemRetryConfig(config.getFileSystemRetryConfig()).build(); + .setFileSystemRetryConfig(config.getFileSystemRetryConfig()) + .setProperties(config.getProps()).build(); return HoodieSparkTable.create(config, (HoodieSparkEngineContext) context, metaClient, refreshTimeline); } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieMetastoreConfig.java b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieMetastoreConfig.java new file mode 100644 index 0000000000000..36e2798a4d32a --- /dev/null +++ b/hudi-common/src/main/java/org/apache/hudi/common/config/HoodieMetastoreConfig.java @@ -0,0 +1,93 @@ +/* + * 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.hudi.common.config; + +import javax.annotation.concurrent.Immutable; +import java.util.Properties; + +/** + * Configurations used by the HUDI Metastore. + */ +@Immutable +@ConfigClassProperty(name = "Metastore Configs", + groupName = ConfigGroups.Names.WRITE_CLIENT, + description = "Configurations used by the Hudi Metastore.") +public class HoodieMetastoreConfig extends HoodieConfig { + + public static final String METASTORE_PREFIX = "hoodie.metastore"; + + public static final ConfigProperty METASTORE_ENABLE = ConfigProperty + .key(METASTORE_PREFIX + ".enable") + .defaultValue(false) + .withDocumentation("Use metastore server to store hoodie table metadata"); + + public static final ConfigProperty METASTORE_URLS = ConfigProperty + .key(METASTORE_PREFIX + ".uris") + .defaultValue("thrift://localhost:9090") + .withDocumentation("Metastore server uris"); + + public static final ConfigProperty METASTORE_CONNECTION_RETRIES = ConfigProperty + .key(METASTORE_PREFIX + ".connect.retries") + .defaultValue(3) + .withDocumentation("Number of retries while opening a connection to metastore"); + + public static final ConfigProperty METASTORE_CONNECTION_RETRY_DELAY = ConfigProperty + .key(METASTORE_PREFIX + ".connect.retry.delay") + .defaultValue(1) + .withDocumentation("Number of seconds for the client to wait between consecutive connection attempts"); + + public static HoodieMetastoreConfig.Builder newBuilder() { + return new HoodieMetastoreConfig.Builder(); + } + + public boolean enableMetastore() { + return getBoolean(METASTORE_ENABLE); + } + + public String getMetastoreUris() { + return getStringOrDefault(METASTORE_URLS); + } + + public int getConnectionRetryLimit() { + return getIntOrDefault(METASTORE_CONNECTION_RETRIES); + } + + public int getConnectionRetryDelay() { + return getIntOrDefault(METASTORE_CONNECTION_RETRY_DELAY); + } + + public static class Builder { + private final HoodieMetastoreConfig config = new HoodieMetastoreConfig(); + + public Builder fromProperties(Properties props) { + this.config.getProps().putAll(props); + return this; + } + + public Builder setUris(String uris) { + config.setValue(METASTORE_URLS, uris); + return this; + } + + public HoodieMetastoreConfig build() { + config.setDefaults(HoodieMetastoreConfig.class.getName()); + return config; + } + } +} diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java index 546ddf7a30671..9945eb0650feb 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java @@ -19,6 +19,7 @@ package org.apache.hudi.common.table; import org.apache.hudi.common.config.HoodieConfig; +import org.apache.hudi.common.config.HoodieMetastoreConfig; import org.apache.hudi.common.config.SerializableConfiguration; import org.apache.hudi.common.fs.ConsistencyGuardConfig; import org.apache.hudi.common.fs.FSUtils; @@ -38,6 +39,7 @@ import org.apache.hudi.common.table.timeline.versioning.TimelineLayoutVersion; import org.apache.hudi.common.util.CommitUtils; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.ReflectionUtils; import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.exception.HoodieException; @@ -98,21 +100,22 @@ public class HoodieTableMetaClient implements Serializable { // NOTE: Since those two parameters lay on the hot-path of a lot of computations, we // use tailored extension of the {@code Path} class allowing to avoid repetitive // computations secured by its immutability - private SerializablePath basePath; - private SerializablePath metaPath; + protected SerializablePath basePath; + protected SerializablePath metaPath; private transient HoodieWrapperFileSystem fs; private boolean loadActiveTimelineOnLoad; - private SerializableConfiguration hadoopConf; + protected SerializableConfiguration hadoopConf; private HoodieTableType tableType; private TimelineLayoutVersion timelineLayoutVersion; - private HoodieTableConfig tableConfig; - private HoodieActiveTimeline activeTimeline; + protected HoodieTableConfig tableConfig; + protected HoodieActiveTimeline activeTimeline; private HoodieArchivedTimeline archivedTimeline; private ConsistencyGuardConfig consistencyGuardConfig = ConsistencyGuardConfig.newBuilder().build(); private FileSystemRetryConfig fileSystemRetryConfig = FileSystemRetryConfig.newBuilder().build(); + protected HoodieMetastoreConfig metastoreConfig; - private HoodieTableMetaClient(Configuration conf, String basePath, boolean loadActiveTimelineOnLoad, + protected HoodieTableMetaClient(Configuration conf, String basePath, boolean loadActiveTimelineOnLoad, ConsistencyGuardConfig consistencyGuardConfig, Option layoutVersion, String payloadClassName, FileSystemRetryConfig fileSystemRetryConfig) { LOG.info("Loading HoodieTableMetaClient from " + basePath); @@ -367,6 +370,13 @@ public synchronized HoodieArchivedTimeline getArchivedTimeline() { return archivedTimeline; } + public HoodieMetastoreConfig getMetastoreConfig() { + if (metastoreConfig == null) { + metastoreConfig = new HoodieMetastoreConfig(); + } + return metastoreConfig; + } + /** * Returns fresh new archived commits as a timeline from startTs (inclusive). * @@ -451,7 +461,8 @@ public static HoodieTableMetaClient initTableAndGetMetaClient(Configuration hado HoodieTableConfig.create(fs, metaPathDir, props); // We should not use fs.getConf as this might be different from the original configuration // used to create the fs in unit tests - HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder().setConf(hadoopConf).setBasePath(basePath).build(); + HoodieTableMetaClient metaClient = HoodieTableMetaClient.builder().setConf(hadoopConf).setBasePath(basePath) + .setProperties(props).build(); LOG.info("Finished initializing Table of type " + metaClient.getTableConfig().getTableType() + " from " + basePath); return metaClient; } @@ -620,6 +631,21 @@ public void initializeBootstrapDirsIfNotExists() throws IOException { initializeBootstrapDirsIfNotExists(getHadoopConf(), basePath.toString(), getFs()); } + private static HoodieTableMetaClient newMetaClient(Configuration conf, String basePath, boolean loadActiveTimelineOnLoad, + ConsistencyGuardConfig consistencyGuardConfig, Option layoutVersion, + String payloadClassName, FileSystemRetryConfig fileSystemRetryConfig, Properties props) { + HoodieMetastoreConfig metastoreConfig = null == props + ? new HoodieMetastoreConfig.Builder().build() + : new HoodieMetastoreConfig.Builder().fromProperties(props).build(); + return metastoreConfig.enableMetastore() + ? (HoodieTableMetaClient) ReflectionUtils.loadClass("org.apache.hudi.common.table.HoodieTableMetastoreClient", + new Class[]{Configuration.class, ConsistencyGuardConfig.class, FileSystemRetryConfig.class, String.class, String.class, HoodieMetastoreConfig.class}, + conf, consistencyGuardConfig, fileSystemRetryConfig, + props.getProperty(HoodieTableConfig.DATABASE_NAME.key()), props.getProperty(HoodieTableConfig.NAME.key()), metastoreConfig) + : new HoodieTableMetaClient(conf, basePath, + loadActiveTimelineOnLoad, consistencyGuardConfig, layoutVersion, payloadClassName, fileSystemRetryConfig); + } + public static Builder builder() { return new Builder(); } @@ -636,6 +662,7 @@ public static class Builder { private ConsistencyGuardConfig consistencyGuardConfig = ConsistencyGuardConfig.newBuilder().build(); private FileSystemRetryConfig fileSystemRetryConfig = FileSystemRetryConfig.newBuilder().build(); private Option layoutVersion = Option.of(TimelineLayoutVersion.CURR_LAYOUT_VERSION); + private Properties props; public Builder setConf(Configuration conf) { this.conf = conf; @@ -672,11 +699,16 @@ public Builder setLayoutVersion(Option layoutVersion) { return this; } + public Builder setProperties(Properties properties) { + this.props = properties; + return this; + } + public HoodieTableMetaClient build() { ValidationUtils.checkArgument(conf != null, "Configuration needs to be set to init HoodieTableMetaClient"); ValidationUtils.checkArgument(basePath != null, "basePath needs to be set to init HoodieTableMetaClient"); - return new HoodieTableMetaClient(conf, basePath, - loadActiveTimelineOnLoad, consistencyGuardConfig, layoutVersion, payloadClassName, fileSystemRetryConfig); + return newMetaClient(conf, basePath, + loadActiveTimelineOnLoad, consistencyGuardConfig, layoutVersion, payloadClassName, fileSystemRetryConfig, props); } } diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieActiveTimeline.java b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieActiveTimeline.java index d912525fe9271..a62068e655e5d 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieActiveTimeline.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/HoodieActiveTimeline.java @@ -245,7 +245,7 @@ public void deleteInstantFileIfExists(HoodieInstant instant) { } } - private void deleteInstantFile(HoodieInstant instant) { + protected void deleteInstantFile(HoodieInstant instant) { LOG.info("Deleting instant " + instant); Path inFlightCommitFilePath = getInstantFileNamePath(instant.getFileName()); try { @@ -536,7 +536,7 @@ private void transitionState(HoodieInstant fromInstant, HoodieInstant toInstant, transitionState(fromInstant, toInstant, data, false); } - private void transitionState(HoodieInstant fromInstant, HoodieInstant toInstant, Option data, + protected void transitionState(HoodieInstant fromInstant, HoodieInstant toInstant, Option data, boolean allowRedundantTransitions) { ValidationUtils.checkArgument(fromInstant.getTimestamp().equals(toInstant.getTimestamp())); try { @@ -566,7 +566,7 @@ private void transitionState(HoodieInstant fromInstant, HoodieInstant toInstant, } } - private void revertCompleteToInflight(HoodieInstant completed, HoodieInstant inflight) { + protected void revertCompleteToInflight(HoodieInstant completed, HoodieInstant inflight) { ValidationUtils.checkArgument(completed.getTimestamp().equals(inflight.getTimestamp())); Path inFlightCommitFilePath = getInstantFileNamePath(inflight.getFileName()); Path commitFilePath = getInstantFileNamePath(completed.getFileName()); @@ -632,7 +632,7 @@ public void saveToCompactionRequested(HoodieInstant instant, Option cont } /** - * Saves content for inflight/requested REPLACE instant. + * Saves content for requested REPLACE instant. */ public void saveToPendingReplaceCommit(HoodieInstant instant, Option content) { ValidationUtils.checkArgument(instant.getAction().equals(HoodieTimeline.REPLACE_COMMIT_ACTION)); @@ -719,7 +719,7 @@ public void saveToPendingIndexAction(HoodieInstant instant, Option conte createFileInMetaPath(instant.getFileName(), content, false); } - private void createFileInMetaPath(String filename, Option content, boolean allowOverwrite) { + protected void createFileInMetaPath(String filename, Option content, boolean allowOverwrite) { Path fullPath = getInstantFileNamePath(filename); if (allowOverwrite || metaClient.getTimelineLayoutVersion().isNullVersion()) { FileIOUtils.createFileInPath(metaClient.getFs(), fullPath, content); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewManager.java b/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewManager.java index 4683fd6919ab4..35fda6c416ac7 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewManager.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewManager.java @@ -20,12 +20,14 @@ import org.apache.hudi.common.config.HoodieCommonConfig; import org.apache.hudi.common.config.HoodieMetadataConfig; +import org.apache.hudi.common.config.HoodieMetastoreConfig; import org.apache.hudi.common.config.SerializableConfiguration; import org.apache.hudi.common.engine.HoodieEngineContext; import org.apache.hudi.common.function.SerializableSupplier; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.util.Functions.Function2; +import org.apache.hudi.common.util.ReflectionUtils; import org.apache.hudi.common.util.ValidationUtils; import org.apache.hudi.metadata.HoodieMetadataFileSystemView; import org.apache.hudi.metadata.HoodieTableMetadata; @@ -59,6 +61,8 @@ public class FileSystemViewManager { private static final Logger LOG = LogManager.getLogger(FileSystemViewManager.class); + private static final String HOODIE_METASTORE_FILE_SYSTEM_VIEW_CLASS = "org.apache.hudi.common.table.view.HoodieMetastoreFileSystemView"; + private final SerializableConfiguration conf; // The View Storage config used to store file-system views private final FileSystemViewStorageConfig viewStorageConfig; @@ -165,6 +169,11 @@ private static HoodieTableFileSystemView createInMemoryFileSystemView(HoodieMeta return new HoodieMetadataFileSystemView(metaClient, metaClient.getActiveTimeline().filterCompletedAndCompactionInstants(), metadataSupplier.get()); } + if (metaClient.getMetastoreConfig().enableMetastore()) { + return (HoodieTableFileSystemView) ReflectionUtils.loadClass(HOODIE_METASTORE_FILE_SYSTEM_VIEW_CLASS, + new Class[] {HoodieTableMetaClient.class, HoodieTimeline.class, HoodieMetastoreConfig.class}, + metaClient, metaClient.getActiveTimeline().getCommitsTimeline().filterCompletedInstants(), metaClient.getMetastoreConfig()); + } return new HoodieTableFileSystemView(metaClient, timeline, viewConf.isIncrementalTimelineSyncEnabled()); } @@ -184,6 +193,11 @@ public static HoodieTableFileSystemView createInMemoryFileSystemViewWithTimeline if (metadataConfig.enabled()) { return new HoodieMetadataFileSystemView(engineContext, metaClient, timeline, metadataConfig); } + if (metaClient.getMetastoreConfig().enableMetastore()) { + return (HoodieTableFileSystemView) ReflectionUtils.loadClass(HOODIE_METASTORE_FILE_SYSTEM_VIEW_CLASS, + new Class[] {HoodieTableMetaClient.class, HoodieTimeline.class, HoodieMetadataConfig.class}, + metaClient, metaClient.getActiveTimeline().getCommitsTimeline().filterCompletedInstants(), metaClient.getMetastoreConfig()); + } return new HoodieTableFileSystemView(metaClient, timeline); } From fdd96cc97ef6a5033b9657e22278bdffd71a41f3 Mon Sep 17 00:00:00 2001 From: Danny Chan Date: Tue, 17 May 2022 10:34:15 +0800 Subject: [PATCH 33/52] [HUDI-4104] DeltaWriteProfile includes the pending compaction file slice when deciding small buckets (#5594) --- .../apache/hudi/sink/partitioner/profile/DeltaWriteProfile.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/DeltaWriteProfile.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/DeltaWriteProfile.java index aad775a356423..d63696effba4a 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/DeltaWriteProfile.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/profile/DeltaWriteProfile.java @@ -59,7 +59,7 @@ protected List smallFilesProfile(String partitionPath) { List allSmallFileSlices = new ArrayList<>(); // If we can index log files, we can add more inserts to log files for fileIds including those under // pending compaction. - List allFileSlices = fsView.getLatestFileSlicesBeforeOrOn(partitionPath, latestCommitTime.getTimestamp(), false) + List allFileSlices = fsView.getLatestMergedFileSlicesBeforeOrOn(partitionPath, latestCommitTime.getTimestamp()) .collect(Collectors.toList()); for (FileSlice fileSlice : allFileSlices) { if (isSmallFile(fileSlice)) { From d52d13302db2eba94b25ddb680c58682760076e1 Mon Sep 17 00:00:00 2001 From: Danny Chan Date: Tue, 17 May 2022 10:34:57 +0800 Subject: [PATCH 34/52] [HUDI-4101] BucketIndexPartitioner should take partition path for better dispersion (#5590) --- .../org/apache/hudi/index/bucket/BucketIdentifier.java | 2 +- .../java/org/apache/hudi/sink/bulk/RowDataKeyGen.java | 5 +++++ .../hudi/sink/partitioner/BucketIndexPartitioner.java | 8 +++++--- .../main/java/org/apache/hudi/sink/utils/Pipelines.java | 9 +++++---- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java index 1f233b429789d..48ccce1d1740c 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/index/bucket/BucketIdentifier.java @@ -73,7 +73,7 @@ private static List getHashKeysUsingIndexFields(String recordKey, List p.split(":")) .collect(Collectors.toMap(p -> p[0], p -> p[1])); return indexKeyFields.stream() - .map(f -> recordKeyPairs.get(f)).collect(Collectors.toList()); + .map(recordKeyPairs::get).collect(Collectors.toList()); } public static String partitionBucketIdStr(String partition, int bucketId) { diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/RowDataKeyGen.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/RowDataKeyGen.java index b6fecff2042cc..3f84b2799ae56 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/RowDataKeyGen.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/bulk/RowDataKeyGen.java @@ -18,6 +18,7 @@ package org.apache.hudi.sink.bulk; +import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.util.Option; import org.apache.hudi.common.util.PartitionPathEncodeUtils; import org.apache.hudi.common.util.StringUtils; @@ -127,6 +128,10 @@ public static RowDataKeyGen instance(Configuration conf, RowType rowType) { keyGeneratorOpt); } + public HoodieKey getHoodieKey(RowData rowData) { + return new HoodieKey(getRecordKey(rowData), getPartitionPath(rowData)); + } + public String getRecordKey(RowData rowData) { if (this.simpleRecordKey) { return getRecordKey(recordKeyFieldGetter.getFieldOrNull(rowData), this.recordKeyFields[0]); diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketIndexPartitioner.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketIndexPartitioner.java index b9b737ce22857..5fa3d1ab9a0a2 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketIndexPartitioner.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/partitioner/BucketIndexPartitioner.java @@ -18,6 +18,7 @@ package org.apache.hudi.sink.partitioner; +import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.index.bucket.BucketIdentifier; import org.apache.flink.api.common.functions.Partitioner; @@ -28,7 +29,7 @@ * * @param The type of obj to hash */ -public class BucketIndexPartitioner implements Partitioner { +public class BucketIndexPartitioner implements Partitioner { private final int bucketNum; private final String indexKeyFields; @@ -39,8 +40,9 @@ public BucketIndexPartitioner(int bucketNum, String indexKeyFields) { } @Override - public int partition(String key, int numPartitions) { + public int partition(HoodieKey key, int numPartitions) { int curBucket = BucketIdentifier.getBucketId(key, indexKeyFields, bucketNum); - return BucketIdentifier.mod(curBucket, numPartitions); + int globalHash = (key.getPartitionPath() + curBucket).hashCode() & Integer.MAX_VALUE; + return BucketIdentifier.mod(globalHash, numPartitions); } } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/Pipelines.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/Pipelines.java index 3b2ee39528a8b..91ac2beadc080 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/Pipelines.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/utils/Pipelines.java @@ -18,6 +18,7 @@ package org.apache.hudi.sink.utils; +import org.apache.hudi.common.model.HoodieKey; import org.apache.hudi.common.model.HoodieRecord; import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.configuration.OptionsResolver; @@ -96,13 +97,13 @@ public static DataStreamSink bulkInsert(Configuration conf, RowType rowT String indexKeys = conf.getString(FlinkOptions.INDEX_KEY_FIELD); int numBuckets = conf.getInteger(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS); - BucketIndexPartitioner partitioner = new BucketIndexPartitioner<>(numBuckets, indexKeys); + BucketIndexPartitioner partitioner = new BucketIndexPartitioner<>(numBuckets, indexKeys); RowDataKeyGen keyGen = RowDataKeyGen.instance(conf, rowType); RowType rowTypeWithFileId = BucketBulkInsertWriterHelper.rowTypeWithFileId(rowType); InternalTypeInfo typeInfo = InternalTypeInfo.of(rowTypeWithFileId); Map bucketIdToFileId = new HashMap<>(); - dataStream = dataStream.partitionCustom(partitioner, keyGen::getRecordKey) + dataStream = dataStream.partitionCustom(partitioner, keyGen::getHoodieKey) .map(record -> BucketBulkInsertWriterHelper.rowWithFileId(bucketIdToFileId, keyGen, record, indexKeys, numBuckets), typeInfo) .setParallelism(conf.getInteger(FlinkOptions.WRITE_TASKS)); // same parallelism as write task to avoid shuffle if (conf.getBoolean(FlinkOptions.WRITE_BULK_INSERT_SORT_INPUT)) { @@ -319,8 +320,8 @@ public static DataStream hoodieStreamWrite(Configuration conf, int defau WriteOperatorFactory operatorFactory = BucketStreamWriteOperator.getFactory(conf); int bucketNum = conf.getInteger(FlinkOptions.BUCKET_INDEX_NUM_BUCKETS); String indexKeyFields = conf.getString(FlinkOptions.INDEX_KEY_FIELD); - BucketIndexPartitioner partitioner = new BucketIndexPartitioner<>(bucketNum, indexKeyFields); - return dataStream.partitionCustom(partitioner, HoodieRecord::getRecordKey) + BucketIndexPartitioner partitioner = new BucketIndexPartitioner<>(bucketNum, indexKeyFields); + return dataStream.partitionCustom(partitioner, HoodieRecord::getKey) .transform("bucket_write", TypeInformation.of(Object.class), operatorFactory) .uid("uid_bucket_write" + conf.getString(FlinkOptions.TABLE_NAME)) .setParallelism(conf.getInteger(FlinkOptions.WRITE_TASKS)); From d422f69a0dd51694c035db0e15b8127e1ebed277 Mon Sep 17 00:00:00 2001 From: Jin Xing Date: Tue, 17 May 2022 14:12:50 +0800 Subject: [PATCH 35/52] [HUDI-4087] Support dropping RO and RT table in DropHoodieTableCommand (#5564) * [HUDI-4087] Support dropping RO and RT table in DropHoodieTableCommand * Set hoodie.query.as.ro.table in serde properties --- .../hudi/command/DropHoodieTableCommand.scala | 79 ++++----- .../apache/spark/sql/hudi/TestDropTable.scala | 166 ++++++++++++++++++ 2 files changed, 200 insertions(+), 45 deletions(-) diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/DropHoodieTableCommand.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/DropHoodieTableCommand.scala index 954f08ce645c5..68582fc2795dd 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/DropHoodieTableCommand.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/DropHoodieTableCommand.scala @@ -21,12 +21,10 @@ import org.apache.hadoop.fs.Path import org.apache.hudi.client.common.HoodieSparkEngineContext import org.apache.hudi.common.fs.FSUtils import org.apache.hudi.common.model.HoodieTableType +import org.apache.hudi.hive.util.ConfigUtils import org.apache.spark.sql._ import org.apache.spark.sql.catalyst.TableIdentifier -import org.apache.spark.sql.catalyst.analysis.NoSuchDatabaseException -import org.apache.spark.sql.catalyst.catalog.{CatalogTableType, HoodieCatalogTable} -import org.apache.spark.sql.hive.HiveClientUtils -import org.apache.spark.sql.hudi.HoodieSqlCommonUtils.isEnableHive +import org.apache.spark.sql.catalyst.catalog._ import scala.util.control.NonFatal @@ -69,13 +67,13 @@ extends HoodieLeafRunnableCommand { val catalog = sparkSession.sessionState.catalog // Drop table in the catalog - val enableHive = isEnableHive(sparkSession) - if (enableHive) { - dropHiveDataSourceTable(sparkSession, hoodieCatalogTable) + if (HoodieTableType.MERGE_ON_READ == hoodieCatalogTable.tableType && purge) { + val (rtTableOpt, roTableOpt) = getTableRTAndRO(catalog, hoodieCatalogTable) + rtTableOpt.foreach(table => catalog.dropTable(table.identifier, true, false)) + roTableOpt.foreach(table => catalog.dropTable(table.identifier, true, false)) + catalog.dropTable(table.identifier.copy(table = hoodieCatalogTable.tableName), ifExists, purge) } else { - if (catalog.tableExists(tableIdentifier)) { - catalog.dropTable(tableIdentifier, ifExists, purge) - } + catalog.dropTable(table.identifier, ifExists, purge) } // Recursively delete table directories @@ -88,42 +86,33 @@ extends HoodieLeafRunnableCommand { } } - private def dropHiveDataSourceTable( - sparkSession: SparkSession, - hoodieCatalogTable: HoodieCatalogTable): Unit = { - val table = hoodieCatalogTable.table - val dbName = table.identifier.database.get - val tableName = hoodieCatalogTable.tableName - - // check database exists - val dbExists = sparkSession.sessionState.catalog.databaseExists(dbName) - if (!dbExists) { - throw new NoSuchDatabaseException(dbName) - } - - if (HoodieTableType.MERGE_ON_READ == hoodieCatalogTable.tableType && purge) { - val snapshotTableName = tableName + MOR_SNAPSHOT_TABLE_SUFFIX - val roTableName = tableName + MOR_READ_OPTIMIZED_TABLE_SUFFIX - - dropHiveTable(sparkSession, dbName, snapshotTableName) - dropHiveTable(sparkSession, dbName, roTableName) + private def getTableRTAndRO(catalog: SessionCatalog, + hoodieTable: HoodieCatalogTable): (Option[CatalogTable], Option[CatalogTable]) = { + val rtIdt = hoodieTable.table.identifier.copy( + table = s"${hoodieTable.tableName}${MOR_SNAPSHOT_TABLE_SUFFIX}") + val roIdt = hoodieTable.table.identifier.copy( + table = s"${hoodieTable.tableName}${MOR_READ_OPTIMIZED_TABLE_SUFFIX}") + + var rtTableOpt: Option[CatalogTable] = None + var roTableOpt: Option[CatalogTable] = None + if (catalog.tableExists(rtIdt)) { + val rtTable = catalog.getTableMetadata(rtIdt) + if (rtTable.storage.locationUri.equals(hoodieTable.table.storage.locationUri)) { + rtTable.storage.properties.get(ConfigUtils.IS_QUERY_AS_RO_TABLE) match { + case Some(v) if v.equalsIgnoreCase("false") => rtTableOpt = Some(rtTable) + case _ => // do-nothing + } + } } - - dropHiveTable(sparkSession, dbName, tableName, purge) - } - - private def dropHiveTable( - sparkSession: SparkSession, - dbName: String, - tableName: String, - purge: Boolean = false): Unit = { - // check table exists - if (sparkSession.sessionState.catalog.tableExists(new TableIdentifier(tableName, Option(dbName)))) { - val client = HiveClientUtils.newClientForMetadata(sparkSession.sparkContext.conf, - sparkSession.sessionState.newHadoopConf()) - - // drop hive table. - client.dropTable(dbName, tableName, ifExists, purge) + if (catalog.tableExists(roIdt)) { + val roTable = catalog.getTableMetadata(roIdt) + if (roTable.storage.locationUri.equals(hoodieTable.table.storage.locationUri)) { + roTable.storage.properties.get(ConfigUtils.IS_QUERY_AS_RO_TABLE) match { + case Some(v) if v.equalsIgnoreCase("true") => roTableOpt = Some(roTable) + case _ => // do-nothing + } + } } + (rtTableOpt, roTableOpt) } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDropTable.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDropTable.scala index ed43d37d0388e..174835cbac0bf 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDropTable.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/TestDropTable.scala @@ -17,6 +17,9 @@ package org.apache.spark.sql.hudi +import org.apache.spark.sql.catalyst.TableIdentifier +import org.apache.spark.sql.catalyst.catalog.SessionCatalog + class TestDropTable extends HoodieSparkSqlTestBase { test("Test Drop Table") { @@ -72,4 +75,167 @@ class TestDropTable extends HoodieSparkSqlTestBase { } } } + + test("Test Drop RO & RT table by purging base table.") { + withTempDir { tmp => + val tableName = generateTableName + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | ts long + |) using hudi + | location '${tmp.getCanonicalPath}/$tableName' + | tblproperties ( + | type = 'mor', + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + + spark.sql( + s""" + |create table ${tableName}_ro using hudi + | location '${tmp.getCanonicalPath}/$tableName' + | tblproperties ( + | type = 'mor', + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + alterSerdeProperties(spark.sessionState.catalog, TableIdentifier(s"${tableName}_ro"), + Map("hoodie.query.as.ro.table" -> "true")) + + spark.sql( + s""" + |create table ${tableName}_rt using hudi + | location '${tmp.getCanonicalPath}/$tableName' + | tblproperties ( + | type = 'mor', + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + alterSerdeProperties(spark.sessionState.catalog, TableIdentifier(s"${tableName}_rt"), + Map("hoodie.query.as.ro.table" -> "false")) + + spark.sql(s"drop table ${tableName} purge") + checkAnswer("show tables")() + } + } + + test("Test Drop RO & RT table by one by one.") { + withTempDir { tmp => + val tableName = generateTableName + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | ts long + |) using hudi + | location '${tmp.getCanonicalPath}/$tableName' + | tblproperties ( + | type = 'mor', + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + + spark.sql( + s""" + |create table ${tableName}_ro using hudi + | location '${tmp.getCanonicalPath}/$tableName' + | tblproperties ( + | type = 'mor', + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + alterSerdeProperties(spark.sessionState.catalog, TableIdentifier(s"${tableName}_ro"), + Map("hoodie.query.as.ro.table" -> "true")) + + spark.sql( + s""" + |create table ${tableName}_rt using hudi + | location '${tmp.getCanonicalPath}/$tableName' + | tblproperties ( + | type = 'mor', + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + alterSerdeProperties(spark.sessionState.catalog, TableIdentifier(s"${tableName}_rt"), + Map("hoodie.query.as.ro.table" -> "false")) + + spark.sql(s"drop table ${tableName}_ro") + checkAnswer("show tables")( + Seq("default", tableName, false), Seq("default", s"${tableName}_rt", false)) + + spark.sql(s"drop table ${tableName}_rt") + checkAnswer("show tables")(Seq("default", tableName, false)) + + spark.sql(s"drop table ${tableName}") + checkAnswer("show tables")() + } + } + + test("Test Drop RO table with purge") { + withTempDir { tmp => + val tableName = generateTableName + spark.sql( + s""" + |create table $tableName ( + | id int, + | name string, + | ts long + |) using hudi + | location '${tmp.getCanonicalPath}/$tableName' + | tblproperties ( + | type = 'mor', + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + + spark.sql( + s""" + |create table ${tableName}_ro using hudi + | location '${tmp.getCanonicalPath}/$tableName' + | tblproperties ( + | type = 'mor', + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + alterSerdeProperties(spark.sessionState.catalog, TableIdentifier(s"${tableName}_ro"), + Map("hoodie.query.as.ro.table" -> "true")) + + spark.sql( + s""" + |create table ${tableName}_rt using hudi + | location '${tmp.getCanonicalPath}/$tableName' + | tblproperties ( + | type = 'mor', + | primaryKey = 'id', + | preCombineField = 'ts' + | ) + """.stripMargin) + alterSerdeProperties(spark.sessionState.catalog, TableIdentifier(s"${tableName}_rt"), + Map("hoodie.query.as.ro.table" -> "false")) + + spark.sql(s"drop table ${tableName}_ro purge") + checkAnswer("show tables")() + } + } + + private def alterSerdeProperties(sessionCatalog: SessionCatalog, tableIdt: TableIdentifier, + newProperties: Map[String, String]): Unit = { + val catalogTable = spark.sessionState.catalog.getTableMetadata(tableIdt) + val storage = catalogTable.storage + val storageProperties = storage.properties ++ newProperties + val newCatalogTable = catalogTable.copy(storage = storage.copy(properties = storageProperties)) + sessionCatalog.alterTable(newCatalogTable) + } } From 99555c897acf9bdd576e7ab233dc448d537e7aea Mon Sep 17 00:00:00 2001 From: BruceLin Date: Tue, 17 May 2022 21:09:27 +0800 Subject: [PATCH 36/52] [HUDI-4110] Clean the marker files for flink compaction (#5604) --- .../java/org/apache/hudi/client/HoodieFlinkWriteClient.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java index 524758a675cfc..f62592a49182a 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java @@ -372,6 +372,9 @@ public void completeCompaction( } finally { this.txnManager.endTransaction(Option.of(compactionInstant)); } + WriteMarkersFactory + .get(config.getMarkersType(), table, compactionCommitTime) + .quietDeleteMarkerDir(context, config.getMarkersDeleteParallelism()); if (compactionTimer != null) { long durationInMs = metrics.getDurationInMs(compactionTimer.stop()); try { From f8b939961553fc3f647862c65d0040783eb726b4 Mon Sep 17 00:00:00 2001 From: Sivabalan Narayanan Date: Tue, 17 May 2022 09:58:18 -0400 Subject: [PATCH 37/52] [MINOR] Fixing spark long running yaml for non-partitioned (#5607) --- .../spark-long-running-non-partitioned.yaml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docker/demo/config/test-suite/spark-long-running-non-partitioned.yaml b/docker/demo/config/test-suite/spark-long-running-non-partitioned.yaml index 3c47729e66470..dfbfba0a15700 100644 --- a/docker/demo/config/test-suite/spark-long-running-non-partitioned.yaml +++ b/docker/demo/config/test-suite/spark-long-running-non-partitioned.yaml @@ -14,24 +14,24 @@ # See the License for the specific language governing permissions and # limitations under the License. dag_name: cow-spark-deltastreamer-long-running-multi-partitions.yaml -dag_rounds: 6 -dag_intermittent_delay_mins: 0 +dag_rounds: 10 +dag_intermittent_delay_mins: 1 dag_content: first_insert: config: record_size: 200 num_partitions_insert: 1 repeat_count: 1 - num_records_insert: 10000 + num_records_insert: 1000 type: SparkInsertNode deps: none first_upsert: config: record_size: 200 num_partitions_insert: 1 - num_records_insert: 300 + num_records_insert: 1000 repeat_count: 1 - num_records_upsert: 3000 + num_records_upsert: 1000 num_partitions_upsert: 1 type: SparkUpsertNode deps: first_insert @@ -43,7 +43,6 @@ dag_content: deps: first_upsert second_validate: config: - validate_once_every_itr : 3 validate_hive: false delete_input_data: true type: ValidateDatasetNode From ebbe56e8622441a57fda63b0392cd6f7c265ec1e Mon Sep 17 00:00:00 2001 From: Danny Chan Date: Wed, 18 May 2022 09:30:09 +0800 Subject: [PATCH 38/52] [minor] Some code refactoring for LogFileComparator and Instant instantiation (#5600) --- .../hudi/table/action/compact/CompactHelpers.java | 3 +-- .../org/apache/hudi/client/HoodieFlinkWriteClient.java | 2 +- .../org/apache/hudi/client/SparkRDDWriteClient.java | 6 +++--- .../org/apache/hudi/common/model/HoodieLogFile.java | 7 +++++-- .../hudi/configuration/HadoopConfigurations.java | 10 ++++++++-- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/CompactHelpers.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/CompactHelpers.java index a348eb0ed3a76..3379d16f4c035 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/CompactHelpers.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/table/action/compact/CompactHelpers.java @@ -25,7 +25,6 @@ import org.apache.hudi.common.model.HoodieRecordPayload; import org.apache.hudi.common.model.HoodieWriteStat; import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; -import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.timeline.HoodieTimeline; import org.apache.hudi.common.table.timeline.TimelineMetadataUtils; import org.apache.hudi.common.util.Option; @@ -77,7 +76,7 @@ public void completeInflightCompaction(HoodieTable table, String compactionCommi HoodieActiveTimeline activeTimeline = table.getActiveTimeline(); try { activeTimeline.transitionCompactionInflightToComplete( - new HoodieInstant(HoodieInstant.State.INFLIGHT, HoodieTimeline.COMPACTION_ACTION, compactionCommitTime), + HoodieTimeline.getCompactionInflightInstant(compactionCommitTime), Option.of(commitMetadata.toJsonString().getBytes(StandardCharsets.UTF_8))); } catch (IOException e) { throw new HoodieCompactionException( diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java index f62592a49182a..ce75452d27ff4 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java @@ -358,7 +358,7 @@ public void completeCompaction( String compactionCommitTime) { this.context.setJobStatus(this.getClass().getSimpleName(), "Collect compaction write status and commit compaction: " + config.getTableName()); List writeStats = metadata.getWriteStats(); - final HoodieInstant compactionInstant = new HoodieInstant(HoodieInstant.State.INFLIGHT, HoodieTimeline.COMPACTION_ACTION, compactionCommitTime); + final HoodieInstant compactionInstant = HoodieTimeline.getCompactionInflightInstant(compactionCommitTime); try { this.txnManager.beginTransaction(Option.of(compactionInstant), Option.empty()); finalizeWrite(table, compactionCommitTime, writeStats); diff --git a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java index df82e75db92ca..7f9ec05e3c5eb 100644 --- a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java +++ b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/client/SparkRDDWriteClient.java @@ -305,7 +305,7 @@ protected void completeCompaction(HoodieCommitMetadata metadata, String compactionCommitTime) { this.context.setJobStatus(this.getClass().getSimpleName(), "Collect compaction write status and commit compaction: " + config.getTableName()); List writeStats = metadata.getWriteStats(); - final HoodieInstant compactionInstant = new HoodieInstant(HoodieInstant.State.INFLIGHT, HoodieTimeline.COMPACTION_ACTION, compactionCommitTime); + final HoodieInstant compactionInstant = HoodieTimeline.getCompactionInflightInstant(compactionCommitTime); try { this.txnManager.beginTransaction(Option.of(compactionInstant), Option.empty()); finalizeWrite(table, compactionCommitTime, writeStats); @@ -382,7 +382,7 @@ private void completeClustering(HoodieReplaceCommitMetadata metadata, + writeStats.stream().filter(s -> s.getTotalWriteErrors() > 0L).map(s -> s.getFileId()).collect(Collectors.joining(","))); } - final HoodieInstant clusteringInstant = new HoodieInstant(HoodieInstant.State.INFLIGHT, HoodieTimeline.REPLACE_COMMIT_ACTION, clusteringCommitTime); + final HoodieInstant clusteringInstant = HoodieTimeline.getReplaceCommitInflightInstant(clusteringCommitTime); try { this.txnManager.beginTransaction(Option.of(clusteringInstant), Option.empty()); @@ -393,7 +393,7 @@ private void completeClustering(HoodieReplaceCommitMetadata metadata, LOG.info("Committing Clustering " + clusteringCommitTime + ". Finished with result " + metadata); table.getActiveTimeline().transitionReplaceInflightToComplete( - HoodieTimeline.getReplaceCommitInflightInstant(clusteringCommitTime), + clusteringInstant, Option.of(metadata.toJsonString().getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new HoodieClusteringException("unable to transition clustering inflight to complete: " + clusteringCommitTime, e); diff --git a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieLogFile.java b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieLogFile.java index 5b5a6432e633c..d4ad2cae1fe18 100644 --- a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieLogFile.java +++ b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodieLogFile.java @@ -40,6 +40,9 @@ public class HoodieLogFile implements Serializable { public static final String DELTA_EXTENSION = ".log"; public static final Integer LOGFILE_BASE_VERSION = 1; + private static final Comparator LOG_FILE_COMPARATOR = new LogFileComparator(); + private static final Comparator LOG_FILE_COMPARATOR_REVERSED = new LogFileComparator().reversed(); + private transient FileStatus fileStatus; private final String pathStr; private long fileLen; @@ -129,11 +132,11 @@ public HoodieLogFile rollOver(FileSystem fs, String logWriteToken) throws IOExce } public static Comparator getLogFileComparator() { - return new LogFileComparator(); + return LOG_FILE_COMPARATOR; } public static Comparator getReverseLogFileComparator() { - return new LogFileComparator().reversed(); + return LOG_FILE_COMPARATOR_REVERSED; } /** diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java index 7784e7caaae2a..72f20311504d0 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java @@ -23,10 +23,16 @@ import java.util.Map; +/** + * Utilities for fetching hadoop configurations. + */ public class HadoopConfigurations { private static final String HADOOP_PREFIX = "hadoop."; private static final String PARQUET_PREFIX = "parquet."; + /** + * Creates a merged hadoop configuration with given flink configuration and hadoop configuration. + */ public static org.apache.hadoop.conf.Configuration getParquetConf( org.apache.flink.configuration.Configuration options, org.apache.hadoop.conf.Configuration hadoopConf) { @@ -37,12 +43,12 @@ public static org.apache.hadoop.conf.Configuration getParquetConf( } /** - * Create a new hadoop configuration that is initialized with the given flink configuration. + * Creates a new hadoop configuration that is initialized with the given flink configuration. */ public static org.apache.hadoop.conf.Configuration getHadoopConf(Configuration conf) { org.apache.hadoop.conf.Configuration hadoopConf = FlinkClientUtil.getHadoopConf(); Map options = FlinkOptions.getPropertiesWithPrefix(conf.toMap(), HADOOP_PREFIX); - options.forEach((k, v) -> hadoopConf.set(k, v)); + options.forEach(hadoopConf::set); return hadoopConf; } } From f1f8a1abb7636b43463ee86bf6db453ddb1256e1 Mon Sep 17 00:00:00 2001 From: Danny Chan Date: Wed, 18 May 2022 10:17:00 +0800 Subject: [PATCH 39/52] [HUDI-4109] Copy the old record directly when it is chosen for merging (#5603) --- .../src/main/java/org/apache/hudi/io/HoodieMergeHandle.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieMergeHandle.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieMergeHandle.java index 2e2a894f5e96c..b999cc6906406 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieMergeHandle.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieMergeHandle.java @@ -265,6 +265,9 @@ private boolean writeUpdateRecord(HoodieRecord hoodieRecord, GenericRecord ol if (oldRecord != record) { // the incoming record is chosen isDelete = HoodieOperation.isDelete(hoodieRecord.getOperation()); + } else { + // the incoming record is dropped + return false; } } return writeRecord(hoodieRecord, indexedRecord, isDelete); From a1017c66aaa377dad7e5e62f773bb714d53fc353 Mon Sep 17 00:00:00 2001 From: luokey <854194341@qq.com> Date: Wed, 18 May 2022 11:21:14 +0800 Subject: [PATCH 40/52] Clean the marker files for flink compaction (#5611) Co-authored-by: 854194341@qq.com --- .../org/apache/hudi/sink/compact/CompactionPlanOperator.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/CompactionPlanOperator.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/CompactionPlanOperator.java index 48d4f48989b0a..338352d4b0c93 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/CompactionPlanOperator.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/CompactionPlanOperator.java @@ -25,6 +25,7 @@ import org.apache.hudi.common.util.CompactionUtils; import org.apache.hudi.common.util.Option; import org.apache.hudi.table.HoodieFlinkTable; +import org.apache.hudi.table.marker.WriteMarkersFactory; import org.apache.hudi.util.CompactionUtil; import org.apache.hudi.util.FlinkTables; @@ -134,6 +135,9 @@ private void scheduleCompaction(HoodieFlinkTable table, long checkpointId) th List operations = compactionPlan.getOperations().stream() .map(CompactionOperation::convertFromAvroRecordInstance).collect(toList()); LOG.info("Execute compaction plan for instant {} as {} file groups", compactionInstantTime, operations.size()); + WriteMarkersFactory + .get(table.getConfig().getMarkersType(), table, compactionInstantTime) + .deleteMarkerDir(table.getContext(), table.getConfig().getMarkersDeleteParallelism()); for (CompactionOperation operation : operations) { output.collect(new StreamRecord<>(new CompactionPlanEvent(compactionInstantTime, operation))); } From 008616c4f68a134290c46b0a258e92f2be67b4c3 Mon Sep 17 00:00:00 2001 From: Zhaojing Yu Date: Wed, 18 May 2022 18:43:48 +0800 Subject: [PATCH 41/52] [HUDI-3942] [RFC-50] Improve Timeline Server (#5392) --- rfc/rfc-50/ComparisonDiagram.png | Bin 0 -> 200212 bytes rfc/rfc-50/CurrentDesign.png | Bin 0 -> 120217 bytes rfc/rfc-50/Design.png | Bin 0 -> 114358 bytes rfc/rfc-50/SchematicDiagram.png | Bin 0 -> 133584 bytes rfc/rfc-50/rfc-50.md | 93 +++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+) create mode 100644 rfc/rfc-50/ComparisonDiagram.png create mode 100644 rfc/rfc-50/CurrentDesign.png create mode 100644 rfc/rfc-50/Design.png create mode 100644 rfc/rfc-50/SchematicDiagram.png create mode 100644 rfc/rfc-50/rfc-50.md diff --git a/rfc/rfc-50/ComparisonDiagram.png b/rfc/rfc-50/ComparisonDiagram.png new file mode 100644 index 0000000000000000000000000000000000000000..20fa4673144ece6f30083ee0a69f6ba068d6aa01 GIT binary patch literal 200212 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

XrVufrX5G1-N6KEa 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%0AogUwDDX=qfIsu!7@( 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%0A2Iwl_PK8AVCXu8)IFg3#(e;^!h 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 CQ~S$E6Amh$56e$p16`*FIb=do09N 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 MGxQxz*zZsO2On;lM*si- literal 0 HcmV?d00001 diff --git a/rfc/rfc-50/CurrentDesign.png b/rfc/rfc-50/CurrentDesign.png new file mode 100644 index 0000000000000000000000000000000000000000..15e38cb9b1cf1dce5c9f4a180b5eb89ffdc1de30 GIT binary patch literal 120217 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 literal 0 HcmV?d00001 diff --git a/rfc/rfc-50/Design.png b/rfc/rfc-50/Design.png new file mode 100644 index 0000000000000000000000000000000000000000..86a726c636dabe8678cb2fa2c8cf957c546385a4 GIT binary patch literal 114358 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 literal 0 HcmV?d00001 diff --git a/rfc/rfc-50/SchematicDiagram.png b/rfc/rfc-50/SchematicDiagram.png new file mode 100644 index 0000000000000000000000000000000000000000..9db88decadc4d08e4a4feb86c8384912dd1065f5 GIT binary patch literal 133584 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 &Y!} zrNn)Q=?X-;c%*mUYR2%vyB!8`ROmi)?C7O=Zg5vk5{W4cdp;+dysbx>XW?*Q=eFeB zRU-AJ<9@5kPj~?Zfq2_mKq#3X%o*oBc#}6!4;+EMVhNo8qDv%f%)dN=#)GBK^*#6O zUiJYM5fQdj3!wm1ck9@xubmsBOQhHG1Q=L5U6xv*pZ}gxGpDoM|2gnT;5=;=68>4RQW9@lQC6>Z=pWlP|f+fX~L8!CP`bJ zgc7sSe#>ML%rkkO;l|Os4>=i_M&v~gDxF`k|4nhuO`-lF$M&B?%XD2hT)c~1t7npB zN>V3NSFYSredQ88L&dg>(--sE*Y>gZtZj~+TdHbVlOM&q@imQZ*$xXRzr&-j=16@+ z6G%m3*`0ku{?HTE$DrZ6wVD)R!1wk-0qz~fSGP9 znAHEbi3m@AwN(9J7T-w!@irk7H<+yK+F`+J+^(9HG`%4wt{p%d?eBa}dlM00fVm19 ztBGJa3)(1GL)#D}9Nw-wcGvJq2CzsSMCjgKc7Xp3WfTDbFVJ_Q`H^`*db7mjJc$Xg z-q`j38$s)F)w5WaGVPU$J?@G~(nj`yU+qBoG9oGi?l@#e;7MR8R4 z|1R{IA$M)Q)c+q7mP*K60Otbm^GK0MLPYEisZ5h5p@mVW9}=f)0hKyvY(|on+C;wN?)^pn>RJ0gBA{OV$rGsm301N_G0^xxkf{S`Lo4VKfZ7br zV&SKDdtJn^7jS8WLErd6x7=MDLkm;lX!ftq27^X5*uUB!L4FQf%o(UgD`^#*bfm&y zhamy(v0pdAV9*PLI&IMX3F!^MA-$alBVK#EFB+`T4_WUgs&_@Y!A*JqN)RHBtx%Kk zugDF>4%6}$CLt+Jh>Zm-BMgnh(LK$&@F9RAH@pSYs5~~`dczU~A zke-wAKY*`^r~=RtqZ3L`1wD{qTYx+JA0-puNI^???u1-#on1c5)&Dp=1h;$%Scm8K zer1<(p770l(RhkFCpWa}LO+o1HoV6LnwI5;&g3dAy73gFxKrv%4nKAMDBO52yk1Le z>JdlR<=l9msgd7gjDk}Z=6n3jMhtrKxk@f7dIM6WGtSmGsaO188oDLO zA?G;p#DEG7hJqiRB*0OIwWG52)c}7SiZ&Pnfvar5l^+%=(oJmQ-uU-U@L`DR#JAfW zCQ1G^c0nf4wGMTafBgS9-Q{wz8?C=~&Vg(EL@oJla|(3R5kk6pb+G^Src;p-iCP?s z1duUslt76iZW98lIcE_RNX)0iLnOQlL4-mYVC;kts$@_s;@&nk8Hr=_e*X__@xK4< z?$#L-FhpJ;RaywK2X@5eKtoI`g2svYjrOgs#9~>i&nk?lIUE9k2plrOgMJxRRYaHL z7lT|erGtqXDXAG35)1X)yyU%KE_tFNBMS;Y|2Vc_b=sgtyl#noUSdOU7exLGL7Aov zoMy1m2Ab{ny%?dQ?+m-6xBo{fFSM}_;8G+eHfU`+RcM204~Wv$1C^ZPFfnC<6t{bH z64*cvTs=$5SRw=8gxZNe+KOb8AioLZ`ht?&h5~_;L<%q%cS!^A-{F91Kn*v6dZ;%b z?_(sO3xUcuFbFklNt6A5-=1zw2Z;ZDyV!6VaAJLbrkO!=EU4%B^M7LT1Xn$o9GLt4 z%x8mxJ4WMI6uB^{R%&1vCvND>cPk)V4^TG3KR@UdJ+2`K!2XB19lkp%Y$fPFxkTY+xG)w JQT_hn{{Ur)MlS#W literal 0 HcmV?d00001 diff --git a/rfc/rfc-50/rfc-50.md b/rfc/rfc-50/rfc-50.md new file mode 100644 index 0000000000000..91e205c7dbeff --- /dev/null +++ b/rfc/rfc-50/rfc-50.md @@ -0,0 +1,93 @@ + + +# RFC-50: Improve Timeline Server + +## Proposers +- @yuzhaojing + +## Approvers + - @xushiyan + - @danny0405 + +## Abstract + +Support client to obtain timeline from timeline server. + +## Background + +At its core, Hudi maintains a timeline of all actions performed on the table at different instants of time. Before each operation is performed on the Hoodie table, the information of the HUDI table needs to be obtained through the timeline. At present, there are two ways to obtain the timeline of HUDI : +- Create a MetaClient and get the complete timeline through MetaClient #getActiveTimeline, which will directly scan the HDFS directory of metadata +- Get the timeline through FileSystemView#getTimeline. This timeline is the cache timeline obtained by requesting the Embedded timeline service. There is no need to repeatedly scan the HDFS directory of metadata, but this timeline only contains completed instants + +### Problem description + +- HUDI designs the Timeline service for processing and caching when accessing metadata , but currently does not converge all access to metadata to the Timeline service, such as the acquisition of a complete timeline. +- When the number of tasks written increases, a large number of repeated access to metadata will lead to high HDFS NameNode requests, causing greater pressure and not easy to expand. + +### Spark and Flink write flow comparison diagram + +Since Hudi is designed based on the Spark micro-batch model, in the Spark write process, all operations on the timeline are completed on the driver side, and then distributed to the executor side to start the write operation. + +But for Flink , Write tasks are resident services due to their pure streaming model. There is also no highly reliable communication mechanism between the user-side JM and the TM in Flink, so the TM needs to obtain the latest instant by polling the timeline for writing. + +![](ComparisonDiagram.png) + +### Current + +![](CurrentDesign.png) + +The current design implementation has two main problems with the convergence timeline +- Since the timeline of the task is pulled from the Embedded timeline service, the refresh mechanism of the Embedded timeline service itself will doesn't work +- MetaClient and HoodieTable are decoupled. Obtain the timeline in MetaClient and then request the Embedded timeline service to obtain file-related information through the FileSystemViewManager in HoodieTable combined with the timeline. There are circular dependencies and problems in the case of using MetaClient alone without creating HoodieTable + +## Implementation + +### Design target + +The goal of this solution is to converge the acquisition of timelines and obtain them through the Embedded timeline service uniformly. The timeline is pulled through HDFS only when the Embedded timeline service is not started. + +### Converge the request to loop instant in Flink to JM + +- Store the latest instant on the Embedded Timeline Server. Every time JM modifies the instant state, it actively performs a sync to Embedded Timeline Server +- Return the latest instant directly when the task pulls the latest instant + +### Converge the request to pull instant in meta client initialization to JM + +- Abstract the timeline-related acquisition methods into the new interface TableTimelineView, and create the corresponding TimelineViewManager in MetaClient, and obtain the timeline through TimelineViewManager. + +![](Design.png) + +### Flink optimization before and after schematic diagram + +![](SchematicDiagram.png) + +## Rollout/Adoption Plan + +- What impact (if any) will there be on existing users? + - Since the Embedded Timeline Service is used to pull the timeline, users who use flink to write to hudi will observe that file system requests are greatly reduced, thereby reducing the pressure on the file system. + - However, in a scenario with a relatively high degree of parallelism, it may be necessary to increase the resources of JM to ensure the effectiveness of the response +- If we are changing behavior how will we phase out the older behavior? + - Add a configuration to control this behavior +- If we need special migration tools, describe them here. + - No special migration tools will be necessary +- When will we remove the existing behavior + - In subsequent releases (1.0 or later) +## Test Plan + +Test plan +No additional regression testing is required, as the behavior of MetaClient's active timeline has not been changed From 199f64255e9996813d6c0b43949ec93a00ac1952 Mon Sep 17 00:00:00 2001 From: cxzl25 Date: Wed, 18 May 2022 19:18:52 +0800 Subject: [PATCH 42/52] [HUDI-4111] Bump ANTLR runtime version in Spark 3.x (#5606) --- pom.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pom.xml b/pom.xml index 7caff57f066b4..0adbf22b58c4c 100644 --- a/pom.xml +++ b/pom.xml @@ -1657,6 +1657,7 @@ 1.12.2 1.10.2 1.6.12 + 4.8 ${fasterxml.spark3.version} ${fasterxml.spark3.version} ${fasterxml.spark3.version} @@ -1688,6 +1689,7 @@ hudi-spark3-common ${scalatest.spark3.version} ${kafka.spark3.version} + 4.8-1 ${fasterxml.spark3.version} ${fasterxml.spark3.version} ${fasterxml.spark3.version} @@ -1722,6 +1724,7 @@ 1.12.2 1.10.2 1.6.12 + 4.8 ${fasterxml.spark3.version} ${fasterxml.spark3.version} ${fasterxml.spark3.version} From 551aa959c57721a5cc4d3f63f79e0201978980a2 Mon Sep 17 00:00:00 2001 From: Danny Chan Date: Wed, 18 May 2022 20:30:54 +0800 Subject: [PATCH 43/52] Revert "[HUDI-3870] Add timeout rollback for flink online compaction (#5314)" (#5622) This reverts commit 6f9b02decb5bb2b83709b1b6ec04a97e4d102c11. --- .../hudi/sink/compact/CompactionPlanOperator.java | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/CompactionPlanOperator.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/CompactionPlanOperator.java index 338352d4b0c93..d5e718883b86c 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/CompactionPlanOperator.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/compact/CompactionPlanOperator.java @@ -89,7 +89,8 @@ public void notifyCheckpointComplete(long checkpointId) { // when the earliest inflight instant has timed out, assumes it has failed // already and just rolls it back. - CompactionUtil.rollbackEarliestCompaction(table, conf); + // comment out: do we really need the timeout rollback ? + // CompactionUtil.rollbackEarliestCompaction(table, conf); scheduleCompaction(table, checkpointId); } catch (Throwable throwable) { // make it fail-safe @@ -99,8 +100,7 @@ public void notifyCheckpointComplete(long checkpointId) { private void scheduleCompaction(HoodieFlinkTable table, long checkpointId) throws IOException { // the first instant takes the highest priority. - HoodieTimeline pendingCompactionTimeline = table.getActiveTimeline().filterPendingCompactionTimeline(); - Option firstRequested = pendingCompactionTimeline + Option firstRequested = table.getActiveTimeline().filterPendingCompactionTimeline() .filter(instant -> instant.getState() == HoodieInstant.State.REQUESTED).firstInstant(); if (!firstRequested.isPresent()) { // do nothing. @@ -108,13 +108,6 @@ private void scheduleCompaction(HoodieFlinkTable table, long checkpointId) th return; } - Option firstInflight = pendingCompactionTimeline - .filter(instant -> instant.getState() == HoodieInstant.State.INFLIGHT).firstInstant(); - if (firstInflight.isPresent()) { - LOG.warn("Waiting for pending compaction instant : " + firstInflight + " to complete, skip scheduling new compaction plans"); - return; - } - String compactionInstantTime = firstRequested.get().getTimestamp(); // generate compaction plan From 6573469e73ea51ed6d1c24504e8be5abfa91c642 Mon Sep 17 00:00:00 2001 From: huberylee Date: Thu, 19 May 2022 09:48:03 +0800 Subject: [PATCH 44/52] [HUDI-4116] Unify clustering/compaction related procedures' output type (#5620) * Unify clustering/compaction related procedures' output type * Address review comments --- .../org/apache/hudi/HoodieCLIUtils.scala | 15 ++- .../command/CompactionHoodiePathCommand.scala | 11 +- .../CompactionHoodieTableCommand.scala | 13 +-- .../CompactionShowHoodiePathCommand.scala | 12 +- .../CompactionShowHoodieTableCommand.scala | 12 +- .../procedures/RunClusteringProcedure.scala | 34 +++++- .../procedures/RunCompactionProcedure.scala | 29 +++-- .../procedures/ShowClusteringProcedure.scala | 37 ++++++- .../procedures/ShowCompactionProcedure.scala | 16 +-- .../procedure/TestClusteringProcedure.scala | 103 +++++++++++++----- .../procedure/TestCompactionProcedure.scala | 78 ++++++++++--- 11 files changed, 247 insertions(+), 113 deletions(-) diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCLIUtils.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCLIUtils.scala index 58c33248234c2..552e3cfc9b9c3 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCLIUtils.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieCLIUtils.scala @@ -19,14 +19,14 @@ package org.apache.hudi +import org.apache.hudi.avro.model.HoodieClusteringGroup import org.apache.hudi.client.SparkRDDWriteClient import org.apache.hudi.common.table.{HoodieTableMetaClient, TableSchemaResolver} import org.apache.spark.api.java.JavaSparkContext import org.apache.spark.sql.SparkSession import org.apache.spark.sql.hudi.HoodieSqlCommonUtils.withSparkConf -import scala.collection.JavaConverters.mapAsJavaMapConverter -import scala.collection.immutable.Map +import scala.collection.JavaConverters.{collectionAsScalaIterableConverter, mapAsJavaMapConverter} object HoodieCLIUtils { @@ -46,4 +46,15 @@ object HoodieCLIUtils { DataSourceUtils.createHoodieClient(jsc, schemaStr, basePath, metaClient.getTableConfig.getTableName, finalParameters.asJava) } + + def extractPartitions(clusteringGroups: Seq[HoodieClusteringGroup]): String = { + var partitionPaths: Seq[String] = Seq.empty + clusteringGroups.foreach(g => + g.getSlices.asScala.foreach(slice => + partitionPaths = partitionPaths :+ slice.getPartitionPath + ) + ) + + partitionPaths.sorted.mkString(",") + } } diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionHoodiePathCommand.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionHoodiePathCommand.scala index 5b513f7500c10..57aff092b7429 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionHoodiePathCommand.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionHoodiePathCommand.scala @@ -19,11 +19,9 @@ package org.apache.spark.sql.hudi.command import org.apache.hudi.common.model.HoodieTableType import org.apache.hudi.common.table.HoodieTableMetaClient - -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference} +import org.apache.spark.sql.catalyst.expressions.Attribute import org.apache.spark.sql.catalyst.plans.logical.CompactionOperation.{CompactionOperation, RUN, SCHEDULE} import org.apache.spark.sql.hudi.command.procedures.{HoodieProcedureUtils, RunCompactionProcedure} -import org.apache.spark.sql.types.StringType import org.apache.spark.sql.{Row, SparkSession} import org.apache.spark.unsafe.types.UTF8String @@ -50,10 +48,5 @@ case class CompactionHoodiePathCommand(path: String, RunCompactionProcedure.builder.get().build.call(procedureArgs) } - override val output: Seq[Attribute] = { - operation match { - case RUN => Seq.empty - case SCHEDULE => Seq(AttributeReference("instant", StringType, nullable = false)()) - } - } + override val output: Seq[Attribute] = RunCompactionProcedure.builder.get().build.outputType.toAttributes } diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionHoodieTableCommand.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionHoodieTableCommand.scala index 5e362314c2df7..adaaeae9e55c9 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionHoodieTableCommand.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionHoodieTableCommand.scala @@ -18,10 +18,10 @@ package org.apache.spark.sql.hudi.command import org.apache.spark.sql.catalyst.catalog.CatalogTable -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference} -import org.apache.spark.sql.catalyst.plans.logical.CompactionOperation.{CompactionOperation, RUN, SCHEDULE} +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.plans.logical.CompactionOperation.CompactionOperation import org.apache.spark.sql.hudi.HoodieSqlCommonUtils.getTableLocation -import org.apache.spark.sql.types.StringType +import org.apache.spark.sql.hudi.command.procedures.RunCompactionProcedure import org.apache.spark.sql.{Row, SparkSession} @Deprecated @@ -35,10 +35,5 @@ case class CompactionHoodieTableCommand(table: CatalogTable, CompactionHoodiePathCommand(basePath, operation, instantTimestamp).run(sparkSession) } - override val output: Seq[Attribute] = { - operation match { - case RUN => Seq.empty - case SCHEDULE => Seq(AttributeReference("instant", StringType, nullable = false)()) - } - } + override val output: Seq[Attribute] = RunCompactionProcedure.builder.get().build.outputType.toAttributes } diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionShowHoodiePathCommand.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionShowHoodiePathCommand.scala index 965724163b96c..95a4ecf7800e6 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionShowHoodiePathCommand.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionShowHoodiePathCommand.scala @@ -19,10 +19,8 @@ package org.apache.spark.sql.hudi.command import org.apache.hudi.common.model.HoodieTableType import org.apache.hudi.common.table.HoodieTableMetaClient - -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference} +import org.apache.spark.sql.catalyst.expressions.Attribute import org.apache.spark.sql.hudi.command.procedures.{HoodieProcedureUtils, ShowCompactionProcedure} -import org.apache.spark.sql.types.{IntegerType, StringType} import org.apache.spark.sql.{Row, SparkSession} import org.apache.spark.unsafe.types.UTF8String @@ -42,11 +40,5 @@ case class CompactionShowHoodiePathCommand(path: String, limit: Int) ShowCompactionProcedure.builder.get().build.call(procedureArgs) } - override val output: Seq[Attribute] = { - Seq( - AttributeReference("instant", StringType, nullable = false)(), - AttributeReference("action", StringType, nullable = false)(), - AttributeReference("size", IntegerType, nullable = false)() - ) - } + override val output: Seq[Attribute] = ShowCompactionProcedure.builder.get().build.outputType.toAttributes } diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionShowHoodieTableCommand.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionShowHoodieTableCommand.scala index f3f0a8e529be9..afd15d5153db6 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionShowHoodieTableCommand.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CompactionShowHoodieTableCommand.scala @@ -18,9 +18,9 @@ package org.apache.spark.sql.hudi.command import org.apache.spark.sql.catalyst.catalog.CatalogTable -import org.apache.spark.sql.catalyst.expressions.{Attribute, AttributeReference} +import org.apache.spark.sql.catalyst.expressions.Attribute import org.apache.spark.sql.hudi.HoodieSqlCommonUtils.getTableLocation -import org.apache.spark.sql.types.{IntegerType, StringType} +import org.apache.spark.sql.hudi.command.procedures.ShowCompactionProcedure import org.apache.spark.sql.{Row, SparkSession} @Deprecated @@ -32,11 +32,5 @@ case class CompactionShowHoodieTableCommand(table: CatalogTable, limit: Int) CompactionShowHoodiePathCommand(basePath, limit).run(sparkSession) } - override val output: Seq[Attribute] = { - Seq( - AttributeReference("timestamp", StringType, nullable = false)(), - AttributeReference("action", StringType, nullable = false)(), - AttributeReference("size", IntegerType, nullable = false)() - ) - } + override val output: Seq[Attribute] = ShowCompactionProcedure.builder.get().build.outputType.toAttributes } diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunClusteringProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunClusteringProcedure.scala index 231d0939cc2e7..b353aebe50ac9 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunClusteringProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunClusteringProcedure.scala @@ -18,7 +18,7 @@ package org.apache.spark.sql.hudi.command.procedures import org.apache.hudi.DataSourceReadOptions.{QUERY_TYPE, QUERY_TYPE_SNAPSHOT_OPT_VAL} -import org.apache.hudi.common.table.timeline.HoodieActiveTimeline +import org.apache.hudi.common.table.timeline.{HoodieActiveTimeline, HoodieTimeline} import org.apache.hudi.common.table.{HoodieTableMetaClient, TableSchemaResolver} import org.apache.hudi.common.util.ValidationUtils.checkArgument import org.apache.hudi.common.util.{ClusteringUtils, Option => HOption} @@ -32,6 +32,7 @@ import org.apache.spark.sql.execution.datasources.FileStatusCache import org.apache.spark.sql.types._ import java.util.function.Supplier + import scala.collection.JavaConverters._ class RunClusteringProcedure extends BaseProcedure @@ -50,13 +51,15 @@ class RunClusteringProcedure extends BaseProcedure ProcedureParameter.optional(0, "table", DataTypes.StringType, None), ProcedureParameter.optional(1, "path", DataTypes.StringType, None), ProcedureParameter.optional(2, "predicate", DataTypes.StringType, None), - ProcedureParameter.optional(3, "order", DataTypes.StringType, None) + ProcedureParameter.optional(3, "order", DataTypes.StringType, None), + ProcedureParameter.optional(4, "show_involved_partition", DataTypes.BooleanType, false) ) private val OUTPUT_TYPE = new StructType(Array[StructField]( StructField("timestamp", DataTypes.StringType, nullable = true, Metadata.empty), - StructField("partition", DataTypes.StringType, nullable = true, Metadata.empty), - StructField("groups", DataTypes.IntegerType, nullable = true, Metadata.empty) + StructField("input_group_size", DataTypes.IntegerType, nullable = true, Metadata.empty), + StructField("state", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("involved_partitions", DataTypes.StringType, nullable = true, Metadata.empty) )) def parameters: Array[ProcedureParameter] = PARAMETERS @@ -70,6 +73,7 @@ class RunClusteringProcedure extends BaseProcedure val tablePath = getArgValueOrDefault(args, PARAMETERS(1)) val predicate = getArgValueOrDefault(args, PARAMETERS(2)) val orderColumns = getArgValueOrDefault(args, PARAMETERS(3)) + val showInvolvedPartitions = getArgValueOrDefault(args, PARAMETERS(4)).get.asInstanceOf[Boolean] val basePath: String = getBasePath(tableName, tablePath) val metaClient = HoodieTableMetaClient.builder.setConf(jsc.hadoopConfiguration()).setBasePath(basePath).build @@ -114,7 +118,27 @@ class RunClusteringProcedure extends BaseProcedure pendingClustering.foreach(client.cluster(_, true)) logInfo(s"Finish clustering all the instants: ${pendingClustering.mkString(",")}," + s" time cost: ${System.currentTimeMillis() - startTs}ms.") - Seq.empty[Row] + + val clusteringInstants = metaClient.reloadActiveTimeline().getInstants.iterator().asScala + .filter(p => p.getAction == HoodieTimeline.REPLACE_COMMIT_ACTION && pendingClustering.contains(p.getTimestamp)) + .toSeq + .sortBy(f => f.getTimestamp) + .reverse + + val clusteringPlans = clusteringInstants.map(instant => + ClusteringUtils.getClusteringPlan(metaClient, instant) + ) + + if (showInvolvedPartitions) { + clusteringPlans.map { p => + Row(p.get().getLeft.getTimestamp, p.get().getRight.getInputGroups.size(), + p.get().getLeft.getState.name(), HoodieCLIUtils.extractPartitions(p.get().getRight.getInputGroups.asScala)) + } + } else { + clusteringPlans.map { p => + Row(p.get().getLeft.getTimestamp, p.get().getRight.getInputGroups.size(), p.get().getLeft.getState.name(), "*") + } + } } override def build: Procedure = new RunClusteringProcedure() diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunCompactionProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunCompactionProcedure.scala index 9bca33f3882d4..3e5a7e29e4022 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunCompactionProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RunCompactionProcedure.scala @@ -20,10 +20,9 @@ package org.apache.spark.sql.hudi.command.procedures import org.apache.hudi.common.model.HoodieCommitMetadata import org.apache.hudi.common.table.HoodieTableMetaClient import org.apache.hudi.common.table.timeline.{HoodieActiveTimeline, HoodieTimeline} -import org.apache.hudi.common.util.{HoodieTimer, Option => HOption} +import org.apache.hudi.common.util.{CompactionUtils, HoodieTimer, Option => HOption} import org.apache.hudi.exception.HoodieException import org.apache.hudi.{HoodieCLIUtils, SparkAdapterSupport} - import org.apache.spark.internal.Logging import org.apache.spark.sql.Row import org.apache.spark.sql.types._ @@ -47,7 +46,9 @@ class RunCompactionProcedure extends BaseProcedure with ProcedureBuilder with Sp ) private val OUTPUT_TYPE = new StructType(Array[StructField]( - StructField("instant", DataTypes.StringType, nullable = true, Metadata.empty) + StructField("timestamp", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("operation_size", DataTypes.IntegerType, nullable = true, Metadata.empty), + StructField("state", DataTypes.StringType, nullable = true, Metadata.empty) )) def parameters: Array[ProcedureParameter] = PARAMETERS @@ -66,13 +67,12 @@ class RunCompactionProcedure extends BaseProcedure with ProcedureBuilder with Sp val metaClient = HoodieTableMetaClient.builder.setConf(jsc.hadoopConfiguration()).setBasePath(basePath).build val client = HoodieCLIUtils.createHoodieClientFromPath(sparkSession, basePath, Map.empty) + var willCompactionInstants: Seq[String] = Seq.empty operation match { case "schedule" => val instantTime = instantTimestamp.map(_.toString).getOrElse(HoodieActiveTimeline.createNewInstantTime) if (client.scheduleCompactionAtInstant(instantTime, HOption.empty[java.util.Map[String, String]])) { - Seq(Row(instantTime)) - } else { - Seq.empty[Row] + willCompactionInstants = Seq(instantTime) } case "run" => // Do compaction @@ -81,7 +81,7 @@ class RunCompactionProcedure extends BaseProcedure with ProcedureBuilder with Sp .filter(p => p.getAction == HoodieTimeline.COMPACTION_ACTION) .map(_.getTimestamp) .toSeq.sortBy(f => f) - val willCompactionInstants = if (instantTimestamp.isEmpty) { + willCompactionInstants = if (instantTimestamp.isEmpty) { if (pendingCompactionInstants.nonEmpty) { pendingCompactionInstants } else { // If there are no pending compaction, schedule to generate one. @@ -102,9 +102,9 @@ class RunCompactionProcedure extends BaseProcedure with ProcedureBuilder with Sp s"$basePath, Available pending compaction instants are: ${pendingCompactionInstants.mkString(",")} ") } } + if (willCompactionInstants.isEmpty) { logInfo(s"No need to compaction on $basePath") - Seq.empty[Row] } else { logInfo(s"Run compaction at instants: [${willCompactionInstants.mkString(",")}] on $basePath") val timer = new HoodieTimer @@ -116,10 +116,21 @@ class RunCompactionProcedure extends BaseProcedure with ProcedureBuilder with Sp } logInfo(s"Finish Run compaction at instants: [${willCompactionInstants.mkString(",")}]," + s" spend: ${timer.endTimer()}ms") - Seq.empty[Row] } case _ => throw new UnsupportedOperationException(s"Unsupported compaction operation: $operation") } + + val compactionInstants = metaClient.reloadActiveTimeline().getInstants.iterator().asScala + .filter(instant => willCompactionInstants.contains(instant.getTimestamp)) + .toSeq + .sortBy(p => p.getTimestamp) + .reverse + + compactionInstants.map(instant => + (instant, CompactionUtils.getCompactionPlan(metaClient, instant.getTimestamp)) + ).map { case (instant, plan) => + Row(instant.getTimestamp, plan.getOperations.size(), instant.getState.name()) + } } private def handleResponse(metadata: HoodieCommitMetadata): Unit = { diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowClusteringProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowClusteringProcedure.scala index a9d808217c0a9..092610119e606 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowClusteringProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowClusteringProcedure.scala @@ -17,26 +17,31 @@ package org.apache.spark.sql.hudi.command.procedures -import org.apache.hudi.SparkAdapterSupport +import org.apache.hudi.{HoodieCLIUtils, SparkAdapterSupport} import org.apache.hudi.common.table.HoodieTableMetaClient +import org.apache.hudi.common.table.timeline.HoodieTimeline import org.apache.hudi.common.util.ClusteringUtils import org.apache.spark.internal.Logging import org.apache.spark.sql.Row import org.apache.spark.sql.types._ import java.util.function.Supplier + import scala.collection.JavaConverters._ class ShowClusteringProcedure extends BaseProcedure with ProcedureBuilder with SparkAdapterSupport with Logging { private val PARAMETERS = Array[ProcedureParameter]( ProcedureParameter.optional(0, "table", DataTypes.StringType, None), ProcedureParameter.optional(1, "path", DataTypes.StringType, None), - ProcedureParameter.optional(2, "limit", DataTypes.IntegerType, 20) + ProcedureParameter.optional(2, "limit", DataTypes.IntegerType, 20), + ProcedureParameter.optional(3, "show_involved_partition", DataTypes.BooleanType, false) ) private val OUTPUT_TYPE = new StructType(Array[StructField]( StructField("timestamp", DataTypes.StringType, nullable = true, Metadata.empty), - StructField("groups", DataTypes.IntegerType, nullable = true, Metadata.empty) + StructField("input_group_size", DataTypes.IntegerType, nullable = true, Metadata.empty), + StructField("state", DataTypes.StringType, nullable = true, Metadata.empty), + StructField("involved_partitions", DataTypes.StringType, nullable = true, Metadata.empty) )) def parameters: Array[ProcedureParameter] = PARAMETERS @@ -49,12 +54,32 @@ class ShowClusteringProcedure extends BaseProcedure with ProcedureBuilder with S val tableName = getArgValueOrDefault(args, PARAMETERS(0)) val tablePath = getArgValueOrDefault(args, PARAMETERS(1)) val limit = getArgValueOrDefault(args, PARAMETERS(2)).get.asInstanceOf[Int] + val showInvolvedPartitions = getArgValueOrDefault(args, PARAMETERS(3)).get.asInstanceOf[Boolean] val basePath: String = getBasePath(tableName, tablePath) val metaClient = HoodieTableMetaClient.builder.setConf(jsc.hadoopConfiguration()).setBasePath(basePath).build - ClusteringUtils.getAllPendingClusteringPlans(metaClient).iterator().asScala.map { p => - Row(p.getLeft.getTimestamp, p.getRight.getInputGroups.size()) - }.toSeq.take(limit) + val clusteringInstants = metaClient.getActiveTimeline.getInstants.iterator().asScala + .filter(p => p.getAction == HoodieTimeline.REPLACE_COMMIT_ACTION) + .toSeq + .sortBy(f => f.getTimestamp) + .reverse + .take(limit) + + val clusteringPlans = clusteringInstants.map(instant => + ClusteringUtils.getClusteringPlan(metaClient, instant) + ) + + if (showInvolvedPartitions) { + clusteringPlans.map { p => + Row(p.get().getLeft.getTimestamp, p.get().getRight.getInputGroups.size(), + p.get().getLeft.getState.name(), HoodieCLIUtils.extractPartitions(p.get().getRight.getInputGroups.asScala)) + } + } else { + clusteringPlans.map { p => + Row(p.get().getLeft.getTimestamp, p.get().getRight.getInputGroups.size(), + p.get().getLeft.getState.name(), "*") + } + } } override def build: Procedure = new ShowClusteringProcedure() diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowCompactionProcedure.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowCompactionProcedure.scala index d484d65323447..7a7bb2cf9d996 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowCompactionProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/ShowCompactionProcedure.scala @@ -44,8 +44,8 @@ class ShowCompactionProcedure extends BaseProcedure with ProcedureBuilder with S private val OUTPUT_TYPE = new StructType(Array[StructField]( StructField("timestamp", DataTypes.StringType, nullable = true, Metadata.empty), - StructField("action", DataTypes.StringType, nullable = true, Metadata.empty), - StructField("size", DataTypes.IntegerType, nullable = true, Metadata.empty) + StructField("operation_size", DataTypes.IntegerType, nullable = true, Metadata.empty), + StructField("state", DataTypes.StringType, nullable = true, Metadata.empty) )) def parameters: Array[ProcedureParameter] = PARAMETERS @@ -64,17 +64,17 @@ class ShowCompactionProcedure extends BaseProcedure with ProcedureBuilder with S assert(metaClient.getTableType == HoodieTableType.MERGE_ON_READ, s"Cannot show compaction on a Non Merge On Read table.") - val timeLine = metaClient.getActiveTimeline - val compactionInstants = timeLine.getInstants.iterator().asScala + val compactionInstants = metaClient.getActiveTimeline.getInstants.iterator().asScala .filter(p => p.getAction == HoodieTimeline.COMPACTION_ACTION) .toSeq .sortBy(f => f.getTimestamp) .reverse .take(limit) - val compactionPlans = compactionInstants.map(instant => - (instant, CompactionUtils.getCompactionPlan(metaClient, instant.getTimestamp))) - compactionPlans.map { case (instant, plan) => - Row(instant.getTimestamp, instant.getAction, plan.getOperations.size()) + + compactionInstants.map(instant => + (instant, CompactionUtils.getCompactionPlan(metaClient, instant.getTimestamp)) + ).map { case (instant, plan) => + Row(instant.getTimestamp, plan.getOperations.size(), instant.getState.name()) } } diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringProcedure.scala index f975651bd7527..df4d8c90e2e6f 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestClusteringProcedure.scala @@ -20,10 +20,9 @@ package org.apache.spark.sql.hudi.procedure import org.apache.hadoop.fs.Path -import org.apache.hudi.common.table.timeline.{HoodieActiveTimeline, HoodieTimeline} +import org.apache.hudi.common.table.timeline.{HoodieActiveTimeline, HoodieInstant, HoodieTimeline} import org.apache.hudi.common.util.{Option => HOption} import org.apache.hudi.{HoodieCLIUtils, HoodieDataSourceHelpers} - import org.apache.spark.sql.hudi.HoodieSparkSqlTestBase import scala.collection.JavaConverters.asScalaIteratorConverter @@ -64,28 +63,22 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { val secondScheduleInstant = HoodieActiveTimeline.createNewInstantTime client.scheduleClusteringAtInstant(secondScheduleInstant, HOption.empty()) checkAnswer(s"call show_clustering('$tableName')")( - Seq(firstScheduleInstant, 3), - Seq(secondScheduleInstant, 1) + Seq(secondScheduleInstant, 1, HoodieInstant.State.REQUESTED.name(), "*"), + Seq(firstScheduleInstant, 3, HoodieInstant.State.REQUESTED.name(), "*") ) // Do clustering for all clustering plan generated above, and no new clustering // instant will be generated because of there is no commit after the second // clustering plan generated - spark.sql(s"call run_clustering(table => '$tableName', order => 'ts')") + checkAnswer(s"call run_clustering(table => '$tableName', order => 'ts', show_involved_partition => true)")( + Seq(secondScheduleInstant, 1, HoodieInstant.State.COMPLETED.name(), "ts=1003"), + Seq(firstScheduleInstant, 3, HoodieInstant.State.COMPLETED.name(), "ts=1000,ts=1001,ts=1002") + ) // No new commits val fs = new Path(basePath).getFileSystem(spark.sessionState.newHadoopConf()) assertResult(false)(HoodieDataSourceHelpers.hasNewCommits(fs, basePath, secondScheduleInstant)) - checkAnswer(s"select id, name, price, ts from $tableName order by id")( - Seq(1, "a1", 10.0, 1000), - Seq(2, "a2", 10.0, 1001), - Seq(3, "a3", 10.0, 1002), - Seq(4, "a4", 10.0, 1003) - ) - // After clustering there should be no pending clustering. - checkAnswer(s"call show_clustering(table => '$tableName')")() - // Check the number of finished clustering instants val finishedClustering = HoodieDataSourceHelpers.allCompletedCommitsCompactions(fs, basePath) .getInstants @@ -94,10 +87,23 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { .toSeq assertResult(2)(finishedClustering.size) + checkAnswer(s"select id, name, price, ts from $tableName order by id")( + Seq(1, "a1", 10.0, 1000), + Seq(2, "a2", 10.0, 1001), + Seq(3, "a3", 10.0, 1002), + Seq(4, "a4", 10.0, 1003) + ) + + // After clustering there should be no pending clustering and all clustering instants should be completed + checkAnswer(s"call show_clustering(table => '$tableName')")( + Seq(secondScheduleInstant, 1, HoodieInstant.State.COMPLETED.name(), "*"), + Seq(firstScheduleInstant, 3, HoodieInstant.State.COMPLETED.name(), "*") + ) + // Do clustering without manual schedule(which will do the schedule if no pending clustering exists) spark.sql(s"insert into $tableName values(5, 'a5', 10, 1004)") spark.sql(s"insert into $tableName values(6, 'a6', 10, 1005)") - spark.sql(s"call run_clustering(table => '$tableName', order => 'ts')") + spark.sql(s"call run_clustering(table => '$tableName', order => 'ts', show_involved_partition => true)").show() val thirdClusteringInstant = HoodieDataSourceHelpers.allCompletedCommitsCompactions(fs, basePath) .findInstantsAfter(secondScheduleInstant) @@ -142,7 +148,7 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { | location '$basePath' """.stripMargin) - spark.sql(s"call run_clustering(path => '$basePath')") + spark.sql(s"call run_clustering(path => '$basePath')").show() checkAnswer(s"call show_clustering(path => '$basePath')")() spark.sql(s"insert into $tableName values(1, 'a1', 10, 1000)") @@ -152,18 +158,22 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { // Generate the first clustering plan val firstScheduleInstant = HoodieActiveTimeline.createNewInstantTime client.scheduleClusteringAtInstant(firstScheduleInstant, HOption.empty()) - checkAnswer(s"call show_clustering(path => '$basePath')")( - Seq(firstScheduleInstant, 3) + checkAnswer(s"call show_clustering(path => '$basePath', show_involved_partition => true)")( + Seq(firstScheduleInstant, 3, HoodieInstant.State.REQUESTED.name(), "ts=1000,ts=1001,ts=1002") ) // Do clustering for all the clustering plan - spark.sql(s"call run_clustering(path => '$basePath', order => 'ts')") + checkAnswer(s"call run_clustering(path => '$basePath', order => 'ts')")( + Seq(firstScheduleInstant, 3, HoodieInstant.State.COMPLETED.name(), "*") + ) + checkAnswer(s"select id, name, price, ts from $tableName order by id")( Seq(1, "a1", 10.0, 1000), Seq(2, "a2", 10.0, 1001), Seq(3, "a3", 10.0, 1002) ) + val fs = new Path(basePath).getFileSystem(spark.sessionState.newHadoopConf()) - HoodieDataSourceHelpers.hasNewCommits(fs, basePath, firstScheduleInstant) + assertResult(false)(HoodieDataSourceHelpers.hasNewCommits(fs, basePath, firstScheduleInstant)) // Check the number of finished clustering instants var finishedClustering = HoodieDataSourceHelpers.allCompletedCommitsCompactions(fs, basePath) @@ -176,7 +186,12 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { // Do clustering without manual schedule(which will do the schedule if no pending clustering exists) spark.sql(s"insert into $tableName values(4, 'a4', 10, 1003)") spark.sql(s"insert into $tableName values(5, 'a5', 10, 1004)") - spark.sql(s"call run_clustering(table => '$tableName', predicate => 'ts >= 1003L')") + val resultA = spark.sql(s"call run_clustering(table => '$tableName', predicate => 'ts >= 1003L', show_involved_partition => true)") + .collect() + .map(row => Seq(row.getString(0), row.getInt(1), row.getString(2), row.getString(3))) + assertResult(1)(resultA.length) + assertResult("ts=1003,ts=1004")(resultA(0)(3)) + checkAnswer(s"select id, name, price, ts from $tableName order by id")( Seq(1, "a1", 10.0, 1000), Seq(2, "a2", 10.0, 1001), @@ -220,6 +235,8 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { val fs = new Path(basePath).getFileSystem(spark.sessionState.newHadoopConf()) // Test partition pruning with single predicate + var resultA: Array[Seq[Any]] = Array.empty + { spark.sql(s"insert into $tableName values(1, 'a1', 10, 1000)") spark.sql(s"insert into $tableName values(2, 'a2', 10, 1001)") @@ -230,7 +247,11 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { )("Only partition predicates are allowed") // Do clustering table with partition predicate - spark.sql(s"call run_clustering(table => '$tableName', predicate => 'ts <= 1001L', order => 'ts')") + resultA = spark.sql(s"call run_clustering(table => '$tableName', predicate => 'ts <= 1001L', order => 'ts', show_involved_partition => true)") + .collect() + .map(row => Seq(row.getString(0), row.getInt(1), row.getString(2), row.getString(3))) + assertResult(1)(resultA.length) + assertResult("ts=1000,ts=1001")(resultA(0)(3)) // There is 1 completed clustering instant val clusteringInstants = HoodieDataSourceHelpers.allCompletedCommitsCompactions(fs, basePath) @@ -245,9 +266,12 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { val clusteringPlan = HoodieDataSourceHelpers.getClusteringPlan(fs, basePath, clusteringInstant.getTimestamp) assertResult(true)(clusteringPlan.isPresent) assertResult(2)(clusteringPlan.get().getInputGroups.size()) + assertResult(resultA(0)(1))(clusteringPlan.get().getInputGroups.size()) - // No pending clustering instant - checkAnswer(s"call show_clustering(table => '$tableName')")() + // All clustering instants are completed + checkAnswer(s"call show_clustering(table => '$tableName', show_involved_partition => true)")( + Seq(resultA(0).head, resultA(0)(1), HoodieInstant.State.COMPLETED.name(), "ts=1000,ts=1001") + ) checkAnswer(s"select id, name, price, ts from $tableName order by id")( Seq(1, "a1", 10.0, 1000), @@ -257,6 +281,8 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { } // Test partition pruning with {@code And} predicates + var resultB: Array[Seq[Any]] = Array.empty + { spark.sql(s"insert into $tableName values(4, 'a4', 10, 1003)") spark.sql(s"insert into $tableName values(5, 'a5', 10, 1004)") @@ -267,7 +293,11 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { )("Only partition predicates are allowed") // Do clustering table with partition predicate - spark.sql(s"call run_clustering(table => '$tableName', predicate => 'ts > 1001L and ts <= 1005L', order => 'ts')") + resultB = spark.sql(s"call run_clustering(table => '$tableName', predicate => 'ts > 1001L and ts <= 1005L', order => 'ts', show_involved_partition => true)") + .collect() + .map(row => Seq(row.getString(0), row.getInt(1), row.getString(2), row.getString(3))) + assertResult(1)(resultB.length) + assertResult("ts=1002,ts=1003,ts=1004,ts=1005")(resultB(0)(3)) // There are 2 completed clustering instants val clusteringInstants = HoodieDataSourceHelpers.allCompletedCommitsCompactions(fs, basePath) @@ -283,8 +313,11 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { assertResult(true)(clusteringPlan.isPresent) assertResult(4)(clusteringPlan.get().getInputGroups.size()) - // No pending clustering instant - checkAnswer(s"call show_clustering(table => '$tableName')")() + // All clustering instants are completed + checkAnswer(s"call show_clustering(table => '$tableName', show_involved_partition => true)")( + Seq(resultA(0).head, resultA(0)(1), HoodieInstant.State.COMPLETED.name(), "ts=1000,ts=1001"), + Seq(resultB(0).head, resultB(0)(1), HoodieInstant.State.COMPLETED.name(), "ts=1002,ts=1003,ts=1004,ts=1005") + ) checkAnswer(s"select id, name, price, ts from $tableName order by id")( Seq(1, "a1", 10.0, 1000), @@ -297,6 +330,8 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { } // Test partition pruning with {@code And}-{@code Or} predicates + var resultC: Array[Seq[Any]] = Array.empty + { spark.sql(s"insert into $tableName values(7, 'a7', 10, 1006)") spark.sql(s"insert into $tableName values(8, 'a8', 10, 1007)") @@ -308,7 +343,11 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { )("Only partition predicates are allowed") // Do clustering table with partition predicate - spark.sql(s"call run_clustering(table => '$tableName', predicate => '(ts >= 1006L and ts < 1008L) or ts >= 1009L', order => 'ts')") + resultC = spark.sql(s"call run_clustering(table => '$tableName', predicate => '(ts >= 1006L and ts < 1008L) or ts >= 1009L', order => 'ts', show_involved_partition => true)") + .collect() + .map(row => Seq(row.getString(0), row.getInt(1), row.getString(2), row.getString(3))) + assertResult(1)(resultC.length) + assertResult("ts=1006,ts=1007,ts=1009")(resultC(0)(3)) // There are 3 completed clustering instants val clusteringInstants = HoodieDataSourceHelpers.allCompletedCommitsCompactions(fs, basePath) @@ -324,8 +363,12 @@ class TestClusteringProcedure extends HoodieSparkSqlTestBase { assertResult(true)(clusteringPlan.isPresent) assertResult(3)(clusteringPlan.get().getInputGroups.size()) - // No pending clustering instant - checkAnswer(s"call show_clustering(table => '$tableName')")() + // All clustering instants are completed + checkAnswer(s"call show_clustering(table => '$tableName', show_involved_partition => true)")( + Seq(resultA(0).head, resultA(0)(1), HoodieInstant.State.COMPLETED.name(), "ts=1000,ts=1001"), + Seq(resultB(0).head, resultB(0)(1), HoodieInstant.State.COMPLETED.name(), "ts=1002,ts=1003,ts=1004,ts=1005"), + Seq(resultC(0).head, resultC(0)(1), HoodieInstant.State.COMPLETED.name(), "ts=1006,ts=1007,ts=1009") + ) checkAnswer(s"select id, name, price, ts from $tableName order by id")( Seq(1, "a1", 10.0, 1000), diff --git a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCompactionProcedure.scala b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCompactionProcedure.scala index 0f6f96f91196f..39332d859171d 100644 --- a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCompactionProcedure.scala +++ b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/procedure/TestCompactionProcedure.scala @@ -19,6 +19,7 @@ package org.apache.spark.sql.hudi.procedure +import org.apache.hudi.common.table.timeline.HoodieInstant import org.apache.spark.sql.hudi.HoodieSparkSqlTestBase class TestCompactionProcedure extends HoodieSparkSqlTestBase { @@ -48,22 +49,52 @@ class TestCompactionProcedure extends HoodieSparkSqlTestBase { spark.sql(s"insert into $tableName values(4, 'a4', 10, 1000)") spark.sql(s"update $tableName set price = 11 where id = 1") - spark.sql(s"call run_compaction(op => 'schedule', table => '$tableName')") + // Schedule the first compaction + val resultA = spark.sql(s"call run_compaction(op => 'schedule', table => '$tableName')") + .collect() + .map(row => Seq(row.getString(0), row.getInt(1), row.getString(2))) + spark.sql(s"update $tableName set price = 12 where id = 2") - spark.sql(s"call run_compaction('schedule', '$tableName')") - val compactionRows = spark.sql(s"call show_compaction(table => '$tableName', limit => 10)").collect() - val timestamps = compactionRows.map(_.getString(0)) + + // Schedule the second compaction + val resultB = spark.sql(s"call run_compaction('schedule', '$tableName')") + .collect() + .map(row => Seq(row.getString(0), row.getInt(1), row.getString(2))) + + assertResult(1)(resultA.length) + assertResult(1)(resultB.length) + val showCompactionSql: String = s"call show_compaction(table => '$tableName', limit => 10)" + checkAnswer(showCompactionSql)( + resultA(0), + resultB(0) + ) + + val compactionRows = spark.sql(showCompactionSql).collect() + val timestamps = compactionRows.map(_.getString(0)).sorted assertResult(2)(timestamps.length) - spark.sql(s"call run_compaction(op => 'run', table => '$tableName', timestamp => ${timestamps(1)})") + // Execute the second scheduled compaction instant actually + checkAnswer(s"call run_compaction(op => 'run', table => '$tableName', timestamp => ${timestamps(1)})")( + Seq(resultB(0).head, resultB(0)(1), HoodieInstant.State.COMPLETED.name()) + ) checkAnswer(s"select id, name, price, ts from $tableName order by id")( Seq(1, "a1", 11.0, 1000), Seq(2, "a2", 12.0, 1000), Seq(3, "a3", 10.0, 1000), Seq(4, "a4", 10.0, 1000) ) - assertResult(1)(spark.sql(s"call show_compaction('$tableName')").collect().length) - spark.sql(s"call run_compaction(op => 'run', table => '$tableName', timestamp => ${timestamps(0)})") + + // A compaction action eventually becomes commit when completed, so show_compaction + // can only see the first scheduled compaction instant + val resultC = spark.sql(s"call show_compaction('$tableName')") + .collect() + .map(row => Seq(row.getString(0), row.getInt(1), row.getString(2))) + assertResult(1)(resultC.length) + assertResult(resultA)(resultC) + + checkAnswer(s"call run_compaction(op => 'run', table => '$tableName', timestamp => ${timestamps(0)})")( + Seq(resultA(0).head, resultA(0)(1), HoodieInstant.State.COMPLETED.name()) + ) checkAnswer(s"select id, name, price, ts from $tableName order by id")( Seq(1, "a1", 11.0, 1000), Seq(2, "a2", 12.0, 1000), @@ -98,25 +129,40 @@ class TestCompactionProcedure extends HoodieSparkSqlTestBase { spark.sql(s"insert into $tableName values(3, 'a3', 10, 1000)") spark.sql(s"update $tableName set price = 11 where id = 1") - spark.sql(s"call run_compaction(op => 'run', path => '${tmp.getCanonicalPath}')") + checkAnswer(s"call run_compaction(op => 'run', path => '${tmp.getCanonicalPath}')")() checkAnswer(s"select id, name, price, ts from $tableName order by id")( Seq(1, "a1", 11.0, 1000), Seq(2, "a2", 10.0, 1000), Seq(3, "a3", 10.0, 1000) ) assertResult(0)(spark.sql(s"call show_compaction(path => '${tmp.getCanonicalPath}')").collect().length) - // schedule compaction first + spark.sql(s"update $tableName set price = 12 where id = 1") - spark.sql(s"call run_compaction(op=> 'schedule', path => '${tmp.getCanonicalPath}')") - // schedule compaction second + // Schedule the first compaction + val resultA = spark.sql(s"call run_compaction(op=> 'schedule', path => '${tmp.getCanonicalPath}')") + .collect() + .map(row => Seq(row.getString(0), row.getInt(1), row.getString(2))) + spark.sql(s"update $tableName set price = 12 where id = 2") - spark.sql(s"call run_compaction(op => 'schedule', path => '${tmp.getCanonicalPath}')") - // show compaction - assertResult(2)(spark.sql(s"call show_compaction(path => '${tmp.getCanonicalPath}')").collect().length) - // run compaction for all the scheduled compaction - spark.sql(s"call run_compaction(op => 'run', path => '${tmp.getCanonicalPath}')") + // Schedule the second compaction + val resultB = spark.sql(s"call run_compaction(op => 'schedule', path => '${tmp.getCanonicalPath}')") + .collect() + .map(row => Seq(row.getString(0), row.getInt(1), row.getString(2))) + + assertResult(1)(resultA.length) + assertResult(1)(resultB.length) + checkAnswer(s"call show_compaction(path => '${tmp.getCanonicalPath}')")( + resultA(0), + resultB(0) + ) + + // Run compaction for all the scheduled compaction + checkAnswer(s"call run_compaction(op => 'run', path => '${tmp.getCanonicalPath}')")( + Seq(resultA(0).head, resultA(0)(1), HoodieInstant.State.COMPLETED.name()), + Seq(resultB(0).head, resultB(0)(1), HoodieInstant.State.COMPLETED.name()) + ) checkAnswer(s"select id, name, price, ts from $tableName order by id")( Seq(1, "a1", 12.0, 1000), From 6f37863ba8ded3f5550cf608b3e42fe197331ed4 Mon Sep 17 00:00:00 2001 From: Danny Chan Date: Thu, 19 May 2022 10:59:05 +0800 Subject: [PATCH 45/52] [HUDI-4114] Remove the unnecessary fs view sync for BaseWriteClient#initTable (#5617) No need to #sync actively because the table instance is instantiated freshly, its view manager has empty fiew instantces, the fs view would be synced lazily when is it requested. --- .../main/java/org/apache/hudi/client/BaseHoodieWriteClient.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java index 2f425acbc7f2b..723bd33b8c401 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java @@ -1466,8 +1466,6 @@ protected final HoodieTable initTable(WriteOperationType operationType, Option Date: Fri, 20 May 2022 18:10:24 +0800 Subject: [PATCH 46/52] [HUDI-4119] the first read result is incorrect when Flink upsert- Kafka connector is used in HUDi (#5626) * HUDI-4119 the first read result is incorrect when Flink upsert- Kafka connector is used in HUDi Co-authored-by: aliceyyan --- .../apache/hudi/table/HoodieTableSource.java | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java index bad592aa21d28..1836857383554 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/table/HoodieTableSource.java @@ -20,12 +20,16 @@ import org.apache.hudi.avro.HoodieAvroUtils; import org.apache.hudi.common.model.BaseFile; +import org.apache.hudi.common.model.HoodieCommitMetadata; import org.apache.hudi.common.model.HoodieLogFile; import org.apache.hudi.common.model.HoodieTableType; import org.apache.hudi.common.table.HoodieTableMetaClient; import org.apache.hudi.common.table.TableSchemaResolver; +import org.apache.hudi.common.table.timeline.HoodieActiveTimeline; +import org.apache.hudi.common.table.timeline.HoodieInstant; import org.apache.hudi.common.table.view.HoodieTableFileSystemView; import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.collection.Pair; import org.apache.hudi.configuration.FlinkOptions; import org.apache.hudi.configuration.HadoopConfigurations; import org.apache.hudi.configuration.OptionsResolver; @@ -381,8 +385,8 @@ private List buildFileIndex() { } private InputFormat getStreamInputFormat() { - // if table does not exist, use schema from the DDL - Schema tableAvroSchema = this.metaClient == null ? inferSchemaFromDdl() : getTableAvroSchema(); + // if table does not exist or table data does not exist, use schema from the DDL + Schema tableAvroSchema = (this.metaClient == null || !tableDataExists()) ? inferSchemaFromDdl() : getTableAvroSchema(); final DataType rowDataType = AvroSchemaConverter.convertToDataType(tableAvroSchema); final RowType rowType = (RowType) rowDataType.getLogicalType(); final RowType requiredRowType = (RowType) getProducedDataType().notNull().getLogicalType(); @@ -399,6 +403,15 @@ private List buildFileIndex() { throw new HoodieException(errMsg); } + /** + * Returns whether the hoodie table data exists . + */ + private boolean tableDataExists() { + HoodieActiveTimeline activeTimeline = metaClient.getActiveTimeline(); + Option> instantAndCommitMetadata = activeTimeline.getLastCommitMetadataWithValidData(); + return instantAndCommitMetadata.isPresent(); + } + private MergeOnReadInputFormat mergeOnReadInputFormat( RowType rowType, RowType requiredRowType, From c7576f7613ba76ab7db6608598182d8970ce1bd9 Mon Sep 17 00:00:00 2001 From: Danny Chan Date: Fri, 20 May 2022 21:31:23 +0800 Subject: [PATCH 47/52] [HUDI-4130] Remove the upgrade/downgrade for flink #initTable (#5642) --- .../org/apache/hudi/client/BaseHoodieWriteClient.java | 2 +- .../org/apache/hudi/client/HoodieFlinkWriteClient.java | 10 ++++++++-- .../hudi/sink/StreamWriteOperatorCoordinator.java | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java index 723bd33b8c401..270027df18053 100644 --- a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java +++ b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/BaseHoodieWriteClient.java @@ -1551,7 +1551,7 @@ private void setWriteTimer(HoodieTable table) { } } - private void tryUpgrade(HoodieTableMetaClient metaClient, Option instantTime) { + protected void tryUpgrade(HoodieTableMetaClient metaClient, Option instantTime) { UpgradeDowngrade upgradeDowngrade = new UpgradeDowngrade(metaClient, config, context, upgradeDowngradeHelper); diff --git a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java index ce75452d27ff4..2d23c3afb7f14 100644 --- a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java +++ b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkWriteClient.java @@ -407,14 +407,20 @@ protected HoodieTable doInitTable(HoodieTableMetaClient metaClient, Option instantTime) { + // do nothing. + // flink executes the upgrade/downgrade once when initializing the first instant on start up, + // no need to execute the upgrade/downgrade on each write in streaming. + } + /** * Upgrade downgrade the Hoodie table. * *

This action should only be executed once for each commit. * The modification of the table properties is not thread safe. */ - public void upgradeDowngrade(String instantTime) { - HoodieTableMetaClient metaClient = createMetaClient(true); + public void upgradeDowngrade(String instantTime, HoodieTableMetaClient metaClient) { new UpgradeDowngrade(metaClient, config, context, FlinkUpgradeDowngradeHelper.getInstance()) .run(HoodieTableVersion.current(), instantTime); } diff --git a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java index 023b1e696583a..39976e5ee2dc4 100644 --- a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java +++ b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/sink/StreamWriteOperatorCoordinator.java @@ -394,7 +394,7 @@ private void initInstant(String instant) { // starts a new instant startInstant(); // upgrade downgrade - this.writeClient.upgradeDowngrade(this.instant); + this.writeClient.upgradeDowngrade(this.instant, this.metaClient); }, "initialize instant %s", instant); } From 85b146d3d508e68b2f1ff10f232c37c6a474b77b Mon Sep 17 00:00:00 2001 From: huberylee Date: Fri, 20 May 2022 22:25:32 +0800 Subject: [PATCH 48/52] [HUDI-3985] Refactor DLASyncTool to support read hoodie table as spark datasource table (#5532) --- .../org/apache/hudi/DataSourceOptions.scala | 2 +- .../hudi/command/DropHoodieTableCommand.scala | 2 +- .../CreateHoodieTableAsSelectCommand.scala | 2 +- .../sql/hudi/catalog/HoodieCatalog.scala | 2 +- .../{hudi-dla-sync => hudi-adb-sync}/pom.xml | 2 +- .../src/assembly/src.xml | 0 .../sync/adb/AbstractAdbSyncHoodieClient.java | 128 +++++ .../apache/hudi/sync/adb/AdbSyncConfig.java | 240 ++++++++++ .../org/apache/hudi/sync/adb/AdbSyncTool.java | 283 +++++++++++ .../hudi/sync/adb/HoodieAdbJdbcClient.java | 440 ++++++++++++++++++ .../hudi/sync/adb/HoodieAdbSyncException.java | 29 ++ .../hudi/sync/adb/TestAdbSyncConfig.java | 65 +++ .../resources/log4j-surefire-quiet.properties | 0 .../test/resources/log4j-surefire.properties | 0 .../org/apache/hudi/dla/DLASyncConfig.java | 111 ----- .../java/org/apache/hudi/dla/DLASyncTool.java | 213 --------- .../org/apache/hudi/dla/HoodieDLAClient.java | 428 ----------------- .../java/org/apache/hudi/dla/util/Utils.java | 77 --- .../apache/hudi/dla/TestDLASyncConfig.java | 55 --- .../org/apache/hudi/hive/HiveSyncTool.java | 84 +--- .../apache/hudi/hive/TestHiveSyncTool.java | 2 +- .../hive/TestParquet2SparkSchemaUtils.java | 2 +- .../hudi/sync/common/AbstractSyncTool.java | 82 ++++ .../hudi/sync/common}/util/ConfigUtils.java | 2 +- .../util/Parquet2SparkSchemaUtils.java | 2 +- hudi-sync/pom.xml | 2 +- 26 files changed, 1281 insertions(+), 974 deletions(-) rename hudi-sync/{hudi-dla-sync => hudi-adb-sync}/pom.xml (99%) rename hudi-sync/{hudi-dla-sync => hudi-adb-sync}/src/assembly/src.xml (100%) create mode 100644 hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AbstractAdbSyncHoodieClient.java create mode 100644 hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AdbSyncConfig.java create mode 100644 hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AdbSyncTool.java create mode 100644 hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/HoodieAdbJdbcClient.java create mode 100644 hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/HoodieAdbSyncException.java create mode 100644 hudi-sync/hudi-adb-sync/src/test/java/org/apache/hudi/sync/adb/TestAdbSyncConfig.java rename hudi-sync/{hudi-dla-sync => hudi-adb-sync}/src/test/resources/log4j-surefire-quiet.properties (100%) rename hudi-sync/{hudi-dla-sync => hudi-adb-sync}/src/test/resources/log4j-surefire.properties (100%) delete mode 100644 hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/DLASyncConfig.java delete mode 100644 hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/DLASyncTool.java delete mode 100644 hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/HoodieDLAClient.java delete mode 100644 hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/util/Utils.java delete mode 100644 hudi-sync/hudi-dla-sync/src/test/java/org/apache/hudi/dla/TestDLASyncConfig.java rename hudi-sync/{hudi-hive-sync/src/main/java/org/apache/hudi/hive => hudi-sync-common/src/main/java/org/apache/hudi/sync/common}/util/ConfigUtils.java (98%) rename hudi-sync/{hudi-hive-sync/src/main/java/org/apache/hudi/hive => hudi-sync-common/src/main/java/org/apache/hudi/sync/common}/util/Parquet2SparkSchemaUtils.java (99%) diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala index 0d4c7cf184ddc..36dd07f28a180 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/DataSourceOptions.scala @@ -26,11 +26,11 @@ import org.apache.hudi.common.table.HoodieTableConfig import org.apache.hudi.common.util.Option import org.apache.hudi.common.util.ValidationUtils.checkState import org.apache.hudi.config.{HoodieClusteringConfig, HoodieWriteConfig} -import org.apache.hudi.hive.util.ConfigUtils import org.apache.hudi.hive.{HiveSyncConfig, HiveSyncTool} import org.apache.hudi.keygen.constant.KeyGeneratorOptions import org.apache.hudi.keygen.{ComplexKeyGenerator, CustomKeyGenerator, NonpartitionedKeyGenerator, SimpleKeyGenerator} import org.apache.hudi.sync.common.HoodieSyncConfig +import org.apache.hudi.sync.common.util.ConfigUtils import org.apache.log4j.LogManager import org.apache.spark.sql.execution.datasources.{DataSourceUtils => SparkDataSourceUtils} diff --git a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/DropHoodieTableCommand.scala b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/DropHoodieTableCommand.scala index 68582fc2795dd..c24d0fd992d97 100644 --- a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/DropHoodieTableCommand.scala +++ b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/DropHoodieTableCommand.scala @@ -21,7 +21,7 @@ import org.apache.hadoop.fs.Path import org.apache.hudi.client.common.HoodieSparkEngineContext import org.apache.hudi.common.fs.FSUtils import org.apache.hudi.common.model.HoodieTableType -import org.apache.hudi.hive.util.ConfigUtils +import org.apache.hudi.sync.common.util.ConfigUtils import org.apache.spark.sql._ import org.apache.spark.sql.catalyst.TableIdentifier import org.apache.spark.sql.catalyst.catalog._ diff --git a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala index 66aeb850e49e7..733bd67a0b0d7 100644 --- a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala +++ b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/CreateHoodieTableAsSelectCommand.scala @@ -21,10 +21,10 @@ import org.apache.hadoop.conf.Configuration import org.apache.hadoop.fs.Path import org.apache.hudi.DataSourceWriteOptions import org.apache.hudi.hive.HiveSyncConfig -import org.apache.hudi.hive.util.ConfigUtils import org.apache.hudi.sql.InsertMode import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat, CatalogTable, CatalogTableType, HoodieCatalogTable} import org.apache.spark.sql.catalyst.catalog.HoodieCatalogTable.needFilterProps +import org.apache.hudi.sync.common.util.ConfigUtils import org.apache.spark.sql.catalyst.plans.QueryPlan import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, Project} import org.apache.spark.sql.hudi.HoodieSqlCommonUtils diff --git a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala index 2c5261a12f146..f30976f58ea26 100644 --- a/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala +++ b/hudi-spark-datasource/hudi-spark3/src/main/scala/org/apache/spark/sql/hudi/catalog/HoodieCatalog.scala @@ -20,8 +20,8 @@ package org.apache.spark.sql.hudi.catalog import org.apache.hadoop.fs.Path import org.apache.hudi.exception.HoodieException -import org.apache.hudi.hive.util.ConfigUtils import org.apache.hudi.sql.InsertMode +import org.apache.hudi.sync.common.util.ConfigUtils import org.apache.hudi.{DataSourceWriteOptions, SparkAdapterSupport} import org.apache.spark.sql.HoodieSpark3SqlUtils.convertTransforms import org.apache.spark.sql.catalyst.TableIdentifier diff --git a/hudi-sync/hudi-dla-sync/pom.xml b/hudi-sync/hudi-adb-sync/pom.xml similarity index 99% rename from hudi-sync/hudi-dla-sync/pom.xml rename to hudi-sync/hudi-adb-sync/pom.xml index 3770225ef7fcb..0dd8783b67133 100644 --- a/hudi-sync/hudi-dla-sync/pom.xml +++ b/hudi-sync/hudi-adb-sync/pom.xml @@ -25,7 +25,7 @@ 4.0.0 - hudi-dla-sync + hudi-adb-sync jar diff --git a/hudi-sync/hudi-dla-sync/src/assembly/src.xml b/hudi-sync/hudi-adb-sync/src/assembly/src.xml similarity index 100% rename from hudi-sync/hudi-dla-sync/src/assembly/src.xml rename to hudi-sync/hudi-adb-sync/src/assembly/src.xml diff --git a/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AbstractAdbSyncHoodieClient.java b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AbstractAdbSyncHoodieClient.java new file mode 100644 index 0000000000000..84316ddb1152b --- /dev/null +++ b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AbstractAdbSyncHoodieClient.java @@ -0,0 +1,128 @@ +/* + * 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.hudi.sync.adb; + +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.table.timeline.HoodieTimeline; +import org.apache.hudi.common.util.StringUtils; +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.hive.PartitionValueExtractor; +import org.apache.hudi.hive.SchemaDifference; +import org.apache.hudi.sync.common.AbstractSyncHoodieClient; + +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public abstract class AbstractAdbSyncHoodieClient extends AbstractSyncHoodieClient { + protected AdbSyncConfig adbSyncConfig; + protected PartitionValueExtractor partitionValueExtractor; + protected HoodieTimeline activeTimeline; + + public AbstractAdbSyncHoodieClient(AdbSyncConfig syncConfig, FileSystem fs) { + super(syncConfig.basePath, syncConfig.assumeDatePartitioning, + syncConfig.useFileListingFromMetadata, false, fs); + this.adbSyncConfig = syncConfig; + final String clazz = adbSyncConfig.partitionValueExtractorClass; + try { + this.partitionValueExtractor = (PartitionValueExtractor) Class.forName(clazz).newInstance(); + } catch (Exception e) { + throw new HoodieException("Fail to init PartitionValueExtractor class " + clazz, e); + } + + activeTimeline = metaClient.getActiveTimeline().getCommitsTimeline().filterCompletedInstants(); + } + + public List getPartitionEvents(Map, String> tablePartitions, + List partitionStoragePartitions) { + Map paths = new HashMap<>(); + + for (Map.Entry, String> entry : tablePartitions.entrySet()) { + List partitionValues = entry.getKey(); + String fullTablePartitionPath = entry.getValue(); + paths.put(String.join(", ", partitionValues), fullTablePartitionPath); + } + List events = new ArrayList<>(); + for (String storagePartition : partitionStoragePartitions) { + Path storagePartitionPath = FSUtils.getPartitionPath(adbSyncConfig.basePath, storagePartition); + String fullStoragePartitionPath = Path.getPathWithoutSchemeAndAuthority(storagePartitionPath).toUri().getPath(); + // Check if the partition values or if hdfs path is the same + List storagePartitionValues = partitionValueExtractor.extractPartitionValuesInPath(storagePartition); + if (adbSyncConfig.useHiveStylePartitioning) { + String partition = String.join("/", storagePartitionValues); + storagePartitionPath = FSUtils.getPartitionPath(adbSyncConfig.basePath, partition); + fullStoragePartitionPath = Path.getPathWithoutSchemeAndAuthority(storagePartitionPath).toUri().getPath(); + } + if (!storagePartitionValues.isEmpty()) { + String storageValue = String.join(", ", storagePartitionValues); + if (!paths.containsKey(storageValue)) { + events.add(PartitionEvent.newPartitionAddEvent(storagePartition)); + } else if (!paths.get(storageValue).equals(fullStoragePartitionPath)) { + events.add(PartitionEvent.newPartitionUpdateEvent(storagePartition)); + } + } + } + return events; + } + + public void close() { + + } + + public abstract Map, String> scanTablePartitions(String tableName) throws Exception; + + public abstract void updateTableDefinition(String tableName, SchemaDifference schemaDiff) throws Exception; + + public abstract boolean databaseExists(String databaseName) throws Exception; + + public abstract void createDatabase(String databaseName) throws Exception; + + public abstract void dropTable(String tableName); + + protected String getDatabasePath() { + String dbLocation = adbSyncConfig.dbLocation; + Path dbLocationPath; + if (StringUtils.isNullOrEmpty(dbLocation)) { + if (new Path(adbSyncConfig.basePath).isRoot()) { + dbLocationPath = new Path(adbSyncConfig.basePath); + } else { + dbLocationPath = new Path(adbSyncConfig.basePath).getParent(); + } + } else { + dbLocationPath = new Path(dbLocation); + } + return generateAbsolutePathStr(dbLocationPath); + } + + protected String generateAbsolutePathStr(Path path) { + String absolutePathStr = path.toString(); + if (path.toUri().getScheme() == null) { + absolutePathStr = getDefaultFs() + absolutePathStr; + } + return absolutePathStr.endsWith("/") ? absolutePathStr : absolutePathStr + "/"; + } + + protected String getDefaultFs() { + return fs.getConf().get("fs.defaultFS"); + } +} diff --git a/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AdbSyncConfig.java b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AdbSyncConfig.java new file mode 100644 index 0000000000000..ae2e7024e5870 --- /dev/null +++ b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AdbSyncConfig.java @@ -0,0 +1,240 @@ +/* + * 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.hudi.sync.adb; + +import org.apache.hudi.common.config.ConfigProperty; +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.sync.common.HoodieSyncConfig; + +import com.beust.jcommander.Parameter; + +/** + * Configs needed to sync data into Alibaba Cloud AnalyticDB(ADB). + */ +public class AdbSyncConfig extends HoodieSyncConfig { + + @Parameter(names = {"--user"}, description = "Adb username", required = true) + public String adbUser; + + @Parameter(names = {"--pass"}, description = "Adb password", required = true) + public String adbPass; + + @Parameter(names = {"--jdbc-url"}, description = "Adb jdbc connect url", required = true) + public String jdbcUrl; + + @Parameter(names = {"--skip-ro-suffix"}, description = "Whether skip the `_ro` suffix for read optimized table when syncing") + public Boolean skipROSuffix; + + @Parameter(names = {"--skip-rt-sync"}, description = "Whether skip the rt table when syncing") + public Boolean skipRTSync; + + @Parameter(names = {"--hive-style-partitioning"}, description = "Whether use hive style partitioning, true if like the following style: field1=value1/field2=value2") + public Boolean useHiveStylePartitioning; + + @Parameter(names = {"--support-timestamp"}, description = "If true, converts int64(timestamp_micros) to timestamp type") + public Boolean supportTimestamp; + + @Parameter(names = {"--spark-datasource"}, description = "Whether sync this table as spark data source table") + public Boolean syncAsSparkDataSourceTable; + + @Parameter(names = {"--table-properties"}, description = "Table properties, to support read hoodie table as datasource table", required = true) + public String tableProperties; + + @Parameter(names = {"--serde-properties"}, description = "Serde properties, to support read hoodie table as datasource table", required = true) + public String serdeProperties; + + @Parameter(names = {"--spark-schema-length-threshold"}, description = "The maximum length allowed in a single cell when storing additional schema information in Hive's metastore") + public int sparkSchemaLengthThreshold; + + @Parameter(names = {"--db-location"}, description = "Database location") + public String dbLocation; + + @Parameter(names = {"--auto-create-database"}, description = "Whether auto create adb database") + public Boolean autoCreateDatabase = true; + + @Parameter(names = {"--skip-last-commit-time-sync"}, description = "Whether skip last commit time syncing") + public Boolean skipLastCommitTimeSync = false; + + @Parameter(names = {"--drop-table-before-creation"}, description = "Whether drop table before creation") + public Boolean dropTableBeforeCreation = false; + + @Parameter(names = {"--help", "-h"}, help = true) + public Boolean help = false; + + public static final ConfigProperty ADB_SYNC_USER = ConfigProperty + .key("hoodie.datasource.adb.sync.username") + .noDefaultValue() + .withDocumentation("ADB username"); + + public static final ConfigProperty ADB_SYNC_PASS = ConfigProperty + .key("hoodie.datasource.adb.sync.password") + .noDefaultValue() + .withDocumentation("ADB user password"); + + public static final ConfigProperty ADB_SYNC_JDBC_URL = ConfigProperty + .key("hoodie.datasource.adb.sync.jdbc_url") + .noDefaultValue() + .withDocumentation("Adb jdbc connect url"); + + public static final ConfigProperty ADB_SYNC_SKIP_RO_SUFFIX = ConfigProperty + .key("hoodie.datasource.adb.sync.skip_ro_suffix") + .defaultValue(true) + .withDocumentation("Whether skip the `_ro` suffix for read optimized table when syncing"); + + public static final ConfigProperty ADB_SYNC_SKIP_RT_SYNC = ConfigProperty + .key("hoodie.datasource.adb.sync.skip_rt_sync") + .defaultValue(true) + .withDocumentation("Whether skip the rt table when syncing"); + + public static final ConfigProperty ADB_SYNC_USE_HIVE_STYLE_PARTITIONING = ConfigProperty + .key("hoodie.datasource.adb.sync.hive_style_partitioning") + .defaultValue(false) + .withDocumentation("Whether use hive style partitioning, true if like the following style: field1=value1/field2=value2"); + + public static final ConfigProperty ADB_SYNC_SUPPORT_TIMESTAMP = ConfigProperty + .key("hoodie.datasource.adb.sync.support_timestamp") + .defaultValue(false) + .withDocumentation("If true, converts int64(timestamp_micros) to timestamp type"); + + public static final ConfigProperty ADB_SYNC_SYNC_AS_SPARK_DATA_SOURCE_TABLE = ConfigProperty + .key("hoodie.datasource.adb.sync.sync_as_spark_datasource") + .defaultValue(true) + .withDocumentation("Whether sync this table as spark data source table"); + + public static final ConfigProperty ADB_SYNC_TABLE_PROPERTIES = ConfigProperty + .key("hoodie.datasource.adb.sync.table_properties") + .noDefaultValue() + .withDocumentation("Table properties, to support read hoodie table as datasource table"); + + public static final ConfigProperty ADB_SYNC_SERDE_PROPERTIES = ConfigProperty + .key("hoodie.datasource.adb.sync.serde_properties") + .noDefaultValue() + .withDocumentation("Serde properties, to support read hoodie table as datasource table"); + + public static final ConfigProperty ADB_SYNC_SCHEMA_STRING_LENGTH_THRESHOLD = ConfigProperty + .key("hoodie.datasource.adb.sync.schema_string_length_threshold") + .defaultValue(4000) + .withDocumentation("The maximum length allowed in a single cell when storing additional schema information in Hive's metastore"); + + public static final ConfigProperty ADB_SYNC_DB_LOCATION = ConfigProperty + .key("hoodie.datasource.adb.sync.db_location") + .noDefaultValue() + .withDocumentation("Database location"); + + public static final ConfigProperty ADB_SYNC_AUTO_CREATE_DATABASE = ConfigProperty + .key("hoodie.datasource.adb.sync.auto_create_database") + .defaultValue(true) + .withDocumentation("Whether auto create adb database"); + + public static final ConfigProperty ADB_SYNC_SKIP_LAST_COMMIT_TIME_SYNC = ConfigProperty + .key("hoodie.datasource.adb.sync.skip_last_commit_time_sync") + .defaultValue(false) + .withDocumentation("Whether skip last commit time syncing"); + + public static final ConfigProperty ADB_SYNC_DROP_TABLE_BEFORE_CREATION = ConfigProperty + .key("hoodie.datasource.adb.sync.drop_table_before_creation") + .defaultValue(false) + .withDocumentation("Whether drop table before creation"); + + public AdbSyncConfig() { + this(new TypedProperties()); + } + + public AdbSyncConfig(TypedProperties props) { + super(props); + + adbUser = getString(ADB_SYNC_USER); + adbPass = getString(ADB_SYNC_PASS); + jdbcUrl = getString(ADB_SYNC_JDBC_URL); + skipROSuffix = getBooleanOrDefault(ADB_SYNC_SKIP_RO_SUFFIX); + skipRTSync = getBooleanOrDefault(ADB_SYNC_SKIP_RT_SYNC); + useHiveStylePartitioning = getBooleanOrDefault(ADB_SYNC_USE_HIVE_STYLE_PARTITIONING); + supportTimestamp = getBooleanOrDefault(ADB_SYNC_SUPPORT_TIMESTAMP); + syncAsSparkDataSourceTable = getBooleanOrDefault(ADB_SYNC_SYNC_AS_SPARK_DATA_SOURCE_TABLE); + tableProperties = getString(ADB_SYNC_TABLE_PROPERTIES); + serdeProperties = getString(ADB_SYNC_SERDE_PROPERTIES); + sparkSchemaLengthThreshold = getIntOrDefault(ADB_SYNC_SCHEMA_STRING_LENGTH_THRESHOLD); + dbLocation = getString(ADB_SYNC_DB_LOCATION); + autoCreateDatabase = getBooleanOrDefault(ADB_SYNC_AUTO_CREATE_DATABASE); + skipLastCommitTimeSync = getBooleanOrDefault(ADB_SYNC_SKIP_LAST_COMMIT_TIME_SYNC); + dropTableBeforeCreation = getBooleanOrDefault(ADB_SYNC_DROP_TABLE_BEFORE_CREATION); + } + + public static TypedProperties toProps(AdbSyncConfig cfg) { + TypedProperties properties = new TypedProperties(); + properties.put(META_SYNC_DATABASE_NAME.key(), cfg.databaseName); + properties.put(META_SYNC_TABLE_NAME.key(), cfg.tableName); + properties.put(ADB_SYNC_USER.key(), cfg.adbUser); + properties.put(ADB_SYNC_PASS.key(), cfg.adbPass); + properties.put(ADB_SYNC_JDBC_URL.key(), cfg.jdbcUrl); + properties.put(META_SYNC_BASE_PATH.key(), cfg.basePath); + properties.put(META_SYNC_PARTITION_FIELDS.key(), String.join(",", cfg.partitionFields)); + properties.put(META_SYNC_PARTITION_EXTRACTOR_CLASS.key(), cfg.partitionValueExtractorClass); + properties.put(META_SYNC_ASSUME_DATE_PARTITION.key(), String.valueOf(cfg.assumeDatePartitioning)); + properties.put(ADB_SYNC_SKIP_RO_SUFFIX.key(), String.valueOf(cfg.skipROSuffix)); + properties.put(ADB_SYNC_SKIP_RT_SYNC.key(), String.valueOf(cfg.skipRTSync)); + properties.put(ADB_SYNC_USE_HIVE_STYLE_PARTITIONING.key(), String.valueOf(cfg.useHiveStylePartitioning)); + properties.put(META_SYNC_USE_FILE_LISTING_FROM_METADATA.key(), String.valueOf(cfg.useFileListingFromMetadata)); + properties.put(ADB_SYNC_SUPPORT_TIMESTAMP.key(), String.valueOf(cfg.supportTimestamp)); + properties.put(ADB_SYNC_TABLE_PROPERTIES.key(), cfg.tableProperties); + properties.put(ADB_SYNC_SERDE_PROPERTIES.key(), cfg.serdeProperties); + properties.put(ADB_SYNC_SYNC_AS_SPARK_DATA_SOURCE_TABLE.key(), String.valueOf(cfg.syncAsSparkDataSourceTable)); + properties.put(ADB_SYNC_SCHEMA_STRING_LENGTH_THRESHOLD.key(), String.valueOf(cfg.sparkSchemaLengthThreshold)); + properties.put(META_SYNC_SPARK_VERSION.key(), cfg.sparkVersion); + properties.put(ADB_SYNC_DB_LOCATION.key(), cfg.dbLocation); + properties.put(ADB_SYNC_AUTO_CREATE_DATABASE.key(), String.valueOf(cfg.autoCreateDatabase)); + properties.put(ADB_SYNC_SKIP_LAST_COMMIT_TIME_SYNC.key(), String.valueOf(cfg.skipLastCommitTimeSync)); + properties.put(ADB_SYNC_DROP_TABLE_BEFORE_CREATION.key(), String.valueOf(cfg.dropTableBeforeCreation)); + + return properties; + } + + @Override + public String toString() { + return "AdbSyncConfig{" + + "adbUser='" + adbUser + '\'' + + ", adbPass='" + adbPass + '\'' + + ", jdbcUrl='" + jdbcUrl + '\'' + + ", skipROSuffix=" + skipROSuffix + + ", skipRTSync=" + skipRTSync + + ", useHiveStylePartitioning=" + useHiveStylePartitioning + + ", supportTimestamp=" + supportTimestamp + + ", syncAsSparkDataSourceTable=" + syncAsSparkDataSourceTable + + ", tableProperties='" + tableProperties + '\'' + + ", serdeProperties='" + serdeProperties + '\'' + + ", sparkSchemaLengthThreshold=" + sparkSchemaLengthThreshold + + ", dbLocation='" + dbLocation + '\'' + + ", autoCreateDatabase=" + autoCreateDatabase + + ", skipLastCommitTimeSync=" + skipLastCommitTimeSync + + ", dropTableBeforeCreation=" + dropTableBeforeCreation + + ", help=" + help + + ", databaseName='" + databaseName + '\'' + + ", tableName='" + tableName + '\'' + + ", basePath='" + basePath + '\'' + + ", baseFileFormat='" + baseFileFormat + '\'' + + ", partitionFields=" + partitionFields + + ", partitionValueExtractorClass='" + partitionValueExtractorClass + '\'' + + ", assumeDatePartitioning=" + assumeDatePartitioning + + ", decodePartition=" + decodePartition + + ", useFileListingFromMetadata=" + useFileListingFromMetadata + + ", isConditionalSync=" + isConditionalSync + + ", sparkVersion='" + sparkVersion + '\'' + + '}'; + } +} diff --git a/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AdbSyncTool.java b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AdbSyncTool.java new file mode 100644 index 0000000000000..8c2f9e20451ca --- /dev/null +++ b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/AdbSyncTool.java @@ -0,0 +1,283 @@ +/* + * 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.hudi.sync.adb; + +import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.model.HoodieFileFormat; +import org.apache.hudi.common.model.HoodieTableType; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.hadoop.utils.HoodieInputFormatUtils; +import org.apache.hudi.hive.SchemaDifference; +import org.apache.hudi.hive.util.HiveSchemaUtil; +import org.apache.hudi.sync.common.AbstractSyncHoodieClient.PartitionEvent; +import org.apache.hudi.sync.common.AbstractSyncHoodieClient.PartitionEvent.PartitionEventType; +import org.apache.hudi.sync.common.AbstractSyncTool; +import org.apache.hudi.sync.common.util.ConfigUtils; + +import com.beust.jcommander.JCommander; +import org.apache.hadoop.conf.Configuration; +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat; +import org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe; +import org.apache.parquet.schema.MessageType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Adb sync tool is mainly used to sync hoodie tables to Alibaba Cloud AnalyticDB(ADB), + * it can be used as API `AdbSyncTool.syncHoodieTable(AdbSyncConfig)` or as command + * line `java -cp hoodie-hive.jar AdbSyncTool [args]` + * + *

+ * This utility will get the schema from the latest commit and will sync ADB table schema, + * incremental partitions will be synced as well. + */ +@SuppressWarnings("WeakerAccess") +public class AdbSyncTool extends AbstractSyncTool { + private static final Logger LOG = LoggerFactory.getLogger(AdbSyncTool.class); + + public static final String SUFFIX_SNAPSHOT_TABLE = "_rt"; + public static final String SUFFIX_READ_OPTIMIZED_TABLE = "_ro"; + + private final AdbSyncConfig adbSyncConfig; + private final AbstractAdbSyncHoodieClient hoodieAdbClient; + private final String snapshotTableName; + private final Option roTableTableName; + + public AdbSyncTool(TypedProperties props, Configuration conf, FileSystem fs) { + super(props, conf, fs); + this.adbSyncConfig = new AdbSyncConfig(props); + this.hoodieAdbClient = getHoodieAdbClient(adbSyncConfig, fs); + switch (hoodieAdbClient.getTableType()) { + case COPY_ON_WRITE: + this.snapshotTableName = adbSyncConfig.tableName; + this.roTableTableName = Option.empty(); + break; + case MERGE_ON_READ: + this.snapshotTableName = adbSyncConfig.tableName + SUFFIX_SNAPSHOT_TABLE; + this.roTableTableName = adbSyncConfig.skipROSuffix ? Option.of(adbSyncConfig.tableName) + : Option.of(adbSyncConfig.tableName + SUFFIX_READ_OPTIMIZED_TABLE); + break; + default: + throw new HoodieAdbSyncException("Unknown table type:" + hoodieAdbClient.getTableType() + + ", basePath:" + hoodieAdbClient.getBasePath()); + } + } + + private AbstractAdbSyncHoodieClient getHoodieAdbClient(AdbSyncConfig adbSyncConfig, FileSystem fs) { + return new HoodieAdbJdbcClient(adbSyncConfig, fs); + } + + @Override + public void syncHoodieTable() { + try { + switch (hoodieAdbClient.getTableType()) { + case COPY_ON_WRITE: + syncHoodieTable(snapshotTableName, false, false); + break; + case MERGE_ON_READ: + // Sync a ro table for MOR table + syncHoodieTable(roTableTableName.get(), false, true); + // Sync a rt table for MOR table + if (!adbSyncConfig.skipRTSync) { + syncHoodieTable(snapshotTableName, true, false); + } + break; + default: + throw new HoodieAdbSyncException("Unknown table type:" + hoodieAdbClient.getTableType() + + ", basePath:" + hoodieAdbClient.getBasePath()); + } + } catch (Exception re) { + throw new HoodieAdbSyncException("Sync hoodie table to ADB failed, tableName:" + adbSyncConfig.tableName, re); + } finally { + hoodieAdbClient.close(); + } + } + + private void syncHoodieTable(String tableName, boolean useRealtimeInputFormat, + boolean readAsOptimized) throws Exception { + LOG.info("Try to sync hoodie table, tableName:{}, path:{}, tableType:{}", + tableName, hoodieAdbClient.getBasePath(), hoodieAdbClient.getTableType()); + + if (adbSyncConfig.autoCreateDatabase) { + try { + synchronized (AdbSyncTool.class) { + if (!hoodieAdbClient.databaseExists(adbSyncConfig.databaseName)) { + hoodieAdbClient.createDatabase(adbSyncConfig.databaseName); + } + } + } catch (Exception e) { + throw new HoodieAdbSyncException("Failed to create database:" + adbSyncConfig.databaseName + + ", useRealtimeInputFormat = " + useRealtimeInputFormat, e); + } + } else if (!hoodieAdbClient.databaseExists(adbSyncConfig.databaseName)) { + throw new HoodieAdbSyncException("ADB database does not exists:" + adbSyncConfig.databaseName); + } + + // Currently HoodieBootstrapRelation does support reading bootstrap MOR rt table, + // so we disable the syncAsSparkDataSourceTable here to avoid read such kind table + // by the data source way (which will use the HoodieBootstrapRelation). + // TODO after we support bootstrap MOR rt table in HoodieBootstrapRelation[HUDI-2071], + // we can remove this logical. + if (hoodieAdbClient.isBootstrap() + && hoodieAdbClient.getTableType() == HoodieTableType.MERGE_ON_READ + && !readAsOptimized) { + adbSyncConfig.syncAsSparkDataSourceTable = false; + LOG.info("Disable sync as spark datasource table for mor rt table:{}", tableName); + } + + if (adbSyncConfig.dropTableBeforeCreation) { + LOG.info("Drop table before creation, tableName:{}", tableName); + hoodieAdbClient.dropTable(tableName); + } + + boolean tableExists = hoodieAdbClient.tableExists(tableName); + + // Get the parquet schema for this table looking at the latest commit + MessageType schema = hoodieAdbClient.getDataSchema(); + + // Sync schema if needed + syncSchema(tableName, tableExists, useRealtimeInputFormat, readAsOptimized, schema); + LOG.info("Sync schema complete, start syncing partitions for table:{}", tableName); + + // Get the last time we successfully synced partitions + Option lastCommitTimeSynced = Option.empty(); + if (tableExists) { + lastCommitTimeSynced = hoodieAdbClient.getLastCommitTimeSynced(tableName); + } + LOG.info("Last commit time synced was found:{}", lastCommitTimeSynced.orElse("null")); + + // Scan synced partitions + List writtenPartitionsSince; + if (adbSyncConfig.partitionFields.isEmpty()) { + writtenPartitionsSince = new ArrayList<>(); + } else { + writtenPartitionsSince = hoodieAdbClient.getPartitionsWrittenToSince(lastCommitTimeSynced); + } + LOG.info("Scan partitions complete, partitionNum:{}", writtenPartitionsSince.size()); + + // Sync the partitions if needed + syncPartitions(tableName, writtenPartitionsSince); + + // Update sync commit time + // whether to skip syncing commit time stored in tbl properties, since it is time consuming. + if (!adbSyncConfig.skipLastCommitTimeSync) { + hoodieAdbClient.updateLastCommitTimeSynced(tableName); + } + LOG.info("Sync complete for table:{}", tableName); + } + + /** + * Get the latest schema from the last commit and check if its in sync with the ADB + * table schema. If not, evolves the table schema. + * + * @param tableName The table to be synced + * @param tableExists Whether target table exists + * @param useRealTimeInputFormat Whether using realtime input format + * @param readAsOptimized Whether read as optimized table + * @param schema The extracted schema + */ + private void syncSchema(String tableName, boolean tableExists, boolean useRealTimeInputFormat, + boolean readAsOptimized, MessageType schema) throws Exception { + // Append spark table properties & serde properties + Map tableProperties = ConfigUtils.toMap(adbSyncConfig.tableProperties); + Map serdeProperties = ConfigUtils.toMap(adbSyncConfig.serdeProperties); + if (adbSyncConfig.syncAsSparkDataSourceTable) { + Map sparkTableProperties = getSparkTableProperties(adbSyncConfig.partitionFields, + adbSyncConfig.sparkVersion, adbSyncConfig.sparkSchemaLengthThreshold, schema); + Map sparkSerdeProperties = getSparkSerdeProperties(readAsOptimized, adbSyncConfig.basePath); + tableProperties.putAll(sparkTableProperties); + serdeProperties.putAll(sparkSerdeProperties); + LOG.info("Sync as spark datasource table, tableName:{}, tableExists:{}, tableProperties:{}, sederProperties:{}", + tableName, tableExists, tableProperties, serdeProperties); + } + + // Check and sync schema + if (!tableExists) { + LOG.info("ADB table [{}] is not found, creating it", tableName); + String inputFormatClassName = HoodieInputFormatUtils.getInputFormatClassName(HoodieFileFormat.PARQUET, useRealTimeInputFormat); + + // Custom serde will not work with ALTER TABLE REPLACE COLUMNS + // https://github.com/apache/hive/blob/release-1.1.0/ql/src/java/org/apache/hadoop/hive + // /ql/exec/DDLTask.java#L3488 + hoodieAdbClient.createTable(tableName, schema, inputFormatClassName, MapredParquetOutputFormat.class.getName(), + ParquetHiveSerDe.class.getName(), serdeProperties, tableProperties); + } else { + // Check if the table schema has evolved + Map tableSchema = hoodieAdbClient.getTableSchema(tableName); + SchemaDifference schemaDiff = HiveSchemaUtil.getSchemaDifference(schema, tableSchema, adbSyncConfig.partitionFields, + adbSyncConfig.supportTimestamp); + if (!schemaDiff.isEmpty()) { + LOG.info("Schema difference found for table:{}", tableName); + hoodieAdbClient.updateTableDefinition(tableName, schemaDiff); + } else { + LOG.info("No Schema difference for table:{}", tableName); + } + } + } + + /** + * Syncs the list of storage partitions passed in (checks if the partition is in adb, if not adds it or if the + * partition path does not match, it updates the partition path). + */ + private void syncPartitions(String tableName, List writtenPartitionsSince) { + try { + if (adbSyncConfig.partitionFields.isEmpty()) { + LOG.info("Not a partitioned table."); + return; + } + + Map, String> partitions = hoodieAdbClient.scanTablePartitions(tableName); + List partitionEvents = hoodieAdbClient.getPartitionEvents(partitions, writtenPartitionsSince); + List newPartitions = filterPartitions(partitionEvents, PartitionEventType.ADD); + LOG.info("New Partitions:{}", newPartitions); + hoodieAdbClient.addPartitionsToTable(tableName, newPartitions); + List updatePartitions = filterPartitions(partitionEvents, PartitionEventType.UPDATE); + LOG.info("Changed Partitions:{}", updatePartitions); + hoodieAdbClient.updatePartitionsToTable(tableName, updatePartitions); + } catch (Exception e) { + throw new HoodieAdbSyncException("Failed to sync partitions for table:" + tableName, e); + } + } + + private List filterPartitions(List events, PartitionEventType eventType) { + return events.stream().filter(s -> s.eventType == eventType) + .map(s -> s.storagePartition).collect(Collectors.toList()); + } + + public static void main(String[] args) { + // parse the params + final AdbSyncConfig cfg = new AdbSyncConfig(); + JCommander cmd = new JCommander(cfg, null, args); + if (cfg.help || args.length == 0) { + cmd.usage(); + System.exit(1); + } + + Configuration hadoopConf = new Configuration(); + FileSystem fs = FSUtils.getFs(cfg.basePath, hadoopConf); + new AdbSyncTool(AdbSyncConfig.toProps(cfg), hadoopConf, fs).syncHoodieTable(); + } +} diff --git a/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/HoodieAdbJdbcClient.java b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/HoodieAdbJdbcClient.java new file mode 100644 index 0000000000000..a347ba701110d --- /dev/null +++ b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/HoodieAdbJdbcClient.java @@ -0,0 +1,440 @@ +/* + * 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.hudi.sync.adb; + +import org.apache.hudi.common.fs.FSUtils; +import org.apache.hudi.common.util.Option; +import org.apache.hudi.common.util.StringUtils; +import org.apache.hudi.common.util.ValidationUtils; +import org.apache.hudi.exception.HoodieException; +import org.apache.hudi.hive.HiveSyncConfig; +import org.apache.hudi.hive.HoodieHiveSyncException; +import org.apache.hudi.hive.SchemaDifference; +import org.apache.hudi.hive.util.HiveSchemaUtil; + +import org.apache.hadoop.fs.FileSystem; +import org.apache.hadoop.fs.Path; +import org.apache.parquet.schema.MessageType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public class HoodieAdbJdbcClient extends AbstractAdbSyncHoodieClient { + private static final Logger LOG = LoggerFactory.getLogger(HoodieAdbJdbcClient.class); + + public static final String HOODIE_LAST_COMMIT_TIME_SYNC = "hoodie_last_sync"; + // Make sure we have the jdbc driver in classpath + private static final String DRIVER_NAME = "com.mysql.jdbc.Driver"; + public static final String ADB_ESCAPE_CHARACTER = ""; + private static final String TBL_PROPERTIES_STR = "TBLPROPERTIES"; + + static { + try { + Class.forName(DRIVER_NAME); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Could not find " + DRIVER_NAME + " in classpath. ", e); + } + } + + private Connection connection; + + public HoodieAdbJdbcClient(AdbSyncConfig syncConfig, FileSystem fs) { + super(syncConfig, fs); + createAdbConnection(); + LOG.info("Init adb jdbc client success, jdbcUrl:{}", syncConfig.jdbcUrl); + } + + private void createAdbConnection() { + if (connection == null) { + try { + Class.forName(DRIVER_NAME); + } catch (ClassNotFoundException e) { + LOG.error("Unable to load jdbc driver class", e); + return; + } + try { + this.connection = DriverManager.getConnection( + adbSyncConfig.jdbcUrl, adbSyncConfig.adbUser, adbSyncConfig.adbPass); + } catch (SQLException e) { + throw new HoodieException("Cannot create adb connection ", e); + } + } + } + + @Override + public void createTable(String tableName, MessageType storageSchema, String inputFormatClass, + String outputFormatClass, String serdeClass, + Map serdeProperties, Map tableProperties) { + try { + LOG.info("Creating table:{}", tableName); + String createSQLQuery = HiveSchemaUtil.generateCreateDDL(tableName, storageSchema, + getHiveSyncConfig(), inputFormatClass, outputFormatClass, serdeClass, serdeProperties, tableProperties); + executeAdbSql(createSQLQuery); + } catch (IOException e) { + throw new HoodieException("Fail to create table:" + tableName, e); + } + } + + @Override + public void dropTable(String tableName) { + LOG.info("Dropping table:{}", tableName); + String dropTable = "drop table if exists `" + adbSyncConfig.databaseName + "`.`" + tableName + "`"; + executeAdbSql(dropTable); + } + + public Map getTableSchema(String tableName) { + Map schema = new HashMap<>(); + ResultSet result = null; + try { + DatabaseMetaData databaseMetaData = connection.getMetaData(); + result = databaseMetaData.getColumns(adbSyncConfig.databaseName, + adbSyncConfig.databaseName, tableName, null); + while (result.next()) { + String columnName = result.getString(4); + String columnType = result.getString(6); + if ("DECIMAL".equals(columnType)) { + int columnSize = result.getInt("COLUMN_SIZE"); + int decimalDigits = result.getInt("DECIMAL_DIGITS"); + columnType += String.format("(%s,%s)", columnSize, decimalDigits); + } + schema.put(columnName, columnType); + } + return schema; + } catch (SQLException e) { + throw new HoodieException("Fail to get table schema:" + tableName, e); + } finally { + closeQuietly(result, null); + } + } + + @Override + public void addPartitionsToTable(String tableName, List partitionsToAdd) { + if (partitionsToAdd.isEmpty()) { + LOG.info("No partitions to add for table:{}", tableName); + return; + } + + LOG.info("Adding partitions to table:{}, partitionNum:{}", tableName, partitionsToAdd.size()); + String sql = constructAddPartitionsSql(tableName, partitionsToAdd); + executeAdbSql(sql); + } + + private void executeAdbSql(String sql) { + Statement stmt = null; + try { + stmt = connection.createStatement(); + LOG.info("Executing sql:{}", sql); + stmt.execute(sql); + } catch (SQLException e) { + throw new HoodieException("Fail to execute sql:" + sql, e); + } finally { + closeQuietly(null, stmt); + } + } + + private T executeQuerySQL(String sql, Function function) { + Statement stmt = null; + try { + stmt = connection.createStatement(); + LOG.info("Executing sql:{}", sql); + return function.apply(stmt.executeQuery(sql)); + } catch (SQLException e) { + throw new HoodieException("Fail to execute sql:" + sql, e); + } finally { + closeQuietly(null, stmt); + } + } + + public void createDatabase(String databaseName) { + String rootPath = getDatabasePath(); + LOG.info("Creating database:{}, databaseLocation:{}", databaseName, rootPath); + String sql = constructCreateDatabaseSql(rootPath); + executeAdbSql(sql); + } + + public boolean databaseExists(String databaseName) { + String sql = constructShowCreateDatabaseSql(databaseName); + Function transform = resultSet -> { + try { + return resultSet.next(); + } catch (Exception e) { + if (e.getMessage().contains("Unknown database `" + databaseName + "`")) { + return false; + } else { + throw new HoodieException("Fail to execute sql:" + sql, e); + } + } + }; + return executeQuerySQL(sql, transform); + } + + @Override + public boolean doesTableExist(String tableName) { + String sql = constructShowLikeTableSql(tableName); + Function transform = resultSet -> { + try { + return resultSet.next(); + } catch (Exception e) { + throw new HoodieException("Fail to execute sql:" + sql, e); + } + }; + return executeQuerySQL(sql, transform); + } + + @Override + public boolean tableExists(String tableName) { + return doesTableExist(tableName); + } + + @Override + public Option getLastCommitTimeSynced(String tableName) { + String sql = constructShowCreateTableSql(tableName); + + Function> transform = resultSet -> { + try { + if (resultSet.next()) { + String table = resultSet.getString(2); + Map attr = new HashMap<>(); + int index = table.indexOf(TBL_PROPERTIES_STR); + if (index != -1) { + String sub = table.substring(index + TBL_PROPERTIES_STR.length()); + sub = sub + .replaceAll("\\(", "") + .replaceAll("\\)", "") + .replaceAll("'", ""); + String[] str = sub.split(","); + + for (String s : str) { + String key = s.split("=")[0].trim(); + String value = s.split("=")[1].trim(); + attr.put(key, value); + } + } + return Option.ofNullable(attr.getOrDefault(HOODIE_LAST_COMMIT_TIME_SYNC, null)); + } + return Option.empty(); + } catch (Exception e) { + throw new HoodieException("Fail to execute sql:" + sql, e); + } + }; + return executeQuerySQL(sql, transform); + } + + @Override + public void updateLastCommitTimeSynced(String tableName) { + // Set the last commit time from the TBLProperties + String lastCommitSynced = activeTimeline.lastInstant().get().getTimestamp(); + try { + String sql = constructUpdateTblPropertiesSql(tableName, lastCommitSynced); + executeAdbSql(sql); + } catch (Exception e) { + throw new HoodieHiveSyncException("Fail to get update last commit time synced:" + lastCommitSynced, e); + } + } + + @Override + public Option getLastReplicatedTime(String tableName) { + throw new UnsupportedOperationException("Not support getLastReplicatedTime yet"); + } + + @Override + public void updateLastReplicatedTimeStamp(String tableName, String timeStamp) { + throw new UnsupportedOperationException("Not support updateLastReplicatedTimeStamp yet"); + } + + @Override + public void deleteLastReplicatedTimeStamp(String tableName) { + throw new UnsupportedOperationException("Not support deleteLastReplicatedTimeStamp yet"); + } + + @Override + public void updatePartitionsToTable(String tableName, List changedPartitions) { + if (changedPartitions.isEmpty()) { + LOG.info("No partitions to change for table:{}", tableName); + return; + } + + LOG.info("Changing partitions on table:{}, changedPartitionNum:{}", tableName, changedPartitions.size()); + List sqlList = constructChangePartitionsSql(tableName, changedPartitions); + for (String sql : sqlList) { + executeAdbSql(sql); + } + } + + @Override + public void dropPartitions(String tableName, List partitionsToDrop) { + throw new UnsupportedOperationException("Not support dropPartitions yet."); + } + + public Map, String> scanTablePartitions(String tableName) { + String sql = constructShowPartitionSql(tableName); + Function, String>> transform = resultSet -> { + Map, String> partitions = new HashMap<>(); + try { + while (resultSet.next()) { + if (resultSet.getMetaData().getColumnCount() > 0) { + String str = resultSet.getString(1); + if (!StringUtils.isNullOrEmpty(str)) { + List values = partitionValueExtractor.extractPartitionValuesInPath(str); + Path storagePartitionPath = FSUtils.getPartitionPath(adbSyncConfig.basePath, String.join("/", values)); + String fullStoragePartitionPath = Path.getPathWithoutSchemeAndAuthority(storagePartitionPath).toUri().getPath(); + partitions.put(values, fullStoragePartitionPath); + } + } + } + } catch (Exception e) { + throw new HoodieException("Fail to execute sql:" + sql, e); + } + return partitions; + }; + return executeQuerySQL(sql, transform); + } + + public void updateTableDefinition(String tableName, SchemaDifference schemaDiff) { + LOG.info("Adding columns for table:{}", tableName); + schemaDiff.getAddColumnTypes().forEach((columnName, columnType) -> + executeAdbSql(constructAddColumnSql(tableName, columnName, columnType)) + ); + + LOG.info("Updating columns' definition for table:{}", tableName); + schemaDiff.getUpdateColumnTypes().forEach((columnName, columnType) -> + executeAdbSql(constructChangeColumnSql(tableName, columnName, columnType)) + ); + } + + private String constructAddPartitionsSql(String tableName, List partitions) { + StringBuilder sqlBuilder = new StringBuilder("alter table `"); + sqlBuilder.append(adbSyncConfig.databaseName).append("`").append(".`") + .append(tableName).append("`").append(" add if not exists "); + for (String partition : partitions) { + String partitionClause = getPartitionClause(partition); + Path partitionPath = FSUtils.getPartitionPath(adbSyncConfig.basePath, partition); + String fullPartitionPathStr = generateAbsolutePathStr(partitionPath); + sqlBuilder.append(" partition (").append(partitionClause).append(") location '") + .append(fullPartitionPathStr).append("' "); + } + + return sqlBuilder.toString(); + } + + private List constructChangePartitionsSql(String tableName, List partitions) { + List changePartitions = new ArrayList<>(); + String useDatabase = "use `" + adbSyncConfig.databaseName + "`"; + changePartitions.add(useDatabase); + + String alterTable = "alter table `" + tableName + "`"; + for (String partition : partitions) { + String partitionClause = getPartitionClause(partition); + Path partitionPath = FSUtils.getPartitionPath(adbSyncConfig.basePath, partition); + String fullPartitionPathStr = generateAbsolutePathStr(partitionPath); + String changePartition = alterTable + " add if not exists partition (" + partitionClause + + ") location '" + fullPartitionPathStr + "'"; + changePartitions.add(changePartition); + } + + return changePartitions; + } + + /** + * Generate Hive Partition from partition values. + * + * @param partition Partition path + * @return partition clause + */ + private String getPartitionClause(String partition) { + List partitionValues = partitionValueExtractor.extractPartitionValuesInPath(partition); + ValidationUtils.checkArgument(adbSyncConfig.partitionFields.size() == partitionValues.size(), + "Partition key parts " + adbSyncConfig.partitionFields + + " does not match with partition values " + partitionValues + ". Check partition strategy. "); + List partBuilder = new ArrayList<>(); + for (int i = 0; i < adbSyncConfig.partitionFields.size(); i++) { + partBuilder.add(adbSyncConfig.partitionFields.get(i) + "='" + partitionValues.get(i) + "'"); + } + + return String.join(",", partBuilder); + } + + private String constructShowPartitionSql(String tableName) { + return String.format("show partitions `%s`.`%s`", adbSyncConfig.databaseName, tableName); + } + + private String constructShowCreateTableSql(String tableName) { + return String.format("show create table `%s`.`%s`", adbSyncConfig.databaseName, tableName); + } + + private String constructShowLikeTableSql(String tableName) { + return String.format("show tables from `%s` like '%s'", adbSyncConfig.databaseName, tableName); + } + + private String constructCreateDatabaseSql(String rootPath) { + return String.format("create database if not exists `%s` with dbproperties(catalog = 'oss', location = '%s')", + adbSyncConfig.databaseName, rootPath); + } + + private String constructShowCreateDatabaseSql(String databaseName) { + return String.format("show create database `%s`", databaseName); + } + + private String constructUpdateTblPropertiesSql(String tableName, String lastCommitSynced) { + return String.format("alter table `%s`.`%s` set tblproperties('%s' = '%s')", + adbSyncConfig.databaseName, tableName, HOODIE_LAST_COMMIT_TIME_SYNC, lastCommitSynced); + } + + private String constructAddColumnSql(String tableName, String columnName, String columnType) { + return String.format("alter table `%s`.`%s` add columns(`%s` %s)", + adbSyncConfig.databaseName, tableName, columnName, columnType); + } + + private String constructChangeColumnSql(String tableName, String columnName, String columnType) { + return String.format("alter table `%s`.`%s` change `%s` `%s` %s", + adbSyncConfig.databaseName, tableName, columnName, columnName, columnType); + } + + private HiveSyncConfig getHiveSyncConfig() { + HiveSyncConfig hiveSyncConfig = new HiveSyncConfig(); + hiveSyncConfig.partitionFields = adbSyncConfig.partitionFields; + hiveSyncConfig.databaseName = adbSyncConfig.databaseName; + Path basePath = new Path(adbSyncConfig.basePath); + hiveSyncConfig.basePath = generateAbsolutePathStr(basePath); + return hiveSyncConfig; + } + + @Override + public void close() { + try { + if (connection != null) { + connection.close(); + } + } catch (SQLException e) { + LOG.error("Fail to close connection", e); + } + } +} diff --git a/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/HoodieAdbSyncException.java b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/HoodieAdbSyncException.java new file mode 100644 index 0000000000000..0deb9b94cd525 --- /dev/null +++ b/hudi-sync/hudi-adb-sync/src/main/java/org/apache/hudi/sync/adb/HoodieAdbSyncException.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. + */ + +package org.apache.hudi.sync.adb; + +public class HoodieAdbSyncException extends RuntimeException { + public HoodieAdbSyncException(String message) { + super(message); + } + + public HoodieAdbSyncException(String message, Throwable t) { + super(message, t); + } +} diff --git a/hudi-sync/hudi-adb-sync/src/test/java/org/apache/hudi/sync/adb/TestAdbSyncConfig.java b/hudi-sync/hudi-adb-sync/src/test/java/org/apache/hudi/sync/adb/TestAdbSyncConfig.java new file mode 100644 index 0000000000000..f4eb8fc7fc453 --- /dev/null +++ b/hudi-sync/hudi-adb-sync/src/test/java/org/apache/hudi/sync/adb/TestAdbSyncConfig.java @@ -0,0 +1,65 @@ +/* + * 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.hudi.sync.adb; + +import org.apache.hudi.common.config.TypedProperties; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +public class TestAdbSyncConfig { + @Test + public void testCopy() { + AdbSyncConfig adbSyncConfig = new AdbSyncConfig(); + adbSyncConfig.partitionFields = Arrays.asList("a", "b"); + adbSyncConfig.basePath = "/tmp"; + adbSyncConfig.assumeDatePartitioning = true; + adbSyncConfig.databaseName = "test"; + adbSyncConfig.tableName = "test"; + adbSyncConfig.adbUser = "adb"; + adbSyncConfig.adbPass = "adb"; + adbSyncConfig.jdbcUrl = "jdbc:mysql://localhost:3306"; + adbSyncConfig.skipROSuffix = false; + adbSyncConfig.tableProperties = "spark.sql.sources.provider= 'hudi'\\n" + + "spark.sql.sources.schema.numParts = '1'\\n " + + "spark.sql.sources.schema.part.0 ='xx'\\n " + + "spark.sql.sources.schema.numPartCols = '1'\\n" + + "spark.sql.sources.schema.partCol.0 = 'dt'"; + adbSyncConfig.serdeProperties = "'path'='/tmp/test_db/tbl'"; + adbSyncConfig.dbLocation = "file://tmp/test_db"; + + TypedProperties props = AdbSyncConfig.toProps(adbSyncConfig); + AdbSyncConfig copied = new AdbSyncConfig(props); + + assertEquals(copied.partitionFields, adbSyncConfig.partitionFields); + assertEquals(copied.basePath, adbSyncConfig.basePath); + assertEquals(copied.assumeDatePartitioning, adbSyncConfig.assumeDatePartitioning); + assertEquals(copied.databaseName, adbSyncConfig.databaseName); + assertEquals(copied.tableName, adbSyncConfig.tableName); + assertEquals(copied.adbUser, adbSyncConfig.adbUser); + assertEquals(copied.adbPass, adbSyncConfig.adbPass); + assertEquals(copied.basePath, adbSyncConfig.basePath); + assertEquals(copied.jdbcUrl, adbSyncConfig.jdbcUrl); + assertEquals(copied.skipROSuffix, adbSyncConfig.skipROSuffix); + assertEquals(copied.supportTimestamp, adbSyncConfig.supportTimestamp); + } +} diff --git a/hudi-sync/hudi-dla-sync/src/test/resources/log4j-surefire-quiet.properties b/hudi-sync/hudi-adb-sync/src/test/resources/log4j-surefire-quiet.properties similarity index 100% rename from hudi-sync/hudi-dla-sync/src/test/resources/log4j-surefire-quiet.properties rename to hudi-sync/hudi-adb-sync/src/test/resources/log4j-surefire-quiet.properties diff --git a/hudi-sync/hudi-dla-sync/src/test/resources/log4j-surefire.properties b/hudi-sync/hudi-adb-sync/src/test/resources/log4j-surefire.properties similarity index 100% rename from hudi-sync/hudi-dla-sync/src/test/resources/log4j-surefire.properties rename to hudi-sync/hudi-adb-sync/src/test/resources/log4j-surefire.properties diff --git a/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/DLASyncConfig.java b/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/DLASyncConfig.java deleted file mode 100644 index d4d580fe276af..0000000000000 --- a/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/DLASyncConfig.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * 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.hudi.dla; - -import org.apache.hudi.common.config.HoodieMetadataConfig; -import org.apache.hudi.hive.SlashEncodedDayPartitionValueExtractor; - -import com.beust.jcommander.Parameter; - -import java.io.Serializable; -import java.util.ArrayList; -import java.util.List; - -/** - * Configs needed to sync data into DLA. - */ -public class DLASyncConfig implements Serializable { - - @Parameter(names = {"--database"}, description = "name of the target database in DLA", required = true) - public String databaseName; - - @Parameter(names = {"--table"}, description = "name of the target table in DLA", required = true) - public String tableName; - - @Parameter(names = {"--user"}, description = "DLA username", required = true) - public String dlaUser; - - @Parameter(names = {"--pass"}, description = "DLA password", required = true) - public String dlaPass; - - @Parameter(names = {"--jdbc-url"}, description = "DLA jdbc connect url", required = true) - public String jdbcUrl; - - @Parameter(names = {"--base-path"}, description = "Basepath of hoodie table to sync", required = true) - public String basePath; - - @Parameter(names = "--partitioned-by", description = "Fields in the schema partitioned by") - public List partitionFields = new ArrayList<>(); - - @Parameter(names = "--partition-value-extractor", description = "Class which implements PartitionValueExtractor " - + "to extract the partition values from HDFS path") - public String partitionValueExtractorClass = SlashEncodedDayPartitionValueExtractor.class.getName(); - - @Parameter(names = {"--assume-date-partitioning"}, description = "Assume standard yyyy/mm/dd partitioning, this" - + " exists to support backward compatibility. If you use hoodie 0.3.x, do not set this parameter") - public Boolean assumeDatePartitioning = false; - - @Parameter(names = {"--skip-ro-suffix"}, description = "Skip the `_ro` suffix for Read optimized table, when registering") - public Boolean skipROSuffix = false; - - @Parameter(names = {"--skip-rt-sync"}, description = "Skip the RT table syncing") - public Boolean skipRTSync = false; - - @Parameter(names = {"--hive-style-partitioning"}, description = "Use DLA hive style partitioning, true if like the following style: field1=value1/field2=value2") - public Boolean useDLASyncHiveStylePartitioning = false; - - @Parameter(names = {"--use-file-listing-from-metadata"}, description = "Fetch file listing from Hudi's metadata") - public Boolean useFileListingFromMetadata = HoodieMetadataConfig.DEFAULT_METADATA_ENABLE_FOR_READERS; - - @Parameter(names = {"--help", "-h"}, help = true) - public Boolean help = false; - - @Parameter(names = {"--support-timestamp"}, description = "If true, converts int64(timestamp_micros) to timestamp type") - public Boolean supportTimestamp = false; - - public static DLASyncConfig copy(DLASyncConfig cfg) { - DLASyncConfig newConfig = new DLASyncConfig(); - newConfig.databaseName = cfg.databaseName; - newConfig.tableName = cfg.tableName; - newConfig.dlaUser = cfg.dlaUser; - newConfig.dlaPass = cfg.dlaPass; - newConfig.jdbcUrl = cfg.jdbcUrl; - newConfig.basePath = cfg.basePath; - newConfig.partitionFields = cfg.partitionFields; - newConfig.partitionValueExtractorClass = cfg.partitionValueExtractorClass; - newConfig.assumeDatePartitioning = cfg.assumeDatePartitioning; - newConfig.skipROSuffix = cfg.skipROSuffix; - newConfig.skipRTSync = cfg.skipRTSync; - newConfig.useDLASyncHiveStylePartitioning = cfg.useDLASyncHiveStylePartitioning; - newConfig.useFileListingFromMetadata = cfg.useFileListingFromMetadata; - newConfig.supportTimestamp = cfg.supportTimestamp; - return newConfig; - } - - @Override - public String toString() { - return "DLASyncConfig{databaseName='" + databaseName + '\'' + ", tableName='" + tableName + '\'' - + ", dlaUser='" + dlaUser + '\'' + ", dlaPass='" + dlaPass + '\'' + ", jdbcUrl='" + jdbcUrl + '\'' - + ", basePath='" + basePath + '\'' + ", partitionFields=" + partitionFields + ", partitionValueExtractorClass='" - + partitionValueExtractorClass + '\'' + ", assumeDatePartitioning=" + assumeDatePartitioning - + ", useDLASyncHiveStylePartitioning=" + useDLASyncHiveStylePartitioning - + ", useFileListingFromMetadata=" + useFileListingFromMetadata - + ", help=" + help + '}'; - } -} diff --git a/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/DLASyncTool.java b/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/DLASyncTool.java deleted file mode 100644 index 97838d03ed66b..0000000000000 --- a/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/DLASyncTool.java +++ /dev/null @@ -1,213 +0,0 @@ -/* - * 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.hudi.dla; - -import com.beust.jcommander.JCommander; -import org.apache.hadoop.conf.Configuration; -import org.apache.hadoop.fs.FileSystem; -import org.apache.hadoop.hive.ql.io.parquet.MapredParquetOutputFormat; -import org.apache.hadoop.hive.ql.io.parquet.serde.ParquetHiveSerDe; - -import org.apache.hudi.common.config.TypedProperties; -import org.apache.hudi.common.fs.FSUtils; -import org.apache.hudi.common.model.HoodieFileFormat; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.dla.util.Utils; -import org.apache.hudi.exception.HoodieException; -import org.apache.hudi.exception.InvalidTableException; -import org.apache.hudi.hadoop.utils.HoodieInputFormatUtils; -import org.apache.hudi.hive.SchemaDifference; -import org.apache.hudi.hive.util.HiveSchemaUtil; -import org.apache.hudi.sync.common.AbstractSyncHoodieClient; -import org.apache.hudi.sync.common.AbstractSyncTool; -import org.apache.log4j.LogManager; -import org.apache.log4j.Logger; -import org.apache.parquet.schema.MessageType; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * Tool to sync a hoodie table with a dla table. Either use it as a api - * DLASyncTool.syncHoodieTable(DLASyncConfig) or as a command line java -cp hoodie-hive.jar DLASyncTool [args] - *

- * This utility will get the schema from the latest commit and will sync dla table schema Also this will sync the - * partitions incrementally (all the partitions modified since the last commit) - */ -@SuppressWarnings("WeakerAccess") -public class DLASyncTool extends AbstractSyncTool { - - private static final Logger LOG = LogManager.getLogger(DLASyncTool.class); - public static final String SUFFIX_SNAPSHOT_TABLE = "_rt"; - public static final String SUFFIX_READ_OPTIMIZED_TABLE = "_ro"; - - private final DLASyncConfig cfg; - private final HoodieDLAClient hoodieDLAClient; - private final String snapshotTableName; - private final Option roTableTableName; - - public DLASyncTool(TypedProperties properties, Configuration conf, FileSystem fs) { - super(properties, conf, fs); - this.hoodieDLAClient = new HoodieDLAClient(Utils.propertiesToConfig(properties), fs); - this.cfg = Utils.propertiesToConfig(properties); - switch (hoodieDLAClient.getTableType()) { - case COPY_ON_WRITE: - this.snapshotTableName = cfg.tableName; - this.roTableTableName = Option.empty(); - break; - case MERGE_ON_READ: - this.snapshotTableName = cfg.tableName + SUFFIX_SNAPSHOT_TABLE; - this.roTableTableName = cfg.skipROSuffix ? Option.of(cfg.tableName) : - Option.of(cfg.tableName + SUFFIX_READ_OPTIMIZED_TABLE); - break; - default: - LOG.error("Unknown table type " + hoodieDLAClient.getTableType()); - throw new InvalidTableException(hoodieDLAClient.getBasePath()); - } - } - - @Override - public void syncHoodieTable() { - try { - switch (hoodieDLAClient.getTableType()) { - case COPY_ON_WRITE: - syncHoodieTable(snapshotTableName, false); - break; - case MERGE_ON_READ: - // sync a RO table for MOR - syncHoodieTable(roTableTableName.get(), false); - // sync a RT table for MOR - if (!cfg.skipRTSync) { - syncHoodieTable(snapshotTableName, true); - } - break; - default: - LOG.error("Unknown table type " + hoodieDLAClient.getTableType()); - throw new InvalidTableException(hoodieDLAClient.getBasePath()); - } - } catch (RuntimeException re) { - throw new HoodieException("Got runtime exception when dla syncing " + cfg.tableName, re); - } finally { - hoodieDLAClient.close(); - } - } - - private void syncHoodieTable(String tableName, boolean useRealtimeInputFormat) { - LOG.info("Trying to sync hoodie table " + tableName + " with base path " + hoodieDLAClient.getBasePath() - + " of type " + hoodieDLAClient.getTableType()); - // Check if the necessary table exists - boolean tableExists = hoodieDLAClient.tableExists(tableName); - // Get the parquet schema for this table looking at the latest commit - MessageType schema = hoodieDLAClient.getDataSchema(); - // Sync schema if needed - syncSchema(tableName, tableExists, useRealtimeInputFormat, schema); - - LOG.info("Schema sync complete. Syncing partitions for " + tableName); - // Get the last time we successfully synced partitions - // TODO : once DLA supports alter table properties - Option lastCommitTimeSynced = Option.empty(); - /*if (tableExists) { - lastCommitTimeSynced = hoodieDLAClient.getLastCommitTimeSynced(tableName); - }*/ - LOG.info("Last commit time synced was found to be " + lastCommitTimeSynced.orElse("null")); - List writtenPartitionsSince = hoodieDLAClient.getPartitionsWrittenToSince(lastCommitTimeSynced); - LOG.info("Storage partitions scan complete. Found " + writtenPartitionsSince.size()); - // Sync the partitions if needed - syncPartitions(tableName, writtenPartitionsSince); - - hoodieDLAClient.updateLastCommitTimeSynced(tableName); - LOG.info("Sync complete for " + tableName); - } - - /** - * Get the latest schema from the last commit and check if its in sync with the dla table schema. If not, evolves the - * table schema. - * - * @param tableExists - does table exist - * @param schema - extracted schema - */ - private void syncSchema(String tableName, boolean tableExists, boolean useRealTimeInputFormat, MessageType schema) { - // Check and sync schema - if (!tableExists) { - LOG.info("DLA table " + tableName + " is not found. Creating it"); - - String inputFormatClassName = HoodieInputFormatUtils.getInputFormatClassName(HoodieFileFormat.PARQUET, useRealTimeInputFormat); - - // Custom serde will not work with ALTER TABLE REPLACE COLUMNS - // https://github.com/apache/hive/blob/release-1.1.0/ql/src/java/org/apache/hadoop/hive - // /ql/exec/DDLTask.java#L3488 - hoodieDLAClient.createTable(tableName, schema, inputFormatClassName, MapredParquetOutputFormat.class.getName(), - ParquetHiveSerDe.class.getName(), new HashMap<>(), new HashMap<>()); - } else { - // Check if the table schema has evolved - Map tableSchema = hoodieDLAClient.getTableSchema(tableName); - SchemaDifference schemaDiff = HiveSchemaUtil.getSchemaDifference(schema, tableSchema, cfg.partitionFields, cfg.supportTimestamp); - if (!schemaDiff.isEmpty()) { - LOG.info("Schema difference found for " + tableName); - hoodieDLAClient.updateTableDefinition(tableName, schemaDiff); - } else { - LOG.info("No Schema difference for " + tableName); - } - } - } - - /** - * Syncs the list of storage partitions passed in (checks if the partition is in dla, if not adds it or if the - * partition path does not match, it updates the partition path). - */ - private void syncPartitions(String tableName, List writtenPartitionsSince) { - try { - if (cfg.partitionFields.isEmpty()) { - LOG.info("not a partitioned table."); - return; - } - Map, String> partitions = hoodieDLAClient.scanTablePartitions(tableName); - List partitionEvents = - hoodieDLAClient.getPartitionEvents(partitions, writtenPartitionsSince); - List newPartitions = filterPartitions(partitionEvents, AbstractSyncHoodieClient.PartitionEvent.PartitionEventType.ADD); - LOG.info("New Partitions " + newPartitions); - hoodieDLAClient.addPartitionsToTable(tableName, newPartitions); - List updatePartitions = filterPartitions(partitionEvents, AbstractSyncHoodieClient.PartitionEvent.PartitionEventType.UPDATE); - LOG.info("Changed Partitions " + updatePartitions); - hoodieDLAClient.updatePartitionsToTable(tableName, updatePartitions); - } catch (Exception e) { - throw new HoodieException("Failed to sync partitions for table " + tableName, e); - } - } - - private List filterPartitions(List events, AbstractSyncHoodieClient.PartitionEvent.PartitionEventType eventType) { - return events.stream().filter(s -> s.eventType == eventType).map(s -> s.storagePartition) - .collect(Collectors.toList()); - } - - public static void main(String[] args) { - // parse the params - final DLASyncConfig cfg = new DLASyncConfig(); - JCommander cmd = new JCommander(cfg, null, args); - if (cfg.help || args.length == 0) { - cmd.usage(); - System.exit(1); - } - Configuration hadoopConf = new Configuration(); - FileSystem fs = FSUtils.getFs(cfg.basePath, hadoopConf); - new DLASyncTool(Utils.configToProperties(cfg), hadoopConf, fs).syncHoodieTable(); - } -} diff --git a/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/HoodieDLAClient.java b/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/HoodieDLAClient.java deleted file mode 100644 index 10869eaf27b64..0000000000000 --- a/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/HoodieDLAClient.java +++ /dev/null @@ -1,428 +0,0 @@ -/* - * 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.hudi.dla; - -import org.apache.hudi.common.fs.FSUtils; -import org.apache.hudi.common.util.Option; -import org.apache.hudi.common.util.StringUtils; -import org.apache.hudi.common.util.ValidationUtils; -import org.apache.hudi.exception.HoodieException; -import org.apache.hudi.hive.HiveSyncConfig; -import org.apache.hudi.hive.HoodieHiveSyncException; -import org.apache.hudi.hive.PartitionValueExtractor; -import org.apache.hudi.hive.SchemaDifference; -import org.apache.hudi.hive.util.HiveSchemaUtil; -import org.apache.hudi.sync.common.AbstractSyncHoodieClient; - -import org.apache.hadoop.fs.FileSystem; -import org.apache.hadoop.fs.Path; -import org.apache.log4j.LogManager; -import org.apache.log4j.Logger; -import org.apache.parquet.schema.MessageType; - -import java.io.IOException; -import java.sql.Connection; -import java.sql.DatabaseMetaData; -import java.sql.DriverManager; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -public class HoodieDLAClient extends AbstractSyncHoodieClient { - private static final Logger LOG = LogManager.getLogger(HoodieDLAClient.class); - private static final String HOODIE_LAST_COMMIT_TIME_SYNC = "hoodie_last_sync"; - // Make sure we have the dla JDBC driver in classpath - private static final String DRIVER_NAME = "com.mysql.jdbc.Driver"; - private static final String DLA_ESCAPE_CHARACTER = ""; - private static final String TBL_PROPERTIES_STR = "TBLPROPERTIES"; - - static { - try { - Class.forName(DRIVER_NAME); - } catch (ClassNotFoundException e) { - throw new IllegalStateException("Could not find " + DRIVER_NAME + " in classpath. ", e); - } - } - - private Connection connection; - private DLASyncConfig dlaConfig; - private PartitionValueExtractor partitionValueExtractor; - - public HoodieDLAClient(DLASyncConfig syncConfig, FileSystem fs) { - super(syncConfig.basePath, syncConfig.assumeDatePartitioning, syncConfig.useFileListingFromMetadata, - false, fs); - this.dlaConfig = syncConfig; - try { - this.partitionValueExtractor = - (PartitionValueExtractor) Class.forName(dlaConfig.partitionValueExtractorClass).newInstance(); - } catch (Exception e) { - throw new HoodieException( - "Failed to initialize PartitionValueExtractor class " + dlaConfig.partitionValueExtractorClass, e); - } - createDLAConnection(); - } - - private void createDLAConnection() { - if (connection == null) { - try { - Class.forName(DRIVER_NAME); - } catch (ClassNotFoundException e) { - LOG.error("Unable to load DLA driver class", e); - return; - } - try { - this.connection = DriverManager.getConnection(dlaConfig.jdbcUrl, dlaConfig.dlaUser, dlaConfig.dlaPass); - LOG.info("Successfully established DLA connection to " + dlaConfig.jdbcUrl); - } catch (SQLException e) { - throw new HoodieException("Cannot create dla connection ", e); - } - } - } - - @Override - public void createTable(String tableName, MessageType storageSchema, String inputFormatClass, - String outputFormatClass, String serdeClass, - Map serdeProperties, Map tableProperties) { - try { - String createSQLQuery = HiveSchemaUtil.generateCreateDDL(tableName, storageSchema, toHiveSyncConfig(), - inputFormatClass, outputFormatClass, serdeClass, serdeProperties, tableProperties); - LOG.info("Creating table with " + createSQLQuery); - updateDLASQL(createSQLQuery); - } catch (IOException e) { - throw new HoodieException("Failed to create table " + tableName, e); - } - } - - public Map getTableSchema(String tableName) { - if (!tableExists(tableName)) { - throw new IllegalArgumentException( - "Failed to get schema for table " + tableName + " does not exist"); - } - Map schema = new HashMap<>(); - ResultSet result = null; - try { - DatabaseMetaData databaseMetaData = connection.getMetaData(); - result = databaseMetaData.getColumns(dlaConfig.databaseName, dlaConfig.databaseName, tableName, null); - while (result.next()) { - TYPE_CONVERTOR.doConvert(result, schema); - } - return schema; - } catch (SQLException e) { - throw new HoodieException("Failed to get table schema for " + tableName, e); - } finally { - closeQuietly(result, null); - } - } - - @Override - public void addPartitionsToTable(String tableName, List partitionsToAdd) { - if (partitionsToAdd.isEmpty()) { - LOG.info("No partitions to add for " + tableName); - return; - } - LOG.info("Adding partitions " + partitionsToAdd.size() + " to table " + tableName); - String sql = constructAddPartitions(tableName, partitionsToAdd); - updateDLASQL(sql); - } - - public String constructAddPartitions(String tableName, List partitions) { - return constructDLAAddPartitions(tableName, partitions); - } - - String generateAbsolutePathStr(Path path) { - String absolutePathStr = path.toString(); - if (path.toUri().getScheme() == null) { - absolutePathStr = getDefaultFs() + absolutePathStr; - } - return absolutePathStr.endsWith("/") ? absolutePathStr : absolutePathStr + "/"; - } - - public List constructChangePartitions(String tableName, List partitions) { - List changePartitions = new ArrayList<>(); - String useDatabase = "USE " + DLA_ESCAPE_CHARACTER + dlaConfig.databaseName + DLA_ESCAPE_CHARACTER; - changePartitions.add(useDatabase); - String alterTable = "ALTER TABLE " + DLA_ESCAPE_CHARACTER + tableName + DLA_ESCAPE_CHARACTER; - for (String partition : partitions) { - String partitionClause = getPartitionClause(partition); - Path partitionPath = FSUtils.getPartitionPath(dlaConfig.basePath, partition); - String fullPartitionPathStr = generateAbsolutePathStr(partitionPath); - String changePartition = - alterTable + " ADD IF NOT EXISTS PARTITION (" + partitionClause + ") LOCATION '" + fullPartitionPathStr + "'"; - changePartitions.add(changePartition); - } - return changePartitions; - } - - /** - * Generate Hive Partition from partition values. - * - * @param partition Partition path - * @return - */ - public String getPartitionClause(String partition) { - List partitionValues = partitionValueExtractor.extractPartitionValuesInPath(partition); - ValidationUtils.checkArgument(dlaConfig.partitionFields.size() == partitionValues.size(), - "Partition key parts " + dlaConfig.partitionFields + " does not match with partition values " + partitionValues - + ". Check partition strategy. "); - List partBuilder = new ArrayList<>(); - for (int i = 0; i < dlaConfig.partitionFields.size(); i++) { - partBuilder.add(dlaConfig.partitionFields.get(i) + "='" + partitionValues.get(i) + "'"); - } - return partBuilder.stream().collect(Collectors.joining(",")); - } - - private String constructDLAAddPartitions(String tableName, List partitions) { - StringBuilder alterSQL = new StringBuilder("ALTER TABLE "); - alterSQL.append(DLA_ESCAPE_CHARACTER).append(dlaConfig.databaseName) - .append(DLA_ESCAPE_CHARACTER).append(".").append(DLA_ESCAPE_CHARACTER) - .append(tableName).append(DLA_ESCAPE_CHARACTER).append(" ADD IF NOT EXISTS "); - for (String partition : partitions) { - String partitionClause = getPartitionClause(partition); - Path partitionPath = FSUtils.getPartitionPath(dlaConfig.basePath, partition); - String fullPartitionPathStr = generateAbsolutePathStr(partitionPath); - alterSQL.append(" PARTITION (").append(partitionClause).append(") LOCATION '").append(fullPartitionPathStr) - .append("' "); - } - return alterSQL.toString(); - } - - private void updateDLASQL(String sql) { - Statement stmt = null; - try { - stmt = connection.createStatement(); - LOG.info("Executing SQL " + sql); - stmt.execute(sql); - } catch (SQLException e) { - throw new HoodieException("Failed in executing SQL " + sql, e); - } finally { - closeQuietly(null, stmt); - } - } - - @Override - public boolean doesTableExist(String tableName) { - return tableExists(tableName); - } - - @Override - public boolean tableExists(String tableName) { - String sql = consutructShowCreateTableSQL(tableName); - Statement stmt = null; - ResultSet rs = null; - try { - stmt = connection.createStatement(); - rs = stmt.executeQuery(sql); - } catch (SQLException e) { - return false; - } finally { - closeQuietly(rs, stmt); - } - return true; - } - - @Override - public Option getLastCommitTimeSynced(String tableName) { - String sql = consutructShowCreateTableSQL(tableName); - Statement stmt = null; - ResultSet rs = null; - try { - stmt = connection.createStatement(); - rs = stmt.executeQuery(sql); - if (rs.next()) { - String table = rs.getString(2); - Map attr = new HashMap<>(); - int index = table.indexOf(TBL_PROPERTIES_STR); - if (index != -1) { - String sub = table.substring(index + TBL_PROPERTIES_STR.length()); - sub = sub.replaceAll("\\(", "").replaceAll("\\)", "").replaceAll("'", ""); - String[] str = sub.split(","); - - for (int i = 0; i < str.length; i++) { - String key = str[i].split("=")[0].trim(); - String value = str[i].split("=")[1].trim(); - attr.put(key, value); - } - } - return Option.ofNullable(attr.getOrDefault(HOODIE_LAST_COMMIT_TIME_SYNC, null)); - } - return Option.empty(); - } catch (Exception e) { - throw new HoodieHiveSyncException("Failed to get the last commit time synced from the table", e); - } finally { - closeQuietly(rs, stmt); - } - } - - @Override - public void updateLastCommitTimeSynced(String tableName) { - // TODO : dla do not support update tblproperties, so do nothing. - } - - @Override - public Option getLastReplicatedTime(String tableName) { - // no op; unsupported - return Option.empty(); - } - - @Override - public void updateLastReplicatedTimeStamp(String tableName, String timeStamp) { - // no op; unsupported - } - - @Override - public void deleteLastReplicatedTimeStamp(String tableName) { - // no op; unsupported - } - - @Override - public void updatePartitionsToTable(String tableName, List changedPartitions) { - if (changedPartitions.isEmpty()) { - LOG.info("No partitions to change for " + tableName); - return; - } - LOG.info("Changing partitions " + changedPartitions.size() + " on " + tableName); - List sqls = constructChangePartitions(tableName, changedPartitions); - for (String sql : sqls) { - updateDLASQL(sql); - } - } - - @Override - public void dropPartitions(String tableName, List partitionsToDrop) { - throw new UnsupportedOperationException("Not support dropPartitions yet."); - } - - public Map, String> scanTablePartitions(String tableName) { - String sql = constructShowPartitionSQL(tableName); - Statement stmt = null; - ResultSet rs = null; - Map, String> partitions = new HashMap<>(); - try { - stmt = connection.createStatement(); - LOG.info("Executing SQL " + sql); - rs = stmt.executeQuery(sql); - while (rs.next()) { - if (rs.getMetaData().getColumnCount() > 0) { - String str = rs.getString(1); - if (!StringUtils.isNullOrEmpty(str)) { - List values = partitionValueExtractor.extractPartitionValuesInPath(str); - Path storagePartitionPath = FSUtils.getPartitionPath(dlaConfig.basePath, String.join("/", values)); - String fullStoragePartitionPath = Path.getPathWithoutSchemeAndAuthority(storagePartitionPath).toUri().getPath(); - partitions.put(values, fullStoragePartitionPath); - } - } - } - return partitions; - } catch (SQLException e) { - throw new HoodieException("Failed in executing SQL " + sql, e); - } finally { - closeQuietly(rs, stmt); - } - } - - public List getPartitionEvents(Map, String> tablePartitions, List partitionStoragePartitions) { - Map paths = new HashMap<>(); - - for (Map.Entry, String> entry : tablePartitions.entrySet()) { - List partitionValues = entry.getKey(); - Collections.sort(partitionValues); - String fullTablePartitionPath = entry.getValue(); - paths.put(String.join(", ", partitionValues), fullTablePartitionPath); - } - List events = new ArrayList<>(); - for (String storagePartition : partitionStoragePartitions) { - Path storagePartitionPath = FSUtils.getPartitionPath(dlaConfig.basePath, storagePartition); - String fullStoragePartitionPath = Path.getPathWithoutSchemeAndAuthority(storagePartitionPath).toUri().getPath(); - // Check if the partition values or if hdfs path is the same - List storagePartitionValues = partitionValueExtractor.extractPartitionValuesInPath(storagePartition); - if (dlaConfig.useDLASyncHiveStylePartitioning) { - String partition = String.join("/", storagePartitionValues); - storagePartitionPath = FSUtils.getPartitionPath(dlaConfig.basePath, partition); - fullStoragePartitionPath = Path.getPathWithoutSchemeAndAuthority(storagePartitionPath).toUri().getPath(); - } - Collections.sort(storagePartitionValues); - if (!storagePartitionValues.isEmpty()) { - String storageValue = String.join(", ", storagePartitionValues); - if (!paths.containsKey(storageValue)) { - events.add(PartitionEvent.newPartitionAddEvent(storagePartition)); - } else if (!paths.get(storageValue).equals(fullStoragePartitionPath)) { - events.add(PartitionEvent.newPartitionUpdateEvent(storagePartition)); - } - } - } - return events; - } - - public void updateTableDefinition(String tableName, SchemaDifference schemaDiff) { - ValidationUtils.checkArgument(schemaDiff.getDeleteColumns().size() == 0, "not support delete columns"); - ValidationUtils.checkArgument(schemaDiff.getUpdateColumnTypes().size() == 0, "not support alter column type"); - Map columns = schemaDiff.getAddColumnTypes(); - for (Map.Entry entry : columns.entrySet()) { - String columnName = entry.getKey(); - String columnType = entry.getValue(); - StringBuilder sqlBuilder = new StringBuilder("ALTER TABLE ").append(DLA_ESCAPE_CHARACTER) - .append(dlaConfig.databaseName).append(DLA_ESCAPE_CHARACTER).append(".") - .append(DLA_ESCAPE_CHARACTER).append(tableName) - .append(DLA_ESCAPE_CHARACTER).append(" ADD COLUMNS(") - .append(columnName).append(" ").append(columnType).append(" )"); - LOG.info("Updating table definition with " + sqlBuilder); - updateDLASQL(sqlBuilder.toString()); - } - } - - @Override - public void close() { - try { - if (connection != null) { - connection.close(); - } - } catch (SQLException e) { - LOG.error("Could not close connection ", e); - } - } - - private String constructShowPartitionSQL(String tableName) { - String sql = "show partitions " + dlaConfig.databaseName + "." + tableName; - return sql; - } - - private String consutructShowCreateTableSQL(String tableName) { - String sql = "show create table " + dlaConfig.databaseName + "." + tableName; - return sql; - } - - private String getDefaultFs() { - return fs.getConf().get("fs.defaultFS"); - } - - private HiveSyncConfig toHiveSyncConfig() { - HiveSyncConfig hiveSyncConfig = new HiveSyncConfig(); - hiveSyncConfig.partitionFields = dlaConfig.partitionFields; - hiveSyncConfig.databaseName = dlaConfig.databaseName; - Path basePath = new Path(dlaConfig.basePath); - hiveSyncConfig.basePath = generateAbsolutePathStr(basePath); - return hiveSyncConfig; - } -} diff --git a/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/util/Utils.java b/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/util/Utils.java deleted file mode 100644 index d1b0dd4e9d56f..0000000000000 --- a/hudi-sync/hudi-dla-sync/src/main/java/org/apache/hudi/dla/util/Utils.java +++ /dev/null @@ -1,77 +0,0 @@ -/* - * 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.hudi.dla.util; - -import org.apache.hudi.common.config.TypedProperties; -import org.apache.hudi.common.util.StringUtils; -import org.apache.hudi.dla.DLASyncConfig; - -import java.util.ArrayList; -import java.util.Arrays; - -public class Utils { - public static String DLA_DATABASE_OPT_KEY = "hoodie.datasource.dla_sync.database"; - public static String DLA_TABLE_OPT_KEY = "hoodie.datasource.dla_sync.table"; - public static String DLA_USER_OPT_KEY = "hoodie.datasource.dla_sync.username"; - public static String DLA_PASS_OPT_KEY = "hoodie.datasource.dla_sync.password"; - public static String DLA_URL_OPT_KEY = "hoodie.datasource.dla_sync.jdbcurl"; - public static String BATH_PATH = "basePath"; - public static String DLA_PARTITION_FIELDS_OPT_KEY = "hoodie.datasource.dla_sync.partition_fields"; - public static String DLA_PARTITION_EXTRACTOR_CLASS_OPT_KEY = "hoodie.datasource.dla_sync.partition_extractor_class"; - public static String DLA_ASSUME_DATE_PARTITIONING = "hoodie.datasource.dla_sync.assume_date_partitioning"; - public static String DLA_SKIP_RO_SUFFIX = "hoodie.datasource.dla_sync.skip_ro_suffix"; - public static String DLA_SKIP_RT_SYNC = "hoodie.datasource.dla_sync.skip_rt_sync"; - public static String DLA_SYNC_HIVE_STYLE_PARTITIONING = "hoodie.datasource.dla_sync.hive.style.partitioning"; - - public static TypedProperties configToProperties(DLASyncConfig cfg) { - TypedProperties properties = new TypedProperties(); - properties.put(DLA_DATABASE_OPT_KEY, cfg.databaseName); - properties.put(DLA_TABLE_OPT_KEY, cfg.tableName); - properties.put(DLA_USER_OPT_KEY, cfg.dlaUser); - properties.put(DLA_PASS_OPT_KEY, cfg.dlaPass); - properties.put(DLA_URL_OPT_KEY, cfg.jdbcUrl); - properties.put(BATH_PATH, cfg.basePath); - properties.put(DLA_PARTITION_EXTRACTOR_CLASS_OPT_KEY, cfg.partitionValueExtractorClass); - properties.put(DLA_ASSUME_DATE_PARTITIONING, String.valueOf(cfg.assumeDatePartitioning)); - properties.put(DLA_SKIP_RO_SUFFIX, String.valueOf(cfg.skipROSuffix)); - properties.put(DLA_SYNC_HIVE_STYLE_PARTITIONING, String.valueOf(cfg.useDLASyncHiveStylePartitioning)); - return properties; - } - - public static DLASyncConfig propertiesToConfig(TypedProperties properties) { - DLASyncConfig config = new DLASyncConfig(); - config.databaseName = properties.getProperty(DLA_DATABASE_OPT_KEY); - config.tableName = properties.getProperty(DLA_TABLE_OPT_KEY); - config.dlaUser = properties.getProperty(DLA_USER_OPT_KEY); - config.dlaPass = properties.getProperty(DLA_PASS_OPT_KEY); - config.jdbcUrl = properties.getProperty(DLA_URL_OPT_KEY); - config.basePath = properties.getProperty(BATH_PATH); - if (StringUtils.isNullOrEmpty(properties.getProperty(DLA_PARTITION_FIELDS_OPT_KEY))) { - config.partitionFields = new ArrayList<>(); - } else { - config.partitionFields = Arrays.asList(properties.getProperty(DLA_PARTITION_FIELDS_OPT_KEY).split(",")); - } - config.partitionValueExtractorClass = properties.getProperty(DLA_PARTITION_EXTRACTOR_CLASS_OPT_KEY); - config.assumeDatePartitioning = Boolean.parseBoolean(properties.getProperty(DLA_ASSUME_DATE_PARTITIONING, "false")); - config.skipROSuffix = Boolean.parseBoolean(properties.getProperty(DLA_SKIP_RO_SUFFIX, "false")); - config.skipRTSync = Boolean.parseBoolean(properties.getProperty(DLA_SKIP_RT_SYNC, "false")); - config.useDLASyncHiveStylePartitioning = Boolean.parseBoolean(properties.getProperty(DLA_SYNC_HIVE_STYLE_PARTITIONING, "false")); - return config; - } -} diff --git a/hudi-sync/hudi-dla-sync/src/test/java/org/apache/hudi/dla/TestDLASyncConfig.java b/hudi-sync/hudi-dla-sync/src/test/java/org/apache/hudi/dla/TestDLASyncConfig.java deleted file mode 100644 index 366d5a24efb06..0000000000000 --- a/hudi-sync/hudi-dla-sync/src/test/java/org/apache/hudi/dla/TestDLASyncConfig.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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.hudi.dla; - -import org.junit.jupiter.api.Test; -import java.util.Arrays; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -public class TestDLASyncConfig { - @Test - public void testCopy() { - DLASyncConfig dlaSyncConfig = new DLASyncConfig(); - List partitions = Arrays.asList("a", "b"); - dlaSyncConfig.partitionFields = partitions; - dlaSyncConfig.basePath = "/tmp"; - dlaSyncConfig.assumeDatePartitioning = true; - dlaSyncConfig.databaseName = "test"; - dlaSyncConfig.tableName = "test"; - dlaSyncConfig.dlaUser = "dla"; - dlaSyncConfig.dlaPass = "dla"; - dlaSyncConfig.jdbcUrl = "jdbc:mysql://localhost:3306"; - dlaSyncConfig.skipROSuffix = false; - - DLASyncConfig copied = DLASyncConfig.copy(dlaSyncConfig); - - assertEquals(copied.partitionFields, dlaSyncConfig.partitionFields); - assertEquals(copied.basePath, dlaSyncConfig.basePath); - assertEquals(copied.assumeDatePartitioning, dlaSyncConfig.assumeDatePartitioning); - assertEquals(copied.databaseName, dlaSyncConfig.databaseName); - assertEquals(copied.tableName, dlaSyncConfig.tableName); - assertEquals(copied.dlaUser, dlaSyncConfig.dlaUser); - assertEquals(copied.dlaPass, dlaSyncConfig.dlaPass); - assertEquals(copied.basePath, dlaSyncConfig.basePath); - assertEquals(copied.jdbcUrl, dlaSyncConfig.jdbcUrl); - assertEquals(copied.skipROSuffix, dlaSyncConfig.skipROSuffix); - assertEquals(copied.supportTimestamp, dlaSyncConfig.supportTimestamp); - } -} diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncTool.java b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncTool.java index 939fc114c0883..5e343b9a62a00 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncTool.java +++ b/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/HiveSyncTool.java @@ -27,9 +27,8 @@ import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.InvalidTableException; import org.apache.hudi.hadoop.utils.HoodieInputFormatUtils; -import org.apache.hudi.hive.util.ConfigUtils; +import org.apache.hudi.sync.common.util.ConfigUtils; import org.apache.hudi.hive.util.HiveSchemaUtil; -import org.apache.hudi.hive.util.Parquet2SparkSchemaUtils; import org.apache.hudi.sync.common.AbstractSyncHoodieClient.PartitionEvent; import org.apache.hudi.sync.common.AbstractSyncHoodieClient.PartitionEvent.PartitionEventType; import org.apache.hudi.sync.common.AbstractSyncTool; @@ -43,20 +42,13 @@ import org.apache.hadoop.hive.metastore.api.FieldSchema; import org.apache.log4j.LogManager; import org.apache.log4j.Logger; -import org.apache.parquet.schema.GroupType; import org.apache.parquet.schema.MessageType; -import org.apache.parquet.schema.PrimitiveType; -import org.apache.parquet.schema.Type; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import static org.apache.parquet.schema.OriginalType.UTF8; -import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY; - /** * Tool to sync a hoodie HDFS table with a hive metastore table. Either use it as a api * HiveSyncTool.syncHoodieTable(HiveSyncConfig) or as a command line java -cp hoodie-hive-sync.jar HiveSyncTool [args] @@ -248,8 +240,9 @@ private boolean syncSchema(String tableName, boolean tableExists, boolean useRea Map tableProperties = ConfigUtils.toMap(hiveSyncConfig.tableProperties); Map serdeProperties = ConfigUtils.toMap(hiveSyncConfig.serdeProperties); if (hiveSyncConfig.syncAsSparkDataSourceTable) { - Map sparkTableProperties = getSparkTableProperties(hiveSyncConfig.sparkSchemaLengthThreshold, schema); - Map sparkSerdeProperties = getSparkSerdeProperties(readAsOptimized); + Map sparkTableProperties = getSparkTableProperties(hiveSyncConfig.partitionFields, + hiveSyncConfig.sparkVersion, hiveSyncConfig.sparkSchemaLengthThreshold, schema); + Map sparkSerdeProperties = getSparkSerdeProperties(readAsOptimized, hiveSyncConfig.basePath); tableProperties.putAll(sparkTableProperties); serdeProperties.putAll(sparkSerdeProperties); } @@ -309,75 +302,6 @@ private boolean syncSchema(String tableName, boolean tableExists, boolean useRea return schemaChanged; } - /** - * Get Spark Sql related table properties. This is used for spark datasource table. - * @param schema The schema to write to the table. - * @return A new parameters added the spark's table properties. - */ - private Map getSparkTableProperties(int schemaLengthThreshold, MessageType schema) { - // Convert the schema and partition info used by spark sql to hive table properties. - // The following code refers to the spark code in - // https://github.com/apache/spark/blob/master/sql/hive/src/main/scala/org/apache/spark/sql/hive/HiveExternalCatalog.scala - GroupType originGroupType = schema.asGroupType(); - List partitionNames = hiveSyncConfig.partitionFields; - List partitionCols = new ArrayList<>(); - List dataCols = new ArrayList<>(); - Map column2Field = new HashMap<>(); - - for (Type field : originGroupType.getFields()) { - column2Field.put(field.getName(), field); - } - // Get partition columns and data columns. - for (String partitionName : partitionNames) { - // Default the unknown partition fields to be String. - // Keep the same logical with HiveSchemaUtil#getPartitionKeyType. - partitionCols.add(column2Field.getOrDefault(partitionName, - new PrimitiveType(Type.Repetition.REQUIRED, BINARY, partitionName, UTF8))); - } - - for (Type field : originGroupType.getFields()) { - if (!partitionNames.contains(field.getName())) { - dataCols.add(field); - } - } - - List reOrderedFields = new ArrayList<>(); - reOrderedFields.addAll(dataCols); - reOrderedFields.addAll(partitionCols); - GroupType reOrderedType = new GroupType(originGroupType.getRepetition(), originGroupType.getName(), reOrderedFields); - - Map sparkProperties = new HashMap<>(); - sparkProperties.put("spark.sql.sources.provider", "hudi"); - if (!StringUtils.isNullOrEmpty(hiveSyncConfig.sparkVersion)) { - sparkProperties.put("spark.sql.create.version", hiveSyncConfig.sparkVersion); - } - // Split the schema string to multi-parts according the schemaLengthThreshold size. - String schemaString = Parquet2SparkSchemaUtils.convertToSparkSchemaJson(reOrderedType); - int numSchemaPart = (schemaString.length() + schemaLengthThreshold - 1) / schemaLengthThreshold; - sparkProperties.put("spark.sql.sources.schema.numParts", String.valueOf(numSchemaPart)); - // Add each part of schema string to sparkProperties - for (int i = 0; i < numSchemaPart; i++) { - int start = i * schemaLengthThreshold; - int end = Math.min(start + schemaLengthThreshold, schemaString.length()); - sparkProperties.put("spark.sql.sources.schema.part." + i, schemaString.substring(start, end)); - } - // Add partition columns - if (!partitionNames.isEmpty()) { - sparkProperties.put("spark.sql.sources.schema.numPartCols", String.valueOf(partitionNames.size())); - for (int i = 0; i < partitionNames.size(); i++) { - sparkProperties.put("spark.sql.sources.schema.partCol." + i, partitionNames.get(i)); - } - } - return sparkProperties; - } - - private Map getSparkSerdeProperties(boolean readAsOptimized) { - Map sparkSerdeProperties = new HashMap<>(); - sparkSerdeProperties.put("path", hiveSyncConfig.basePath); - sparkSerdeProperties.put(ConfigUtils.IS_QUERY_AS_RO_TABLE, String.valueOf(readAsOptimized)); - return sparkSerdeProperties; - } - /** * Syncs the list of storage partitions passed in (checks if the partition is in hive, if not adds it or if the * partition path does not match, it updates the partition path). diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java index 1c2d53ed96ded..b801f4d7daa11 100644 --- a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestHiveSyncTool.java @@ -28,7 +28,7 @@ import org.apache.hudi.common.util.StringUtils; import org.apache.hudi.common.util.collection.ImmutablePair; import org.apache.hudi.hive.testutils.HiveTestUtil; -import org.apache.hudi.hive.util.ConfigUtils; +import org.apache.hudi.sync.common.util.ConfigUtils; import org.apache.hudi.sync.common.AbstractSyncHoodieClient.PartitionEvent; import org.apache.hudi.sync.common.AbstractSyncHoodieClient.PartitionEvent.PartitionEventType; diff --git a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestParquet2SparkSchemaUtils.java b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestParquet2SparkSchemaUtils.java index 3ca31b04395a1..b6940629af3d2 100644 --- a/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestParquet2SparkSchemaUtils.java +++ b/hudi-sync/hudi-hive-sync/src/test/java/org/apache/hudi/hive/TestParquet2SparkSchemaUtils.java @@ -18,7 +18,7 @@ package org.apache.hudi.hive; -import org.apache.hudi.hive.util.Parquet2SparkSchemaUtils; +import org.apache.hudi.sync.common.util.Parquet2SparkSchemaUtils; import org.apache.spark.sql.execution.SparkSqlParser; import org.apache.spark.sql.execution.datasources.parquet.SparkToParquetSchemaConverter; import org.apache.spark.sql.internal.SQLConf; diff --git a/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/AbstractSyncTool.java b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/AbstractSyncTool.java index 680b4a17ef5d9..972ae1f96c512 100644 --- a/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/AbstractSyncTool.java +++ b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/AbstractSyncTool.java @@ -18,12 +18,26 @@ package org.apache.hudi.sync.common; import org.apache.hudi.common.config.TypedProperties; +import org.apache.hudi.common.util.StringUtils; +import org.apache.hudi.sync.common.util.ConfigUtils; +import org.apache.hudi.sync.common.util.Parquet2SparkSchemaUtils; import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.FileSystem; +import org.apache.parquet.schema.GroupType; +import org.apache.parquet.schema.MessageType; +import org.apache.parquet.schema.PrimitiveType; +import org.apache.parquet.schema.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Properties; +import static org.apache.parquet.schema.OriginalType.UTF8; +import static org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.BINARY; + /** * Base class to sync Hudi meta data with Metastores to make * Hudi table queryable through external systems. @@ -46,4 +60,72 @@ public AbstractSyncTool(Properties props, FileSystem fileSystem) { public abstract void syncHoodieTable(); + /** + * Get Spark Sql related table properties. This is used for spark datasource table. + * @param schema The schema to write to the table. + * @return A new parameters added the spark's table properties. + */ + protected Map getSparkTableProperties(List partitionNames, String sparkVersion, + int schemaLengthThreshold, MessageType schema) { + // Convert the schema and partition info used by spark sql to hive table properties. + // The following code refers to the spark code in + // https://github.com/apache/spark/blob/master/sql/hive/src/main/scala/org/apache/spark/sql/hive/HiveExternalCatalog.scala + GroupType originGroupType = schema.asGroupType(); + List partitionCols = new ArrayList<>(); + List dataCols = new ArrayList<>(); + Map column2Field = new HashMap<>(); + + for (Type field : originGroupType.getFields()) { + column2Field.put(field.getName(), field); + } + // Get partition columns and data columns. + for (String partitionName : partitionNames) { + // Default the unknown partition fields to be String. + // Keep the same logical with HiveSchemaUtil#getPartitionKeyType. + partitionCols.add(column2Field.getOrDefault(partitionName, + new PrimitiveType(Type.Repetition.REQUIRED, BINARY, partitionName, UTF8))); + } + + for (Type field : originGroupType.getFields()) { + if (!partitionNames.contains(field.getName())) { + dataCols.add(field); + } + } + + List reOrderedFields = new ArrayList<>(); + reOrderedFields.addAll(dataCols); + reOrderedFields.addAll(partitionCols); + GroupType reOrderedType = new GroupType(originGroupType.getRepetition(), originGroupType.getName(), reOrderedFields); + + Map sparkProperties = new HashMap<>(); + sparkProperties.put("spark.sql.sources.provider", "hudi"); + if (!StringUtils.isNullOrEmpty(sparkVersion)) { + sparkProperties.put("spark.sql.create.version", sparkVersion); + } + // Split the schema string to multi-parts according the schemaLengthThreshold size. + String schemaString = Parquet2SparkSchemaUtils.convertToSparkSchemaJson(reOrderedType); + int numSchemaPart = (schemaString.length() + schemaLengthThreshold - 1) / schemaLengthThreshold; + sparkProperties.put("spark.sql.sources.schema.numParts", String.valueOf(numSchemaPart)); + // Add each part of schema string to sparkProperties + for (int i = 0; i < numSchemaPart; i++) { + int start = i * schemaLengthThreshold; + int end = Math.min(start + schemaLengthThreshold, schemaString.length()); + sparkProperties.put("spark.sql.sources.schema.part." + i, schemaString.substring(start, end)); + } + // Add partition columns + if (!partitionNames.isEmpty()) { + sparkProperties.put("spark.sql.sources.schema.numPartCols", String.valueOf(partitionNames.size())); + for (int i = 0; i < partitionNames.size(); i++) { + sparkProperties.put("spark.sql.sources.schema.partCol." + i, partitionNames.get(i)); + } + } + return sparkProperties; + } + + protected Map getSparkSerdeProperties(boolean readAsOptimized, String basePath) { + Map sparkSerdeProperties = new HashMap<>(); + sparkSerdeProperties.put("path", basePath); + sparkSerdeProperties.put(ConfigUtils.IS_QUERY_AS_RO_TABLE, String.valueOf(readAsOptimized)); + return sparkSerdeProperties; + } } diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/ConfigUtils.java b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/ConfigUtils.java similarity index 98% rename from hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/ConfigUtils.java rename to hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/ConfigUtils.java index 94ebdaadd8ff3..ca5224aef4697 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/ConfigUtils.java +++ b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/ConfigUtils.java @@ -16,7 +16,7 @@ * limitations under the License. */ -package org.apache.hudi.hive.util; +package org.apache.hudi.sync.common.util; import java.util.HashMap; import java.util.Map; diff --git a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/Parquet2SparkSchemaUtils.java b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/Parquet2SparkSchemaUtils.java similarity index 99% rename from hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/Parquet2SparkSchemaUtils.java rename to hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/Parquet2SparkSchemaUtils.java index debc262b5518c..c5b98c17eb4a1 100644 --- a/hudi-sync/hudi-hive-sync/src/main/java/org/apache/hudi/hive/util/Parquet2SparkSchemaUtils.java +++ b/hudi-sync/hudi-sync-common/src/main/java/org/apache/hudi/sync/common/util/Parquet2SparkSchemaUtils.java @@ -16,7 +16,7 @@ * limitations under the License. */ -package org.apache.hudi.hive.util; +package org.apache.hudi.sync.common.util; import org.apache.hudi.common.util.ValidationUtils; import org.apache.parquet.schema.GroupType; diff --git a/hudi-sync/pom.xml b/hudi-sync/pom.xml index 0ee145418f5ee..ffcbac8a652ef 100644 --- a/hudi-sync/pom.xml +++ b/hudi-sync/pom.xml @@ -32,7 +32,7 @@ hudi-datahub-sync - hudi-dla-sync + hudi-adb-sync hudi-hive-sync hudi-sync-common From 7d02b1fd3c74abfbd118f69a10a8c106cc900a3e Mon Sep 17 00:00:00 2001 From: Sivabalan Narayanan Date: Fri, 20 May 2022 19:27:35 -0400 Subject: [PATCH 49/52] [MINOR] Minor fixes to exception log and removing unwanted metrics flush in integ test (#5646) --- .../hudi/integ/testsuite/dag/scheduler/DagScheduler.java | 3 --- .../org/apache/hudi/utilities/deltastreamer/DeltaSync.java | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/dag/scheduler/DagScheduler.java b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/dag/scheduler/DagScheduler.java index 0183f52c2a17a..ab80df0d6a1db 100644 --- a/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/dag/scheduler/DagScheduler.java +++ b/hudi-integ-test/src/main/java/org/apache/hudi/integ/testsuite/dag/scheduler/DagScheduler.java @@ -117,9 +117,6 @@ private void execute(ExecutorService service, WorkflowDag workflowDag) throws Ex if (curRound < workflowDag.getRounds()) { new DelayNode(workflowDag.getIntermittentDelayMins()).execute(executionContext, curRound); } - - // After each level, report and flush the metrics - Metrics.flush(); } while (curRound++ < workflowDag.getRounds()); log.info("Finished workloads"); } diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/DeltaSync.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/DeltaSync.java index 8f44b8b7d0b34..a1a804b9ed123 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/DeltaSync.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/deltastreamer/DeltaSync.java @@ -846,7 +846,7 @@ private Schema getSchemaForWriteConfig(Schema targetSchema) { } return newWriteSchema; } catch (Exception e) { - throw new HoodieException("Failed to fetch schema from table."); + throw new HoodieException("Failed to fetch schema from table ", e); } } From 2af98303d3881e5d1da7d2e08f904b18f8b79488 Mon Sep 17 00:00:00 2001 From: wangxianghu Date: Sat, 21 May 2022 07:12:53 +0400 Subject: [PATCH 50/52] [HUDI-4122] Fix NPE caused by adding kafka nodes (#5632) --- .../sources/helpers/KafkaOffsetGen.java | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java index 564c5e2058453..1abd2616b9be5 100644 --- a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java +++ b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/KafkaOffsetGen.java @@ -22,17 +22,17 @@ import org.apache.hudi.common.config.ConfigProperty; import org.apache.hudi.common.config.TypedProperties; import org.apache.hudi.common.util.Option; -import org.apache.hudi.utilities.exception.HoodieDeltaStreamerException; import org.apache.hudi.exception.HoodieException; import org.apache.hudi.exception.HoodieNotSupportedException; - import org.apache.hudi.utilities.deltastreamer.HoodieDeltaStreamerMetrics; +import org.apache.hudi.utilities.exception.HoodieDeltaStreamerException; import org.apache.hudi.utilities.sources.AvroKafkaSource; + import org.apache.kafka.clients.consumer.CommitFailedException; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.consumer.KafkaConsumer; -import org.apache.kafka.clients.consumer.OffsetAndTimestamp; import org.apache.kafka.clients.consumer.OffsetAndMetadata; +import org.apache.kafka.clients.consumer.OffsetAndTimestamp; import org.apache.kafka.common.PartitionInfo; import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.errors.TimeoutException; @@ -48,6 +48,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -169,9 +170,14 @@ public static class Config { .withDocumentation("Kafka topic name."); public static final ConfigProperty KAFKA_CHECKPOINT_TYPE = ConfigProperty - .key("hoodie.deltastreamer.source.kafka.checkpoint.type") - .defaultValue("string") - .withDocumentation("Kafka chepoint type."); + .key("hoodie.deltastreamer.source.kafka.checkpoint.type") + .defaultValue("string") + .withDocumentation("Kafka checkpoint type."); + + public static final ConfigProperty KAFKA_FETCH_PARTITION_TIME_OUT = ConfigProperty + .key("hoodie.deltastreamer.source.kafka.fetch_partition.time.out") + .defaultValue(300 * 1000L) + .withDocumentation("Time out for fetching partitions. 5min by default"); public static final ConfigProperty ENABLE_KAFKA_COMMIT_OFFSET = ConfigProperty .key("hoodie.deltastreamer.source.kafka.enable.commit.offset") @@ -236,8 +242,7 @@ public OffsetRange[] getNextOffsetRanges(Option lastCheckpointStr, long if (!checkTopicExists(consumer)) { throw new HoodieException("Kafka topic:" + topicName + " does not exist"); } - List partitionInfoList; - partitionInfoList = consumer.partitionsFor(topicName); + List partitionInfoList = fetchPartitionInfos(consumer, topicName); Set topicPartitions = partitionInfoList.stream() .map(x -> new TopicPartition(x.topic(), x.partition())).collect(Collectors.toSet()); @@ -287,6 +292,32 @@ public OffsetRange[] getNextOffsetRanges(Option lastCheckpointStr, long return CheckpointUtils.computeOffsetRanges(fromOffsets, toOffsets, numEvents); } + /** + * Fetch partition infos for given topic. + * + * @param consumer + * @param topicName + */ + private List fetchPartitionInfos(KafkaConsumer consumer, String topicName) { + long timeout = this.props.getLong(Config.KAFKA_FETCH_PARTITION_TIME_OUT.key(), Config.KAFKA_FETCH_PARTITION_TIME_OUT.defaultValue()); + long start = System.currentTimeMillis(); + + List partitionInfos; + do { + partitionInfos = consumer.partitionsFor(topicName); + try { + TimeUnit.SECONDS.sleep(10); + } catch (InterruptedException e) { + LOG.error("Sleep failed while fetching partitions"); + } + } while (partitionInfos == null && (System.currentTimeMillis() <= (start + timeout))); + + if (partitionInfos == null) { + throw new HoodieDeltaStreamerException(String.format("Can not find metadata for topic %s from kafka cluster", topicName)); + } + return partitionInfos; + } + /** * Fetch checkpoint offsets for each partition. * @param consumer instance of {@link KafkaConsumer} to fetch offsets from. From b5adba3e55e57b67618e114f29397912a9f7f7c0 Mon Sep 17 00:00:00 2001 From: Raymond Xu <2701446+xushiyan@users.noreply.github.com> Date: Sat, 21 May 2022 05:34:08 -0700 Subject: [PATCH 51/52] [MINOR] remove unused gson test dependency (#5652) --- pom.xml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/pom.xml b/pom.xml index 0adbf22b58c4c..9e7b4ab14a559 100644 --- a/pom.xml +++ b/pom.xml @@ -1041,13 +1041,6 @@ - - com.google.code.gson - gson - 2.3.1 - test - - org.apache.curator From 8ec625d4d53be9ca7987c86484ac459fafbd8ecc Mon Sep 17 00:00:00 2001 From: YueZhang <69956021+zhangyue19921010@users.noreply.github.com> Date: Sat, 21 May 2022 21:16:14 +0800 Subject: [PATCH 52/52] [HUDI-3858] Shade javax.servlet for Spark bundle jar (#5295) Co-authored-by: yuezhang --- packaging/hudi-spark-bundle/pom.xml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packaging/hudi-spark-bundle/pom.xml b/packaging/hudi-spark-bundle/pom.xml index 698cc534d0807..d6a5eb6924618 100644 --- a/packaging/hudi-spark-bundle/pom.xml +++ b/packaging/hudi-spark-bundle/pom.xml @@ -29,6 +29,7 @@ true ${project.parent.basedir} + 3.1.0 @@ -80,6 +81,7 @@ org.apache.hudi:hudi-timeline-service org.apache.hudi:hudi-aws + javax.servlet:javax.servlet-api com.beust:jcommander io.javalin:javalin @@ -138,6 +140,10 @@ + + javax.servlet. + org.apache.hudi.javax.servlet. + org.apache.spark.sql.avro. org.apache.hudi.org.apache.spark.sql.avro. @@ -378,6 +384,12 @@ hive-service ${hive.version} ${spark.bundle.hive.scope} + + + servlet-api + javax.servlet + + @@ -426,6 +438,13 @@ curator-recipes ${zk-curator.version} + + + javax.servlet + javax.servlet-api + ${javax.servlet.version} + +