This is an automated email from the ASF dual-hosted git repository.
vinishjail97 pushed a commit to branch branch-0.4
in repository https://gitbox.apache.org/repos/asf/incubator-xtable.git
The following commit(s) were added to refs/heads/branch-0.4 by this push:
new f2d98733 [836] xtable-spark-runtime: thin drop-in Spark bundle for
0.4.x (Spark 3.4 + 3.5) (#843)
f2d98733 is described below
commit f2d987330a84d8c559310ac1c8f31cfb32f6e025
Author: Vinish Reddy <[email protected]>
AuthorDate: Wed Jul 22 00:20:38 2026 +0530
[836] xtable-spark-runtime: thin drop-in Spark bundle for 0.4.x (Spark 3.4
+ 3.5) (#843)
* [836] xtable-spark-runtime: thin drop-in Spark bundle (Hudi 0.x)
Ports the xtable-spark-runtime packaging slice onto the Hudi 0.x line
(main-hudi-0x, Hudi 0.14 / Spark 3.4). New thin, relocated bundle that
runs an incremental XTable sync inside a Spark job; engines are provided
by the cluster, never bundled.
Module:
- xtable-spark-runtime_${scala.binary.version}: xtable-core compile;
Spark/Hadoop and the engines (Hudi/Iceberg/Delta) provided. Curated
shade allowlist (xtable modules + guava/protobuf/commons-cli relocated);
avro/parquet/jackson NOT relocated (exchanged with the engines). Thin
~3.7 MB bundle.
- XTableSparkSync: standalone spark-submit entry point (Apache Commons CLI).
- XTableSyncService / TableSyncSpec: build an INCREMENTAL ConversionConfig
and run ConversionController.sync; target metadata path = source data
path (required by Hudi; Iceberg data lives under <basePath>/data).
Hudi 0.14 specifics (vs the Hudi 1.x variant on main):
- No hudi-hadoop-common (that split is 1.x); hudi-common provides FSUtils.
- Engine classpath uses the hudi-spark bundle (its regenerated Avro model
classes link on Avro 1.12, which Iceberg 1.9.2 requires) plus
hudi-java-client for the Hudi target's Java write client, with the raw
hudi-common excluded so the bundle's clean DecimalWrapper wins.
ConversionTargetFactory: make ServiceLoader discovery resilient so a
subset of engines works when others are absent (warn + skip on
LinkageError/ServiceConfigurationError; name-based Delta-Kernel check).
ITXTableSparkRuntimeBundle: spark-submits the shaded jar for one case per
direction across Hudi, Iceberg and Delta (source and target), engines on
a flat classpath, asserting data-equivalence over comparable scalar
columns. Requires a Spark 3.4 SPARK_HOME; skipped otherwise.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* [836] xtable-spark-runtime: address review feedback (packaging,
licensing, RFC)
Publish the shaded jar as the MAIN artifact with a dependency-reduced POM
(shadedArtifactAttached=false, createDependencyReducedPom=true), matching
iceberg-spark-runtime / hudi-spark-bundle. Resolving the coordinate via a
Maven dependency or --packages now yields the relocated bundle and pulls no
un-relocated transitive deps; --jars is equivalent. The thin main jar +
separate -bundle classifier (which re-introduced the cluster guava clash)
is gone. IT findBundleJar() now picks the shaded main jar.
Pass release/scripts/validate_shaded_license_coverage.sh (the existing
allowlist gate): the shade <includes> must equal the runtime dependency
tree, so jackson / scala-library / log4j-1.2-api - which every Spark
runtime supplies - are declared provided (dropped from the tree, kept off
the shaded jar) and excluded from the IT's flat engine classpath so Spark's
own copies win. avro/parquet stay on the flat classpath (the engine's newer
avro must win over Spark 3.4's).
Bundled-dependency licensing: add META-INF/LICENSE-bundled and
NOTICE-bundled (wired via IncludeResourceTransformer) attributing the only
bundled third-party - guava's closure (Apache-2.0) and commons-cli
(Apache-2.0), plus checker-qual (MIT). Remove the dead protobuf-java
allowlist entry and relocation: protobuf resolves as provided and was never
bundled.
RFC-3: scope v1 to the CLI (XTableSparkSync); mark the config-only
XTableSyncListener on-ramp (and XTableSparkConfig / PlanTargetResolver) as a
deferred follow-up. Fix the activation example to the shipped CLI and
document the required engine Avro version + flat-classpath placement.
XTableSyncService: normalize sourceFormat with Locale.ROOT.
Root pom: exclude the shade-generated dependency-reduced-pom.xml from
spotless (no license header; CI runs clean install).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* [836] xtable-spark-runtime: trim duplicated pom comments
Consolidate repeated rationale in the bundle pom (no functional change):
- Tell the hudi-common DecimalWrapper / Avro-1.8-1.9 story once (on the
hudi-spark bundle dep); the hudi-java-client exclusion just points to it.
- State "engine Avro must win on a flat classpath" once (engine-classpath
plugin); the avro dep and excludeGroupIds comments reference it.
- Explain jackson/scala-library are Spark-supplied once.
- Fix a stale "Spark 3.5" reference to Spark 3.4 (this is the Hudi 0.x
line).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* [836] ci: trigger Maven CI + License Check on main-hudi-0x
Add main-hudi-0x to the push and pull_request branch filters of both
workflows (same change as #841) so CI runs on PRs targeting the Hudi 0.x
release line, including this one. A pull_request workflow's branch filter
takes effect from the PR's own branch, so this makes CI fire on #843.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Address review feedback: guard ServiceLoader.hasNext() and drain
spark-submit stdout off-thread
- ConversionTargetFactory: hasNext() resolves provider classes lazily and
can
throw ServiceConfigurationError too, so move it inside the existing
ServiceConfigurationError|LinkageError guard alongside next().
- ITXTableSparkRuntimeBundle: drain spark-submit stdout on a background
thread
so a hung process is caught by waitFor(10min) instead of blocking forever
on
readOutput() reading until EOF.
* [836] ci: point Maven CI + License Check triggers at branch-0.4
main-hudi-0x was deleted and replaced by branch-0.4 (the 0.4.0 release
line),
so update the push/pull_request branch filters accordingly.
* [836] ci: validate xtable-spark-runtime bundle against a real Spark distro
ITXTableSparkRuntimeBundle spark-submits the shaded bundle jar and
self-skips unless SPARK_HOME is set, so the main CI never runs it. Add a
path-filtered workflow that installs a matching Spark 3.4 distribution
from the Apache archive, sets SPARK_HOME, and runs the failsafe IT.
* [836] ci: run spark-runtime validation on every branch PR (drop path
filter)
The workflow is intended to be a required status check on branch-0.4. A
required check whose workflow is skipped by a path filter never reports,
leaving PRs blocked on a pending check, so run it unconditionally on the
covered branches. The Spark distro is cached, so the added cost is the
reactor build plus the ~20s IT.
* [836] xtable-spark-runtime: address review feedback (CLI validation, unix
flags, docs)
- XTableSparkSync: validate --sourceformat/--targets up front and fail fast
(before SparkSession creation) on empty or unsupported values. Split the
allowed sets: sources are Hudi/Iceberg/Delta/Paimon/Parquet, targets are
Hudi/Iceberg/Delta (Paimon/Parquet are read-only, no ConversionTarget).
- XTableSyncService.sourceProviderFor: wire Paimon and Parquet source
providers
(previously threw UnsupportedOperationException); engines remain
cluster-provided.
- Rename CLI long-opts to unix-style lowercase (--basepath, --sourceformat,
--tablename, --datapath, --partitionspec); update javadoc, IT and RFC
example.
- basePathToName -> basePathToTableName: handle "/", trailing slashes and
null
by throwing with a "pass --tablename" hint instead of an empty table name.
- Add XTableSparkSyncTest covering table-name derivation and source/target
format validation.
- ConversionTargetFactory: log the skipped provider's error class/message so
operators can distinguish an intentionally-absent engine from a linkage
error.
- spark-runtime-validation.yml: add a workflow_dispatch spark_version input
and
document why the Spark 3.4 line is pinned (Delta 2.4.0 is Spark-3.4-only).
- RFC-3: add a supported-formats/engine-versions section, "from application
code" (Scala/Java + PySpark) activation examples, and list @vinothchandar
as
an approver.
* [836] xtable-spark-runtime: support Spark 3.5 via Delta Kernel
Delta source/target now auto-switch from delta-core to the Spark-free
Delta Kernel implementation on Spark 3.5+ (where the bundled delta-core
does not run), controlled by a single --usedeltakernel toggle that is
auto-enabled by Spark version. Hudi/Iceberg sync was already Spark-free.
Also runs the spark-runtime bundle IT on both Spark 3.4.3 and 3.5.9 via
a CI matrix.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* [836] Give Maven CI Build and License Check unique job names
Both workflows used a job named "build", so both reported the same
status-check context and could not be required distinctly. Set unique
job names so branch protection (see apache/incubator-xtable#848) can
require each on branch-0.4.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* [836] xtable-spark-runtime: add --datasetconfig for multi-table sync
XTableSparkSync accepts a --datasetconfig YAML (sourceFormat, targetFormats,
datasets[]) to sync multiple tables in one run, mutually exclusive with the
single-table --basepath/--sourceformat/--targets flags. The config is read
through the Spark Hadoop config, so it may live on a local or cloud
(s3/gcs/abfs) path, and reuses the same schema as the RunSync utility.
Parsed with SnakeYAML's SafeConstructor (plain maps/lists only, no arbitrary
type instantiation). SnakeYAML is bundled and relocated to
org.apache.xtable.shaded (like guava/commons-cli) because Spark ships its
own
version (1.33 on 3.4, 2.0 on 3.5) that would otherwise clash; its
multi-release
classes are filtered out so no un-relocated org.yaml classes remain.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
.github/workflows/mvn-ci-build.yml | 1 +
.github/workflows/mvn-license-check.yml | 1 +
.github/workflows/spark-runtime-validation.yml | 93 +++++
pom.xml | 3 +
rfc/rfc-3/rfc-3.md | 297 ++++++++++++++
.../xtable/conversion/ConversionTargetFactory.java | 36 +-
xtable-spark-runtime/pom.xml | 407 +++++++++++++++++++
.../org/apache/xtable/spark/TableSyncSpec.java | 61 +++
.../org/apache/xtable/spark/XTableSparkSync.java | 440 +++++++++++++++++++++
.../org/apache/xtable/spark/XTableSyncService.java | 145 +++++++
.../src/main/resources/META-INF/LICENSE-bundled | 222 +++++++++++
.../src/main/resources/META-INF/NOTICE-bundled | 22 ++
.../xtable/spark/ITXTableSparkRuntimeBundle.java | 318 +++++++++++++++
.../apache/xtable/spark/XTableSparkSyncTest.java | 197 +++++++++
14 files changed, 2240 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/mvn-ci-build.yml
b/.github/workflows/mvn-ci-build.yml
index 5e3e1b76..3a7050de 100644
--- a/.github/workflows/mvn-ci-build.yml
+++ b/.github/workflows/mvn-ci-build.yml
@@ -33,6 +33,7 @@ on:
jobs:
build:
+ name: Maven CI Build
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
diff --git a/.github/workflows/mvn-license-check.yml
b/.github/workflows/mvn-license-check.yml
index 970e982d..193bc286 100644
--- a/.github/workflows/mvn-license-check.yml
+++ b/.github/workflows/mvn-license-check.yml
@@ -33,6 +33,7 @@ on:
jobs:
build:
+ name: License Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
diff --git a/.github/workflows/spark-runtime-validation.yml
b/.github/workflows/spark-runtime-validation.yml
new file mode 100644
index 00000000..a817d0a5
--- /dev/null
+++ b/.github/workflows/spark-runtime-validation.yml
@@ -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.
+#
+name: Spark Runtime Bundle Validation
+
+# Validates the shaded xtable-spark-runtime bundle end-to-end:
ITXTableSparkRuntimeBundle
+# spark-submits the bundled jar against a real Spark distribution for one case
per direction
+# across Hudi, Iceberg and Delta. The IT self-skips unless SPARK_HOME is set,
so the main CI
+# never exercises it; this workflow installs each Spark distro in the matrix
and runs it.
+# Runs on both Spark 3.4 and 3.5: Hudi/Iceberg sync is Spark-free, and a Delta
target/source
+# auto-switches from delta-core to the Spark-free Delta Kernel on Spark 3.5+.
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ !contains(github.ref, 'main') }}
+
+# Runs on every push/PR to the covered branches (no path filter) so the check
always
+# reports — required status checks that never trigger leave a PR blocked on a
pending check.
+on:
+ push:
+ branches:
+ - "main"
+ - "branch-0.4"
+
+ pull_request:
+ branches:
+ - "main"
+ - "branch-0.4"
+
+ workflow_dispatch:
+
+env:
+ SPARK_HADOOP_PROFILE: "hadoop3"
+
+jobs:
+ validate-spark-runtime-bundle:
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ # 3.4: Delta via delta-core. 3.5: Delta auto-switches to the
Spark-free Delta Kernel.
+ spark_version: ["3.4.3", "3.5.9"]
+ env:
+ SPARK_VERSION: ${{ matrix.spark_version }}
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Set up JDK 11
+ uses: actions/setup-java@v5
+ with:
+ java-version: '11'
+ distribution: 'temurin'
+ cache: maven
+
+ - name: Cache Spark distribution
+ uses: actions/cache@v4
+ with:
+ path: ~/spark
+ key: spark-${{ env.SPARK_VERSION }}-bin-${{ env.SPARK_HADOOP_PROFILE
}}
+
+ - name: Install Spark
+ run: |
+ set -euo pipefail
+ SPARK_DIR="spark-${SPARK_VERSION}-bin-${SPARK_HADOOP_PROFILE}"
+ if [ ! -d "$HOME/spark/$SPARK_DIR" ]; then
+ mkdir -p "$HOME/spark"
+ echo "Downloading $SPARK_DIR from the Apache archive"
+ curl -fsSL
"https://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/${SPARK_DIR}.tgz"
\
+ | tar -xz -C "$HOME/spark"
+ fi
+ echo "SPARK_HOME=$HOME/spark/$SPARK_DIR" >> "$GITHUB_ENV"
+
+ - name: Build reactor (skip tests)
+ run: ./mvnw clean install -ntp -B -DskipTests
+
+ - name: Run xtable-spark-runtime bundle IT
+ env:
+ SPARK_LOCAL_IP: "127.0.0.1"
+ run: ./mvnw verify -ntp -B -pl xtable-spark-runtime
diff --git a/pom.xml b/pom.xml
index 434bb21e..3478a7aa 100644
--- a/pom.xml
+++ b/pom.xml
@@ -54,6 +54,7 @@
<module>xtable-aws</module>
<module>xtable-hive-metastore</module>
<module>xtable-service</module>
+ <module>xtable-spark-runtime</module>
</modules>
<properties>
@@ -937,6 +938,8 @@
<excludes>
<exclude>.idea/**</exclude>
<exclude>${target.dir.pattern}</exclude>
+ <!-- Generated by maven-shade-plugin (no license
header); not source. -->
+ <exclude>**/dependency-reduced-pom.xml</exclude>
<exclude>website/node_modules/**</exclude>
<exclude>website/build/**</exclude>
<exclude>website/.docusaurus/**</exclude>
diff --git a/rfc/rfc-3/rfc-3.md b/rfc/rfc-3/rfc-3.md
new file mode 100644
index 00000000..bb41415d
--- /dev/null
+++ b/rfc/rfc-3/rfc-3.md
@@ -0,0 +1,297 @@
+<!--
+ 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.
+-->
+# RFC-3: xtable-spark-runtime - in-job metadata sync via a thin Spark bundle
+
+## Proposers
+
+- @vinishjail97
+
+## Approvers
+- @vinothchandar
+- 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`).
+
+### What ships in v1
+
+This PR delivers the **thin bundle plus the standalone `spark-submit` entry
point
+`XTableSparkSync`** — enough to run a relocated, in-cluster sync from one
command. The config-only,
+driver-side auto-sync (`XTableSyncListener` registered via
`spark.sql.queryExecutionListeners`, with
+`XTableSparkConfig` and `PlanTargetResolver`) is the headline on-ramp this RFC
designs toward, but it
+is **deferred to a follow-up PR** — those classes are not in v1. The listener
design is retained
+below as the target so the entry point and config schema stay aligned.
+
+### Supported formats and engine versions (v1)
+
+The bundle can read from Hudi, Iceberg, Delta, Paimon and Parquet, and write
to Hudi, Iceberg and
+Delta. Paimon and Parquet are **source-only** (there is no Paimon/Parquet
`ConversionTarget`), so they
+are valid for `--sourceformat` but not `--targets`. Syncing a format to itself
is a no-op and not a
+supported combination.
+
+Source \ Target:
+
+- Hudi source: to Iceberg yes, to Delta yes, to Hudi no (self)
+- Iceberg source: to Hudi yes, to Delta yes, to Iceberg no (self)
+- Delta source: to Hudi yes, to Iceberg yes, to Delta no (self)
+- Paimon source (read-only): to Hudi yes, to Iceberg yes, to Delta yes
+- Parquet source (read-only): to Hudi yes, to Iceberg yes, to Delta yes
+
+Engines are **provided by the cluster**, not bundled — the user supplies the
engine jars on the
+classpath (see Packaging / classpath notes below). This bundle targets the
Spark 3.4 line, pinned to a
+mutually-compatible engine set: Spark 3.4.x, Scala 2.12, Hudi 0.14.0, Iceberg
1.9.2, Delta 2.4.0.
+Delta 2.4.0 is Spark-3.4-only, which is why the line is pinned to Spark 3.4
rather than 3.5. The
+spark-submit smoke test (`ITXTableSparkRuntimeBundle`) currently exercises one
case per direction for
+Hudi/Iceberg/Delta; Paimon and Parquet sources are wired but not yet covered
by the smoke test.
+
+### Activation — v1 (CLI entry point)
+
+```
+spark-submit \
+ --jars /path/to/xtable-spark-runtime_2.12-<ver>.jar \
+ --class org.apache.xtable.spark.XTableSparkSync \
+ /path/to/xtable-spark-runtime_2.12-<ver>.jar \
+ --basepath /warehouse/db/orders --sourceformat HUDI --targets ICEBERG,DELTA
--tablename orders
+```
+
+Because the shaded jar is the **main artifact with a dependency-reduced POM**
(see Packaging),
+`--packages org.apache.xtable:xtable-spark-runtime_2.12:<ver>` resolves the
same relocated bundle and
+pulls no un-relocated transitive deps; `--jars` with the built jar is
equivalent.
+
+**Engine Avro / classpath placement (required).** The bundle does not carry
avro/parquet — it uses
+the engine's. Iceberg 1.9.2 compiles against Avro >= 1.12, newer than the Avro
Spark 3.4 ships (1.11),
+so the engine's Avro must win on the classpath. Supply the engine jars on a
**flat** classpath
+(`spark.driver.extraClassPath`/`spark.executor.extraClassPath`, or `--jars`),
not layered under a
+child classloader: `--packages` puts a second Avro in a child loader, which
breaks cross-boundary Avro
+casts (`NoSuchMethodError`/`ClassCastException`). This is why the bundle smoke
test builds a single
+flat `spark.driver.extraClassPath`.
+
+### Activation — v1 (from application code)
+
+The same sync can be triggered from an existing Spark job (Scala/Java or
PySpark) instead of a
+separate `spark-submit`, reusing the active `SparkSession`'s Hadoop
configuration. This is the
+programmatic equivalent of the CLI above and runs after your own write
completes.
+
+Scala / Java:
+
+```scala
+import org.apache.xtable.spark.{TableSyncSpec, XTableSyncService}
+
+// ... after writing the source table with `spark` ...
+val spec = TableSyncSpec.builder()
+ .key("orders")
+ .basePath("/warehouse/db/orders")
+ .sourceFormat("HUDI")
+ .targets(java.util.Arrays.asList("ICEBERG", "DELTA"))
+ .build()
+
+new XTableSyncService().sync(spec, spark.sparkContext.hadoopConfiguration)
+```
+
+PySpark (via the JVM gateway, with the bundle on the driver classpath):
+
+```python
+# after writing the source table with `spark` ...
+jvm = spark._jvm
+spec = (jvm.org.apache.xtable.spark.TableSyncSpec.builder()
+ .key("orders")
+ .basePath("/warehouse/db/orders")
+ .sourceFormat("HUDI")
+ .targets(jvm.java.util.Arrays.asList("ICEBERG", "DELTA"))
+ .build())
+hadoop_conf = spark._jsc.hadoopConfiguration()
+jvm.org.apache.xtable.spark.XTableSyncService().sync(spec, hadoop_conf)
+```
+
+These examples, plus a `--packages`/`--jars` quickstart, should be folded into
the docs-site
+quickstart in a docs follow-up.
+
+### Planned (follow-up): config-only listener activation
+
+```
+spark-submit --packages org.apache.xtable:xtable-spark-runtime_2.12:<ver> \
+ --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 `<k>`: `spark.xtable.<k>.basePath` (path-based) **or**
`spark.xtable.<k>.sourceTable=db.table`
+ (name-based; resolved to a base path via the active `SparkSession` catalog);
plus
+ `spark.xtable.<k>.sourceFormat` and `spark.xtable.<k>.targets` (comma list).
Optional:
+ `spark.xtable.<k>.dataPath`, `spark.xtable.<k>.namespace`.
+
+Both path-based and name-based table selection are planned for the listener
on-ramp.
+
+### Components
+
+Shipped in v1:
+- **`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`.
+- **`TableSyncSpec`** — immutable description of one table to sync (key,
basePath, dataPath,
+ namespace, sourceFormat, targets).
+- **`XTableSparkSync`** — the standalone `spark-submit` entry point. Parses
CLI args (commons-cli)
+ into a `TableSyncSpec` and drives `XTableSyncService`.
+
+Planned (follow-up, **not in v1**):
+- **`XTableSparkConfig`** — parse `SparkConf`/`Map` (+ optional `SparkSession`
for name resolution)
+ into `List<TableSyncSpec>`, validating required keys and failing fast with
clear messages.
+- **`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<String>`.
+- **`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 (planned listener)
+
+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 the listener 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 its `failureaccess`) and `commons-cli` for
the CLI — which are
+relocated under `org.apache.xtable.shaded.*`. The allowlist is include-only,
so its implicit
+"provided by the cluster" assumptions (e.g. `jackson-datatype-jsr310` via
`xtable-api`,
+`log4j-1.2-api` via `xtable-core`) are documented in the pom; a
`dependency:analyze`/enforcer gate to
+keep it from silently under-bundling is a tracked follow-up.
+
+The shaded jar is published as the **main artifact with a dependency-reduced
POM**
+(`shadedArtifactAttached=false`, `createDependencyReducedPom=true`) — the same
shape as
+`iceberg-spark-runtime` and `hudi-spark-bundle`, so resolving the coordinate
(Maven dep or
+`--packages`) yields the relocated bundle and no un-relocated transitive deps.
Because the jar
+relocates and ships `guava`/`failureaccess`/`commons-cli` (all Apache-2.0),
its `LICENSE`/`NOTICE`
+carry those attributions (`LICENSE-bundled`/`NOTICE-bundled` via
`IncludeResourceTransformer`).
+`protobuf` is **not** bundled — it resolves as `provided` (supplied by the
engines), so it needs no
+relocation or notice.
+
+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.8 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:** this bundle targets the Spark line of its engine build
(Spark 3.4, Scala 2.12),
+ with newer Spark lines as follow-ups. 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):** the config-only `XTableSyncListener`
on-ramp, a
+ `StreamingQueryListener` variant, a `CALL xtable.sync(...)` SQL procedure,
and the async execution
+ opt-in.
+
+## Test Plan
+
+- **Bundle smoke test (`ITXTableSparkRuntimeBundle`), v1:** `spark-submit` the
shaded main jar via
+ `XTableSparkSync` for one case per direction (Hudi↔Iceberg, Hudi↔Delta),
supplying the engines on a
+ **flat** `spark.driver.extraClassPath` (single Avro on one loader), and
assert the target is
+ data-equivalent to the source by reading both back through Spark (Hudi with
metadata enabled;
+ Iceberg non-vectorized to avoid a cross-engine timestamp Arrow-cast).
`SPARK_HOME`-gated. This
+ validates that the relocated jar is self-contained and runs a real sync.
+- **Planned (with the listener follow-up):** unit tests for
`XTableSparkConfig` parsing (path- and
+ name-based, missing-key errors) and `PlanTargetResolver` path extraction;
and an
+ `ITXTableSyncListener` embedded `local[*]` test that registers the listener
via
+ `spark.sql.queryExecutionListeners` and poll-asserts both targets appear
after a write.
+- **Bundle sanity:** package the module and inspect the shaded main jar —
confirm it is a few MB
+ (~3.8 MB), `org.apache.spark`/`org.apache.hadoop`/`org.apache.avro` are
absent, and the 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 f1e7bbb6..0d924d95 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,31 @@ public class ConversionTargetFactory {
TableFormat.DELTA.equalsIgnoreCase(tableFormatName)
&&
DeltaConversionTargetConfig.fromProperties(properties).isUseKernel();
ServiceLoader<ConversionTarget> loader =
ServiceLoader.load(ConversionTarget.class);
- for (ConversionTarget target : loader) {
+ Iterator<ConversionTarget> iterator = loader.iterator();
+ while (true) {
+ ConversionTarget target;
+ try {
+ // hasNext() also resolves provider classes lazily, so it can throw
+ // ServiceConfigurationError too - it must be inside the guard
alongside next().
+ if (!iterator.hasNext()) {
+ break;
+ }
+ 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. The offending
+ // provider is consumed before the error is thrown, so the next
hasNext() advances past it.
+ log.warn(
+ "Skipping a registered ConversionTarget whose engine library is
not on the classpath "
+ + "({}: {}); provide the missing engine if you need this
target format. This is "
+ + "expected when an engine is intentionally absent, but
indicates a linkage problem "
+ + "if the engine is present.",
+ error.getClass().getName(),
+ error.getMessage(),
+ error);
+ continue;
+ }
if (target.getTableFormat().equalsIgnoreCase(tableFormatName)
&& isDeltaKernelTarget(target) == useKernel) {
return target;
@@ -96,7 +123,10 @@ public class ConversionTargetFactory {
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 00000000..e1c07f02
--- /dev/null
+++ b/xtable-spark-runtime/pom.xml
@@ -0,0 +1,407 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ ~ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.xtable</groupId>
+ <artifactId>xtable</artifactId>
+ <version>0.2.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>xtable-spark-runtime_${scala.binary.version}</artifactId>
+ <name>XTable Project Spark Runtime</name>
+ <description>
+ Thin, relocated, drop-in Spark bundle that runs incremental XTable
metadata sync. v1 ships
+ the standalone spark-submit entry point (XTableSparkSync); the
config-only, driver-side
+ QueryExecutionListener on-ramp is a planned follow-up (see rfc/rfc-3).
+ </description>
+
+ <dependencies>
+ <!-- XTable engine (bundled) -->
+ <dependency>
+ <groupId>org.apache.xtable</groupId>
+ <artifactId>xtable-core_${scala.binary.version}</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- Command-line parsing for the standalone XTableSparkSync entry
point (bundled). -->
+ <dependency>
+ <groupId>commons-cli</groupId>
+ <artifactId>commons-cli</artifactId>
+ </dependency>
+
+ <!-- Parses the optional dataset-config YAML. Bundled (relocated
below) because it is not
+ reliably on a Spark runtime classpath; Apache-2.0 and has no
transitive dependencies. -->
+ <dependency>
+ <groupId>org.yaml</groupId>
+ <artifactId>snakeyaml</artifactId>
+ <version>2.2</version>
+ </dependency>
+
+ <!-- Logging API (lombok @Log4j2); provided by the cluster runtime -->
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-api</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
+ <!-- Spark: provided by the cluster. Brings QueryExecutionListener,
QueryExecution,
+ Catalyst LogicalPlan, and SparkSession. -->
+ <dependency>
+ <groupId>org.apache.spark</groupId>
+ <artifactId>spark-sql_${scala.binary.version}</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
+ <!-- Hadoop: provided by the cluster. -->
+ <dependency>
+ <groupId>org.apache.hadoop</groupId>
+ <artifactId>hadoop-common</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
+ <!--
+ Engine libraries (Hudi / Iceberg / Delta) are provided, not bundled:
the user brings their
+ own engine version and the thin bundle stays compatible across
versions (see the shade
+ plugin below). Hudi is supplied as the hudi-spark bundle, whose
regenerated DecimalWrapper
+ links cleanly on Avro >=1.10; raw hudi-common's
Avro-1.8/1.9-generated model classes
+ reference org.apache.avro.data.TimeConversions.TimeConversion,
removed in Avro 1.10 and thus
+ absent from the Avro 1.12 that Iceberg 1.9.2 requires.
+ -->
+ <dependency>
+ <groupId>org.apache.hudi</groupId>
+
<artifactId>hudi-spark${spark.version.prefix}-bundle_${scala.binary.version}</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <!--
+ XTable's Hudi TARGET writes via the Java client
(HoodieJavaEngineContext), which the Spark
+ bundle does not ship. Exclude its transitive hudi-common so the
bundle's clean DecimalWrapper
+ is the only one on the classpath (see above).
+ -->
+ <dependency>
+ <groupId>org.apache.hudi</groupId>
+ <artifactId>hudi-java-client</artifactId>
+ <scope>provided</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>org.apache.hudi</groupId>
+ <artifactId>hudi-common</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.iceberg</groupId>
+ <artifactId>iceberg-core</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.iceberg</groupId>
+ <artifactId>iceberg-api</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>io.delta</groupId>
+ <artifactId>delta-core_${scala.binary.version}</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>io.delta</groupId>
+ <artifactId>delta-kernel-api</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>io.delta</groupId>
+ <artifactId>delta-kernel-defaults</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
+ <!--
+ avro/parquet-avro are provided by the engine runtime (Iceberg 1.9.2
needs Avro >=1.12, newer
+ than Spark 3.4's). Kept provided so the smoke test puts them on the
flat engine classpath;
+ why the engine's Avro must win is explained on the engine-classpath
plugin below.
+ -->
+ <dependency>
+ <groupId>org.apache.avro</groupId>
+ <artifactId>avro</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.parquet</groupId>
+ <artifactId>parquet-avro</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <!-- Hudi uses jol for in-memory sizing; the user's Hudi runtime
supplies it. Provided so it
+ is on the flat engine classpath for the smoke test. -->
+ <dependency>
+ <groupId>org.openjdk.jol</groupId>
+ <artifactId>jol-core</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
+ <!--
+ Jackson, scala-library and the log4j 1.2 bridge arrive transitively
through xtable-core but
+ are supplied by every Spark runtime (Spark itself depends on them).
Declaring them provided
+ here (nearest-wins over the transitive compile scope) keeps them out
of the shaded jar and
+ out of this module's runtime dependency tree, so the shade allowlist
stays limited to what
+ XTable actually bundles - which is what
release/scripts/validate_shaded_license_coverage.sh
+ enforces (shade <includes> must equal the runtime tree, and every
bundled dep must be listed
+ in META-INF/LICENSE-bundled). guava (used internally, relocated) is
intentionally left to
+ bundle. Versions come from the parent's dependencyManagement.
+ -->
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-core</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-databind</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.datatype</groupId>
+ <artifactId>jackson-datatype-jsr310</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.module</groupId>
+
<artifactId>jackson-module-scala_${scala.binary.version}</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.scala-lang</groupId>
+ <artifactId>scala-library</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-1.2-api</artifactId>
+ <scope>provided</scope>
+ </dependency>
+
+ <!-- Test Dependencies -->
+ <dependency>
+ <groupId>org.apache.xtable</groupId>
+ <artifactId>xtable-core_${scala.binary.version}</artifactId>
+ <version>${project.version}</version>
+ <classifier>tests</classifier>
+ <type>test-jar</type>
+ <scope>test</scope>
+ </dependency>
+ <!-- Iceberg Spark runtime to create the Iceberg source table in-JVM
and read targets back.
+ Hudi's in-JVM table creation uses the provided hudi-spark bundle
above. -->
+ <dependency>
+ <groupId>org.apache.iceberg</groupId>
+
<artifactId>iceberg-spark-runtime-${spark.version.prefix}_${scala.binary.version}</artifactId>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- Junit -->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-api</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-params</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-engine</artifactId>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- Test logging -->
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-core</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.logging.log4j</groupId>
+ <artifactId>log4j-slf4j2-impl</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <!--
+ The bundle smoke test (ITXTableSparkRuntimeBundle) spark-submits
the shaded jar with
+ the engine libraries on a FLAT classpath
(spark.driver.extraClassPath), not via the
+ submit packages/jars flags. A layered packages/jars classloader
puts the engines' avro
+ in a child loader separate from Spark's own avro, which breaks
cross-boundary casts
+ (e.g. iceberg PartitionData to avro IndexedRecord). Resolve the
engine (provided-scope)
+ jars to a flat classpath here, excluding Spark/Hadoop (the
cluster provides them) and
+ xtable (bundled), so a single avro is on one loader, matching a
real cluster.
+ -->
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>engine-classpath</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build-classpath</goal>
+ </goals>
+ <configuration>
+ <includeScope>provided</includeScope>
+ <!--
+ Exclude Spark/Hadoop, XTable's own modules,
logging, and the libraries
+ Spark itself supplies (jackson, scala-library)
so cluster copies win;
+ avro/parquet stay so the engine's newer avro
wins (see above).
+ -->
+
<excludeGroupIds>org.apache.spark,org.apache.hadoop,org.apache.xtable,org.projectlombok,org.slf4j,org.apache.logging.log4j,org.scala-lang,com.fasterxml.jackson.core,com.fasterxml.jackson.datatype,com.fasterxml.jackson.module</excludeGroupIds>
+
<outputFile>${project.build.directory}/engine-classpath.txt</outputFile>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <!--
+ Thin, curated bundle. Spark/Hadoop and the engine libraries
(Hudi/Iceberg/Delta) are
+ provided by the user's runtime, and so are the libraries XTable
exchanges with them
+ (avro, parquet, jackson, commons) - relocating those would break
the API boundary
+ (e.g. Hudi returning a real org.apache.avro.Schema to relocated
XTable code). The
+ bundle therefore contains only XTable's own modules plus the
libraries it uses purely
+ internally (guava), which are relocated to avoid clashes with
the cluster.
+
+ The shaded jar is the MAIN artifact
(shadedArtifactAttached=false) with a
+ dependency-reduced POM, matching how iceberg-spark-runtime and
hudi-spark-bundle
+ publish: resolving the coordinate (a plain Maven dependency or
the submit packages flag)
+ yields the relocated bundle and pulls no un-relocated transitive
deps. Consuming a thin,
+ un-shaded main jar plus a separate classifier would re-introduce
the cluster guava clash
+ that relocation exists to prevent.
+ -->
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-shade-plugin</artifactId>
+ <executions>
+ <execution>
+ <phase>package</phase>
+ <goals>
+ <goal>shade</goal>
+ </goals>
+ <configuration>
+
<shadedArtifactAttached>false</shadedArtifactAttached>
+
<createDependencyReducedPom>true</createDependencyReducedPom>
+ <!--
+ Include-only allowlist. Everything NOT listed
here is assumed to be
+ provided by the user's Spark/engine runtime and
is deliberately left out
+ of the jar. Known implicit "provided by cluster"
assumptions carried by
+ the bundled xtable modules:
jackson-datatype-jsr310 (via xtable-api) and
+ log4j-1.2-api (via xtable-core). If a future
compile dependency of
+ xtable-api/xtable-core is NOT cluster-provided,
it must be added here or
+ it will silently drop out and surface only as a
runtime
+ ClassNotFoundException. protobuf-java is
intentionally absent: it
+ resolves as provided (via the engines), so it is
never bundled and needs
+ no relocation. A hard verification gate
(dependency:analyze / enforcer)
+ over this allowlist is a tracked follow-up.
+ -->
+ <artifactSet>
+ <includes>
+
<include>org.apache.xtable:xtable-api</include>
+
<include>org.apache.xtable:xtable-core_${scala.binary.version}</include>
+
<include>org.apache.xtable:xtable-hudi-support-utils</include>
+ <include>com.google.guava:guava</include>
+
<include>com.google.guava:failureaccess</include>
+
<include>com.google.guava:listenablefuture</include>
+
<include>com.google.errorprone:error_prone_annotations</include>
+
<include>com.google.j2objc:j2objc-annotations</include>
+
<include>org.checkerframework:checker-qual</include>
+ <include>commons-cli:commons-cli</include>
+ <include>org.yaml:snakeyaml</include>
+ </includes>
+ </artifactSet>
+ <relocations>
+ <relocation>
+ <pattern>com.google.common</pattern>
+
<shadedPattern>org.apache.xtable.shaded.com.google.common</shadedPattern>
+ </relocation>
+ <relocation>
+ <pattern>com.google.thirdparty</pattern>
+
<shadedPattern>org.apache.xtable.shaded.com.google.thirdparty</shadedPattern>
+ </relocation>
+ <relocation>
+ <pattern>org.apache.commons.cli</pattern>
+
<shadedPattern>org.apache.xtable.shaded.org.apache.commons.cli</shadedPattern>
+ </relocation>
+ <relocation>
+ <pattern>org.yaml.snakeyaml</pattern>
+
<shadedPattern>org.apache.xtable.shaded.org.yaml.snakeyaml</shadedPattern>
+ </relocation>
+ </relocations>
+ <filters>
+ <filter>
+ <artifact>*:*</artifact>
+ <excludes>
+ <exclude>META-INF/*.SF</exclude>
+ <exclude>META-INF/*.DSA</exclude>
+ <exclude>META-INF/*.RSA</exclude>
+ <exclude>**/Log4j2Plugins.dat</exclude>
+ </excludes>
+ </filter>
+ <!--
+ Drop SnakeYAML's multi-release (Java 9+)
copies: shade does not
+ relocate classes under META-INF/versions, so
keeping them would
+ leave un-relocated org.yaml.snakeyaml
classes in the bundle. The
+ base (relocated) classes work on all
supported Java versions.
+ -->
+ <filter>
+ <artifact>org.yaml:snakeyaml</artifact>
+ <excludes>
+ <exclude>META-INF/versions/**</exclude>
+ </excludes>
+ </filter>
+ </filters>
+ <transformers>
+ <transformer
implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
+ <transformer
implementation="org.apache.maven.plugins.shade.resource.ApacheLicenseResourceTransformer"/>
+ <transformer
implementation="org.apache.maven.plugins.shade.resource.DontIncludeResourceTransformer">
+ <resources>
+ <resource>LICENSE</resource>
+ <resource>NOTICE</resource>
+ <resource>NOTICE.txt</resource>
+ </resources>
+ </transformer>
+ <!--
+ Bundled-dependency attribution: the shaded
jar relocates and ships
+ guava, failureaccess and commons-cli (all
Apache-2.0), so its
+ LICENSE/NOTICE must carry their attributions
per ASF policy.
+ -->
+ <transformer
implementation="org.apache.maven.plugins.shade.resource.IncludeResourceTransformer">
+ <resource>META-INF/LICENSE</resource>
+
<file>target/classes/META-INF/LICENSE-bundled</file>
+ </transformer>
+ <transformer
implementation="org.apache.maven.plugins.shade.resource.IncludeResourceTransformer">
+ <resource>META-INF/NOTICE</resource>
+
<file>target/classes/META-INF/NOTICE-bundled</file>
+ </transformer>
+ </transformers>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
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 00000000..37f7f56c
--- /dev/null
+++
b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/TableSyncSpec.java
@@ -0,0 +1,61 @@
+/*
+ * 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<String> targets;
+
+ /** Use the Spark-free Delta Kernel implementation for the Delta source
and/or target. */
+ boolean useDeltaKernel;
+}
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 00000000..7360144e
--- /dev/null
+++
b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSparkSync.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.xtable.spark;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+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.hadoop.fs.Path;
+import org.apache.spark.sql.SparkSession;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+
+import org.apache.xtable.model.storage.TableFormat;
+
+/**
+ * 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.
+ *
+ * <pre>
+ * $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
+ * </pre>
+ */
+@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 DATASET_CONFIG = "datasetconfig";
+ private static final String USE_DELTA_KERNEL = "usedeltakernel";
+ private static final String HELP = "help";
+
+ /**
+ * Formats usable as --sourceformat. Must stay in sync with the source
providers wired in {@link
+ * XTableSyncService#sourceProviderFor}. Paimon and Parquet are read-only
source formats (no
+ * corresponding ConversionTarget exists).
+ */
+ // package-private for unit testing
+ static final Set<String> SUPPORTED_SOURCE_FORMATS =
+ new LinkedHashSet<>(
+ Arrays.asList(
+ TableFormat.HUDI,
+ TableFormat.ICEBERG,
+ TableFormat.DELTA,
+ TableFormat.PAIMON,
+ TableFormat.PARQUET));
+
+ /**
+ * Formats usable as --targets. Must stay in sync with the {@code
ConversionTarget}s registered on
+ * the classpath (Hudi/Iceberg/Delta); Paimon and Parquet have no target
implementation. Used with
+ * {@link #SUPPORTED_SOURCE_FORMATS} to fail fast on invalid formats before
a SparkSession starts.
+ */
+ // package-private for unit testing
+ static final Set<String> SUPPORTED_TARGET_FORMATS =
+ new LinkedHashSet<>(Arrays.asList(TableFormat.HUDI, TableFormat.ICEBERG,
TableFormat.DELTA));
+
+ private static final Options OPTIONS =
+ new Options()
+ .addOption(
+ Option.builder()
+ .longOpt(BASE_PATH)
+ .hasArg()
+ .desc("The base path of the source table (single-table
mode)")
+ .build())
+ .addOption(
+ Option.builder()
+ .longOpt(SOURCE_FORMAT)
+ .hasArg()
+ .desc("The source table format, e.g. HUDI, DELTA or ICEBERG
(single-table mode)")
+ .build())
+ .addOption(
+ Option.builder()
+ .longOpt(TARGETS)
+ .hasArg()
+ .desc(
+ "Comma-separated target formats to sync to, e.g.
ICEBERG,DELTA "
+ + "(single-table mode)")
+ .build())
+ .addOption(
+ Option.builder()
+ .longOpt(DATASET_CONFIG)
+ .hasArg()
+ .argName("path")
+ .desc(
+ "Path to a YAML dataset config (sourceFormat,
targetFormats, datasets[]) to "
+ + "sync multiple tables in one run; may be a local
or cloud (s3/gcs/abfs) "
+ + "path. Mutually exclusive with
--basepath/--sourceformat/--targets.")
+ .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(USE_DELTA_KERNEL)
+ .desc(
+ "Use the Spark-free Delta Kernel implementation for the
Delta source and/or "
+ + "target instead of the default delta-core path.
When omitted this is "
+ + "auto-enabled on Spark 3.5+ (where the bundled
delta-core does not "
+ + "run); pass it explicitly to force Kernel on any
Spark version.")
+ .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;
+ }
+
+ boolean datasetMode = cmd.hasOption(DATASET_CONFIG);
+ if (datasetMode
+ && (cmd.hasOption(BASE_PATH) || cmd.hasOption(SOURCE_FORMAT) ||
cmd.hasOption(TARGETS))) {
+ throw new IllegalArgumentException(
+ "--datasetconfig is mutually exclusive with
--basepath/--sourceformat/--targets");
+ }
+ // Single-table flags are validated up front so an invalid value fails
fast, before a
+ // SparkSession is created. A --datasetconfig is on a local or cloud path,
so it is read through
+ // the Spark Hadoop config below and validated there.
+ if (!datasetMode) {
+ requireOption(cmd, BASE_PATH);
+ requireOption(cmd, SOURCE_FORMAT);
+ requireOption(cmd, TARGETS);
+ validateFormat(
+ SOURCE_FORMAT,
+ cmd.getOptionValue(SOURCE_FORMAT).trim().toUpperCase(Locale.ROOT),
+ SUPPORTED_SOURCE_FORMATS);
+ parseTargets(cmd.getOptionValue(TARGETS)); // validates formats and
non-emptiness
+ }
+
+ boolean useDeltaKernelFlag = cmd.hasOption(USE_DELTA_KERNEL);
+
+ SparkSession spark =
SparkSession.builder().appName("xtable-spark-sync").getOrCreate();
+ try {
+ Configuration hadoopConf = spark.sparkContext().hadoopConfiguration();
+
+ String sourceFormat;
+ List<String> targetFormats;
+ List<TableSyncSpec.TableSyncSpecBuilder> builders = new ArrayList<>();
+ if (datasetMode) {
+ DatasetConfig config =
readDatasetConfig(cmd.getOptionValue(DATASET_CONFIG), hadoopConf);
+ sourceFormat = config.sourceFormat;
+ targetFormats = config.targetFormats;
+ for (DatasetConfig.Table table : config.datasets) {
+ builders.add(
+ TableSyncSpec.builder()
+ .key(
+ table.tableName != null
+ ? table.tableName
+ : basePathToTableName(table.tableBasePath))
+ .basePath(table.tableBasePath)
+ .dataPath(table.tableDataPath)
+ .namespace(table.namespace == null ? null :
table.namespace.split("\\."))
+ .partitionSpec(table.partitionSpec)
+ .sourceFormat(sourceFormat)
+ .targets(targetFormats));
+ }
+ } else {
+ sourceFormat =
cmd.getOptionValue(SOURCE_FORMAT).trim().toUpperCase(Locale.ROOT);
+ targetFormats = parseTargets(cmd.getOptionValue(TARGETS));
+ String basePath = cmd.getOptionValue(BASE_PATH);
+ String namespace = cmd.getOptionValue(NAMESPACE);
+ builders.add(
+ TableSyncSpec.builder()
+ .key(cmd.getOptionValue(TABLE_NAME,
basePathToTableName(basePath)))
+ .basePath(basePath)
+ .dataPath(cmd.getOptionValue(DATA_PATH))
+ .namespace(namespace == null ? null : namespace.split("\\."))
+ .partitionSpec(cmd.getOptionValue(PARTITION_SPEC))
+ .sourceFormat(sourceFormat)
+ .targets(targetFormats));
+ }
+
+ // Use the Spark-free Delta Kernel when --usedeltakernel is passed, or
auto-enable it when
+ // Delta is involved and Spark is 3.5+ (where the bundled delta-core
does not run). One
+ // decision for the run, applied to every table.
+ boolean deltaInvolved =
+ TableFormat.DELTA.equals(sourceFormat) ||
targetFormats.contains(TableFormat.DELTA);
+ boolean useDeltaKernel = useDeltaKernelFlag;
+ if (!useDeltaKernel && deltaInvolved &&
isSparkAtLeast35(spark.version())) {
+ useDeltaKernel = true;
+ log.info(
+ "Spark {} detected (>= 3.5); auto-enabling the Delta Kernel
implementation for Delta "
+ + "source/target. Pass --usedeltakernel to force it on any
Spark version.",
+ spark.version());
+ }
+
+ XTableSyncService service = new XTableSyncService();
+ int failures = 0;
+ for (TableSyncSpec.TableSyncSpecBuilder builder : builders) {
+ TableSyncSpec spec = builder.useDeltaKernel(useDeltaKernel).build();
+ try {
+ log.info("Starting XTable sync for {} ({})", spec.getKey(),
spec.getBasePath());
+ service.sync(spec, hadoopConf);
+ log.info("Completed XTable sync for {}", spec.getKey());
+ } catch (RuntimeException e) {
+ // One table failing should not abort the rest of the batch; report
at the end.
+ failures++;
+ log.error("XTable sync failed for {} ({})", spec.getKey(),
spec.getBasePath(), e);
+ }
+ }
+ if (failures > 0) {
+ throw new IllegalStateException(
+ failures + " of " + builders.size() + " table sync(s) failed; see
logs above");
+ }
+ } finally {
+ spark.stop();
+ }
+ }
+
+ private static void requireOption(CommandLine cmd, String option) {
+ if (!cmd.hasOption(option)) {
+ throw new IllegalArgumentException(
+ "--" + option + " is required unless --" + DATASET_CONFIG + " is
provided");
+ }
+ }
+
+ // Splits the comma-separated --targets value, upper-cases and validates
each, and requires at
+ // least one. package-private for unit testing.
+ static List<String> parseTargets(String targetsValue) {
+ List<String> targetFormats =
+ Arrays.stream(targetsValue.split(","))
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .map(s -> s.toUpperCase(Locale.ROOT))
+ .collect(Collectors.toList());
+ if (targetFormats.isEmpty()) {
+ throw new IllegalArgumentException(
+ "--targets resolved to an empty list; provide at least one target
format, e.g. ICEBERG,DELTA");
+ }
+ targetFormats.forEach(target -> validateFormat(TARGETS, target,
SUPPORTED_TARGET_FORMATS));
+ return targetFormats;
+ }
+
+ // Reads the --datasetconfig YAML (local or cloud path) through the Spark
Hadoop config.
+ static DatasetConfig readDatasetConfig(String path, Configuration
hadoopConf) {
+ Path configPath = new Path(path);
+ try (InputStream in =
configPath.getFileSystem(hadoopConf).open(configPath)) {
+ return parseDatasetConfig(in);
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Failed to read --datasetconfig at "
+ path, e);
+ }
+ }
+
+ // Parses the dataset YAML with SnakeYAML's SafeConstructor (plain
maps/lists/scalars only, no
+ // arbitrary-type instantiation) and validates required fields.
package-private for unit testing.
+ @SuppressWarnings("unchecked")
+ static DatasetConfig parseDatasetConfig(InputStream in) {
+ Object loaded = new Yaml(new SafeConstructor(new
LoaderOptions())).load(in);
+ if (!(loaded instanceof Map)) {
+ throw new IllegalArgumentException("--datasetconfig is empty or not a
YAML mapping");
+ }
+ Map<String, Object> root = (Map<String, Object>) loaded;
+
+ DatasetConfig config = new DatasetConfig();
+ config.sourceFormat = requireString(root,
"sourceFormat").trim().toUpperCase(Locale.ROOT);
+ validateFormat(SOURCE_FORMAT, config.sourceFormat,
SUPPORTED_SOURCE_FORMATS);
+
+ Object targetsRaw = root.get("targetFormats");
+ if (!(targetsRaw instanceof List) || ((List<?>) targetsRaw).isEmpty()) {
+ throw new IllegalArgumentException(
+ "--datasetconfig 'targetFormats' must be a non-empty list");
+ }
+ config.targetFormats =
+ ((List<Object>) targetsRaw)
+ .stream()
+ .map(String::valueOf)
+ .map(s -> s.trim().toUpperCase(Locale.ROOT))
+ .collect(Collectors.toList());
+ config.targetFormats.forEach(t -> validateFormat(TARGETS, t,
SUPPORTED_TARGET_FORMATS));
+
+ Object datasetsRaw = root.get("datasets");
+ if (!(datasetsRaw instanceof List) || ((List<?>) datasetsRaw).isEmpty()) {
+ throw new IllegalArgumentException("--datasetconfig 'datasets' must be a
non-empty list");
+ }
+ config.datasets = new ArrayList<>();
+ for (Object entry : (List<Object>) datasetsRaw) {
+ if (!(entry instanceof Map)) {
+ throw new IllegalArgumentException("--datasetconfig 'datasets' entries
must be mappings");
+ }
+ Map<String, Object> raw = (Map<String, Object>) entry;
+ DatasetConfig.Table table = new DatasetConfig.Table();
+ table.tableBasePath = requireString(raw, "tableBasePath");
+ table.tableDataPath = asString(raw.get("tableDataPath"));
+ table.tableName = asString(raw.get("tableName"));
+ table.partitionSpec = asString(raw.get("partitionSpec"));
+ table.namespace = asString(raw.get("namespace"));
+ config.datasets.add(table);
+ }
+ return config;
+ }
+
+ private static String requireString(Map<String, Object> map, String key) {
+ String value = asString(map.get(key));
+ if (value == null || value.trim().isEmpty()) {
+ throw new IllegalArgumentException("--datasetconfig is missing required
field '" + key + "'");
+ }
+ return value;
+ }
+
+ private static String asString(Object value) {
+ return value == null ? null : String.valueOf(value);
+ }
+
+ /** Parsed {@code --datasetconfig}; mirrors the RunSync dataset YAML so
configs are shareable. */
+ static final class DatasetConfig {
+ String sourceFormat;
+ List<String> targetFormats;
+ List<Table> datasets;
+
+ static final class Table {
+ String tableBasePath;
+ String tableDataPath;
+ String tableName;
+ String partitionSpec;
+ String namespace;
+ }
+ }
+
+ /**
+ * True when the Spark version is 3.5 or newer; unparseable/null versions
are treated as older.
+ */
+ // package-private for unit testing
+ static boolean isSparkAtLeast35(String sparkVersion) {
+ if (sparkVersion == null || sparkVersion.trim().isEmpty()) {
+ return false;
+ }
+ String[] parts = sparkVersion.trim().split("\\.");
+ try {
+ int major = Integer.parseInt(parts[0]);
+ int minor = parts.length > 1 ?
Integer.parseInt(parts[1].replaceAll("[^0-9].*$", "")) : 0;
+ return major > 3 || (major == 3 && minor >= 5);
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+
+ // package-private for unit testing
+ static void validateFormat(String option, String format, Set<String>
supported) {
+ if (!supported.contains(format)) {
+ throw new IllegalArgumentException(
+ "Unsupported --" + option + " '" + format + "'; expected one of " +
supported);
+ }
+ }
+
+ /**
+ * Derives a table name from the last path segment of {@code basePath}, e.g.
{@code
+ * /warehouse/db/orders -> orders}. Trailing slashes are ignored. Throws
when no usable segment
+ * can be derived (e.g. {@code "/"} or an empty/blank path) so the caller is
told to pass {@code
+ * --tablename} explicitly instead of silently getting an empty table name.
+ */
+ // package-private for unit testing
+ static String basePathToTableName(String basePath) {
+ String trimmed = basePath == null ? "" : basePath;
+ while (trimmed.endsWith("/")) {
+ trimmed = trimmed.substring(0, trimmed.length() - 1);
+ }
+ int idx = trimmed.lastIndexOf('/');
+ String name = idx >= 0 ? trimmed.substring(idx + 1) : trimmed;
+ if (name.isEmpty()) {
+ throw new IllegalArgumentException(
+ "Cannot derive a table name from --basepath '"
+ + basePath
+ + "'; pass --tablename explicitly");
+ }
+ return name;
+ }
+}
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 00000000..e8650708
--- /dev/null
+++
b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSyncService.java
@@ -0,0 +1,145 @@
+/*
+ * 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.Locale;
+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.delta.DeltaConversionTargetConfig;
+import org.apache.xtable.hudi.HudiConversionSourceProvider;
+import org.apache.xtable.hudi.HudiSourceConfig;
+import org.apache.xtable.iceberg.IcebergConversionSourceProvider;
+import org.apache.xtable.kernel.DeltaKernelConversionSourceProvider;
+import org.apache.xtable.model.storage.TableFormat;
+import org.apache.xtable.model.sync.SyncMode;
+import org.apache.xtable.model.sync.SyncResult;
+import org.apache.xtable.paimon.PaimonConversionSourceProvider;
+import org.apache.xtable.parquet.ParquetConversionSourceProvider;
+
+/**
+ * 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.
+ *
+ * <p>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<String, SyncResult> 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 <basePath>/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();
+
+ // In Kernel mode, set the property ConversionTargetFactory reads to pick
the Delta Kernel
+ // writer (ignored for non-Delta targets).
+ List<TargetTable> targetTables =
+ spec.getTargets().stream()
+ .map(
+ targetFormat -> {
+ Properties targetProperties = new Properties();
+ if (spec.isUseDeltaKernel()) {
+ targetProperties.setProperty(
+ DeltaConversionTargetConfig.USE_KERNEL,
Boolean.TRUE.toString());
+ }
+ return TargetTable.builder()
+ .name(spec.getKey())
+ .basePath(dataPath)
+ .namespace(spec.getNamespace())
+ .formatName(targetFormat)
+ .additionalProperties(targetProperties)
+ .build();
+ })
+ .collect(Collectors.toList());
+
+ ConversionConfig conversionConfig =
+ ConversionConfig.builder()
+ .sourceTable(sourceTable)
+ .targetTables(targetTables)
+ .syncMode(SyncMode.INCREMENTAL)
+ .build();
+
+ ConversionSourceProvider<?> sourceProvider =
+ sourceProviderFor(spec.getSourceFormat(), spec.isUseDeltaKernel());
+ 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, boolean useDeltaKernel) {
+ switch (sourceFormat.toUpperCase(Locale.ROOT)) {
+ case TableFormat.HUDI:
+ return new HudiConversionSourceProvider();
+ case TableFormat.DELTA:
+ // The default delta-core reader is Spark-3.4-only; the Kernel reader
is Spark-free.
+ return useDeltaKernel
+ ? new DeltaKernelConversionSourceProvider()
+ : new DeltaConversionSourceProvider();
+ case TableFormat.ICEBERG:
+ return new IcebergConversionSourceProvider();
+ case TableFormat.PAIMON:
+ return new PaimonConversionSourceProvider();
+ case TableFormat.PARQUET:
+ return new ParquetConversionSourceProvider();
+ default:
+ throw new UnsupportedOperationException(
+ "Unsupported source format for spark-runtime sync: " +
sourceFormat);
+ }
+ }
+}
diff --git a/xtable-spark-runtime/src/main/resources/META-INF/LICENSE-bundled
b/xtable-spark-runtime/src/main/resources/META-INF/LICENSE-bundled
new file mode 100644
index 00000000..cfe0372c
--- /dev/null
+++ b/xtable-spark-runtime/src/main/resources/META-INF/LICENSE-bundled
@@ -0,0 +1,222 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+
+---------------------------------------------------------
+
+This product bundles various third-party components under other open source
licenses.
+This section summarizes those components and their licenses.
+
+
+Apache License 2.0
+------------------
+com.google.guava:guava:32.1.3-jre
+com.google.guava:failureaccess:1.0.1
+com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
+com.google.errorprone:error_prone_annotations:2.21.1
+com.google.j2objc:j2objc-annotations:2.8
+commons-cli:commons-cli:1.5.0
+org.yaml:snakeyaml:2.2
+
+MIT License
+-----------
+org.checkerframework:checker-qual:3.37.0
diff --git a/xtable-spark-runtime/src/main/resources/META-INF/NOTICE-bundled
b/xtable-spark-runtime/src/main/resources/META-INF/NOTICE-bundled
new file mode 100644
index 00000000..04bfd54f
--- /dev/null
+++ b/xtable-spark-runtime/src/main/resources/META-INF/NOTICE-bundled
@@ -0,0 +1,22 @@
+Apache XTable (incubating)
+Copyright 2024-2026 The Apache Software Foundation
+
+This product includes software developed at
+The Apache Software Foundation (https://www.apache.org/).
+
+--------------------------------------------------------------------------------
+
+This binary artifact bundles the following projects with NOTICE:
+
+--------------------------------------------------------------------------------
+
+Group: commons-cli Name: commons-cli Version: 1.5.0
+
+NOTICE:
+| Apache Commons CLI
+| Copyright 2002-2021 The Apache Software Foundation
+|
+| This product includes software developed at
+| The Apache Software Foundation (https://www.apache.org/).
+
+--------------------------------------------------------------------------------
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 00000000..ce41f5d9
--- /dev/null
+++
b/xtable-spark-runtime/src/test/java/org/apache/xtable/spark/ITXTableSparkRuntimeBundle.java
@@ -0,0 +1,318 @@
+/*
+ * 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.
+ *
+ * <p>Covers Hudi, Iceberg and Delta as both source and target (one case per
direction). Hudi is
+ * supplied via the {@code hudi-spark} bundle (its regenerated Avro model
classes link on Avro 1.12,
+ * unlike raw hudi-common), so a {@code SPARK_HOME} matching the engine build
(Spark 3.4) is
+ * required. Local-timestamp columns are excluded from the equivalence check
due to known
+ * Hudi/Iceberg representation differences (HUDI-7088).
+ */
+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<Arguments> directions() {
+ // sourceFormat, targetFormat, hudiPartitionSpec (only used for a
partitioned Hudi source).
+ // Delta uses delta-core on Spark 3.4 and auto-switches to the Spark-free
Delta Kernel on Spark
+ // 3.5+ (see XTableSparkSync), so every direction runs on either Spark
line.
+ return Stream.of(
+ Arguments.of("HUDI", "ICEBERG", "level:VALUE"),
+ Arguments.of("ICEBERG", "HUDI", null),
+ Arguments.of("HUDI", "DELTA", "level:VALUE"),
+ Arguments.of("DELTA", "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<String> 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
<basePath>/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<String> 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);
+ }
+ // No kernel flag passed on purpose: XTableSparkSync auto-selects Delta
Kernel on Spark 3.5+.
+
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.environment().put("SPARK_LOCAL_IP", "127.0.0.1");
+ pb.redirectErrorStream(true);
+ Process process = pb.start();
+ // Drain stdout on a separate thread: reading until EOF only returns when
the process exits, so
+ // reading inline before waitFor() would block forever on a hung
spark-submit and never consult
+ // the timeout below. The background drain also keeps the pipe buffer from
filling and stalling
+ // the child.
+ StringBuilder outputBuffer = new StringBuilder();
+ Thread drainer = new Thread(() -> readOutputInto(process, outputBuffer),
"spark-submit-stdout");
+ drainer.setDaemon(true);
+ drainer.start();
+ boolean finished = process.waitFor(10, TimeUnit.MINUTES);
+ if (!finished) {
+ process.destroyForcibly();
+ drainer.join(TimeUnit.SECONDS.toMillis(10));
+ synchronized (outputBuffer) {
+ throw new AssertionError("spark-submit timed out. Output:\n" +
outputBuffer);
+ }
+ }
+ drainer.join(TimeUnit.SECONDS.toMillis(10));
+ String output;
+ synchronized (outputBuffer) {
+ output = outputBuffer.toString();
+ }
+ 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 over the comparable
+ * scalar columns. A Hudi target is read with its metadata table enabled; an
Iceberg target is
+ * read non-vectorized to avoid a timestamp Arrow-vector cast on
cross-engine tables.
+ */
+ private void assertDatasetEquivalence(
+ String sourceFormat,
+ String targetFormat,
+ String basePath,
+ String dataPath,
+ List<String> columns,
+ String orderByColumn,
+ String submitOutput) {
+ Dataset<Row> source =
+ sparkSession
+ .read()
+ .options(readOptions(sourceFormat))
+ .format(sourceFormat.toLowerCase())
+ .load(basePath)
+ .orderBy(orderByColumn);
+ // Compare well-behaved scalar columns. Nested/array/map read parity
across writer and reader
+ // engines is covered in-process by ITConversionController;
local-timestamp columns are excluded
+ // as they have known Hudi/Iceberg representation differences (HUDI-7088).
Neither is what this
+ // smoke test validates - that the bundle jar runs the sync and produces
equivalent data.
+ String[] projection = comparableColumns(source, columns).toArray(new
String[0]);
+ List<String> sourceRows =
source.selectExpr(projection).toJSON().collectAsList();
+ List<String> targetRows =
+ sparkSession
+ .read()
+ .options(readOptions(targetFormat))
+ .format(targetFormat.toLowerCase())
+ .load(dataPath)
+ .orderBy(orderByColumn)
+ .selectExpr(projection)
+ .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 the requested columns that compare cleanly across engines: drops
struct/array/map (reader
+ // parity covered by ITConversionController) and local-timestamp columns
(HUDI-7088 representation
+ // differences between Hudi and Iceberg).
+ private static List<String> comparableColumns(Dataset<Row> source,
List<String> columns) {
+ java.util.Set<String> excluded = 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) {
+ excluded.add(field.name());
+ }
+ }
+ return columns.stream()
+ .filter(c -> !excluded.contains(c))
+ .filter(c -> !c.startsWith("timestamp_local_"))
+ .collect(java.util.stream.Collectors.toList());
+ }
+
+ private static java.util.Map<String, String> readOptions(String format) {
+ java.util.Map<String, String> 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;
+ }
+
+ 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;
+ }
+
+ // The shaded jar is the main artifact (shadedArtifactAttached=false), so it
carries no
+ // classifier.
+ // Skip the pre-shade jar the shade plugin renames to original-*, and the
sources/javadoc/tests
+ // jars.
+ private File findBundleJar() {
+ File targetDir = new File("target");
+ File[] candidates =
+ targetDir.listFiles(
+ (dir, name) ->
+ name.startsWith("xtable-spark-runtime")
+ && name.endsWith(".jar")
+ && !name.startsWith("original-")
+ && !name.contains("-sources")
+ && !name.contains("-javadoc")
+ && !name.contains("-tests"));
+ assertTrue(
+ candidates != null && candidates.length == 1,
+ "Expected exactly one bundle jar in "
+ + targetDir.getAbsolutePath()
+ + " (run the package phase first)");
+ return candidates[0];
+ }
+
+ private static void readOutputInto(Process process, StringBuilder sb) {
+ 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) {
+ synchronized (sb) {
+ sb.append(line).append(System.lineSeparator());
+ }
+ }
+ } catch (java.io.IOException e) {
+ // Stream closed early (e.g. destroyForcibly on timeout) - stop
draining; whatever was
+ // captured so far is still surfaced in the failure message.
+ }
+ }
+}
diff --git
a/xtable-spark-runtime/src/test/java/org/apache/xtable/spark/XTableSparkSyncTest.java
b/xtable-spark-runtime/src/test/java/org/apache/xtable/spark/XTableSparkSyncTest.java
new file mode 100644
index 00000000..9f41c922
--- /dev/null
+++
b/xtable-spark-runtime/src/test/java/org/apache/xtable/spark/XTableSparkSyncTest.java
@@ -0,0 +1,197 @@
+/*
+ * 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class XTableSparkSyncTest {
+
+ @ParameterizedTest
+ @CsvSource({
+ "/warehouse/db/orders, orders",
+ "/warehouse/db/orders/, orders",
+ "/warehouse/db/orders///, orders",
+ "orders, orders",
+ "hdfs://ns/warehouse/db/orders, orders",
+ })
+ void basePathToTableNameDerivesLastSegment(String basePath, String expected)
{
+ assertEquals(expected, XTableSparkSync.basePathToTableName(basePath));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"/", "//", ""})
+ void basePathToTableNameRejectsPathsWithNoUsableSegment(String basePath) {
+ assertThrows(
+ IllegalArgumentException.class, () ->
XTableSparkSync.basePathToTableName(basePath));
+ }
+
+ @Test
+ void basePathToTableNameRejectsNull() {
+ assertThrows(IllegalArgumentException.class, () ->
XTableSparkSync.basePathToTableName(null));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"HUDI", "ICEBERG", "DELTA", "PAIMON", "PARQUET"})
+ void validateFormatAcceptsSupportedSourceFormats(String format) {
+ XTableSparkSync.validateFormat(
+ "sourceformat", format, XTableSparkSync.SUPPORTED_SOURCE_FORMATS);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"HUDI", "ICEBERG", "DELTA"})
+ void validateFormatAcceptsSupportedTargetFormats(String format) {
+ XTableSparkSync.validateFormat("targets", format,
XTableSparkSync.SUPPORTED_TARGET_FORMATS);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"PAIMON", "PARQUET"})
+ void validateFormatRejectsSourceOnlyFormatsAsTargets(String format) {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ XTableSparkSync.validateFormat(
+ "targets", format, XTableSparkSync.SUPPORTED_TARGET_FORMATS));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"HOODIE", "hudi", "", "ICEBERG,DELTA"})
+ void validateFormatRejectsUnsupportedFormats(String format) {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ XTableSparkSync.validateFormat(
+ "sourceformat", format,
XTableSparkSync.SUPPORTED_SOURCE_FORMATS));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"3.5.0", "3.5.9", "3.6.0", "4.0.0", "4.0.0-preview",
"10.2.1"})
+ void isSparkAtLeast35TrueFor35AndNewer(String version) {
+ assertTrue(XTableSparkSync.isSparkAtLeast35(version));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"3.4.3", "3.4.0", "3.3.4", "2.4.8", "3.0.0"})
+ void isSparkAtLeast35FalseForOlder(String version) {
+ assertFalse(XTableSparkSync.isSparkAtLeast35(version));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", " ", "not-a-version", "x.y.z"})
+ void isSparkAtLeast35FalseForNullOrUnparseable(String version) {
+ assertFalse(XTableSparkSync.isSparkAtLeast35(version));
+ }
+
+ @Test
+ void isSparkAtLeast35FalseForNull() {
+ assertFalse(XTableSparkSync.isSparkAtLeast35(null));
+ }
+
+ private static InputStream yaml(String content) {
+ return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ void parseDatasetConfigReadsAndNormalizesFields() {
+ XTableSparkSync.DatasetConfig config =
+ XTableSparkSync.parseDatasetConfig(
+ yaml(
+ "sourceFormat: hudi\n"
+ + "targetFormats:\n"
+ + " - iceberg\n"
+ + " - DELTA\n"
+ + "datasets:\n"
+ + " - tableBasePath: /data/orders\n"
+ + " tableName: orders\n"
+ + " partitionSpec: level:VALUE\n"
+ + " - tableBasePath: /data/customers\n"
+ + " namespace: db.sales\n"));
+
+ assertEquals("HUDI", config.sourceFormat);
+ assertEquals(2, config.targetFormats.size());
+ assertEquals("ICEBERG", config.targetFormats.get(0));
+ assertEquals("DELTA", config.targetFormats.get(1));
+ assertEquals(2, config.datasets.size());
+ assertEquals("/data/orders", config.datasets.get(0).tableBasePath);
+ assertEquals("orders", config.datasets.get(0).tableName);
+ assertEquals("level:VALUE", config.datasets.get(0).partitionSpec);
+ assertEquals("/data/customers", config.datasets.get(1).tableBasePath);
+ assertEquals("db.sales", config.datasets.get(1).namespace);
+ assertNull(config.datasets.get(1).tableName);
+ }
+
+ @Test
+ void parseDatasetConfigRejectsMissingSourceFormat() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ XTableSparkSync.parseDatasetConfig(
+ yaml("targetFormats: [ICEBERG]\ndatasets:\n - tableBasePath:
/data/orders\n")));
+ }
+
+ @Test
+ void parseDatasetConfigRejectsEmptyTargetFormats() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ XTableSparkSync.parseDatasetConfig(
+ yaml("sourceFormat: HUDI\ntargetFormats: []\ndatasets:\n -
tableBasePath: /d\n")));
+ }
+
+ @Test
+ void parseDatasetConfigRejectsInvalidTargetFormat() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ XTableSparkSync.parseDatasetConfig(
+ yaml(
+ "sourceFormat: HUDI\ntargetFormats: [PAIMON]\ndatasets:\n
- tableBasePath: /d\n")));
+ }
+
+ @Test
+ void parseDatasetConfigRejectsMissingDatasets() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ XTableSparkSync.parseDatasetConfig(
+ yaml("sourceFormat: HUDI\ntargetFormats: [DELTA]\n")));
+ }
+
+ @Test
+ void parseDatasetConfigRejectsDatasetWithoutBasePath() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ XTableSparkSync.parseDatasetConfig(
+ yaml(
+ "sourceFormat: HUDI\ntargetFormats: [DELTA]\ndatasets:\n
- tableName: orders\n")));
+ }
+}