diff --git a/pom.xml b/pom.xml index dcd23758c..144bf5b3b 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,7 @@ xtable-aws xtable-hive-metastore xtable-service + xtable-spark-runtime diff --git a/rfc/rfc-3/rfc-3.md b/rfc/rfc-3/rfc-3.md new file mode 100644 index 000000000..8fe6cc499 --- /dev/null +++ b/rfc/rfc-3/rfc-3.md @@ -0,0 +1,181 @@ + +# RFC-3: xtable-spark-runtime - in-job metadata sync via a thin Spark bundle + +## Proposers + +- @vinishjail97 + +## Approvers +- Anyone from the XTable community can approve/add feedback. + +## Status + +GH Feature Request: https://github.com/apache/incubator-xtable/issues/836 + +> Please keep the status updated in `rfc/README.md`. + +## Abstract + +XTable's modules are published to Maven, so a user can depend on `xtable-core` and assemble their own +runtime today. But there is no maintained, thin, drop-in artifact — everyone re-solves the same +shading/classpath problem, or uses `xtable-utilities`, an unshaded ~1 GB fat jar that is not practical +to add to a Spark job and requires running a separate `RunSync` process with YAML config files. + +XTable conversion is metadata-only and lightweight, so the common case — "I already write this table +with Spark, keep it in sync in other formats" — should be a one-dependency, config-only addition to an +existing pipeline. This RFC proposes `xtable-spark-runtime`: a thin, relocated Spark bundle that +registers a driver-side listener and, after each successful write to a source table, runs an +incremental `ConversionController.sync(...)` for the configured targets. It complements — does not +replace — the standalone CLI (`RunSync`). + +## Background + +Everything needed to run a sync already exists in the engine; the gap is purely packaging and a trigger: + +- The entire sync entry surface is Hadoop-`Configuration`-only: `new ConversionController(conf).sync(config, sourceProvider)`. + The caller supplies only the **source** `ConversionSourceProvider`; target providers are created + internally by `ConversionTargetFactory` (`ConversionController.java`). Source providers instantiate + directly (`new HudiConversionSourceProvider()` / `IcebergConversionSourceProvider` / + `DeltaConversionSourceProvider`) and take `.init(conf)`. +- `SyncMode.INCREMENTAL` is self-healing: `ConversionController` auto-falls back to a full snapshot + per-target when there is no prior sync metadata or incremental is not safe (`isIncrementalSyncSufficient`). + First sync is effectively full; subsequent syncs are incremental. +- The sync watermark (last-synced instant, pending commits) is persisted in the **target's** + `TableSyncMetadata`. There is no additional state to keep on the client side. +- `xtable-hudi-support-extensions` is a pure-Java precedent for a Spark-adjacent module whose + `XTableSyncTool` already builds `SourceTable`/`TargetTable` and calls `ConversionController.sync(...)`; + it declares Spark deps as `provided` and uses a Scala-suffixed artifactId. +- Delta conversion currently runs Delta-on-Spark in-process (`delta-core`); `DeltaConversionUtils.buildSparkSession` + uses `SparkSession.builder()...getOrCreate()` with no `master`, so it reuses the host job's active + session rather than starting a second `SparkContext`. (A Spark-free Delta **Kernel** path exists and, + once it becomes the default, removes the per-Spark-version coupling — tracked separately.) + +## Implementation + +A new **pure-Java** module `xtable-spark-runtime_${scala.binary.version}` (package `org.apache.xtable.spark`). + +### Activation (config only) + +``` +spark-submit --packages org.apache.xtable:xtable-spark-runtime_2.12: \ + --conf spark.sql.queryExecutionListeners=org.apache.xtable.spark.XTableSyncListener \ + --conf spark.xtable.tables=orders \ + --conf spark.xtable.orders.basePath=/warehouse/db/orders \ + --conf spark.xtable.orders.sourceFormat=HUDI \ + --conf spark.xtable.orders.targets=ICEBERG,DELTA +``` + +Config schema (`spark.xtable.*`): +- `spark.xtable.tables` — comma-separated per-table keys (logical names). +- Per key ``: `spark.xtable..basePath` (path-based) **or** `spark.xtable..sourceTable=db.table` + (name-based; resolved to a base path via the active `SparkSession` catalog); plus + `spark.xtable..sourceFormat` and `spark.xtable..targets` (comma list). Optional: + `spark.xtable..dataPath`, `spark.xtable..namespace`. + +Both path-based and name-based table selection are supported from the first release. + +### Components + +- **`TableSyncSpec`** — immutable description of one configured table (key, basePath, dataPath, + namespace, sourceFormat, targets). +- **`XTableSparkConfig`** — parse `SparkConf`/`Map` (+ optional `SparkSession` for name resolution) + into `List`, validating required keys and failing fast with clear messages. +- **`XTableSyncService`** — for one `TableSyncSpec`: build `SourceTable` + `TargetTable`s + + `ConversionConfig(syncMode=INCREMENTAL)`, pick the source provider via a small + `sourceProviderFor(format)` factory, and run `ConversionController.sync(...)`. Mirrors `XTableSyncTool`. +- **`PlanTargetResolver`** — best-effort extraction of the written output path from `qe.analyzed()` + (`InsertIntoHadoopFsRelationCommand`, `SaveIntoDataSourceCommand`, and the DataSource-V2 + `AppendData`/`OverwriteByExpression` nodes used by Iceberg). Isolated and unit-testable; returns + `Optional`. +- **`XTableSyncListener implements QueryExecutionListener`** — **stateless** (only immutable parsed + config). On `onSuccess`, resolve the written path (best-effort) and, if it matches a configured + table (basePath equals or is a prefix of the written path), call `XTableSyncService.sync(...)` + **inline (synchronous)**. Reads and writes to unconfigured tables are ignored — the resolver only + returns a path for recognized write commands, so a read never triggers a sync. Per-table failures + are caught (as `Throwable`) so one table can't stop the others or destabilize Spark's listener bus. + `onFailure` logs only. + +### Execution model: synchronous and stateless + +The sync watermark already lives in the target's `TableSyncMetadata`, and sync is incremental + +idempotent, so a client-side dirty/pending/single-flight structure would only duplicate authoritative +state and could not be more correct. Therefore v1 keeps no execution state. + +Spark delivers `QueryExecutionListener` callbacks asynchronously via the driver's `LiveListenerBus` +(off `SparkListenerSQLExecutionEnd`), not on the thread that ran `df.write` — so a listener cannot +block the write from returning, and the bus processes events on a single dispatch thread. Running the +sync inline on the callback therefore (a) needs no executor or locking, (b) gets single-flight per +table for free (the bus is single-threaded), and (c) completes before JVM exit for a normally +terminating job, because `SparkContext.stop()` drains the bus. A hard `kill -9` may skip a queued +callback; the next commit's sync self-heals it. + +The cost of inline execution is that it occupies the bus dispatch thread for the sync duration — +acceptable for fast metadata-only syncs, which is the common case. + +**Async / "when to trigger" is intentionally left to the user.** Whether to offload the sync to a +background thread, and even whether/when to trigger at all (only after the final write, batching +several writes, latency tolerance), depends on the job's DAG, which only the user knows. Async is an +opt-in follow-up (`spark.xtable.sync.async`), not the default. + +### Packaging (thin bundle) + +Scala-suffixed artifactId. **Spark, Hadoop, and the engine libraries (Hudi / Iceberg / Delta) are all +`provided`** — the user brings their own engine versions via the cluster or the submit +packages/jars flags, and the thin XTable bundle stays compatible across engine versions (Hudi 1.1 / +1.2, etc.). The `maven-shade-plugin` uses a **curated `artifactSet` allowlist**: only XTable's own +modules (`xtable-api`, `xtable-core`, `xtable-hudi-support-utils`) plus the libraries XTable uses +*purely internally* — `guava` and `protobuf` — which are relocated under `org.apache.xtable.shaded.*`. + +Critically, libraries XTable **exchanges across the engine API boundary must not be relocated or +bundled** — e.g. Hudi returns a real `org.apache.avro.Schema`, so a relocated/bundled `avro` in the +XTable jar produces a `NoSuchMethodError` at runtime. `avro` / `parquet` / `jackson` / `commons` are +therefore left to the provided runtime. Resulting bundle: **~3.6 MB**. + +Because `ConversionTargetFactory` discovers targets via `ServiceLoader` and would otherwise fail if +any registered engine (e.g. Delta) is absent, target discovery is made **resilient**: providers whose +engine library is not on the classpath are skipped, so a user can run with just the engines they use +(e.g. Hudi + Iceberg, no Delta). This is a small `xtable-core` change and is validated by the bundle +smoke test. + +A standalone `spark-submit` entry point (`XTableSparkSync`, the RunSync-equivalent for this bundle) +lets the shaded jar run a sync directly and is what the jar-validation test drives. + +## Rollout/Adoption Plan + +- **No breaking changes.** This is a new, additive module; existing `RunSync`/`xtable-utilities` and + `xtable-core` behavior is unchanged. +- **No impact on existing users** unless they opt in by adding the jar and setting `spark.xtable.*`. +- **Spark support:** target Spark 3.5 first (Scala 2.12), with a Spark 4 upgrade as a follow-up. The + per-Spark-version coupling comes from the Delta-on-Spark path; making Delta Kernel the default + (tracked separately) would let a single bundle serve multiple Spark lines. +- **Deferred (follow-up RFCs/PRs):** a `StreamingQueryListener` variant, a `CALL xtable.sync(...)` SQL + procedure, and the async execution opt-in. + +## Test Plan + +- **Unit:** `XTableSparkConfig` parsing (path- and name-based, missing-key errors); + `PlanTargetResolver` path extraction from representative write plans (and empty result when + unresolvable). +- **Integration (`ITXTableSyncListener`, embedded `local[*]`):** register the listener via + `spark.sql.queryExecutionListeners`, configure a Hudi source with `targets=DELTA,ICEBERG`, write the + table, then poll-with-timeout (callback delivery is async) and assert both targets by reading them + back through Spark (`format("delta")` / `format("iceberg")`) and comparing row counts to the source, + mirroring `ITConversionController.checkDatasetEquivalence`. +- **Bundle sanity:** package the module and inspect the shaded jar — confirm size is tens of MB, + `org.apache.spark`/`org.apache.hadoop` are absent, and relocated libraries live under + `org.apache.xtable.shaded.*`. diff --git a/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionTargetFactory.java b/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionTargetFactory.java index f1e7bbb6f..ada649ab7 100644 --- a/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionTargetFactory.java +++ b/xtable-core/src/main/java/org/apache/xtable/conversion/ConversionTargetFactory.java @@ -18,20 +18,23 @@ package org.apache.xtable.conversion; +import java.util.Iterator; import java.util.Properties; +import java.util.ServiceConfigurationError; import java.util.ServiceLoader; import lombok.AccessLevel; import lombok.NoArgsConstructor; +import lombok.extern.log4j.Log4j2; import org.apache.hadoop.conf.Configuration; import org.apache.xtable.delta.DeltaConversionTargetConfig; import org.apache.xtable.exception.NotSupportedException; -import org.apache.xtable.kernel.DeltaKernelConversionTarget; import org.apache.xtable.model.storage.TableFormat; import org.apache.xtable.spi.sync.ConversionTarget; +@Log4j2 @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ConversionTargetFactory { private static final ConversionTargetFactory INSTANCE = new ConversionTargetFactory(); @@ -87,7 +90,21 @@ public ConversionTarget createConversionTargetForName( TableFormat.DELTA.equalsIgnoreCase(tableFormatName) && DeltaConversionTargetConfig.fromProperties(properties).isUseKernel(); ServiceLoader loader = ServiceLoader.load(ConversionTarget.class); - for (ConversionTarget target : loader) { + Iterator iterator = loader.iterator(); + while (iterator.hasNext()) { + ConversionTarget target; + try { + target = iterator.next(); + } catch (ServiceConfigurationError | LinkageError error) { + // A registered target whose engine library is not on the classpath (e.g. Delta when only + // Hudi/Iceberg are provided). Skip it so a subset of engines can still be used; a missing + // engine for the requested format surfaces below as NotSupportedException. + log.warn( + "Skipping a registered ConversionTarget whose engine library is not on the classpath; " + + "provide the missing engine if you need this target format", + error); + continue; + } if (target.getTableFormat().equalsIgnoreCase(tableFormatName) && isDeltaKernelTarget(target) == useKernel) { return target; @@ -96,7 +113,10 @@ && isDeltaKernelTarget(target) == useKernel) { throw new NotSupportedException("Target format is not yet supported: " + tableFormatName); } + private static final String DELTA_KERNEL_TARGET_CLASS = + "org.apache.xtable.kernel.DeltaKernelConversionTarget"; + private static boolean isDeltaKernelTarget(ConversionTarget target) { - return target instanceof DeltaKernelConversionTarget; + return DELTA_KERNEL_TARGET_CLASS.equals(target.getClass().getName()); } } diff --git a/xtable-spark-runtime/pom.xml b/xtable-spark-runtime/pom.xml new file mode 100644 index 000000000..8f8db7438 --- /dev/null +++ b/xtable-spark-runtime/pom.xml @@ -0,0 +1,302 @@ + + + + 4.0.0 + + + org.apache.xtable + xtable + 0.2.0-SNAPSHOT + + + xtable-spark-runtime_${scala.binary.version} + XTable Project Spark Runtime + + Thin, drop-in Spark bundle that runs incremental XTable metadata sync in-job via a + driver-side QueryExecutionListener, activated through spark.xtable.* config only. + + + + + + org.apache.xtable + xtable-core_${scala.binary.version} + ${project.version} + + + + + commons-cli + commons-cli + + + + + org.apache.logging.log4j + log4j-api + provided + + + + + org.apache.spark + spark-sql_${scala.binary.version} + provided + + + + + org.apache.hadoop + hadoop-common + provided + + + + + org.apache.hudi + hudi-common + provided + + + org.apache.hudi + hudi-hadoop-common + ${hudi.version} + provided + + + org.apache.hudi + hudi-java-client + provided + + + org.apache.iceberg + iceberg-core + provided + + + org.apache.iceberg + iceberg-api + provided + + + io.delta + delta-core_${scala.binary.version} + provided + + + io.delta + delta-kernel-api + provided + + + io.delta + delta-kernel-defaults + provided + + + + + org.apache.avro + avro + provided + + + org.apache.parquet + parquet-avro + provided + + + + org.openjdk.jol + jol-core + provided + + + + + org.apache.xtable + xtable-core_${scala.binary.version} + ${project.version} + tests + test-jar + test + + + + org.apache.hudi + hudi-spark${spark.version.prefix}-bundle_${scala.binary.version} + test + + + org.apache.iceberg + iceberg-spark-runtime-${spark.version.prefix}_${scala.binary.version} + test + + + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + + org.apache.logging.log4j + log4j-core + test + + + org.apache.logging.log4j + log4j-slf4j2-impl + test + + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + engine-classpath + pre-integration-test + + build-classpath + + + provided + org.apache.spark,org.apache.hadoop,org.apache.xtable,org.projectlombok,org.slf4j,org.apache.logging.log4j + ${project.build.directory}/engine-classpath.txt + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + + shade + + + true + bundle + false + + + org.apache.xtable:xtable-api + org.apache.xtable:xtable-core_${scala.binary.version} + org.apache.xtable:xtable-hudi-support-utils + com.google.guava:guava + com.google.guava:failureaccess + com.google.protobuf:protobuf-java + commons-cli:commons-cli + + + + + com.google.common + org.apache.xtable.shaded.com.google.common + + + com.google.thirdparty + org.apache.xtable.shaded.com.google.thirdparty + + + com.google.protobuf + org.apache.xtable.shaded.com.google.protobuf + + + org.apache.commons.cli + org.apache.xtable.shaded.org.apache.commons.cli + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + **/Log4j2Plugins.dat + + + + + + + + + LICENSE + NOTICE + NOTICE.txt + + + + + + + + + + diff --git a/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/TableSyncSpec.java b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/TableSyncSpec.java new file mode 100644 index 000000000..0d346010c --- /dev/null +++ b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/TableSyncSpec.java @@ -0,0 +1,58 @@ +/* + * 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.xtable.spark; + +import java.util.List; + +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; + +/** + * Immutable description of a single table to keep in sync, resolved from {@code spark.xtable.*} + * configuration. One {@link TableSyncSpec} is produced per key listed in {@code + * spark.xtable.tables}. + */ +@Value +@Builder +public class TableSyncSpec { + /** The logical key for this table (the token listed in {@code spark.xtable.tables}). */ + @NonNull String key; + + /** Absolute base path of the source table. */ + @NonNull String basePath; + + /** Optional path to the data files; defaults to {@link #basePath} downstream when null. */ + String dataPath; + + /** Optional namespace segments for the table. */ + String[] namespace; + + /** + * Optional Hudi source partition spec (e.g. {@code level:VALUE}); only applies to a partitioned + * Hudi source. Maps to {@code xtable.hudi.source.partition_field_spec_config}. + */ + String partitionSpec; + + /** The source table format, e.g. {@code HUDI} (see {@code TableFormat}). */ + @NonNull String sourceFormat; + + /** The target formats to sync to, e.g. {@code [ICEBERG, DELTA]}. */ + @NonNull List targets; +} diff --git a/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSparkSync.java b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSparkSync.java new file mode 100644 index 000000000..68c8d2599 --- /dev/null +++ b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSparkSync.java @@ -0,0 +1,165 @@ +/* + * 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.xtable.spark; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import lombok.extern.log4j.Log4j2; + +import org.apache.commons.cli.CommandLine; +import org.apache.commons.cli.CommandLineParser; +import org.apache.commons.cli.DefaultParser; +import org.apache.commons.cli.HelpFormatter; +import org.apache.commons.cli.Option; +import org.apache.commons.cli.Options; +import org.apache.commons.cli.ParseException; +import org.apache.hadoop.conf.Configuration; +import org.apache.spark.sql.SparkSession; + +/** + * Standalone {@code spark-submit} entry point that runs a single XTable sync using the relocated + * {@code xtable-spark-runtime} bundle. It is the RunSync-equivalent for this bundle and is + * primarily used to validate that the shaded jar is self-contained on a real Spark install. + * + *
+ * $SPARK_HOME/bin/spark-submit \
+ *   --class org.apache.xtable.spark.XTableSparkSync \
+ *   xtable-spark-runtime_2.12-<ver>.jar \
+ *   --basePath /warehouse/db/orders --sourceFormat HUDI --targets ICEBERG,DELTA
+ * 
+ */ +@Log4j2 +public final class XTableSparkSync { + + private static final String BASE_PATH = "basePath"; + private static final String DATA_PATH = "dataPath"; + private static final String SOURCE_FORMAT = "sourceFormat"; + private static final String TARGETS = "targets"; + private static final String TABLE_NAME = "tableName"; + private static final String NAMESPACE = "namespace"; + private static final String PARTITION_SPEC = "partitionSpec"; + private static final String HELP = "help"; + + private static final Options OPTIONS = + new Options() + .addOption( + Option.builder() + .longOpt(BASE_PATH) + .hasArg() + .required() + .desc("The base path of the source table") + .build()) + .addOption( + Option.builder() + .longOpt(SOURCE_FORMAT) + .hasArg() + .required() + .desc("The source table format, e.g. HUDI, DELTA or ICEBERG") + .build()) + .addOption( + Option.builder() + .longOpt(TARGETS) + .hasArg() + .required() + .desc("Comma-separated target formats to sync to, e.g. ICEBERG,DELTA") + .build()) + .addOption( + Option.builder() + .longOpt(DATA_PATH) + .hasArg() + .desc("The path of the data files if different from the base path") + .build()) + .addOption( + Option.builder() + .longOpt(TABLE_NAME) + .hasArg() + .desc("The table name; defaults to the last segment of the base path") + .build()) + .addOption( + Option.builder() + .longOpt(NAMESPACE) + .hasArg() + .desc("The dot-separated table namespace") + .build()) + .addOption( + Option.builder() + .longOpt(PARTITION_SPEC) + .hasArg() + .desc("The Hudi source partition field spec, e.g. level:VALUE") + .build()) + .addOption(Option.builder().longOpt(HELP).desc("Displays help information").build()); + + private XTableSparkSync() {} + + public static void main(String[] args) { + CommandLineParser parser = new DefaultParser(); + CommandLine cmd; + try { + cmd = parser.parse(OPTIONS, args); + } catch (ParseException e) { + new HelpFormatter().printHelp("xtable-spark-sync", OPTIONS, true); + throw new IllegalArgumentException("Failed to parse arguments", e); + } + if (cmd.hasOption(HELP)) { + new HelpFormatter().printHelp("xtable-spark-sync", OPTIONS, true); + return; + } + + String basePath = cmd.getOptionValue(BASE_PATH); + String tableName = cmd.getOptionValue(TABLE_NAME, basePathToName(basePath)); + List targetFormats = + Arrays.stream(cmd.getOptionValue(TARGETS).split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(s -> s.toUpperCase(Locale.ROOT)) + .collect(Collectors.toList()); + String namespace = cmd.getOptionValue(NAMESPACE); + + TableSyncSpec spec = + TableSyncSpec.builder() + .key(tableName) + .basePath(basePath) + .dataPath(cmd.getOptionValue(DATA_PATH)) + .namespace(namespace == null ? null : namespace.split("\\.")) + .partitionSpec(cmd.getOptionValue(PARTITION_SPEC)) + .sourceFormat(cmd.getOptionValue(SOURCE_FORMAT).toUpperCase(Locale.ROOT)) + .targets(targetFormats) + .build(); + + SparkSession spark = SparkSession.builder().appName("xtable-spark-sync").getOrCreate(); + try { + Configuration hadoopConf = spark.sparkContext().hadoopConfiguration(); + log.info("Starting standalone XTable sync for {}", spec.getBasePath()); + new XTableSyncService().sync(spec, hadoopConf); + log.info("Completed XTable sync for {}", spec.getBasePath()); + } finally { + spark.stop(); + } + } + + private static String basePathToName(String basePath) { + String trimmed = + basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath; + int idx = trimmed.lastIndexOf('/'); + return idx >= 0 ? trimmed.substring(idx + 1) : trimmed; + } +} diff --git a/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSyncService.java b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSyncService.java new file mode 100644 index 000000000..6f9b62e19 --- /dev/null +++ b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSyncService.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.xtable.spark; + +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.stream.Collectors; + +import lombok.extern.log4j.Log4j2; + +import org.apache.hadoop.conf.Configuration; + +import org.apache.xtable.conversion.ConversionConfig; +import org.apache.xtable.conversion.ConversionController; +import org.apache.xtable.conversion.ConversionSourceProvider; +import org.apache.xtable.conversion.SourceTable; +import org.apache.xtable.conversion.TargetTable; +import org.apache.xtable.delta.DeltaConversionSourceProvider; +import org.apache.xtable.hudi.HudiConversionSourceProvider; +import org.apache.xtable.hudi.HudiSourceConfig; +import org.apache.xtable.iceberg.IcebergConversionSourceProvider; +import org.apache.xtable.model.storage.TableFormat; +import org.apache.xtable.model.sync.SyncMode; +import org.apache.xtable.model.sync.SyncResult; + +/** + * Builds a {@link ConversionConfig} from a {@link TableSyncSpec} and runs an incremental {@link + * ConversionController#sync} for it. This is the unit of work triggered by {@link + * XTableSyncListener} after a successful write. + * + *

The sync watermark is persisted in the target's {@code TableSyncMetadata}, and {@link + * SyncMode#INCREMENTAL} auto-falls back to a full snapshot when incremental is not safe (e.g. the + * very first sync), so this call is idempotent and self-healing. + */ +@Log4j2 +public class XTableSyncService { + + /** Runs a single sync for the given spec, returning the per-format results. */ + public Map sync(TableSyncSpec spec, Configuration hadoopConf) { + Properties sourceProperties = new Properties(); + if (spec.getPartitionSpec() != null && !spec.getPartitionSpec().isEmpty()) { + sourceProperties.put(HudiSourceConfig.PARTITION_FIELD_SPEC_CONFIG, spec.getPartitionSpec()); + } + // The data files may live at a different path than the source table root (e.g. Iceberg keeps + // them under /data). Targets write their metadata alongside the data files, so the + // target base path is the data path (required by Hudi), defaulting to the source base path. + String dataPath = + spec.getDataPath() != null && !spec.getDataPath().isEmpty() + ? spec.getDataPath() + : spec.getBasePath(); + SourceTable sourceTable = + SourceTable.builder() + .name(spec.getKey()) + .basePath(spec.getBasePath()) + .dataPath(dataPath) + .namespace(spec.getNamespace()) + .formatName(spec.getSourceFormat()) + .additionalProperties(sourceProperties) + .build(); + + List targetTables = + spec.getTargets().stream() + .map( + targetFormat -> + TargetTable.builder() + .name(spec.getKey()) + .basePath(dataPath) + .namespace(spec.getNamespace()) + .formatName(targetFormat) + .build()) + .collect(Collectors.toList()); + + ConversionConfig conversionConfig = + ConversionConfig.builder() + .sourceTable(sourceTable) + .targetTables(targetTables) + .syncMode(SyncMode.INCREMENTAL) + .build(); + + ConversionSourceProvider sourceProvider = sourceProviderFor(spec.getSourceFormat()); + sourceProvider.init(hadoopConf); + + log.info( + "Running XTable sync for table {} ({} -> {}) at {}", + spec.getKey(), + spec.getSourceFormat(), + spec.getTargets(), + spec.getBasePath()); + return new ConversionController(hadoopConf).sync(conversionConfig, sourceProvider); + } + + private static ConversionSourceProvider sourceProviderFor(String sourceFormat) { + switch (sourceFormat.toUpperCase()) { + case TableFormat.HUDI: + return new HudiConversionSourceProvider(); + case TableFormat.DELTA: + return new DeltaConversionSourceProvider(); + case TableFormat.ICEBERG: + return new IcebergConversionSourceProvider(); + default: + throw new UnsupportedOperationException( + "Unsupported source format for spark-runtime sync: " + sourceFormat); + } + } +} diff --git a/xtable-spark-runtime/src/test/java/org/apache/xtable/spark/ITXTableSparkRuntimeBundle.java b/xtable-spark-runtime/src/test/java/org/apache/xtable/spark/ITXTableSparkRuntimeBundle.java new file mode 100644 index 000000000..70d5c0fb5 --- /dev/null +++ b/xtable-spark-runtime/src/test/java/org/apache/xtable/spark/ITXTableSparkRuntimeBundle.java @@ -0,0 +1,303 @@ +/* + * 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.xtable.spark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import org.apache.spark.api.java.JavaSparkContext; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.apache.hudi.client.HoodieReadClient; + +import org.apache.xtable.GenericTable; +import org.apache.xtable.hudi.HudiTestUtil; + +/** + * Validates that the relocated {@code xtable-spark-runtime} bundle jar is self-contained by running + * an actual XTable sync via {@code $SPARK_HOME/bin/spark-submit} for one case per direction, and + * asserting the target is data-equivalent to the source. Engines (and the Avro/Parquet versions + * they require) are supplied to the submit on a flat classpath via {@code + * spark.driver.extraClassPath} from {@code target/engine-classpath.txt}, never bundled; a flat + * classpath keeps a single Avro on one loader, matching a real cluster. Skipped when {@code + * SPARK_HOME} is not set. + * + *

Directions are limited to Hudi and Iceberg: the root pom pins Delta 2.4.0 (Spark 3.4), which + * does not run on a Spark 3.5 {@code SPARK_HOME}. Delta conversion is covered in-process by {@code + * ITConversionController} (engine) and {@code ITXTableSyncListener} (listener). + */ +class ITXTableSparkRuntimeBundle { + + @TempDir static java.nio.file.Path tempDir; + + private static SparkSession sparkSession; + private static JavaSparkContext jsc; + + @BeforeAll + static void setupSpark() { + sparkSession = + SparkSession.builder() + .config(HoodieReadClient.addHoodieSupport(HudiTestUtil.getSparkConf(tempDir))) + .getOrCreate(); + jsc = JavaSparkContext.fromSparkContext(sparkSession.sparkContext()); + } + + @AfterAll + static void stopSpark() { + if (jsc != null) { + jsc.close(); + } + if (sparkSession != null) { + sparkSession.stop(); + } + } + + private static Stream directions() { + // sourceFormat, targetFormat, hudiPartitionSpec (only used for a partitioned Hudi source) + return Stream.of( + Arguments.of("HUDI", "ICEBERG", "level:VALUE"), Arguments.of("ICEBERG", "HUDI", null)); + } + + @ParameterizedTest(name = "{0} -> {1}") + @MethodSource("directions") + void bundleRunsSyncViaSparkSubmit(String sourceFormat, String targetFormat, String partitionSpec) + throws Exception { + String sparkHome = System.getenv("SPARK_HOME"); + assumeTrue(sparkHome != null && !sparkHome.trim().isEmpty(), "SPARK_HOME is not set"); + + String tableName = sourceFormat.toLowerCase() + "_to_" + targetFormat.toLowerCase(); + String basePath; + String dataPath; + List columns; + String orderByColumn; + try (GenericTable table = + GenericTable.getInstance(tableName, tempDir, sparkSession, jsc, sourceFormat, true)) { + table.insertRows(100); + basePath = table.getBasePath(); + // Targets write metadata alongside the data files; for Iceberg this is /data. + dataPath = table.getDataPath(); + columns = table.getColumnsToSelect(); + orderByColumn = table.getOrderByColumn(); + } + + File bundleJar = findBundleJar(); + File sparkSubmit = new File(sparkHome, "bin/spark-submit"); + assertTrue(sparkSubmit.canExecute(), "spark-submit not executable at " + sparkSubmit); + + // Engines are provided on a FLAT classpath (single avro on one loader), NOT via --packages, + // which would layer them in a child classloader and break cross-boundary avro casts. + String engineClasspath = readEngineClasspath(); + + List command = new ArrayList<>(); + command.add(sparkSubmit.getAbsolutePath()); + command.add("--master"); + command.add("local[2]"); + command.add("--conf"); + command.add("spark.driver.extraClassPath=" + engineClasspath); + command.add("--conf"); + command.add("spark.executor.extraClassPath=" + engineClasspath); + command.add("--class"); + command.add("org.apache.xtable.spark.XTableSparkSync"); + command.add(bundleJar.getAbsolutePath()); + command.add("--basePath"); + command.add(basePath); + command.add("--dataPath"); + command.add(dataPath); + command.add("--sourceFormat"); + command.add(sourceFormat); + command.add("--targets"); + command.add(targetFormat); + command.add("--tableName"); + command.add(tableName); + if (partitionSpec != null) { + command.add("--partitionSpec"); + command.add(partitionSpec); + } + + ProcessBuilder pb = new ProcessBuilder(command); + pb.environment().put("SPARK_LOCAL_IP", "127.0.0.1"); + pb.redirectErrorStream(true); + Process process = pb.start(); + String output = readOutput(process); + boolean finished = process.waitFor(10, TimeUnit.MINUTES); + if (!finished) { + process.destroyForcibly(); + throw new AssertionError("spark-submit timed out. Output:\n" + output); + } + assertEquals(0, process.exitValue(), "spark-submit failed. Output:\n" + output); + + assertDatasetEquivalence( + sourceFormat, targetFormat, basePath, dataPath, columns, orderByColumn, output); + } + + /** + * Reads the source (at its base path) and the target (at the data path, where the target metadata + * was written) back through Spark and asserts they are row-for-row equivalent, mirroring {@code + * ITConversionController.checkDatasetEquivalence}: a Hudi target is read with its metadata table + * enabled, and per-format column expressions normalize representation differences. + */ + private void assertDatasetEquivalence( + String sourceFormat, + String targetFormat, + String basePath, + String dataPath, + List columns, + String orderByColumn, + String submitOutput) { + Dataset source = + sparkSession + .read() + .options(readOptions(sourceFormat)) + .format(sourceFormat.toLowerCase()) + .load(basePath) + .orderBy(orderByColumn); + // Compare scalar columns only. Nested/array/map read parity across writer and reader engines is + // covered in-process by ITConversionController; here it would exercise a Hudi-Parquet list + // encoding vs Iceberg reader quirk unrelated to whether the bundle jar runs the sync. + List scalarColumns = atomicColumns(source, columns); + List sourceRows = + source.selectExpr(selectColumns(scalarColumns, sourceFormat)).toJSON().collectAsList(); + List targetRows = + sparkSession + .read() + .options(readOptions(targetFormat)) + .format(targetFormat.toLowerCase()) + .load(dataPath) + .orderBy(orderByColumn) + .selectExpr(selectColumns(scalarColumns, targetFormat)) + .toJSON() + .collectAsList(); + assertEquals( + 100, + targetRows.size(), + "Unexpected target row count. spark-submit output:\n" + submitOutput); + assertEquals(sourceRows, targetRows, "Target is not data-equivalent to the source"); + } + + // Keeps only the requested columns whose source type is atomic (not struct/array/map). + private static List atomicColumns(Dataset source, List columns) { + java.util.Set nonAtomic = new java.util.HashSet<>(); + for (org.apache.spark.sql.types.StructField field : source.schema().fields()) { + org.apache.spark.sql.types.DataType type = field.dataType(); + if (type instanceof org.apache.spark.sql.types.StructType + || type instanceof org.apache.spark.sql.types.ArrayType + || type instanceof org.apache.spark.sql.types.MapType) { + nonAtomic.add(field.name()); + } + } + return columns.stream() + .filter(c -> !nonAtomic.contains(c)) + .collect(java.util.stream.Collectors.toList()); + } + + private static java.util.Map readOptions(String format) { + java.util.Map options = new java.util.HashMap<>(); + if ("HUDI".equalsIgnoreCase(format)) { + options.put("hoodie.metadata.enable", "true"); + options.put("hoodie.datasource.read.extract.partition.values.from.path", "true"); + } else if ("ICEBERG".equalsIgnoreCase(format)) { + // Use the row-based reader: Iceberg's vectorized Arrow reader mis-casts timestamp columns + // written by Hudi (TimeStampMicroTZVector vs BigIntVector) for this cross-engine table. + options.put("vectorization-enabled", "false"); + } + return options; + } + + // Normalizes local-timestamp columns whose Hudi/Iceberg representations differ, matching + // ITConversionController.getSelectColumnsArr; other columns pass through unchanged. + private static String[] selectColumns(List columns, String format) { + boolean isHudi = "HUDI".equalsIgnoreCase(format); + boolean isIceberg = "ICEBERG".equalsIgnoreCase(format); + return columns.stream() + .map( + colName -> { + if (colName.startsWith("timestamp_local_millis")) { + if (isHudi) { + return String.format( + "unix_millis(CAST(%s AS TIMESTAMP)) AS %s", colName, colName); + } else if (isIceberg) { + return String.format("%s div 1000 AS %s", colName, colName); + } + return colName; + } else if (isHudi && colName.startsWith("timestamp_local_micros")) { + return String.format("unix_micros(CAST(%s AS TIMESTAMP)) AS %s", colName, colName); + } + return colName; + }) + .toArray(String[]::new); + } + + private String readEngineClasspath() throws Exception { + File cpFile = new File("target/engine-classpath.txt"); + assertTrue( + cpFile.isFile(), + "engine classpath not built at " + + cpFile.getAbsolutePath() + + " (dependency:build-classpath)"); + String cp = + new String( + java.nio.file.Files.readAllBytes(cpFile.toPath()), + java.nio.charset.StandardCharsets.UTF_8) + .trim(); + assertTrue(!cp.isEmpty(), "engine classpath file is empty"); + return cp; + } + + private File findBundleJar() { + File targetDir = new File("target"); + File[] candidates = + targetDir.listFiles( + (dir, name) -> name.startsWith("xtable-spark-runtime") && name.endsWith("-bundle.jar")); + assertTrue( + candidates != null && candidates.length == 1, + "Expected exactly one bundle jar in " + + targetDir.getAbsolutePath() + + " (run the package phase first)"); + return candidates[0]; + } + + private static String readOutput(Process process) throws Exception { + StringBuilder sb = new StringBuilder(); + try (java.io.BufferedReader reader = + new java.io.BufferedReader( + new java.io.InputStreamReader( + process.getInputStream(), java.nio.charset.StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append(System.lineSeparator()); + } + } + return sb.toString(); + } +}