This is an automated email from the ASF dual-hosted git repository.

vinishjail97 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-xtable.git


The following commit(s) were added to refs/heads/main by this push:
     new 92003282 [836] Add xtable-spark-runtime, a thin drop-in Spark bundle 
(#964)
92003282 is described below

commit 92003282425ede3e59193a8bf9b30987b18ff9fe
Author: Vinish Reddy <[email protected]>
AuthorDate: Mon Oct 19 06:28:54 2026 +0530

    [836] Add xtable-spark-runtime, a thin drop-in Spark bundle (#964)
    
    * [836] Add xtable-spark-runtime: thin drop-in Spark bundle on main (Hudi 
1.2.1)
    
    Ports the reviewed branch-0.4 module (#843) to main on Hudi 1.2.1:
    
    - xtable-spark-runtime: a ~4 MB relocated bundle (xtable-api, xtable-core,
      xtable-hudi-support-utils, guava, commons-cli, snakeyaml) with every 
engine
      dependency provided, a spark-submit entry point (XTableSparkSync) and an
      in-job API (XTableSyncService / TableSyncSpec).
    - ConversionTargetFactory: skip registered targets whose engine is absent
      from the classpath (ServiceConfigurationError / LinkageError from both
      hasNext() and next()) and resolve the Delta Kernel target by class name.
    - spark-runtime-validation workflow: spark-submits the shaded bundle via
      ITXTableSparkRuntimeBundle on Spark 3.4 and 3.5.
    - Root pom: register the module and exclude the shade-generated
      dependency-reduced-pom.xml from spotless; gitignore that file.
    
    Differences from #843: hudi.version and HoodieCleanStat.builder() already
    landed on main via #835; the module overrides the parent's shade filters
    (combine.self="override") because Maven merges list entries by position and
    the parent's awssdk filter otherwise drops every snakeyaml class.
    
    * Address review feedback
    
    - Fail the batch when a target returns a non-SUCCESS sync status, not only 
on exceptions
    - Ship the checker-qual license text and validate the spark-runtime main 
jar in License Check
    - Test target discovery and NotSupportedException when an engine is missing
    - Drop the explicit tests classifier on the xtable-core test-jar, which the 
build cache extension resolves as a .test-jar file
    
    * Address review feedback
    
    * Write non-Hudi targets at the table base path
    
    * Test Iceberg with a separate data path syncing to Hudi
---
 .github/workflows/mvn-license-check.yml            |   4 +-
 .github/workflows/spark-runtime-validation.yml     |  86 ++++
 .gitignore                                         |   3 +
 pom.xml                                            |   3 +
 release/scripts/validate_bundled_license_texts.py  |  16 +-
 .../conversion/TestConversionTargetFactory.java    | 154 +++++++
 xtable-spark-runtime/pom.xml                       | 324 ++++++++++++++
 .../org/apache/xtable/spark/TableSyncSpec.java     |  54 +++
 .../org/apache/xtable/spark/XTableSparkSync.java   | 472 +++++++++++++++++++++
 .../org/apache/xtable/spark/XTableSyncService.java | 143 +++++++
 .../src/main/resources/META-INF/LICENSE-bundled    | 221 ++++++++++
 .../src/main/resources/META-INF/NOTICE-bundled     |  22 +
 .../META-INF/licenses/LICENSE-checker-qual         |  22 +
 .../xtable/spark/ITXTableSparkRuntimeBundle.java   | 387 +++++++++++++++++
 .../apache/xtable/spark/XTableSparkSyncTest.java   | 255 +++++++++++
 15 files changed, 2162 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/mvn-license-check.yml 
b/.github/workflows/mvn-license-check.yml
index 4a29da3b..6a2e6843 100644
--- a/.github/workflows/mvn-license-check.yml
+++ b/.github/workflows/mvn-license-check.yml
@@ -100,7 +100,7 @@ jobs:
       - name: Build Shaded Bundles
         run: >
           ./mvnw -B -DskipTests -Dmaven.build.cache.enabled=false
-          -pl 
xtable-aws,xtable-hive-metastore,xtable-hudi-support/xtable-hudi-support-extensions
+          -pl 
xtable-aws,xtable-hive-metastore,xtable-hudi-support/xtable-hudi-support-extensions,xtable-spark-runtime
           -am package
           dependency:tree -DoutputType=text 
-DoutputFile=target/dependency-tree.txt
 
@@ -110,7 +110,7 @@ jobs:
       # Nothing above this line resolves xtable-utilities or xtable-service:
       # `apache-rat:check` requires no dependency resolution, `dependency:tree`
       # collects poms rather than jars, and the shaded-bundle build reaches 
only
-      # the `-am` closure of the three modules it names, which excludes both
+      # the `-am` closure of the four modules it names, which excludes both
       # leaves. Without this step the entry never gains the Quarkus stack or
       # xtable-utilities' bundled dependencies, and Maven CI Build - which
       # cannot write the cache - would re-download them from Central on every
diff --git a/.github/workflows/spark-runtime-validation.yml 
b/.github/workflows/spark-runtime-validation.yml
new file mode 100644
index 00000000..2741ab5e
--- /dev/null
+++ b/.github/workflows/spark-runtime-validation.yml
@@ -0,0 +1,86 @@
+#
+# 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
+
+# Runs ITXTableSparkRuntimeBundle, which spark-submits the shaded bundle. The 
IT skips itself
+# unless SPARK_HOME is set, so it only runs here.
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: ${{ !contains(github.ref, 'main') }}
+
+# No path filter, so the check always reports and can be a required status 
check.
+on:
+  push:
+    branches:
+      - "main"
+
+  pull_request:
+    branches:
+      - "main"
+
+  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/.gitignore b/.gitignore
index def30ad3..2b4cdd7c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,3 +54,6 @@ spec/generated
 demo/notebook/spark-warehouse/
 demo/notebook/metastore_db/
 demo/notebook/derby.log
+
+# Written by maven-shade-plugin in xtable-spark-runtime
+dependency-reduced-pom.xml
diff --git a/pom.xml b/pom.xml
index b66c5887..6eaee41e 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>
@@ -970,6 +971,8 @@
                         <excludes>
                             <exclude>.idea/**</exclude>
                             <exclude>${target.dir.pattern}</exclude>
+                            <!-- Written by maven-shade-plugin in 
xtable-spark-runtime -->
+                            <exclude>**/dependency-reduced-pom.xml</exclude>
                             <exclude>website/node_modules/**</exclude>
                             <exclude>website/build/**</exclude>
                             <exclude>website/.docusaurus/**</exclude>
diff --git a/release/scripts/validate_bundled_license_texts.py 
b/release/scripts/validate_bundled_license_texts.py
index a8148f20..905d7528 100644
--- a/release/scripts/validate_bundled_license_texts.py
+++ b/release/scripts/validate_bundled_license_texts.py
@@ -23,7 +23,7 @@ pom chain declares none. Anything not Apache-2.0 must have
 META-INF/licenses/LICENSE-<artifactId>, and a text with no dependency behind it
 is reported too.
 
-Flow, for each */target/*-bundled.jar:
+Flow, for each */target/*-bundled.jar, plus each module in 
MAIN_ARTIFACT_BUNDLES:
 
   main()            find the jars, print each report, exit non-zero on any 
failure
   check()           everything below, for one jar
@@ -56,6 +56,10 @@ M2 = pathlib.Path.home() / ".m2" / "repository"
 ROOT = pathlib.Path(__file__).resolve().parents[2]
 TREE = "target/dependency-tree.txt"
 
+# Modules whose shaded jar replaces the main artifact instead of a 
*-bundled.jar.
+MAIN_ARTIFACT_BUNDLES = 
("xtable-spark-runtime/target/xtable-spark-runtime_*.jar",)
+NOT_BUNDLES = ("-sources.jar", "-tests.jar", "-javadoc.jar")
+
 # Shade cannot merge module descriptors, so it drops them from every 
dependency.
 DROPPED_BY_SHADE = {"module-info.class"}
 
@@ -295,9 +299,17 @@ REPORTS = [
 ]
 
 
+def default_jars() -> list[pathlib.Path]:
+    """Every */target/*-bundled.jar, plus the main jar of each 
MAIN_ARTIFACT_BUNDLES module."""
+    jars = set(ROOT.glob("**/target/*-bundled.jar"))
+    for pattern in MAIN_ARTIFACT_BUNDLES:
+        jars.update(jar for jar in ROOT.glob(pattern) if not 
jar.name.endswith(NOT_BUNDLES))
+    return sorted(jars)
+
+
 def main() -> None:
     """Check every bundled jar, or only those named on the command line."""
-    jars = [pathlib.Path(a) for a in sys.argv[1:]] or 
sorted(ROOT.glob("**/target/*-bundled.jar"))
+    jars = [pathlib.Path(a) for a in sys.argv[1:]] or default_jars()
     if not jars:
         raise SystemExit(
             "FAIL: no */target/*-bundled.jar found; nothing was validated.\n"
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/conversion/TestConversionTargetFactory.java
 
b/xtable-core/src/test/java/org/apache/xtable/conversion/TestConversionTargetFactory.java
index 86096255..ae679ed5 100644
--- 
a/xtable-core/src/test/java/org/apache/xtable/conversion/TestConversionTargetFactory.java
+++ 
b/xtable-core/src/test/java/org/apache/xtable/conversion/TestConversionTargetFactory.java
@@ -23,17 +23,33 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
 import java.util.Properties;
+import java.util.concurrent.Callable;
 
 import org.apache.hadoop.conf.Configuration;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
 import org.apache.xtable.delta.DeltaConversionTarget;
 import org.apache.xtable.delta.DeltaConversionTargetConfig;
 import org.apache.xtable.exception.NotSupportedException;
 import org.apache.xtable.hudi.HudiConversionTarget;
+import org.apache.xtable.iceberg.IcebergConversionTarget;
 import org.apache.xtable.kernel.DeltaKernelConversionTarget;
 import org.apache.xtable.model.storage.TableFormat;
 import org.apache.xtable.spi.sync.ConversionTarget;
@@ -137,6 +153,144 @@ public class TestConversionTargetFactory {
             .createConversionTargetForName(TableFormat.HUDI, properties));
   }
 
+  @Test
+  public void testDiscoversAvailableTargetWhenOtherEnginesAreMissing(@TempDir 
Path tempDir)
+      throws Exception {
+    ConversionTarget target =
+        withEngineMissingClassLoader(
+            tempDir,
+            () ->
+                ConversionTargetFactory.getInstance()
+                    .createConversionTargetForName(TableFormat.ICEBERG));
+    assertEquals(IcebergConversionTarget.class.getName(), 
target.getClass().getName());
+  }
+
+  @Test
+  public void 
testMissingEngineForRequestedFormatFailsWithNotSupported(@TempDir Path tempDir) 
{
+    NotSupportedException thrown =
+        assertThrows(
+            NotSupportedException.class,
+            () ->
+                withEngineMissingClassLoader(
+                    tempDir,
+                    () ->
+                        ConversionTargetFactory.getInstance()
+                            
.createConversionTargetForName(TableFormat.DELTA)));
+    assertTrue(thrown.getMessage().contains(TableFormat.DELTA));
+  }
+
+  /**
+   * Runs the call with a context classloader that registers four providers: 
one whose class does
+   * not exist, two Delta targets whose engine is absent (a missing superclass 
fails in hasNext(), a
+   * missing constructor dependency fails in next()), and the Iceberg target. 
The timeout guards
+   * against the provider iterator retrying a failing entry forever.
+   */
+  private static <T> T withEngineMissingClassLoader(Path tempDir, Callable<T> 
call)
+      throws Exception {
+    Path services = tempDir.resolve(ConversionTarget.class.getName());
+    Files.write(
+        services,
+        Arrays.asList(
+            "org.apache.xtable.missing.NoSuchConversionTarget",
+            EngineSubclassDeltaTarget.class.getName(),
+            EngineFieldDeltaTarget.class.getName(),
+            IcebergConversionTarget.class.getName()),
+        StandardCharsets.UTF_8);
+    ClassLoader loader =
+        new EngineMissingClassLoader(
+            TestConversionTargetFactory.class.getClassLoader(), 
services.toUri().toURL());
+    return assertTimeoutPreemptively(
+        Duration.ofSeconds(30),
+        () -> {
+          Thread thread = Thread.currentThread();
+          ClassLoader previous = thread.getContextClassLoader();
+          thread.setContextClassLoader(loader);
+          try {
+            return call.call();
+          } finally {
+            thread.setContextClassLoader(previous);
+          }
+        });
+  }
+
+  /** Stands in for an engine class; hidden by {@link 
EngineMissingClassLoader}. */
+  public static class DeltaEngine {}
+
+  /** Stands in for an engine base class; hidden by {@link 
EngineMissingClassLoader}. */
+  public static class DeltaEngineTarget extends DeltaConversionTarget {}
+
+  /** A Delta target whose superclass is in the absent engine. */
+  public static class EngineSubclassDeltaTarget extends DeltaEngineTarget {}
+
+  /** A Delta target whose constructor needs the absent engine. */
+  public static class EngineFieldDeltaTarget extends DeltaConversionTarget {
+    private final DeltaEngine engine = new DeltaEngine();
+  }
+
+  /**
+   * Serves a fixed ConversionTarget services file, hides the engine classes, 
and defines the
+   * engine-backed targets itself so that their engine references resolve here.
+   */
+  private static class EngineMissingClassLoader extends ClassLoader {
+    private static final String SERVICES_RESOURCE =
+        "META-INF/services/" + ConversionTarget.class.getName();
+    private static final List<String> HIDDEN =
+        Arrays.asList(DeltaEngine.class.getName(), 
DeltaEngineTarget.class.getName());
+    private static final List<String> CHILD_FIRST =
+        Arrays.asList(
+            EngineSubclassDeltaTarget.class.getName(), 
EngineFieldDeltaTarget.class.getName());
+
+    private final URL services;
+
+    EngineMissingClassLoader(ClassLoader parent, URL services) {
+      super(parent);
+      this.services = services;
+    }
+
+    @Override
+    public Enumeration<URL> getResources(String name) throws IOException {
+      return SERVICES_RESOURCE.equals(name)
+          ? Collections.enumeration(Collections.singletonList(services))
+          : super.getResources(name);
+    }
+
+    @Override
+    protected Class<?> loadClass(String name, boolean resolve) throws 
ClassNotFoundException {
+      synchronized (getClassLoadingLock(name)) {
+        if (HIDDEN.contains(name)) {
+          throw new ClassNotFoundException(name);
+        }
+        if (!CHILD_FIRST.contains(name)) {
+          return super.loadClass(name, resolve);
+        }
+        Class<?> loaded = findLoadedClass(name);
+        if (loaded == null) {
+          try (InputStream in =
+              getParent().getResourceAsStream(name.replace('.', '/') + 
".class")) {
+            byte[] bytes = readAll(in);
+            loaded = defineClass(name, bytes, 0, bytes.length);
+          } catch (IOException e) {
+            throw new ClassNotFoundException(name, e);
+          }
+        }
+        if (resolve) {
+          resolveClass(loaded);
+        }
+        return loaded;
+      }
+    }
+
+    private static byte[] readAll(InputStream in) throws IOException {
+      ByteArrayOutputStream out = new ByteArrayOutputStream();
+      byte[] buffer = new byte[8192];
+      int read;
+      while ((read = in.read(buffer)) != -1) {
+        out.write(buffer, 0, read);
+      }
+      return out.toByteArray();
+    }
+  }
+
   private TargetTable getPerTableConfig(String tableFormat) {
     return TargetTable.builder()
         .name(getTableName())
diff --git a/xtable-spark-runtime/pom.xml b/xtable-spark-runtime/pom.xml
new file mode 100644
index 00000000..34a3de70
--- /dev/null
+++ b/xtable-spark-runtime/pom.xml
@@ -0,0 +1,324 @@
+<?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.5.0-SNAPSHOT</version>
+    </parent>
+
+    <artifactId>xtable-spark-runtime_${scala.binary.version}</artifactId>
+    <name>XTable Project Spark Runtime</name>
+    <description>Thin, relocated Spark bundle that runs XTable metadata 
sync.</description>
+
+    <dependencies>
+        <!-- Bundled -->
+        <dependency>
+            <groupId>org.apache.xtable</groupId>
+            <artifactId>xtable-core_${scala.binary.version}</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>commons-cli</groupId>
+            <artifactId>commons-cli</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>org.yaml</groupId>
+            <artifactId>snakeyaml</artifactId>
+            <version>2.2</version>
+        </dependency>
+
+
+        <!-- Provided by the Spark runtime -->
+        <dependency>
+            <groupId>org.apache.logging.log4j</groupId>
+            <artifactId>log4j-api</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.spark</groupId>
+            <artifactId>spark-sql_${scala.binary.version}</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.hadoop</groupId>
+            <artifactId>hadoop-common</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+
+        <!-- Engines (Hudi, Iceberg, Delta) are provided by the user's 
runtime, not bundled -->
+        <dependency>
+            <groupId>org.apache.hudi</groupId>
+            
<artifactId>hudi-spark${spark.version.prefix}-bundle_${scala.binary.version}</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <!-- The Hudi target uses the Java client; hudi-common comes from the 
hudi-spark bundle -->
+        <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>
+
+        <!-- Iceberg 1.9.2 needs Avro 1.12, newer than Spark 3.4's -->
+        <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>
+        <dependency>
+            <groupId>org.openjdk.jol</groupId>
+            <artifactId>jol-core</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- Transitive via xtable-core but shipped by Spark; provided keeps 
them out of the bundle -->
+        <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>
+            <type>test-jar</type>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.iceberg</groupId>
+            
<artifactId>iceberg-spark-runtime-${spark.version.prefix}_${scala.binary.version}</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <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>
+
+        <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>
+            <!-- Engine classpath for ITXTableSparkRuntimeBundle, passed as 
extraClassPath -->
+            <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>
+                            
<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>
+            <!--
+              The shaded jar is the main artifact, like hudi-spark-bundle and 
iceberg-spark-runtime.
+              It bundles only XTable and the libraries XTable uses internally, 
relocated. Libraries
+              XTable exchanges with the engines (avro, parquet, jackson) stay 
provided.
+            -->
+            <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>
+                            <!-- Must match the runtime dependency tree 
(validate_shaded_license_coverage.sh) -->
+                            <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>
+                            <!-- Override: a positional merge with the 
parent's awssdk filter drops snakeyaml -->
+                            <filters combine.self="override">
+                                <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>
+                                <!-- Shade does not relocate multi-release 
classes -->
+                                <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>
+                                <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..75d35a32
--- /dev/null
+++ 
b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/TableSyncSpec.java
@@ -0,0 +1,54 @@
+/*
+ * 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;
+
+/** One table to sync: its source, its target formats and where its data files 
live. */
+@Value
+@Builder
+public class TableSyncSpec {
+  /** A name for the table, used in log messages. */
+  @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 partition spec for a partitioned Hudi source, e.g. {@code 
level:VALUE}. */
+  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..84a8b863
--- /dev/null
+++ 
b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSparkSync.java
@@ -0,0 +1,472 @@
+/*
+ * 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.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.function.Function;
+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 com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableSet;
+
+import org.apache.xtable.model.storage.TableFormat;
+import org.apache.xtable.model.sync.ErrorDetails;
+import org.apache.xtable.model.sync.SyncResult;
+import org.apache.xtable.model.sync.SyncStatusCode;
+
+/**
+ * {@code spark-submit} entry point for the bundle, the equivalent of {@code 
RunSync}.
+ *
+ * <pre>
+ * $SPARK_HOME/bin/spark-submit \
+ *   --class org.apache.xtable.spark.XTableSparkSync \
+ *   xtable-spark-runtime_2.12-&lt;ver&gt;.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. Keep in sync with {@link
+   * XTableSyncService#sourceProviderFor}.
+   */
+  // package-private for unit testing
+  static final Set<String> SUPPORTED_SOURCE_FORMATS =
+      ImmutableSet.of(
+          TableFormat.HUDI,
+          TableFormat.ICEBERG,
+          TableFormat.DELTA,
+          TableFormat.PAIMON,
+          TableFormat.PARQUET);
+
+  /** Formats usable as --targets. Paimon and Parquet are source-only. */
+  // package-private for unit testing
+  static final Set<String> SUPPORTED_TARGET_FORMATS =
+      ImmutableSet.of(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");
+    }
+    // Validate single-table flags before starting Spark. A --datasetconfig is 
validated after,
+    // because it is read through the Spark Hadoop config.
+    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));
+      }
+
+      // delta-core does not run on Spark 3.5+, so use Delta Kernel there.
+      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();
+      List<TableSyncSpec> specs = new ArrayList<>();
+      for (TableSyncSpec.TableSyncSpecBuilder builder : builders) {
+        specs.add(builder.useDeltaKernel(useDeltaKernel).build());
+      }
+      int failures = syncAll(specs, spec -> service.sync(spec, hadoopConf));
+      if (failures > 0) {
+        throw new IllegalStateException(
+            failures + " of " + specs.size() + " table sync(s) failed; see 
logs above");
+      }
+    } finally {
+      spark.stop();
+    }
+  }
+
+  /**
+   * Syncs every spec and returns how many failed. A table fails when its sync 
throws or when any
+   * target reports a non-SUCCESS status; one failure does not abort the rest 
of the batch.
+   */
+  // package-private for unit testing
+  static int syncAll(
+      List<TableSyncSpec> specs, Function<TableSyncSpec, Map<String, 
SyncResult>> syncer) {
+    int failures = 0;
+    for (TableSyncSpec spec : specs) {
+      try {
+        log.info("Starting XTable sync for {} ({})", spec.getKey(), 
spec.getBasePath());
+        // TableFormatSync reports a target failure as an ERROR status rather 
than throwing.
+        Map<String, String> failedTargets =
+            syncer.apply(spec).entrySet().stream()
+                .filter(
+                    entry ->
+                        
entry.getValue().getTableFormatSyncStatus().getStatusCode()
+                            != SyncStatusCode.SUCCESS)
+                .collect(
+                    Collectors.toMap(
+                        Map.Entry::getKey,
+                        entry -> 
describe(entry.getValue().getTableFormatSyncStatus()),
+                        (a, b) -> a,
+                        TreeMap::new));
+        if (failedTargets.isEmpty()) {
+          log.info("Completed XTable sync for {}", spec.getKey());
+        } else {
+          failures++;
+          log.error(
+              "XTable sync failed for {} ({}) on target(s) {}",
+              spec.getKey(),
+              spec.getBasePath(),
+              failedTargets);
+        }
+      } catch (RuntimeException e) {
+        failures++;
+        log.error("XTable sync failed for {} ({})", spec.getKey(), 
spec.getBasePath(), e);
+      }
+    }
+    return failures;
+  }
+
+  private static String describe(SyncResult.SyncStatus status) {
+    ErrorDetails details = status.getErrorDetails();
+    return details == null || details.getErrorMessage() == null
+        ? String.valueOf(status.getStatusCode())
+        : status.getStatusCode() + ": " + details.getErrorMessage();
+  }
+
+  private static void requireOption(CommandLine cmd, String option) {
+    if (!cmd.hasOption(option)) {
+      throw new IllegalArgumentException(
+          "--" + option + " is required unless --" + DATASET_CONFIG + " is 
provided");
+    }
+  }
+
+  // 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;
+  }
+
+  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);
+    }
+  }
+
+  /**
+   * SafeConstructor builds only maps, lists and scalars, and Jackson binds 
them to {@link
+   * DatasetConfig}. Uses snakeyaml because Spark does not always ship 
jackson-dataformat-yaml.
+   */
+  // package-private for unit testing
+  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");
+    }
+    DatasetConfig config;
+    try {
+      config = DATASET_CONFIG_MAPPER.convertValue(loaded, DatasetConfig.class);
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException("--datasetconfig is invalid: " + 
e.getMessage(), e);
+    }
+
+    config.sourceFormat =
+        requireField(config.sourceFormat, 
"sourceFormat").trim().toUpperCase(Locale.ROOT);
+    validateFormat(SOURCE_FORMAT, config.sourceFormat, 
SUPPORTED_SOURCE_FORMATS);
+
+    if (config.targetFormats == null || config.targetFormats.isEmpty()) {
+      throw new IllegalArgumentException(
+          "--datasetconfig 'targetFormats' must be a non-empty list");
+    }
+    config.targetFormats =
+        config.targetFormats.stream()
+            .map(t -> String.valueOf(t).trim().toUpperCase(Locale.ROOT))
+            .collect(Collectors.toList());
+    config.targetFormats.forEach(t -> validateFormat(TARGETS, t, 
SUPPORTED_TARGET_FORMATS));
+
+    if (config.datasets == null || config.datasets.isEmpty()) {
+      throw new IllegalArgumentException("--datasetconfig 'datasets' must be a 
non-empty list");
+    }
+    for (DatasetConfig.Table table : config.datasets) {
+      if (table == null) {
+        throw new IllegalArgumentException("--datasetconfig 'datasets' entries 
must be mappings");
+      }
+      requireField(table.tableBasePath, "tableBasePath");
+    }
+    return config;
+  }
+
+  private static String requireField(String value, String key) {
+    if (value == null || value.trim().isEmpty()) {
+      throw new IllegalArgumentException("--datasetconfig is missing required 
field '" + key + "'");
+    }
+    return value;
+  }
+
+  // Reads the package-private fields. Unknown keys are ignored, as before.
+  private static final ObjectMapper DATASET_CONFIG_MAPPER =
+      new ObjectMapper()
+          .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
+          .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+  /** 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);
+    }
+  }
+
+  /**
+   * Returns the last path segment, e.g. {@code /warehouse/db/orders -> 
orders}. Throws when there
+   * is none, so the user passes {@code --tablename}.
+   */
+  // 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..43eb720a
--- /dev/null
+++ 
b/xtable-spark-runtime/src/main/java/org/apache/xtable/spark/XTableSyncService.java
@@ -0,0 +1,143 @@
+/*
+ * 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;
+
+/**
+ * Runs an incremental {@link ConversionController#sync} for a {@link 
TableSyncSpec}. The sync falls
+ * back to a full snapshot when an incremental sync is not safe, so it is safe 
to call after every
+ * write.
+ */
+@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());
+    }
+    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();
+
+    // Read by ConversionTargetFactory to pick the Delta Kernel target.
+    List<TargetTable> targetTables =
+        spec.getTargets().stream()
+            .map(
+                targetFormat -> {
+                  Properties targetProperties = new Properties();
+                  if (spec.isUseDeltaKernel()) {
+                    targetProperties.setProperty(
+                        DeltaConversionTargetConfig.USE_KERNEL, 
Boolean.TRUE.toString());
+                  }
+                  // Hudi resolves partitions relative to its base path, so a 
Hudi target sits
+                  // at the data path. Other targets sit at the table root.
+                  String targetPath =
+                      TableFormat.HUDI.equalsIgnoreCase(targetFormat)
+                          ? dataPath
+                          : spec.getBasePath();
+                  return TargetTable.builder()
+                      .name(spec.getKey())
+                      .basePath(targetPath)
+                      .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..5574bfdc
--- /dev/null
+++ b/xtable-spark-runtime/src/main/resources/META-INF/LICENSE-bundled
@@ -0,0 +1,221 @@
+                                 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. See licenses/ for 
text of these licenses.
+
+
+Apache License 2.0
+------------------
+com.google.errorprone:error_prone_annotations:2.21.1
+com.google.guava:failureaccess:1.0.1
+com.google.guava:guava:32.1.3-jre
+com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
+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/main/resources/META-INF/licenses/LICENSE-checker-qual
 
b/xtable-spark-runtime/src/main/resources/META-INF/licenses/LICENSE-checker-qual
new file mode 100644
index 00000000..9837c6b6
--- /dev/null
+++ 
b/xtable-spark-runtime/src/main/resources/META-INF/licenses/LICENSE-checker-qual
@@ -0,0 +1,22 @@
+Checker Framework qualifiers
+Copyright 2004-present by the Checker Framework developers
+
+MIT License:
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
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..061d6e35
--- /dev/null
+++ 
b/xtable-spark-runtime/src/test/java/org/apache/xtable/spark/ITXTableSparkRuntimeBundle.java
@@ -0,0 +1,387 @@
+/*
+ * 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.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+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.apache.spark.sql.types.ArrayType;
+import org.apache.spark.sql.types.DataType;
+import org.apache.spark.sql.types.MapType;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+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.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.hadoop.HadoopTables;
+import org.apache.iceberg.spark.SparkSchemaUtil;
+
+import org.apache.xtable.GenericTable;
+import org.apache.xtable.hudi.HudiTestUtil;
+
+/**
+ * Runs a sync with the shaded bundle through {@code 
$SPARK_HOME/bin/spark-submit}, one case per
+ * direction, and checks the target data equals the source. The engines come 
from {@code
+ * target/engine-classpath.txt}. Skipped when {@code SPARK_HOME} is not set.
+ */
+class ITXTableSparkRuntimeBundle {
+
+  @TempDir static 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
+    return Stream.of(
+        Arguments.of("HUDI", "ICEBERG", "level:VALUE"),
+        Arguments.of("ICEBERG", "HUDI", null),
+        Arguments.of("ICEBERG", "DELTA", 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();
+      dataPath = table.getDataPath();
+      columns = table.getColumnsToSelect();
+      orderByColumn = table.getOrderByColumn();
+    }
+
+    List<String> syncArgs = new ArrayList<>();
+    syncArgs.add("--basepath");
+    syncArgs.add(basePath);
+    syncArgs.add("--datapath");
+    syncArgs.add(dataPath);
+    syncArgs.add("--sourceformat");
+    syncArgs.add(sourceFormat);
+    syncArgs.add("--targets");
+    syncArgs.add(targetFormat);
+    syncArgs.add("--tablename");
+    syncArgs.add(tableName);
+    if (partitionSpec != null) {
+      syncArgs.add("--partitionspec");
+      syncArgs.add(partitionSpec);
+    }
+    String output = runBundleSync(sparkHome, syncArgs);
+
+    assertDatasetEquivalence(
+        sourceFormat, targetFormat, basePath, dataPath, columns, 
orderByColumn, output);
+  }
+
+  /**
+   * Iceberg keeps its metadata at the base path but writes data files to a 
separate {@code
+   * write.data.path}. The Hudi target goes to the data path, so {@code 
.hoodie} lands next to the
+   * Iceberg data files and nothing is written under the Iceberg base path.
+   */
+  @Test
+  void icebergWithSeparateDataPathSyncsToHudi() throws Exception {
+    String sparkHome = System.getenv("SPARK_HOME");
+    assumeTrue(sparkHome != null && !sparkHome.trim().isEmpty(), "SPARK_HOME 
is not set");
+
+    String tableName = "iceberg_split_paths_to_hudi";
+    Path metadataDir = tempDir.resolve(tableName + "_metadata");
+    Path dataDir = tempDir.resolve("separate_data").resolve(tableName);
+    String basePath = metadataDir.toUri().toString();
+    String dataPath = dataDir.toUri().toString();
+
+    Dataset<Row> rows =
+        sparkSession
+            .range(100)
+            .selectExpr(
+                "concat('id-', lpad(cast(id AS string), 3, '0')) AS id",
+                "element_at(array('INFO', 'WARN', 'ERROR'), cast(id % 3 + 1 AS 
int)) AS level",
+                "cast(id AS double) * 1.5 AS amount",
+                "date_add(date '2026-01-01', cast(id AS int)) AS event_date");
+    Schema schema = SparkSchemaUtil.convert(rows.schema());
+    new HadoopTables(jsc.hadoopConfiguration())
+        .create(
+            schema,
+            PartitionSpec.builderFor(schema).identity("level").build(),
+            Collections.singletonMap(TableProperties.WRITE_DATA_LOCATION, 
dataPath),
+            basePath);
+    rows.write().format("iceberg").mode("append").save(basePath);
+    assertTrue(countParquetFiles(dataDir) > 0, "Iceberg did not write data 
files to " + dataDir);
+    assertEquals(0, countParquetFiles(metadataDir), "Iceberg wrote data files 
under the base path");
+
+    List<String> syncArgs =
+        Arrays.asList(
+            "--basepath",
+            basePath,
+            "--datapath",
+            dataPath,
+            "--sourceformat",
+            "ICEBERG",
+            "--targets",
+            "HUDI",
+            "--tablename",
+            tableName);
+    String output = runBundleSync(sparkHome, syncArgs);
+
+    assertTrue(
+        Files.isDirectory(dataDir.resolve(".hoodie")),
+        "Hudi metadata is not at the data path. spark-submit output:\n" + 
output);
+    assertTrue(
+        !Files.exists(metadataDir.resolve(".hoodie")),
+        "Hudi metadata was written to the base path");
+    assertDatasetEquivalence(
+        "ICEBERG",
+        "HUDI",
+        basePath,
+        dataPath,
+        Arrays.asList("id", "level", "amount", "event_date"),
+        "id",
+        output);
+  }
+
+  /** Runs {@code XTableSparkSync} from the bundle through spark-submit and 
returns its output. */
+  private String runBundleSync(String sparkHome, List<String> syncArgs) throws 
Exception {
+    File bundleJar = findBundleJar();
+    File sparkSubmit = new File(sparkHome, "bin/spark-submit");
+    assertTrue(sparkSubmit.canExecute(), "spark-submit not executable at " + 
sparkSubmit);
+
+    // A flat classpath, not --packages: a child classloader breaks Avro casts 
across the boundary.
+    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.addAll(syncArgs);
+
+    ProcessBuilder pb = new ProcessBuilder(command);
+    pb.environment().put("SPARK_LOCAL_IP", "127.0.0.1");
+    pb.redirectErrorStream(true);
+    Process process = pb.start();
+    // Drain on a thread so a hung spark-submit cannot block the timeout below.
+    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);
+    return output;
+  }
+
+  private static long countParquetFiles(Path dir) throws IOException {
+    if (!Files.isDirectory(dir)) {
+      return 0;
+    }
+    try (Stream<Path> files = Files.walk(dir)) {
+      return files.filter(f -> f.toString().endsWith(".parquet")).count();
+    }
+  }
+
+  /** Reads the source and the target back through Spark and asserts the 
scalar columns match. */
+  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);
+    String[] projection = comparableColumns(source, columns).toArray(new 
String[0]);
+    List<String> sourceRows = 
source.selectExpr(projection).toJSON().collectAsList();
+    // Same target location as XTableSyncService.
+    String targetPath = "HUDI".equalsIgnoreCase(targetFormat) ? dataPath : 
basePath;
+    List<String> targetRows =
+        sparkSession
+            .read()
+            .options(readOptions(targetFormat))
+            .format(targetFormat.toLowerCase())
+            .load(targetPath)
+            .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");
+  }
+
+  // Drops nested columns (covered by ITConversionController) and local 
timestamps (HUDI-7088).
+  private static List<String> comparableColumns(Dataset<Row> source, 
List<String> columns) {
+    Set<String> excluded = new HashSet<>();
+    for (StructField field : source.schema().fields()) {
+      DataType type = field.dataType();
+      if (type instanceof StructType || type instanceof ArrayType || type 
instanceof MapType) {
+        excluded.add(field.name());
+      }
+    }
+    return columns.stream()
+        .filter(c -> !excluded.contains(c))
+        .filter(c -> !c.startsWith("timestamp_local_"))
+        .collect(Collectors.toList());
+  }
+
+  private static Map<String, String> readOptions(String format) {
+    Map<String, String> options = new 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)) {
+      // The vectorized reader mis-casts timestamps written by Hudi.
+      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(Files.readAllBytes(cpFile.toPath()), 
StandardCharsets.UTF_8).trim();
+    assertTrue(!cp.isEmpty(), "engine classpath file is empty");
+    return cp;
+  }
+
+  // The shaded jar is the main artifact; skip original-* and the sources, 
javadoc and 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 (BufferedReader reader =
+        new BufferedReader(
+            new InputStreamReader(process.getInputStream(), 
StandardCharsets.UTF_8))) {
+      String line;
+      while ((line = reader.readLine()) != null) {
+        synchronized (sb) {
+          sb.append(line).append(System.lineSeparator());
+        }
+      }
+    } catch (IOException e) {
+      // Closed on timeout; the output so far is still reported.
+    }
+  }
+}
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..733c27e9
--- /dev/null
+++ 
b/xtable-spark-runtime/src/test/java/org/apache/xtable/spark/XTableSparkSyncTest.java
@@ -0,0 +1,255 @@
+/*
+ * 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 java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+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;
+
+import org.apache.xtable.model.sync.ErrorDetails;
+import org.apache.xtable.model.sync.SyncResult;
+import org.apache.xtable.model.sync.SyncStatusCode;
+
+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));
+  }
+
+  @Test
+  void syncAllCountsReturnedErrorsAndThrownFailuresAndContinuesBatch() {
+    List<TableSyncSpec> specs =
+        Arrays.asList(spec("ok"), spec("target_error"), spec("throws"), 
spec("ok_after"));
+    List<String> synced = new ArrayList<>();
+    int failures =
+        XTableSparkSync.syncAll(
+            specs,
+            spec -> {
+              synced.add(spec.getKey());
+              Map<String, SyncResult> results = new HashMap<>();
+              results.put("ICEBERG", syncResult(SyncStatusCode.SUCCESS));
+              switch (spec.getKey()) {
+                case "target_error":
+                  results.put("DELTA", syncResult(SyncStatusCode.ERROR));
+                  return results;
+                case "throws":
+                  throw new IllegalStateException("source unreadable");
+                default:
+                  results.put("DELTA", syncResult(SyncStatusCode.SUCCESS));
+                  return results;
+              }
+            });
+    assertEquals(2, failures);
+    assertEquals(Arrays.asList("ok", "target_error", "throws", "ok_after"), 
synced);
+  }
+
+  private static TableSyncSpec spec(String key) {
+    return TableSyncSpec.builder()
+        .key(key)
+        .basePath("/warehouse/" + key)
+        .sourceFormat("HUDI")
+        .targets(Arrays.asList("ICEBERG", "DELTA"))
+        .build();
+  }
+
+  private static SyncResult syncResult(SyncStatusCode statusCode) {
+    return SyncResult.builder()
+        .tableFormatSyncStatus(
+            SyncResult.SyncStatus.builder()
+                .statusCode(statusCode)
+                .errorDetails(
+                    statusCode == SyncStatusCode.SUCCESS
+                        ? null
+                        : ErrorDetails.builder().errorMessage("commit 
conflict").build())
+                .build())
+        .build();
+  }
+
+  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")));
+  }
+}

Reply via email to