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

voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 3015eb944083 test(integ-test): add Testcontainers E2E for 
VECTOR/BLOB/VARIANT Hive… (#19203)
3015eb944083 is described below

commit 3015eb944083272459040dc5da5755e683674cb5
Author: voonhous <[email protected]>
AuthorDate: Wed Jul 8 18:57:59 2026 +0800

    test(integ-test): add Testcontainers E2E for VECTOR/BLOB/VARIANT Hive… 
(#19203)
    
    * test(integ-test): add Testcontainers E2E for VECTOR/BLOB/VARIANT Hive sync
    
    Testcontainers-based E2E coverage that a Spark write of Hudi's custom
    logical types produces a Hive-syncable schema queryable through a real
    Hive metastore. ITTestCustomTypeHiveSync exercises VECTOR and BLOB (SQL +
    DataFrame) plus Spark 4.0 VARIANT (Spark4-gated), asserting write ->
    hive-sync -> Hive DESCRIBE/SHOW PARTITIONS/SELECT round-trips.
    
    Runs on both a Spark 3.5.3 (JDK 11) and Spark 4.0.2 (JDK 17) stack against
    Hive 2.3.10, via a new integration-tests-hive-sync CI job. The integ2
    package manages its own docker stack and is excluded from the legacy
    in-job compose demo to avoid host-port collisions.
    
    Scoped decomposition of #18535 (Hive-sync half only): the Trino read E2E
    and two bundled production features (blob partial-struct write, Lance
    VARIANT support) are deferred to follow-up PRs.
    
    * test(integ-test): address review comments on testcontainers harness
    
    - delete unused-and-broken HiveService.runQuery (execInContainer has no
      shell, so the added literal quotes would reach hive -e and break parsing)
    - fix assertStdOutContains javadoc: asserts exactly once, not at least once
    - gate integration-tests-hive-sync CI job on the changes job's relevance
      output, matching the legacy integration-tests job
    - trim unused harness API: sparkAdhoc2, unused Containers/Paths/Network
      constants, 
CommandResult.expectToFail/assertExitCodeIs/assertStdErrContains,
      HiveService.executeFile/copyFile, SparkService.copyFile,
      CommandExecutor.copyFileToContainer
    - register compose temp files for deleteOnExit so the temp dir actually
      deletes; add EOF newline; fix ContainerProvider javadoc; rely on root
      dependencyManagement for the managed testcontainers artifact
    
    * test(integ-test): use jdbc hive_sync mode for VARIANT SQL fixture
    
    The VARIANT SQL fixture was the only one syncing via hms mode while
    retaining a jdbcurl (used only by jdbc mode). Align it with every other
    fixture, including the DataFrame VARIANT fixture that syncs the identical
    VariantType, which all use jdbc.
    
    * test(integ-test): move VERBOSE_HIVECONFS into SystemProps
    
    Keep all verbose-mode config in one place. VERBOSE_HIVECONFS was the only
    constant at the outer-class level; group it with SystemProps.HIVE_VERBOSE,
    which its javadoc already references.
---
 .github/workflows/bot.yml                          |  76 ++++++
 ...r-compose_hadoop284_hive2310_spark353_amd64.yml |  76 ------
 ...r-compose_hadoop284_hive2310_spark353_arm64.yml |  72 ------
 docker/demo/sparksql-blob-type-df.commands         | 108 +++++++++
 docker/demo/sparksql-blob-type-sql.commands        | 126 ++++++++++
 docker/demo/sparksql-variant-type-df.commands      |  80 +++++++
 docker/demo/sparksql-variant-type-sql.commands     |  89 +++++++
 docker/demo/sparksql-vector-type-df.commands       |  90 +++++++
 docker/demo/sparksql-vector-type-sql.commands      |  90 +++++++
 docker/hoodie/hadoop/hive_base/entrypoint.sh       |   2 +-
 docker/hoodie/hadoop/sparkmaster/master.sh         |   2 +-
 hudi-integ-test/pom.xml                            |  48 +++-
 .../integ2/testcontainers/ContainerProvider.java   |  37 +++
 .../testcontainers/ITTestBaseTestcontainers.java   | 236 ++++++++++++++++++
 .../testcontainers/ITTestCustomTypeHiveSync.java   | 266 +++++++++++++++++++++
 .../testcontainers/TestcontainersConfig.java       | 110 +++++++++
 .../testcontainers/command/CommandExecutor.java    |  87 +++++++
 .../testcontainers/command/CommandResult.java      | 137 +++++++++++
 .../integ2/testcontainers/service/HiveService.java |  72 ++++++
 .../testcontainers/service/SparkService.java       |  71 ++++++
 20 files changed, 1724 insertions(+), 151 deletions(-)

diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml
index 99fb6ba9a8be..c6901e00503c 100644
--- a/.github/workflows/bot.yml
+++ b/.github/workflows/bot.yml
@@ -1432,6 +1432,82 @@ jobs:
           flags: integration-tests
           token: ${{ secrets.CODECOV_TOKEN }}
 
+  integration-tests-hive-sync:
+    # Testcontainers-based E2E hive sync coverage for Hudi's custom logical 
types
+    # (VECTOR, BLOB) and the Spark 4.0 VARIANT type. Runs the integ2 
testcontainers
+    # suite (ITTestCustomTypeHiveSync) against a real Hive metastore on both a
+    # Spark 3.5.3 and Spark 4.0.2 stack.
+    runs-on: ubuntu-latest
+    needs: changes
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - sparkProfile: 'spark3.5'
+            scalaProfile: '-Dscala-2.12 -Dscala.binary.version=2.12'
+            flinkProfile: 'flink1.20'
+            jdkVersion: '11'
+            composePrefix: 'docker-compose_hadoop284_hive2310_spark353'
+            sparkAdhocImage: 
'apachehudi/hudi-hadoop_2.8.4-hive_2.3.10-sparkadhoc_3.5.3:latest'
+          - sparkProfile: 'spark4.0'
+            scalaProfile: '-Dscala-2.13 -Dscala.binary.version=2.13'
+            flinkProfile: 'flink1.20'
+            jdkVersion: '17'
+            composePrefix: 'docker-compose_hadoop340_hive2310_spark402'
+            sparkAdhocImage: 
'apachehudi/hudi-hadoop_3.4.0-hive_2.3.10-sparkadhoc_4.0.2:latest'
+    steps:
+      - if: needs.changes.outputs.relevant == 'true'
+        uses: actions/checkout@v5
+      - name: Set up JDK ${{ matrix.jdkVersion }}
+        if: needs.changes.outputs.relevant == 'true'
+        uses: actions/setup-java@v5
+        with:
+          java-version: ${{ matrix.jdkVersion }}
+          distribution: 'temurin'
+          architecture: x64
+          cache: maven
+      - name: Free disk space
+        if: needs.changes.outputs.relevant == 'true'
+        run: |
+          sudo rm -rf /usr/share/dotnet
+          sudo rm -rf /usr/local/lib/android
+          sudo rm -rf /opt/ghc
+          sudo rm -rf /usr/local/share/boost
+          docker system prune --all --force --volumes
+      - name: Pre-pull compose images (fails fast if not published)
+        if: needs.changes.outputs.relevant == 'true'
+        env:
+          SPARK_ADHOC_IMAGE: ${{ matrix.sparkAdhocImage }}
+        run: |
+          # Surface missing Spark 4.0.2 images before the 15-minute Maven 
install.
+          # The remaining images in the compose stack are pulled by 
docker-compose at
+          # test time.
+          docker pull "$SPARK_ADHOC_IMAGE"
+      - name: Build and install Hudi artifacts
+        if: needs.changes.outputs.relevant == 'true'
+        env:
+          SPARK_PROFILE: ${{ matrix.sparkProfile }}
+          FLINK_PROFILE: ${{ matrix.flinkProfile }}
+          SCALA_PROFILE: ${{ matrix.scalaProfile }}
+        run:
+          mvn clean install -T 2 $SCALA_PROFILE -D"$SPARK_PROFILE" 
-D"$FLINK_PROFILE" -Pintegration-tests -DskipTests=true 
-Ddocker.compose.skip=true $MVN_ARGS
+      - name: Run integ2 testcontainers suite
+        if: needs.changes.outputs.relevant == 'true'
+        env:
+          SPARK_PROFILE: ${{ matrix.sparkProfile }}
+          SCALA_PROFILE: ${{ matrix.scalaProfile }}
+          COMPOSE_PREFIX: ${{ matrix.composePrefix }}
+        run: |
+          # -DskipITs=false overrides the spark4.0 profile's skipITs=true 
default
+          # (see root pom.xml). Without it, failsafe skips all ITs on the 
spark4.0 matrix row.
+          mvn verify $SCALA_PROFILE -D"$SPARK_PROFILE" -Pintegration-tests \
+            -pl hudi-integ-test \
+            -DskipITs=false \
+            -Ddocker.compose.skip=true \
+            -Dit.test='ITTestCustomTypeHiveSync' \
+            -Dspark.docker.compose.prefix=$COMPOSE_PREFIX \
+            $MVN_ARGS
+
   build-spark-java17:
     runs-on: ubuntu-latest
     needs: changes
diff --git 
a/docker/compose/docker-compose_hadoop284_hive2310_spark353_amd64.yml 
b/docker/compose/docker-compose_hadoop284_hive2310_spark353_amd64.yml
index 1d84417b378a..c302a3c95cca 100644
--- a/docker/compose/docker-compose_hadoop284_hive2310_spark353_amd64.yml
+++ b/docker/compose/docker-compose_hadoop284_hive2310_spark353_amd64.yml
@@ -186,78 +186,6 @@ services:
       - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
       - ALLOW_PLAINTEXT_LISTENER=yes
 
-  presto-coordinator-1:
-    container_name: presto-coordinator-1
-    hostname: presto-coordinator-1
-    image: apachehudi/hudi-hadoop_2.8.4-prestobase_0.271:latest
-    platform: linux/amd64
-    ports:
-      - "8090:8090"
-    environment:
-      - PRESTO_JVM_MAX_HEAP=512M
-      - PRESTO_QUERY_MAX_MEMORY=1GB
-      - PRESTO_QUERY_MAX_MEMORY_PER_NODE=256MB
-      - PRESTO_QUERY_MAX_TOTAL_MEMORY_PER_NODE=384MB
-      - PRESTO_MEMORY_HEAP_HEADROOM_PER_NODE=100MB
-      - TERM=xterm
-    links:
-      - "hivemetastore"
-    volumes:
-      - ${HUDI_WS}:/var/hoodie/ws
-    command: coordinator
-
-  presto-worker-1:
-    container_name: presto-worker-1
-    hostname: presto-worker-1
-    image: apachehudi/hudi-hadoop_2.8.4-prestobase_0.271:latest
-    platform: linux/amd64
-    depends_on: [ "presto-coordinator-1" ]
-    environment:
-      - PRESTO_JVM_MAX_HEAP=512M
-      - PRESTO_QUERY_MAX_MEMORY=1GB
-      - PRESTO_QUERY_MAX_MEMORY_PER_NODE=256MB
-      - PRESTO_QUERY_MAX_TOTAL_MEMORY_PER_NODE=384MB
-      - PRESTO_MEMORY_HEAP_HEADROOM_PER_NODE=100MB
-      - TERM=xterm
-    links:
-      - "hivemetastore"
-      - "hiveserver"
-      - "hive-metastore-postgresql"
-      - "namenode"
-    volumes:
-      - ${HUDI_WS}:/var/hoodie/ws
-    command: worker
-
-  trino-coordinator-1:
-    container_name: trino-coordinator-1
-    hostname: trino-coordinator-1
-    image: apachehudi/hudi-hadoop_2.8.4-trinocoordinator_368:latest
-    platform: linux/amd64
-    ports:
-      - "8091:8091"
-    links:
-      - "hivemetastore"
-    volumes:
-      - ${HUDI_WS}:/var/hoodie/ws
-    command: http://trino-coordinator-1:8091 trino-coordinator-1
-
-  trino-worker-1:
-    container_name: trino-worker-1
-    hostname: trino-worker-1
-    image: apachehudi/hudi-hadoop_2.8.4-trinoworker_368:latest
-    platform: linux/amd64
-    depends_on: [ "trino-coordinator-1" ]
-    ports:
-      - "8092:8092"
-    links:
-      - "hivemetastore"
-      - "hiveserver"
-      - "hive-metastore-postgresql"
-      - "namenode"
-    volumes:
-      - ${HUDI_WS}:/var/hoodie/ws
-    command: http://trino-coordinator-1:8091 trino-worker-1
-
   graphite:
     container_name: graphite
     hostname: graphite
@@ -287,8 +215,6 @@ services:
       - "hiveserver"
       - "hive-metastore-postgresql"
       - "namenode"
-      - "presto-coordinator-1"
-      - "trino-coordinator-1"
     volumes:
       - ${HUDI_WS}:/var/hoodie/ws
 
@@ -311,8 +237,6 @@ services:
       - "hiveserver"
       - "hive-metastore-postgresql"
       - "namenode"
-      - "presto-coordinator-1"
-      - "trino-coordinator-1"
     volumes:
       - ${HUDI_WS}:/var/hoodie/ws
 
diff --git 
a/docker/compose/docker-compose_hadoop284_hive2310_spark353_arm64.yml 
b/docker/compose/docker-compose_hadoop284_hive2310_spark353_arm64.yml
index b995859c8022..a9f042155d30 100644
--- a/docker/compose/docker-compose_hadoop284_hive2310_spark353_arm64.yml
+++ b/docker/compose/docker-compose_hadoop284_hive2310_spark353_arm64.yml
@@ -179,74 +179,6 @@ services:
       - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
       - ALLOW_PLAINTEXT_LISTENER=yes
 
-  presto-coordinator-1:
-    container_name: presto-coordinator-1
-    hostname: presto-coordinator-1
-    image: apachehudi/hudi-hadoop_2.8.4-prestobase_0.271:latest
-    ports:
-      - "8090:8090"
-    environment:
-      - PRESTO_JVM_MAX_HEAP=512M
-      - PRESTO_QUERY_MAX_MEMORY=1GB
-      - PRESTO_QUERY_MAX_MEMORY_PER_NODE=256MB
-      - PRESTO_QUERY_MAX_TOTAL_MEMORY_PER_NODE=384MB
-      - PRESTO_MEMORY_HEAP_HEADROOM_PER_NODE=100MB
-      - TERM=xterm
-    links:
-      - "hivemetastore"
-    volumes:
-      - ${HUDI_WS}:/var/hoodie/ws
-    command: coordinator
-
-  presto-worker-1:
-    container_name: presto-worker-1
-    hostname: presto-worker-1
-    image: apachehudi/hudi-hadoop_2.8.4-prestobase_0.271:latest
-    depends_on: [ "presto-coordinator-1" ]
-    environment:
-      - PRESTO_JVM_MAX_HEAP=512M
-      - PRESTO_QUERY_MAX_MEMORY=1GB
-      - PRESTO_QUERY_MAX_MEMORY_PER_NODE=256MB
-      - PRESTO_QUERY_MAX_TOTAL_MEMORY_PER_NODE=384MB
-      - PRESTO_MEMORY_HEAP_HEADROOM_PER_NODE=100MB
-      - TERM=xterm
-    links:
-      - "hivemetastore"
-      - "hiveserver"
-      - "hive-metastore-postgresql"
-      - "namenode"
-    volumes:
-      - ${HUDI_WS}:/var/hoodie/ws
-    command: worker
-
-  trino-coordinator-1:
-    container_name: trino-coordinator-1
-    hostname: trino-coordinator-1
-    image: apachehudi/hudi-hadoop_2.8.4-trinocoordinator_368:latest
-    ports:
-      - "8091:8091"
-    links:
-      - "hivemetastore"
-    volumes:
-      - ${HUDI_WS}:/var/hoodie/ws
-    command: http://trino-coordinator-1:8091 trino-coordinator-1
-
-  trino-worker-1:
-    container_name: trino-worker-1
-    hostname: trino-worker-1
-    image: apachehudi/hudi-hadoop_2.8.4-trinoworker_368:latest
-    depends_on: [ "trino-coordinator-1" ]
-    ports:
-      - "8092:8092"
-    links:
-      - "hivemetastore"
-      - "hiveserver"
-      - "hive-metastore-postgresql"
-      - "namenode"
-    volumes:
-      - ${HUDI_WS}:/var/hoodie/ws
-    command: http://trino-coordinator-1:8091 trino-worker-1
-
   graphite:
     container_name: graphite
     hostname: graphite
@@ -275,8 +207,6 @@ services:
       - "hiveserver"
       - "hive-metastore-postgresql"
       - "namenode"
-      - "presto-coordinator-1"
-      - "trino-coordinator-1"
     volumes:
       - ${HUDI_WS}:/var/hoodie/ws
 
@@ -298,8 +228,6 @@ services:
       - "hiveserver"
       - "hive-metastore-postgresql"
       - "namenode"
-      - "presto-coordinator-1"
-      - "trino-coordinator-1"
     volumes:
       - ${HUDI_WS}:/var/hoodie/ws
 
diff --git a/docker/demo/sparksql-blob-type-df.commands 
b/docker/demo/sparksql-blob-type-df.commands
new file mode 100644
index 000000000000..28e0663d74e9
--- /dev/null
+++ b/docker/demo/sparksql-blob-type-df.commands
@@ -0,0 +1,108 @@
+/*
+ * 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.
+ */
+
+import org.apache.spark.sql.types._
+import org.apache.spark.sql.{Row, SaveMode}
+
+// BLOB is a struct with fields: type (string), data (binary, nullable), 
reference (struct, nullable)
+val blobMetadata = new MetadataBuilder().putString("hudi_type", "BLOB").build()
+
+val referenceType = StructType(Seq(
+  StructField("external_path", StringType, nullable = false),
+  StructField("offset", LongType, nullable = true),
+  StructField("length", LongType, nullable = true),
+  StructField("managed", BooleanType, nullable = false)
+))
+
+val blobType = StructType(Seq(
+  StructField("type", StringType, nullable = false),
+  StructField("data", BinaryType, nullable = true),
+  StructField("reference", referenceType, nullable = true)
+))
+
+val schema = StructType(Seq(
+  StructField("id", LongType, nullable = false),
+  StructField("name", StringType),
+  StructField("blob_data", blobType, nullable = false, metadata = 
blobMetadata),
+  StructField("dt", StringType)
+))
+
+// Shared Hive-sync + write options factored out so the upsert and delete 
writes
+// reuse the exact same sync configuration as the initial Overwrite.
+// Body is wrapped in { } so the spark-shell REPL keeps the chained 
.option(...)
+// calls attached to the def. Without braces, writer.format("hudi") on the 
first
+// body line parses as a complete expression, the REPL closes the def there, 
and
+// the remaining .option(...) lines get dot-applied to `sc` (SparkContext).
+def applyWriteOpts(writer: org.apache.spark.sql.DataFrameWriter[Row]): 
org.apache.spark.sql.DataFrameWriter[Row] = {
+  writer.format("hudi")
+    .option("hoodie.table.name", "blob_test_df")
+    .option("hoodie.datasource.write.recordkey.field", "id")
+    .option("hoodie.datasource.write.precombine.field", "name")
+    .option("hoodie.datasource.write.partitionpath.field", "dt")
+    .option("hoodie.datasource.hive_sync.enable", "true")
+    .option("hoodie.datasource.hive_sync.database", "default")
+    .option("hoodie.datasource.hive_sync.table", "blob_test_df")
+    .option("hoodie.datasource.hive_sync.jdbcurl", 
"jdbc:hive2://hiveserver:10000/")
+    .option("hoodie.datasource.hive_sync.mode", "jdbc")
+    .option("hoodie.datasource.hive_sync.partition_fields", "dt")
+    .option("hoodie.datasource.hive_sync.partition_extractor_class",
+      "org.apache.hudi.hive.MultiPartKeysValueExtractor")
+    .option("hoodie.datasource.hive_sync.username", "hive")
+    .option("hoodie.datasource.hive_sync.password", "hive")
+}
+
+// Seed two rows in dt='2024-01-01' via Overwrite.
+val seed = Seq(
+  Row(1L, "file1", Row("INLINE", "hello world".getBytes, null), "2024-01-01"),
+  Row(2L, "file2", Row("INLINE", "test data".getBytes, null), "2024-01-01")
+)
+val seedDf = spark.createDataFrame(spark.sparkContext.parallelize(seed), 
schema)
+applyWriteOpts(seedDf.write).mode(SaveMode.Overwrite).save("/user/hive/warehouse/blob_test_df")
+spark.sql("select id, name, blob_data.type, dt from blob_test_df order by 
id").show(10, false)
+println("BLOB_DF_INSERT_SUCCESS")
+
+// Append-upsert: id=2 value changes, id=3 new in a new partition 
dt='2024-01-02'.
+// precombine=name must strictly increase for the matched row so Hudi keeps the
+// new value (alphabetical 'file2-updated' > 'file2').
+val upsertRows = Seq(
+  Row(2L, "file2-updated", Row("INLINE", "updated payload".getBytes, null), 
"2024-01-01"),
+  Row(3L, "file3", Row("INLINE", "brand new".getBytes, null), "2024-01-02")
+)
+val upsertDf = 
spark.createDataFrame(spark.sparkContext.parallelize(upsertRows), schema)
+applyWriteOpts(upsertDf.write)
+  .option("hoodie.datasource.write.operation", "upsert")
+  .mode(SaveMode.Append)
+  .save("/user/hive/warehouse/blob_test_df")
+spark.sql("select id, name, blob_data.type, dt from blob_test_df order by 
id").show(10, false)
+println("BLOB_DF_UPSERT_SUCCESS")
+
+// Delete id=3 via operation=delete. The DF still needs a schema-compatible 
BLOB
+// column (Hudi uses only the record key/partition for delete, the payload is
+// ignored but must deserialize).
+val deleteRows = Seq(
+  Row(3L, "file3", Row("INLINE", "ignored".getBytes, null), "2024-01-02")
+)
+val deleteDf = 
spark.createDataFrame(spark.sparkContext.parallelize(deleteRows), schema)
+applyWriteOpts(deleteDf.write)
+  .option("hoodie.datasource.write.operation", "delete")
+  .mode(SaveMode.Append)
+  .save("/user/hive/warehouse/blob_test_df")
+spark.sql("select id, name, blob_data.type, dt from blob_test_df order by 
id").show(10, false)
+println("BLOB_DF_DELETE_SUCCESS")
+
+println("BLOB_DF_TEST_SUCCESS")
diff --git a/docker/demo/sparksql-blob-type-sql.commands 
b/docker/demo/sparksql-blob-type-sql.commands
new file mode 100644
index 000000000000..8a03b1d16f30
--- /dev/null
+++ b/docker/demo/sparksql-blob-type-sql.commands
@@ -0,0 +1,126 @@
+/*
+ * 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.
+ */
+
+// E2E test: BLOB type with Hive sync via SQL CREATE TABLE path.
+// BLOB is parsed by HoodieSpark3_5ExtendedSqlAstBuilder into a struct
+// <type:string, data:binary, reference:struct<...>>.
+spark.sql("""
+  CREATE TABLE blob_test (
+    id LONG,
+    name STRING,
+    blob_data BLOB,
+    dt STRING
+  ) USING hudi
+  PARTITIONED BY (dt)
+  LOCATION '/user/hive/warehouse/blob_test'
+  TBLPROPERTIES (
+    'primaryKey' = 'id',
+    'preCombineField' = 'name',
+    'hoodie.datasource.hive_sync.enable' = 'true',
+    'hoodie.datasource.hive_sync.database' = 'default',
+    'hoodie.datasource.hive_sync.table' = 'blob_test',
+    'hoodie.datasource.hive_sync.jdbcurl' = 'jdbc:hive2://hiveserver:10000/',
+    'hoodie.datasource.hive_sync.mode' = 'jdbc',
+    'hoodie.datasource.hive_sync.partition_fields' = 'dt',
+    'hoodie.datasource.hive_sync.partition_extractor_class' = 
'org.apache.hudi.hive.MultiPartKeysValueExtractor',
+    'hoodie.datasource.hive_sync.username' = 'hive',
+    'hoodie.datasource.hive_sync.password' = 'hive'
+  )
+""")
+
+// Seed two rows in dt='2024-01-01'. Use OUT_OF_LINE here so subsequent 
UPDATE/MERGE
+// can mutate to a different reference without changing the struct shape 
between ops
+// (avoids mixing INLINE and OUT_OF_LINE branches in a single test).
+spark.sql("""
+  INSERT INTO blob_test VALUES
+    (1, 'file1', named_struct(
+                   'type', 'OUT_OF_LINE',
+                   'data', cast(null as binary),
+                   'reference', named_struct(
+                     'external_path', 'blobs/seed-1',
+                     'offset', 0L,
+                     'length', 11L,
+                     'managed', false)), '2024-01-01'),
+    (2, 'file2', named_struct(
+                   'type', 'OUT_OF_LINE',
+                   'data', cast(null as binary),
+                   'reference', named_struct(
+                     'external_path', 'blobs/seed-2',
+                     'offset', 0L,
+                     'length', 9L,
+                     'managed', false)), '2024-01-01')
+""")
+spark.sql("select id, name, blob_data.type, dt from blob_test").show(10, false)
+println("BLOB_SQL_INSERT_SUCCESS")
+
+// UPDATE exercises the BLOB metadata re-attach on the UPDATE write path.
+// Per RFC-100 external_path and managed are non-null.
+spark.sql("""
+  UPDATE blob_test
+  SET blob_data = named_struct(
+        'type', 'OUT_OF_LINE',
+        'data', cast(null as binary),
+        'reference', named_struct(
+          'external_path', 'blobs/updated-1',
+          'offset', 10L,
+          'length', 100L,
+          'managed', true)),
+      name = 'file1-updated'
+  WHERE id = 1
+""")
+spark.sql("select id, name, blob_data.reference.external_path from blob_test 
where id = 1").show(10, false)
+println("BLOB_SQL_UPDATE_SUCCESS")
+
+// MERGE exercises both MATCHED (UPDATE SET) and NOT MATCHED (INSERT of a new
+// row into a new partition dt='2024-01-02'). The USING CTE strips the 
hudi_type
+// metadata from the struct column, so this specifically validates the reattach
+// path for MERGE.
+spark.sql("""
+  MERGE INTO blob_test t
+  USING (
+    SELECT 2L AS id, 'file2-merged' AS name, named_struct(
+      'type', 'OUT_OF_LINE',
+      'data', cast(null as binary),
+      'reference', named_struct(
+        'external_path', 'blobs/merged-2',
+        'offset', 20L,
+        'length', 200L,
+        'managed', true)) AS blob_data, '2024-01-01' AS dt
+    UNION ALL
+    SELECT 3L AS id, 'file3' AS name, named_struct(
+      'type', 'OUT_OF_LINE',
+      'data', cast(null as binary),
+      'reference', named_struct(
+        'external_path', 'blobs/inserted-3',
+        'offset', 300L,
+        'length', 30L,
+        'managed', false)) AS blob_data, '2024-01-02' AS dt
+  ) s
+  ON t.id = s.id
+  WHEN MATCHED THEN UPDATE SET t.name = s.name, t.blob_data = s.blob_data
+  WHEN NOT MATCHED THEN INSERT (id, name, blob_data, dt) VALUES (s.id, s.name, 
s.blob_data, s.dt)
+""")
+spark.sql("select id, name, blob_data.reference.external_path, dt from 
blob_test order by id").show(10, false)
+println("BLOB_SQL_MERGE_SUCCESS")
+
+// DELETE the NOT MATCHED row we just inserted, returning to 2 rows total.
+spark.sql("DELETE FROM blob_test WHERE id = 3")
+spark.sql("select id, name, blob_data.type, dt from blob_test order by 
id").show(10, false)
+println("BLOB_SQL_DELETE_SUCCESS")
+
+println("BLOB_SQL_TEST_SUCCESS")
diff --git a/docker/demo/sparksql-variant-type-df.commands 
b/docker/demo/sparksql-variant-type-df.commands
new file mode 100644
index 000000000000..dbbeabdad039
--- /dev/null
+++ b/docker/demo/sparksql-variant-type-df.commands
@@ -0,0 +1,80 @@
+/*
+ * 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.
+ */
+
+import org.apache.spark.sql.{DataFrame, SaveMode}
+
+// E2E test: VARIANT type with Hive sync via DataFrame API path.
+// Tests that the HiveSyncTool correctly maps VARIANT to 
struct<value:binary,metadata:binary> in Hive.
+
+// Body is wrapped in { } so the spark-shell REPL keeps the chained 
.option(...)
+// calls attached to the def. Without braces, writer.format("hudi") on the 
first
+// body line parses as a complete expression, the REPL closes the def there, 
and
+// the remaining .option(...) lines get dot-applied to `sc` (SparkContext).
+def applyWriteOpts(writer: org.apache.spark.sql.DataFrameWriter[_]): 
org.apache.spark.sql.DataFrameWriter[_] = {
+  writer.format("hudi")
+    .option("hoodie.table.name", "variant_test_df")
+    .option("hoodie.datasource.write.recordkey.field", "id")
+    .option("hoodie.datasource.write.precombine.field", "name")
+    .option("hoodie.datasource.write.partitionpath.field", "dt")
+    .option("hoodie.datasource.hive_sync.enable", "true")
+    .option("hoodie.datasource.hive_sync.database", "default")
+    .option("hoodie.datasource.hive_sync.table", "variant_test_df")
+    .option("hoodie.datasource.hive_sync.jdbcurl", 
"jdbc:hive2://hiveserver:10000/")
+    .option("hoodie.datasource.hive_sync.mode", "jdbc")
+    .option("hoodie.datasource.hive_sync.partition_fields", "dt")
+    .option("hoodie.datasource.hive_sync.partition_extractor_class",
+      "org.apache.hudi.hive.MultiPartKeysValueExtractor")
+    .option("hoodie.datasource.hive_sync.username", "hive")
+    .option("hoodie.datasource.hive_sync.password", "hive")
+}
+
+// Seed two rows in dt='2024-01-01' via Overwrite.
+val seedDf: DataFrame = spark.sql("""
+  SELECT 1L AS id, 'row1' AS name, parse_json('{"key":"value1"}') AS 
variant_data, '2024-01-01' AS dt
+  UNION ALL
+  SELECT 2L AS id, 'row2' AS name, parse_json('{"key":"value2"}') AS 
variant_data, '2024-01-01' AS dt
+""")
+applyWriteOpts(seedDf.write).mode(SaveMode.Overwrite).save("/user/hive/warehouse/variant_test_df")
+spark.sql("select id, name, cast(variant_data as string), dt from 
variant_test_df order by id").show(10, false)
+println("VARIANT_DF_INSERT_SUCCESS")
+
+// Append-upsert: id=2 mutated, id=3 new in dt='2024-01-02'.
+val upsertDf: DataFrame = spark.sql("""
+  SELECT 2L AS id, 'row2-updated' AS name, 
parse_json('{"key":"value2-updated"}') AS variant_data, '2024-01-01' AS dt
+  UNION ALL
+  SELECT 3L AS id, 'row3' AS name, parse_json('{"key":"value3"}') AS 
variant_data, '2024-01-02' AS dt
+""")
+applyWriteOpts(upsertDf.write)
+  .option("hoodie.datasource.write.operation", "upsert")
+  .mode(SaveMode.Append)
+  .save("/user/hive/warehouse/variant_test_df")
+spark.sql("select id, name, cast(variant_data as string), dt from 
variant_test_df order by id").show(10, false)
+println("VARIANT_DF_UPSERT_SUCCESS")
+
+// Delete id=3 via operation=delete.
+val deleteDf: DataFrame = spark.sql("""
+  SELECT 3L AS id, 'row3' AS name, parse_json('{}') AS variant_data, 
'2024-01-02' AS dt
+""")
+applyWriteOpts(deleteDf.write)
+  .option("hoodie.datasource.write.operation", "delete")
+  .mode(SaveMode.Append)
+  .save("/user/hive/warehouse/variant_test_df")
+spark.sql("select id, name, cast(variant_data as string), dt from 
variant_test_df order by id").show(10, false)
+println("VARIANT_DF_DELETE_SUCCESS")
+
+println("VARIANT_DF_TEST_SUCCESS")
diff --git a/docker/demo/sparksql-variant-type-sql.commands 
b/docker/demo/sparksql-variant-type-sql.commands
new file mode 100644
index 000000000000..c21a62674ef3
--- /dev/null
+++ b/docker/demo/sparksql-variant-type-sql.commands
@@ -0,0 +1,89 @@
+/*
+ * 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.
+ */
+
+// E2E test: VARIANT type with Hive sync via SQL CREATE TABLE path.
+// Tests that VariantType is mapped to struct<value:binary,metadata:binary> in 
Hive.
+spark.sql("""
+  CREATE TABLE variant_test (
+    id LONG,
+    name STRING,
+    variant_data VARIANT,
+    dt STRING
+  ) USING hudi
+  PARTITIONED BY (dt)
+  LOCATION '/user/hive/warehouse/variant_test'
+  TBLPROPERTIES (
+    'primaryKey' = 'id',
+    'preCombineField' = 'name',
+    'hoodie.datasource.hive_sync.enable' = 'true',
+    'hoodie.datasource.hive_sync.database' = 'default',
+    'hoodie.datasource.hive_sync.table' = 'variant_test',
+    'hoodie.datasource.hive_sync.jdbcurl' = 'jdbc:hive2://hiveserver:10000/',
+    'hoodie.datasource.hive_sync.mode' = 'jdbc',
+    'hoodie.datasource.hive_sync.partition_fields' = 'dt',
+    'hoodie.datasource.hive_sync.partition_extractor_class' = 
'org.apache.hudi.hive.MultiPartKeysValueExtractor',
+    'hoodie.datasource.hive_sync.username' = 'hive',
+    'hoodie.datasource.hive_sync.password' = 'hive'
+  )
+""")
+
+spark.sql("""
+  INSERT INTO variant_test VALUES
+    (1, 'row1', parse_json('{"key":"value1"}'), '2024-01-01'),
+    (2, 'row2', parse_json('{"key":"value2"}'), '2024-01-01')
+""")
+spark.sql("select id, name, cast(variant_data as string), dt from variant_test 
order by id").show(10, false)
+println("VARIANT_SQL_INSERT_SUCCESS")
+
+// UPDATE against a VariantType column. Hudi's V1 writer accepts VariantType
+// writes on Spark 4 (see fix f395eea4b4a9); UPDATE exercises the same writer
+// path with castIfNeeded applied to the assignment.
+spark.sql("""
+  UPDATE variant_test
+  SET variant_data = parse_json('{"key":"value1-updated"}'),
+      name = 'row1-updated'
+  WHERE id = 1
+""")
+spark.sql("select id, name, cast(variant_data as string) from variant_test 
where id = 1").show(10, false)
+println("VARIANT_SQL_UPDATE_SUCCESS")
+
+// MERGE exercises both MATCHED (UPDATE) and NOT MATCHED (INSERT into new
+// partition dt='2024-01-02').
+spark.sql("""
+  MERGE INTO variant_test t
+  USING (
+    SELECT 2L AS id, 'row2-merged' AS name,
+           parse_json('{"key":"value2-merged"}') AS variant_data,
+           '2024-01-01' AS dt
+    UNION ALL
+    SELECT 3L AS id, 'row3' AS name,
+           parse_json('{"key":"value3"}') AS variant_data,
+           '2024-01-02' AS dt
+  ) s
+  ON t.id = s.id
+  WHEN MATCHED THEN UPDATE SET t.name = s.name, t.variant_data = s.variant_data
+  WHEN NOT MATCHED THEN INSERT (id, name, variant_data, dt) VALUES (s.id, 
s.name, s.variant_data, s.dt)
+""")
+spark.sql("select id, name, cast(variant_data as string), dt from variant_test 
order by id").show(10, false)
+println("VARIANT_SQL_MERGE_SUCCESS")
+
+spark.sql("DELETE FROM variant_test WHERE id = 3")
+spark.sql("select id, name, cast(variant_data as string), dt from variant_test 
order by id").show(10, false)
+println("VARIANT_SQL_DELETE_SUCCESS")
+
+println("VARIANT_SQL_TEST_SUCCESS")
diff --git a/docker/demo/sparksql-vector-type-df.commands 
b/docker/demo/sparksql-vector-type-df.commands
new file mode 100644
index 000000000000..ef71771be295
--- /dev/null
+++ b/docker/demo/sparksql-vector-type-df.commands
@@ -0,0 +1,90 @@
+/*
+ * 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.
+ */
+
+import org.apache.spark.sql.types._
+import org.apache.spark.sql.{Row, SaveMode}
+
+// Create schema with VECTOR metadata + partition column
+val metadata = new MetadataBuilder().putString("hudi_type", 
"VECTOR(3)").build()
+val schema = StructType(Seq(
+  StructField("id", LongType, nullable = false),
+  StructField("name", StringType),
+  StructField("embedding", ArrayType(FloatType, containsNull = false), 
nullable = false, metadata = metadata),
+  StructField("dt", StringType)
+))
+
+// Body is wrapped in { } so the spark-shell REPL keeps the chained 
.option(...)
+// calls attached to the def. Without braces, writer.format("hudi") on the 
first
+// body line parses as a complete expression, the REPL closes the def there, 
and
+// the remaining .option(...) lines get dot-applied to `sc` (SparkContext).
+def applyWriteOpts(writer: org.apache.spark.sql.DataFrameWriter[Row]): 
org.apache.spark.sql.DataFrameWriter[Row] = {
+  writer.format("hudi")
+    .option("hoodie.table.name", "vector_test_df")
+    .option("hoodie.datasource.write.recordkey.field", "id")
+    .option("hoodie.datasource.write.precombine.field", "name")
+    .option("hoodie.datasource.write.partitionpath.field", "dt")
+    .option("hoodie.datasource.hive_sync.enable", "true")
+    .option("hoodie.datasource.hive_sync.database", "default")
+    .option("hoodie.datasource.hive_sync.table", "vector_test_df")
+    .option("hoodie.datasource.hive_sync.jdbcurl", 
"jdbc:hive2://hiveserver:10000/")
+    .option("hoodie.datasource.hive_sync.mode", "jdbc")
+    .option("hoodie.datasource.hive_sync.partition_fields", "dt")
+    .option("hoodie.datasource.hive_sync.partition_extractor_class",
+      "org.apache.hudi.hive.MultiPartKeysValueExtractor")
+    .option("hoodie.datasource.hive_sync.username", "hive")
+    .option("hoodie.datasource.hive_sync.password", "hive")
+}
+
+// Seed two rows in dt='2024-01-01' via Overwrite.
+val seed = Seq(
+  Row(1L, "doc1", Seq(0.1f, 0.2f, 0.3f), "2024-01-01"),
+  Row(2L, "doc2", Seq(0.4f, 0.5f, 0.6f), "2024-01-01")
+)
+val seedDf = spark.createDataFrame(spark.sparkContext.parallelize(seed), 
schema)
+applyWriteOpts(seedDf.write).mode(SaveMode.Overwrite).save("/user/hive/warehouse/vector_test_df")
+spark.sql("select id, name, embedding, dt from vector_test_df order by 
id").show(10, false)
+println("VECTOR_DF_INSERT_SUCCESS")
+
+// Append-upsert: id=2 mutated, id=3 new in dt='2024-01-02'. New preCombine
+// value (doc2-updated) sorts strictly after the seed (doc2).
+val upsertRows = Seq(
+  Row(2L, "doc2-updated", Seq(0.41f, 0.51f, 0.61f), "2024-01-01"),
+  Row(3L, "doc3", Seq(0.7f, 0.8f, 0.9f), "2024-01-02")
+)
+val upsertDf = 
spark.createDataFrame(spark.sparkContext.parallelize(upsertRows), schema)
+applyWriteOpts(upsertDf.write)
+  .option("hoodie.datasource.write.operation", "upsert")
+  .mode(SaveMode.Append)
+  .save("/user/hive/warehouse/vector_test_df")
+spark.sql("select id, name, embedding, dt from vector_test_df order by 
id").show(10, false)
+println("VECTOR_DF_UPSERT_SUCCESS")
+
+// Delete id=3 via operation=delete. The DF still needs a schema-compatible
+// VECTOR payload (Hudi uses only the record key/partition for delete).
+val deleteRows = Seq(
+  Row(3L, "doc3", Seq(0.0f, 0.0f, 0.0f), "2024-01-02")
+)
+val deleteDf = 
spark.createDataFrame(spark.sparkContext.parallelize(deleteRows), schema)
+applyWriteOpts(deleteDf.write)
+  .option("hoodie.datasource.write.operation", "delete")
+  .mode(SaveMode.Append)
+  .save("/user/hive/warehouse/vector_test_df")
+spark.sql("select id, name, embedding, dt from vector_test_df order by 
id").show(10, false)
+println("VECTOR_DF_DELETE_SUCCESS")
+
+println("VECTOR_DF_TEST_SUCCESS")
diff --git a/docker/demo/sparksql-vector-type-sql.commands 
b/docker/demo/sparksql-vector-type-sql.commands
new file mode 100644
index 000000000000..e87736a4be2e
--- /dev/null
+++ b/docker/demo/sparksql-vector-type-sql.commands
@@ -0,0 +1,90 @@
+/*
+ * 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.
+ */
+
+// E2E test: VECTOR type with Hive sync via SQL CREATE TABLE path.
+// Runs the full INSERT -> UPDATE -> MERGE -> DELETE lifecycle to exercise
+// the custom-type metadata re-attach path on every write command.
+spark.sql("""
+  CREATE TABLE vector_test (
+    id LONG,
+    name STRING,
+    embedding VECTOR(3),
+    dt STRING
+  ) USING hudi
+  PARTITIONED BY (dt)
+  LOCATION '/user/hive/warehouse/vector_test'
+  TBLPROPERTIES (
+    'primaryKey' = 'id',
+    'preCombineField' = 'name',
+    'hoodie.datasource.hive_sync.enable' = 'true',
+    'hoodie.datasource.hive_sync.database' = 'default',
+    'hoodie.datasource.hive_sync.table' = 'vector_test',
+    'hoodie.datasource.hive_sync.jdbcurl' = 'jdbc:hive2://hiveserver:10000/',
+    'hoodie.datasource.hive_sync.mode' = 'jdbc',
+    'hoodie.datasource.hive_sync.partition_fields' = 'dt',
+    'hoodie.datasource.hive_sync.partition_extractor_class' = 
'org.apache.hudi.hive.MultiPartKeysValueExtractor',
+    'hoodie.datasource.hive_sync.username' = 'hive',
+    'hoodie.datasource.hive_sync.password' = 'hive'
+  )
+""")
+
+spark.sql("""
+  INSERT INTO vector_test VALUES
+    (1, 'doc1', array(cast(0.1 as float), cast(0.2 as float), cast(0.3 as 
float)), '2024-01-01'),
+    (2, 'doc2', array(cast(0.4 as float), cast(0.5 as float), cast(0.6 as 
float)), '2024-01-01')
+""")
+spark.sql("select id, name, embedding, dt from vector_test order by 
id").show(10, false)
+println("VECTOR_SQL_INSERT_SUCCESS")
+
+// UPDATE goes through castIfNeeded on the VECTOR column; without the reattach
+// path it would fail schema compat with MISSING_UNION_BRANCH.
+spark.sql("""
+  UPDATE vector_test
+  SET embedding = array(cast(0.9 as float), cast(0.8 as float), cast(0.7 as 
float)),
+      name = 'doc1-updated'
+  WHERE id = 1
+""")
+spark.sql("select id, name, embedding from vector_test where id = 1").show(10, 
false)
+println("VECTOR_SQL_UPDATE_SUCCESS")
+
+// MERGE exercises both MATCHED (UPDATE) and NOT MATCHED (INSERT into new
+// partition dt='2024-01-02'). The USING CTE does not carry the hudi_type
+// marker, so this specifically exercises the reattach path for MERGE.
+spark.sql("""
+  MERGE INTO vector_test t
+  USING (
+    SELECT 2L AS id, 'doc2-merged' AS name,
+           array(cast(0.41 as float), cast(0.51 as float), cast(0.61 as 
float)) AS embedding,
+           '2024-01-01' AS dt
+    UNION ALL
+    SELECT 3L AS id, 'doc3' AS name,
+           array(cast(0.7 as float), cast(0.8 as float), cast(0.9 as float)) 
AS embedding,
+           '2024-01-02' AS dt
+  ) s
+  ON t.id = s.id
+  WHEN MATCHED THEN UPDATE SET t.name = s.name, t.embedding = s.embedding
+  WHEN NOT MATCHED THEN INSERT (id, name, embedding, dt) VALUES (s.id, s.name, 
s.embedding, s.dt)
+""")
+spark.sql("select id, name, embedding, dt from vector_test order by 
id").show(10, false)
+println("VECTOR_SQL_MERGE_SUCCESS")
+
+spark.sql("DELETE FROM vector_test WHERE id = 3")
+spark.sql("select id, name, dt from vector_test order by id").show(10, false)
+println("VECTOR_SQL_DELETE_SUCCESS")
+
+println("VECTOR_SQL_TEST_SUCCESS")
diff --git a/docker/hoodie/hadoop/hive_base/entrypoint.sh 
b/docker/hoodie/hadoop/hive_base/entrypoint.sh
index a3df5e6cf4d7..f048114ff45c 100644
--- a/docker/hoodie/hadoop/hive_base/entrypoint.sh
+++ b/docker/hoodie/hadoop/hive_base/entrypoint.sh
@@ -131,4 +131,4 @@ do
     wait_for_it ${i}
 done
 
-exec $@
+exec "$@"
diff --git a/docker/hoodie/hadoop/sparkmaster/master.sh 
b/docker/hoodie/hadoop/sparkmaster/master.sh
index 9409cbc0c12e..e437f68ddc00 100644
--- a/docker/hoodie/hadoop/sparkmaster/master.sh
+++ b/docker/hoodie/hadoop/sparkmaster/master.sh
@@ -29,4 +29,4 @@ export SPARK_HOME=/opt/spark
 ln -sf /dev/stdout $SPARK_MASTER_LOG/spark-master.out
 
 cd /opt/spark/bin && /opt/spark/sbin/../bin/spark-class 
org.apache.spark.deploy.master.Master \
-    --ip $SPARK_MASTER_HOST --port $SPARK_MASTER_PORT --webui-port 
$SPARK_MASTER_WEBUI_PORT >> $SPARK_MASTER_LOG/spark-master.out
+    --host $SPARK_MASTER_HOST --port $SPARK_MASTER_PORT --webui-port 
$SPARK_MASTER_WEBUI_PORT >> $SPARK_MASTER_LOG/spark-master.out
diff --git a/hudi-integ-test/pom.xml b/hudi-integ-test/pom.xml
index bad23d7c38b0..abd44dba109d 100644
--- a/hudi-integ-test/pom.xml
+++ b/hudi-integ-test/pom.xml
@@ -42,12 +42,13 @@
       <artifactId>jersey-container-servlet-core</artifactId>
     </dependency>
 
+    <!-- TODO: remove this after we migrate integration tests to 
testcontainers (requires docker-java to be >= 3.3.6) -->
     <!-- Docker-Java - Test-->
     <!-- needs to be at top to force javax.ws version -->
     <dependency>
       <groupId>com.github.docker-java</groupId>
       <artifactId>docker-java</artifactId>
-      <version>3.1.2</version>
+      <version>3.3.6</version>
       <scope>test</scope>
       <exclusions>
         <exclusion>
@@ -391,6 +392,19 @@
       <artifactId>trino-jdbc</artifactId>
     </dependency>
 
+    <dependency>
+      <groupId>org.testcontainers</groupId>
+      <artifactId>testcontainers</artifactId>
+      <scope>test</scope>
+    </dependency>
+
+    <dependency>
+      <groupId>org.testcontainers</groupId>
+      <artifactId>junit-jupiter</artifactId>
+      <!-- not managed in the root pom's dependencyManagement, unlike 
testcontainers itself -->
+      <version>${testcontainers.version}</version>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <properties>
@@ -518,6 +532,38 @@
   </build>
 
   <profiles>
+    <profile>
+      <id>integration-tests</id>
+      <!--
+        Tests under org.apache.hudi.integ2.** are testcontainers-based and 
manage
+        their own docker stack. The legacy `integration-tests` CI stage brings 
up
+        its own docker-compose demo in the same job, which collides on host 
ports
+        (e.g. zookeeper 2181). Exclude the integ2 package here so only the
+        dedicated `integration-tests-hive-sync` stage runs them (via 
-Dit.test=).
+      -->
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-failsafe-plugin</artifactId>
+            <configuration combine.self="override">
+              <skip>${skipITs}</skip>
+              <includes>
+                <include>**/IT*.java</include>
+              </includes>
+              <excludes>
+                <exclude>**/integ2/**</exclude>
+              </excludes>
+              <systemPropertyVariables>
+                
<dynamodb-local.endpoint>${dynamodb-local.endpoint}</dynamodb-local.endpoint>
+                
<log4j.configurationFile>${surefire-log4j.file}</log4j.configurationFile>
+              </systemPropertyVariables>
+              <useSystemClassLoader>false</useSystemClassLoader>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
     <profile>
       <id>m1-mac</id>
       <properties>
diff --git 
a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ContainerProvider.java
 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ContainerProvider.java
new file mode 100644
index 000000000000..0283f24e6b67
--- /dev/null
+++ 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ContainerProvider.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.integ2.testcontainers;
+
+import org.testcontainers.containers.ContainerState;
+
+/**
+ * Interface for providing access to containers by service name.
+ * This abstraction allows different implementations (e.g., ComposeContainer, 
individual containers).
+ */
+public interface ContainerProvider {
+
+  /**
+   * Get a container by its service name.
+   *
+   * @param serviceName the full compose service instance name as 
Testcontainers resolves it (e.g. "adhoc-1-1")
+   * @return the container state
+   * @throws IllegalStateException if container not found
+   */
+  ContainerState getContainer(String serviceName);
+}
diff --git 
a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestBaseTestcontainers.java
 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestBaseTestcontainers.java
new file mode 100644
index 000000000000..51fdd57b4bfb
--- /dev/null
+++ 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestBaseTestcontainers.java
@@ -0,0 +1,236 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.integ2.testcontainers;
+
+import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.integ2.testcontainers.service.HiveService;
+import org.apache.hudi.integ2.testcontainers.service.SparkService;
+
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.BeforeAll;
+import org.testcontainers.containers.ComposeContainer;
+import org.testcontainers.containers.ContainerState;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+
+import static 
org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Containers;
+import static 
org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Network;
+import static 
org.apache.hudi.integ2.testcontainers.TestcontainersConfig.SystemProps;
+import static 
org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Timeouts;
+
+/**
+ * Base test class for integration tests using Testcontainers with Docker 
Compose.
+ * Uses a "Service" pattern, where each external service (Hive, Spark, etc.) is
+ * represented by a dedicated service object. This provides better separation 
of
+ * concerns and a cleaner API for tests.
+ */
+@Slf4j
+@Testcontainers
+public abstract class ITTestBaseTestcontainers implements ContainerProvider {
+
+  protected static ComposeContainer environment;
+
+  // Service objects for interacting with different components
+  protected HiveService hive;
+  protected SparkService sparkAdhoc1;
+
+  @BeforeAll
+  public static void setupDockerCompose() {
+    String composeFilePath = 
createProcessedComposeFilePath(getDockerComposeFilePath());
+    String hudiWorkspace = getHudiWorkspace();
+
+    log.info("Starting Docker Compose environment");
+    log.info("Compose file: {}", composeFilePath);
+    log.info("HUDI_WS: {}", hudiWorkspace);
+
+    environment = new ComposeContainer(new File(composeFilePath))
+        .withEnv("HUDI_WS", hudiWorkspace)
+        .withExposedService(Containers.SPARK_MASTER, 
Network.SPARK_MASTER_WEB_UI_PORT,
+            Wait.forListeningPort().forPorts(Network.SPARK_MASTER_WEB_UI_PORT)
+                .withStartupTimeout(Timeouts.CONTAINER_STARTUP))
+        .withStartupTimeout(Timeouts.CONTAINER_STARTUP);
+    environment.start();
+
+    log.info("Docker Compose environment started successfully");
+    log.info("All containers verified and running");
+  }
+
+  /**
+   * Tear down the compose stack between test classes. The docker-compose 
files publish
+   * host ports directly (zookeeper 2181, spark 7077, …), so leaving one
+   * stack up would make the next class's `@BeforeAll` collide on those host 
ports.
+   * Testcontainers' Ryuk reaper only fires at JVM shutdown, which is too late 
when
+   * failsafe reuses a JVM across classes.
+   */
+  @AfterAll
+  public static void tearDownDockerCompose() {
+    if (environment != null) {
+      log.info("Stopping Docker Compose environment");
+      try {
+        environment.stop();
+      } finally {
+        environment = null;
+      }
+    }
+  }
+
+  /**
+   * Initialize service objects. Should be called in @BeforeEach or 
constructor of test class.
+   */
+  protected void initializeServices() {
+    this.hive = new HiveService(this);
+    this.sparkAdhoc1 = new SparkService(this, Containers.ADHOC_1);
+  }
+
+  /**
+   * Skips the test unless the active docker-compose prefix points to a Spark 
4.x stack.
+   * Use for tests that rely on Spark 4.0+ only features (e.g. VARIANT type).
+   */
+  protected static void assumeSpark4Compose() {
+    Assumptions.assumeTrue(isSpark4Compose(),
+        "Test requires a Spark 4.x compose stack; active prefix is '"
+            + System.getProperty(SystemProps.COMPOSE_PREFIX, 
SystemProps.DEFAULT_COMPOSE_PREFIX) + "'");
+  }
+
+  /**
+   * Non-assumption variant of {@link #assumeSpark4Compose()}: returns {@code 
true} when the
+   * active compose prefix points at a Spark 4.x stack. Use in {@code 
@BeforeAll} seeding
+   * to conditionally run Spark 4-only fixtures (e.g. VARIANT) without 
aborting the whole
+   * test class on a Spark 3.5 run.
+   */
+  protected static boolean isSpark4Compose() {
+    String composePrefix = System.getProperty(SystemProps.COMPOSE_PREFIX, 
SystemProps.DEFAULT_COMPOSE_PREFIX);
+    return composePrefix.contains(SystemProps.SPARK_4_PREFIX_TOKEN);
+  }
+
+  /**
+   * Waits for HDFS namenode to be ready by retrying the safemode wait command.
+   * The namenode may take some time to start after Docker Compose reports 
containers as running.
+   */
+  protected void waitForHdfs() throws Exception {
+    for (int i = 1; i <= Timeouts.HDFS_MAX_RETRIES; i++) {
+      try {
+        sparkAdhoc1.executeShellCommand("hdfs dfsadmin -safemode 
wait").expectToSucceed();
+        log.info("HDFS namenode is ready");
+        return;
+      } catch (Throwable e) {
+        if (i == Timeouts.HDFS_MAX_RETRIES) {
+          throw new RuntimeException(
+              "HDFS namenode did not become ready after " + 
Timeouts.HDFS_MAX_RETRIES + " retries", e);
+        }
+        log.info("Waiting for HDFS namenode to be ready (attempt {}/{})", i, 
Timeouts.HDFS_MAX_RETRIES);
+        Thread.sleep(Timeouts.HDFS_RETRY_INTERVAL.toMillis());
+      }
+    }
+  }
+
+  /**
+   * Get a container by service name from the Docker Compose environment.
+   * Docker Compose appends _1 suffix to service names.
+   * Implements ContainerProvider interface.
+   */
+  @Override
+  public ContainerState getContainer(String serviceName) {
+    try {
+      return environment.getContainerByServiceName(serviceName)
+          .orElseThrow(() -> new IllegalStateException("Container not found: " 
+ serviceName));
+    } catch (IllegalStateException e) {
+      log.error("Failed to get container: {}", serviceName, e);
+      throw e;
+    }
+  }
+
+  private static String getHudiWorkspace() {
+    String projectDir = System.getProperty("user.dir");
+    return new File(projectDir, "..").getAbsolutePath();
+  }
+
+  private static String getDockerComposeFilePath() {
+    String projectDir = System.getProperty("user.dir");
+    String os = System.getProperty("os.name").toLowerCase();
+    String arch = System.getProperty("os.arch").toLowerCase();
+    String composePrefix = System.getProperty(SystemProps.COMPOSE_PREFIX, 
SystemProps.DEFAULT_COMPOSE_PREFIX);
+
+    // Determine which compose file to use based on OS and architecture
+    boolean isMacArm64 = os.contains("mac") && arch.contains("aarch64");
+    String archSuffix = isMacArm64 ? "_arm64" : "_amd64";
+    File dockerComposeFile = new File(projectDir,
+        TestcontainersConfig.Paths.COMPOSE_DIR + composePrefix + archSuffix + 
".yml");
+    if (!dockerComposeFile.isFile() || !dockerComposeFile.exists()) {
+      throw new HoodieException(String.format("%s does not exist", 
dockerComposeFile.getAbsolutePath()));
+    }
+    return dockerComposeFile.getAbsolutePath();
+  }
+
+  private static String getHadoopEnvFilePath() {
+    return new File(System.getProperty("user.dir"), 
"../docker/compose/hadoop.env").getAbsolutePath();
+  }
+
+  /**
+   * Reads the original docker-compose file, removes all 'container_name' 
directives, and returns a temporary file containing the modified content. 
Including Testcontainers in the docker-compose file
+   * will cause ContainerLaunchExceptions to be thrown.
+   * <p>
+   * Any env_file will normalize/canonicalize to the temporary directory used. 
This function will make a copy of hadoop.env into the temporary directory to 
ensure that no error is thrown.
+   */
+  private static String createProcessedComposeFilePath(String composeFile) {
+    try {
+      // Read all bytes from the file and convert to a String
+      byte[] bytes = Files.readAllBytes(Paths.get(composeFile));
+      String originalContent = new String(bytes, StandardCharsets.UTF_8);
+
+      // Use a regular expression to find and remove all lines containing 
'container_name'
+      String modifiedContent = 
originalContent.replaceAll("(?m)^\\s*container_name:.*$", "");
+
+      // Create a temporary file to hold our modified configuration
+      Path tempDir = Files.createTempDirectory("hudi-test-compose-");
+
+      // Delete the temp dir at JVM exit. File.delete() cannot remove a 
non-empty directory,
+      // and deleteOnExit runs LIFO, so the files below are registered too: 
they are deleted
+      // first, leaving the dir empty for its own delete.
+      tempDir.toFile().deleteOnExit();
+
+      // Write the modified content to the temporary file as a byte array.
+      File tempDockerComposeFile = new File(tempDir.toFile(), 
"docker-compose.yml");
+      Files.write(tempDockerComposeFile.toPath(), 
modifiedContent.getBytes(StandardCharsets.UTF_8));
+      tempDockerComposeFile.deleteOnExit();
+
+      // tempDir is used as the working directory, docker-compose will look 
for hadoop.env in the SAME temp directory
+      // Copy hadoop.env into the SAME temp directory
+      Path destHadoopEnvPath = tempDir.resolve("hadoop.env");
+      Path originalHadoopEnvPath = 
Paths.get(getHadoopEnvFilePath()).toAbsolutePath().normalize();
+      Files.copy(originalHadoopEnvPath, destHadoopEnvPath, 
StandardCopyOption.REPLACE_EXISTING);
+      destHadoopEnvPath.toFile().deleteOnExit();
+
+      // Return the temporary file for Testcontainers to use
+      return tempDockerComposeFile.toPath().toAbsolutePath().toString();
+    } catch (IOException e) {
+      throw new RuntimeException("Failed to process the docker-compose file", 
e);
+    }
+  }
+}
diff --git 
a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestCustomTypeHiveSync.java
 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestCustomTypeHiveSync.java
new file mode 100644
index 000000000000..3b3cbc5e5e0a
--- /dev/null
+++ 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/ITTestCustomTypeHiveSync.java
@@ -0,0 +1,266 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.integ2.testcontainers;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+
+import static org.apache.hudi.integ2.testcontainers.TestcontainersConfig.Paths;
+
+/**
+ * End-to-end Hive sync coverage for Hudi's custom logical types (VECTOR, 
BLOB) and the
+ * Spark 4.0 VARIANT type, running against a real Hive metastore via the 
Testcontainers
+ * harness.
+ *
+ * Each type is exercised through both paths:
+ *   - SQL CREATE TABLE (`*-sql.commands`) - table name `<type>_test`
+ *   - DataFrame writer API (`*-df.commands`) - table name `<type>_test_df`
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public class ITTestCustomTypeHiveSync extends ITTestBaseTestcontainers {
+
+  // BLOB
+  private static final String BLOB_SQL_TEST_BASE_PATH = 
"/user/hive/warehouse/blob_test";
+  private static final String BLOB_DF_TEST_BASE_PATH = 
"/user/hive/warehouse/blob_test_df";
+  private static final String SPARKSQL_BLOB_TYPE_SQL_COMMANDS = Paths.DEMO_DIR 
+ "/sparksql-blob-type-sql.commands";
+  private static final String SPARKSQL_BLOB_TYPE_DF_COMMANDS = Paths.DEMO_DIR 
+ "/sparksql-blob-type-df.commands";
+
+  // VARIANT
+  private static final String VARIANT_SQL_TEST_BASE_PATH = 
"/user/hive/warehouse/variant_test";
+  private static final String VARIANT_DF_TEST_BASE_PATH = 
"/user/hive/warehouse/variant_test_df";
+  private static final String SPARKSQL_VARIANT_TYPE_SQL_COMMANDS = 
Paths.DEMO_DIR + "/sparksql-variant-type-sql.commands";
+  private static final String SPARKSQL_VARIANT_TYPE_DF_COMMANDS = 
Paths.DEMO_DIR + "/sparksql-variant-type-df.commands";
+
+  // VECTOR
+  private static final String VECTOR_SQL_TEST_BASE_PATH = 
"/user/hive/warehouse/vector_test";
+  private static final String VECTOR_DF_TEST_BASE_PATH = 
"/user/hive/warehouse/vector_test_df";
+  private static final String SPARKSQL_VECTOR_TYPE_SQL_COMMANDS = 
Paths.DEMO_DIR + "/sparksql-vector-type-sql.commands";
+  private static final String SPARKSQL_VECTOR_TYPE_DF_COMMANDS = 
Paths.DEMO_DIR + "/sparksql-vector-type-df.commands";
+
+  // All test paths cleaned in a single `hdfs dfs -rm -R -f` call; -f silently 
skips
+  // non-existent paths so tests that don't create every table don't fail 
teardown.
+  private static final String CLEANUP_PATHS_JOINED = String.join(" ",
+      BLOB_SQL_TEST_BASE_PATH, BLOB_DF_TEST_BASE_PATH,
+      VARIANT_SQL_TEST_BASE_PATH, VARIANT_DF_TEST_BASE_PATH,
+      VECTOR_SQL_TEST_BASE_PATH, VECTOR_DF_TEST_BASE_PATH);
+
+  /**
+   * Run idempotent demo setup once per test class instead of once per @Test. 
The script
+   * (mkdir -p, cp, copyFromLocal -f, chmod +x) is safe to run a single time 
and saves
+   * one shell exec round-trip per test. Requires @TestInstance(PER_CLASS) so 
this method
+   * can be a non-static instance method that uses sparkAdhoc1.
+   */
+  @BeforeAll
+  public void setupOnce() throws Exception {
+    initializeServices();
+    waitForHdfs();
+    sparkAdhoc1.executeShellCommand("/bin/bash " + 
Paths.DEMO_SETUP).expectToSucceed();
+  }
+
+  @AfterEach
+  public void clean() throws Exception {
+    sparkAdhoc1.executeShellCommand("hdfs dfs -rm -R -f " + 
CLEANUP_PATHS_JOINED)
+        .expectToSucceed();
+  }
+
+  // ---------- BLOB ----------
+
+  @Test
+  public void testBlobTypeWithHiveSyncSQL() throws Exception {
+    sparkAdhoc1.executeSQLFile(SPARKSQL_BLOB_TYPE_SQL_COMMANDS)
+        .expectToSucceed()
+        .assertStdOutContainsLine("BLOB_SQL_INSERT_SUCCESS")
+        .assertStdOutContainsLine("BLOB_SQL_UPDATE_SUCCESS")
+        .assertStdOutContainsLine("BLOB_SQL_MERGE_SUCCESS")
+        .assertStdOutContainsLine("BLOB_SQL_DELETE_SUCCESS")
+        .assertStdOutContainsLine("BLOB_SQL_TEST_SUCCESS");
+
+    hive.execute("DESCRIBE default.blob_test")
+        .expectToSucceed()
+        .assertStdOutContains("blob_data");
+
+    // MERGE added dt=2024-01-02; DELETE removed the row but kept the 
partition metadata.
+    hive.execute("SHOW PARTITIONS default.blob_test")
+        .expectToSucceed()
+        .assertStdOutContains("dt=2024-01-01")
+        .assertStdOutContains("dt=2024-01-02");
+
+    // Post-DELETE final row count is 2 (id=1 updated, id=2 merged; id=3 
deleted).
+    hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM 
default.blob_test")
+        .expectToSucceed()
+        .assertStdOutContains("HIVE_COUNT=2");
+
+    // Project a nested struct field through the Hive serde to verify the BLOB
+    // struct layout round-trips correctly (count() and DESCRIBE do not).
+    hive.execute(
+        "SELECT concat('BLOB_TYPE=', blob_data.type) FROM default.blob_test 
WHERE id = 1")
+        .expectToSucceed()
+        .assertStdOutContains("BLOB_TYPE=OUT_OF_LINE");
+  }
+
+  @Test
+  public void testBlobTypeWithHiveSyncDataFrameAPI() throws Exception {
+    sparkAdhoc1.executeSQLFile(SPARKSQL_BLOB_TYPE_DF_COMMANDS)
+        .expectToSucceed()
+        .assertStdOutContainsLine("BLOB_DF_INSERT_SUCCESS")
+        .assertStdOutContainsLine("BLOB_DF_UPSERT_SUCCESS")
+        .assertStdOutContainsLine("BLOB_DF_DELETE_SUCCESS")
+        .assertStdOutContainsLine("BLOB_DF_TEST_SUCCESS");
+
+    hive.execute("DESCRIBE default.blob_test_df")
+        .expectToSucceed()
+        .assertStdOutContains("blob_data");
+
+    hive.execute("SHOW PARTITIONS default.blob_test_df")
+        .expectToSucceed()
+        .assertStdOutContains("dt=2024-01-01")
+        .assertStdOutContains("dt=2024-01-02");
+
+    hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM 
default.blob_test_df")
+        .expectToSucceed()
+        .assertStdOutContains("HIVE_COUNT=2");
+
+    // DF path seed used the INLINE struct branch and id=1 is never mutated,
+    // so projecting blob_data.type through the Hive serde should return 
INLINE.
+    hive.execute(
+        "SELECT concat('BLOB_TYPE=', blob_data.type) FROM default.blob_test_df 
WHERE id = 1")
+        .expectToSucceed()
+        .assertStdOutContains("BLOB_TYPE=INLINE");
+  }
+
+  // ---------- VARIANT (Spark 4.x only) ----------
+
+  @Test
+  public void testVariantTypeWithHiveSyncSQL() throws Exception {
+    assumeSpark4Compose();
+    sparkAdhoc1.executeSQLFile(SPARKSQL_VARIANT_TYPE_SQL_COMMANDS)
+        .expectToSucceed()
+        .assertStdOutContainsLine("VARIANT_SQL_INSERT_SUCCESS")
+        .assertStdOutContainsLine("VARIANT_SQL_UPDATE_SUCCESS")
+        .assertStdOutContainsLine("VARIANT_SQL_MERGE_SUCCESS")
+        .assertStdOutContainsLine("VARIANT_SQL_DELETE_SUCCESS")
+        .assertStdOutContainsLine("VARIANT_SQL_TEST_SUCCESS");
+
+    hive.execute("DESCRIBE default.variant_test")
+        .expectToSucceed()
+        .assertStdOutContains("variant_data");
+
+    hive.execute("SHOW PARTITIONS default.variant_test")
+        .expectToSucceed()
+        .assertStdOutContains("dt=2024-01-01")
+        .assertStdOutContains("dt=2024-01-02");
+
+    // count(*) does not deserialize the variant column, so it is safe even if
+    // the Hive serde can't project the variant payload.
+    hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM 
default.variant_test")
+        .expectToSucceed()
+        .assertStdOutContains("HIVE_COUNT=2");
+  }
+
+  @Test
+  public void testVariantTypeWithHiveSyncDataFrameAPI() throws Exception {
+    assumeSpark4Compose();
+    sparkAdhoc1.executeSQLFile(SPARKSQL_VARIANT_TYPE_DF_COMMANDS)
+        .expectToSucceed()
+        .assertStdOutContainsLine("VARIANT_DF_INSERT_SUCCESS")
+        .assertStdOutContainsLine("VARIANT_DF_UPSERT_SUCCESS")
+        .assertStdOutContainsLine("VARIANT_DF_DELETE_SUCCESS")
+        .assertStdOutContainsLine("VARIANT_DF_TEST_SUCCESS");
+
+    hive.execute("DESCRIBE default.variant_test_df")
+        .expectToSucceed()
+        .assertStdOutContains("variant_data");
+
+    hive.execute("SHOW PARTITIONS default.variant_test_df")
+        .expectToSucceed()
+        .assertStdOutContains("dt=2024-01-01")
+        .assertStdOutContains("dt=2024-01-02");
+
+    hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM 
default.variant_test_df")
+        .expectToSucceed()
+        .assertStdOutContains("HIVE_COUNT=2");
+  }
+
+  // ---------- VECTOR ----------
+
+  @Test
+  public void testVectorTypeWithHiveSyncSQL() throws Exception {
+    sparkAdhoc1.executeSQLFile(SPARKSQL_VECTOR_TYPE_SQL_COMMANDS)
+        .expectToSucceed()
+        .assertStdOutContainsLine("VECTOR_SQL_INSERT_SUCCESS")
+        .assertStdOutContainsLine("VECTOR_SQL_UPDATE_SUCCESS")
+        .assertStdOutContainsLine("VECTOR_SQL_MERGE_SUCCESS")
+        .assertStdOutContainsLine("VECTOR_SQL_DELETE_SUCCESS")
+        .assertStdOutContainsLine("VECTOR_SQL_TEST_SUCCESS");
+
+    hive.execute("DESCRIBE default.vector_test")
+        .expectToSucceed()
+        .assertStdOutContains("embedding")
+        .assertStdOutContains("binary");
+
+    hive.execute("SHOW PARTITIONS default.vector_test")
+        .expectToSucceed()
+        .assertStdOutContains("dt=2024-01-01")
+        .assertStdOutContains("dt=2024-01-02");
+
+    hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM 
default.vector_test")
+        .expectToSucceed()
+        .assertStdOutContains("HIVE_COUNT=2");
+
+    // VECTOR(3) is stored on disk as fixed_len_byte_array(12) and mapped to
+    // Hive BINARY (per RFC-99). length() on the binary column confirms the
+    // bytes round-trip through the Hive serde at the correct width: 3 floats
+    // x 4 bytes each = 12. Any flip to array<float> would return 3 instead.
+    hive.execute(
+        "SELECT concat('VEC_LEN=', length(embedding)) FROM default.vector_test 
WHERE id = 1")
+        .expectToSucceed()
+        .assertStdOutContains("VEC_LEN=12");
+  }
+
+  @Test
+  public void testVectorTypeWithHiveSyncDataFrameAPI() throws Exception {
+    sparkAdhoc1.executeSQLFile(SPARKSQL_VECTOR_TYPE_DF_COMMANDS)
+        .expectToSucceed()
+        .assertStdOutContainsLine("VECTOR_DF_INSERT_SUCCESS")
+        .assertStdOutContainsLine("VECTOR_DF_UPSERT_SUCCESS")
+        .assertStdOutContainsLine("VECTOR_DF_DELETE_SUCCESS")
+        .assertStdOutContainsLine("VECTOR_DF_TEST_SUCCESS");
+
+    hive.execute("DESCRIBE default.vector_test_df")
+        .expectToSucceed()
+        .assertStdOutContains("embedding")
+        .assertStdOutContains("binary");
+
+    hive.execute("SHOW PARTITIONS default.vector_test_df")
+        .expectToSucceed()
+        .assertStdOutContains("dt=2024-01-01")
+        .assertStdOutContains("dt=2024-01-02");
+
+    hive.execute("SELECT concat('HIVE_COUNT=', count(*)) FROM 
default.vector_test_df")
+        .expectToSucceed()
+        .assertStdOutContains("HIVE_COUNT=2");
+
+    hive.execute(
+        "SELECT concat('VEC_LEN=', length(embedding)) FROM 
default.vector_test_df WHERE id = 1")
+        .expectToSucceed()
+        .assertStdOutContains("VEC_LEN=12");
+  }
+}
diff --git 
a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/TestcontainersConfig.java
 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/TestcontainersConfig.java
new file mode 100644
index 000000000000..ad403ff6cccd
--- /dev/null
+++ 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/TestcontainersConfig.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.integ2.testcontainers;
+
+import java.time.Duration;
+import java.util.List;
+
+/**
+ * Central configuration for the integ2 Testcontainers harness. Every constant 
that
+ * describes "how this harness expects the compose environment to look" 
belongs here:
+ * container service names, container-side paths, network endpoints, timeouts, 
and
+ * the keys / defaults of system properties that tune the harness.
+ *
+ * <p>Per-test fixture data (table paths, per-test {@code .commands} scripts) 
does
+ * not belong here, those stay in the relevant test class, though they should 
build
+ * their common prefix from {@link Paths#DEMO_DIR} / {@link Paths#WS_ROOT} 
rather
+ * than inlining the path literal.
+ */
+public final class TestcontainersConfig {
+
+  private TestcontainersConfig() {
+  }
+
+  /** Docker Compose service names. Must match the compose YAML verbatim. */
+  public static final class Containers {
+    public static final String HIVESERVER = "hiveserver";
+    public static final String SPARK_MASTER = "sparkmaster";
+    // Testcontainers appends the replica index, so the adhoc services resolve 
as "<name>-1".
+    public static final String ADHOC_1 = "adhoc-1-1";
+
+    private Containers() {
+    }
+  }
+
+  /** Paths inside the containers (absolute) and the host-side compose 
directory. */
+  public static final class Paths {
+    public static final String WS_ROOT = "/var/hoodie/ws";
+    public static final String DEMO_DIR = WS_ROOT + "/docker/demo";
+    public static final String DEMO_SETUP = DEMO_DIR + 
"/setup_demo_container.sh";
+    public static final String HIVE_TARGET = WS_ROOT + 
"/docker/hoodie/hadoop/hive_base/target";
+    public static final String SPARK_BUNDLE = HIVE_TARGET + 
"/hoodie-spark-bundle.jar";
+    public static final String HADOOP_CONF_DIR = "/etc/hadoop";
+    /** Host-side, relative to the hudi-integ-test module working directory. */
+    public static final String COMPOSE_DIR = "../docker/compose/";
+
+    private Paths() {
+    }
+  }
+
+  /** Network endpoints the harness exposes to tests. */
+  public static final class Network {
+    public static final int SPARK_MASTER_WEB_UI_PORT = 8080;
+
+    private Network() {
+    }
+  }
+
+  /** Waits and timeouts used by the harness. */
+  public static final class Timeouts {
+    public static final Duration CONTAINER_STARTUP = Duration.ofMinutes(5);
+    public static final int HDFS_MAX_RETRIES = 12;
+    public static final Duration HDFS_RETRY_INTERVAL = Duration.ofSeconds(10);
+
+    private Timeouts() {
+    }
+  }
+
+  /** System-property keys and their defaults (read via {@link 
System#getProperty}). */
+  public static final class SystemProps {
+    public static final String COMPOSE_PREFIX = "spark.docker.compose.prefix";
+    public static final String DEFAULT_COMPOSE_PREFIX = 
"docker-compose_hadoop340_hive2310_spark402";
+    /** Substring present in compose prefixes that run Spark 4.x (e.g. 
"...spark402"). */
+    public static final String SPARK_4_PREFIX_TOKEN = "spark4";
+
+    /**
+     * Flip to {@code true} (e.g. {@code -Dhudi.integ.hive.verbose=true}) to 
route
+     * Hive logs to the console so exception stack traces show up in test 
output.
+     */
+    public static final String HIVE_VERBOSE = "hudi.integ.hive.verbose";
+
+    /**
+     * Hiveconf entries applied only when {@link #HIVE_VERBOSE} is enabled.
+     * See {@code HiveService#execute} for the per-flag rationale.
+     */
+    public static final List<String> VERBOSE_HIVECONFS =
+        List.of("hive.root.logger=INFO,console",
+            "hive.exec.mode.local.auto=false",
+            "hive.log.explain.output=true",
+            "hive.server2.logging.operation.verbose=true");
+
+    private SystemProps() {
+    }
+  }
+}
diff --git 
a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandExecutor.java
 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandExecutor.java
new file mode 100644
index 000000000000..f2f01a006c1a
--- /dev/null
+++ 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandExecutor.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.integ2.testcontainers.command;
+
+import lombok.extern.slf4j.Slf4j;
+import org.testcontainers.containers.Container;
+import org.testcontainers.containers.ContainerState;
+
+/**
+ * A utility class for executing commands within a given Testcontainer.
+ * This class is stateless regarding the command being executed but is tied to 
a specific container.
+ */
+@Slf4j
+public final class CommandExecutor {
+
+  private final ContainerState container;
+
+  public CommandExecutor(ContainerState container) {
+    this.container = container;
+  }
+
+  /**
+   * Execute a command in the executor's container.
+   */
+  public CommandResult executeCommand(String... command) throws Exception {
+    String containerIdentifier = getContainerIdentifier(container);
+    String commandStr = String.join(" ", command);
+
+    log.info("==> [{}] Executing: {}", containerIdentifier, commandStr);
+
+    long startTime = System.currentTimeMillis();
+    Container.ExecResult result = container.execInContainer(command);
+    long duration = System.currentTimeMillis() - startTime;
+
+    int exitCode = result.getExitCode();
+    log.info("<== [{}] Exit code: {} ({}ms)", containerIdentifier, exitCode, 
duration);
+
+    if (exitCode != 0) {
+      log.error("STDOUT:\n{}", result.getStdout());
+      log.error("STDERR:\n{}", result.getStderr());
+    } else if (log.isDebugEnabled()) {
+      log.debug("STDOUT:\n{}", result.getStdout());
+      if (!result.getStderr().isEmpty()) {
+        log.debug("STDERR:\n{}", result.getStderr());
+      }
+    }
+
+    return new CommandResult(result);
+  }
+
+  /**
+   * Execute a shell command string.
+   */
+  public CommandResult executeCommandString(String cmd) throws Exception {
+    String[] cmdArray = {"/bin/bash", "-c", cmd};
+    return executeCommand(cmdArray);
+  }
+
+  /**
+   * Get a readable identifier for the container.
+   */
+  private String getContainerIdentifier(ContainerState container) {
+    String containerName = container.getContainerInfo().getName();
+    if (containerName != null && !containerName.isEmpty()) {
+      // Container names start with '/', so remove it
+      String cleanName = containerName.startsWith("/") ? 
containerName.substring(1) : containerName;
+      return cleanName + ":" + container.getContainerId().substring(0, 8);
+    }
+    return container.getContainerId().substring(0, 12);
+  }
+}
diff --git 
a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandResult.java
 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandResult.java
new file mode 100644
index 000000000000..8e65e9aebcba
--- /dev/null
+++ 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/command/CommandResult.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.integ2.testcontainers.command;
+
+import lombok.AllArgsConstructor;
+import org.testcontainers.containers.Container;
+
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * A dedicated class to hold the result of a command execution and provide
+ * fluent assertion methods for cleaner tests.
+ */
+@AllArgsConstructor
+public class CommandResult {
+
+  // Spark 4.0 (Scala 2.13) spark-shell runs under a dumb terminal when stdin
+  // is piped from a file, which flushes the next `scala> ` prompt onto the
+  // same line as preceding async println output (e.g. `scala> MARKER`).
+  // Stripping one-or-more leading `scala>\s+` prefixes normalizes those lines
+  // back to the bare sentinel while leaving Spark 3.5 output (sentinel already
+  // on its own line) unchanged.
+  private static final Pattern REPL_PROMPT_PREFIX = 
Pattern.compile("^(scala>\\s+)+");
+
+  private final String stdout;
+  private final String stderr;
+  private final int exitCode;
+
+  public CommandResult(Container.ExecResult execResult) {
+    this.stdout = execResult.getStdout();
+    this.stderr = execResult.getStderr();
+    this.exitCode = execResult.getExitCode();
+  }
+
+  /**
+   * Asserts that the command's exit code is 0 (success).
+   *
+   * @return The same {@link CommandResult} instance for chaining assertions.
+   * @throws AssertionError if the exit code is not 0.
+   */
+  public CommandResult expectToSucceed() {
+    assertEquals(0, exitCode,
+        String.format("Command failed with exit code %d. Stderr: %s", 
exitCode, stderr));
+    return this;
+  }
+
+  /**
+   * Asserts that the standard output contains a specific substring exactly 
once.
+   *
+   * @param expectedSubstring The substring to search for.
+   * @return The same {@link CommandResult} instance for chaining assertions.
+   */
+  public CommandResult assertStdOutContains(String expectedSubstring) {
+    return assertStdOutContains(expectedSubstring, 1);
+  }
+
+  /**
+   * Asserts that the standard output contains a specific substring an exact 
number of times.
+   *
+   * @param expectedSubstring The substring to search for.
+   * @param times The exact number of times the substring is expected to 
appear.
+   * @return The same {@link CommandResult} instance for chaining assertions.
+   */
+  public CommandResult assertStdOutContains(String expectedSubstring, int 
times) {
+    // Normalize whitespace for more robust matching
+    String stdOutSingleSpaced = stdout.replaceAll("[\\s]+", " ").trim();
+    String expectedOutput = expectedSubstring.replaceAll("[\\s]+", " ").trim();
+
+    int lastIndex = 0;
+    int count = 0;
+    while (lastIndex != -1) {
+      lastIndex = stdOutSingleSpaced.indexOf(expectedOutput, lastIndex);
+      if (lastIndex != -1) {
+        count++;
+        lastIndex += expectedOutput.length();
+      }
+    }
+    assertEquals(times, count,
+        String.format("Expected to find substring '%s' %d times, but found %d. 
Full stdout: %s",
+            expectedOutput, times, count, stdout));
+    return this;
+  }
+
+  /**
+   * Asserts that the standard output contains a line exactly equal to the 
expected value
+   * (after trimming and after stripping any leading {@code scala> } REPL 
prompt prefix),
+   * appearing exactly once. Use this for REPL sentinel markers emitted via
+   * {@code println(...)}. Handles both Scala 2.12's spark-shell echoing the 
input line
+   * back (Spark 3.5) and Scala 2.13's dumb-terminal mode prefixing sentinel 
output with
+   * {@code scala> } on the same line (Spark 4.0).
+   *
+   * @param expectedLine The exact line content to match (after trim and 
prompt strip).
+   * @return The same {@link CommandResult} instance for chaining assertions.
+   */
+  public CommandResult assertStdOutContainsLine(String expectedLine) {
+    return assertStdOutContainsLine(expectedLine, 1);
+  }
+
+  /**
+   * Asserts that the standard output contains a line exactly equal to the 
expected value
+   * (after trimming and after stripping any leading {@code scala> } REPL 
prompt prefix),
+   * appearing an exact number of times.
+   *
+   * @param expectedLine The exact line content to match (after trim and 
prompt strip).
+   * @param times The exact number of matching lines expected.
+   * @return The same {@link CommandResult} instance for chaining assertions.
+   */
+  public CommandResult assertStdOutContainsLine(String expectedLine, int 
times) {
+    String expected = expectedLine.trim();
+    long count = stdout.lines()
+        .map(line -> REPL_PROMPT_PREFIX.matcher(line).replaceFirst("").trim())
+        .filter(expected::equals)
+        .count();
+    assertEquals(times, count,
+        String.format("Expected line '%s' %d times, but found %d. Full stdout: 
%s",
+            expected, times, count, stdout));
+    return this;
+  }
+}
diff --git 
a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/HiveService.java
 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/HiveService.java
new file mode 100644
index 000000000000..5c5151809a7a
--- /dev/null
+++ 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/HiveService.java
@@ -0,0 +1,72 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.integ2.testcontainers.service;
+
+import org.apache.hudi.integ2.testcontainers.ContainerProvider;
+import org.apache.hudi.integ2.testcontainers.TestcontainersConfig;
+import org.apache.hudi.integ2.testcontainers.command.CommandExecutor;
+import org.apache.hudi.integ2.testcontainers.command.CommandResult;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A service wrapper for the Hive container.
+ * This class is responsible for all interactions with the Hive service,
+ * including executing commands and managing files.
+ */
+public class HiveService {
+
+  private final CommandExecutor executor;
+  private final boolean verbose;
+
+  public HiveService(ContainerProvider provider) {
+    this(provider, 
Boolean.getBoolean(TestcontainersConfig.SystemProps.HIVE_VERBOSE));
+  }
+
+  /**
+   * Visible-for-tests overload so callers can toggle verbose mode without
+   * setting the system property at JVM start time.
+   */
+  public HiveService(ContainerProvider provider, boolean verbose) {
+    this.executor = new 
CommandExecutor(provider.getContainer(TestcontainersConfig.Containers.HIVESERVER));
+    this.verbose = verbose;
+  }
+
+  /**
+   * Execute a Hive command and return the result.
+   */
+  public CommandResult execute(String hiveCommand) throws Exception {
+    List<String> hiveCmd = new ArrayList<>();
+    hiveCmd.add("hive");
+    hiveCmd.add("--hiveconf");
+    
hiveCmd.add("hive.input.format=org.apache.hadoop.hive.ql.io.HiveInputFormat");
+    hiveCmd.add("--hiveconf");
+    hiveCmd.add("hive.stats.autogather=false");
+    if (verbose) {
+      for (String kv : TestcontainersConfig.SystemProps.VERBOSE_HIVECONFS) {
+        hiveCmd.add("--hiveconf");
+        hiveCmd.add(kv);
+      }
+    }
+    hiveCmd.add("-e");
+    hiveCmd.add(hiveCommand);
+    return executor.executeCommand(hiveCmd.toArray(new String[0]));
+  }
+}
diff --git 
a/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/SparkService.java
 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/SparkService.java
new file mode 100644
index 000000000000..1c105402d644
--- /dev/null
+++ 
b/hudi-integ-test/src/test/java/org/apache/hudi/integ2/testcontainers/service/SparkService.java
@@ -0,0 +1,71 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.integ2.testcontainers.service;
+
+import org.apache.hudi.integ2.testcontainers.ContainerProvider;
+import org.apache.hudi.integ2.testcontainers.TestcontainersConfig;
+import org.apache.hudi.integ2.testcontainers.command.CommandExecutor;
+import org.apache.hudi.integ2.testcontainers.command.CommandResult;
+
+/**
+ * A service wrapper for the Spark container.
+ * This class is responsible for all interactions with the Spark service,
+ * including executing spark-shell commands.
+ */
+public class SparkService {
+
+  private final CommandExecutor executor;
+
+  public SparkService(ContainerProvider provider, String containerName) {
+    this.executor = new CommandExecutor(provider.getContainer(containerName));
+  }
+
+  /**
+   * Execute a Spark SQL command file.
+   */
+  public CommandResult executeSQLFile(String commandFile) throws Exception {
+    String sparkShellCmd = new StringBuilder()
+        .append("spark-shell --jars 
").append(TestcontainersConfig.Paths.SPARK_BUNDLE)
+        .append(" --master local[2] --driver-class-path 
").append(TestcontainersConfig.Paths.HADOOP_CONF_DIR)
+        .append(" --conf 
spark.serializer=org.apache.spark.serializer.KryoSerializer")
+        .append(" --conf 
spark.sql.catalog.spark_catalog=org.apache.spark.sql.hudi.catalog.HoodieCatalog")
+        .append(" --conf 
spark.sql.extensions=org.apache.spark.sql.hudi.HoodieSparkSessionExtension")
+        .append(" --deploy-mode client --driver-memory 1G --executor-memory 1G 
--num-executors 1")
+        // Pipe via stdin instead of `-i`. With `-i`, Spark 4's REPL boots in
+        // dumb-terminal mode (`WARN jline: Unable to create a system terminal,
+        // creating a dumb terminal`) and silently runs the script — neither
+        // input echo nor exception traces reach stdout, so success markers and
+        // failures alike are invisible to assertStdOutContains.
+        .append(" < ").append(commandFile)
+        .toString();
+
+    return executor.executeCommandString(sparkShellCmd);
+  }
+
+  /**
+   * Executes an arbitrary shell command inside the service's container.
+   * This is useful for hdfs commands, hudi-cli, etc.
+   *
+   * @param command The shell command to execute.
+   * @return The result of the command execution.
+   */
+  public CommandResult executeShellCommand(String command) throws Exception {
+    return executor.executeCommandString(command);
+  }
+}


Reply via email to