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

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


The following commit(s) were added to refs/heads/main by this push:
     new 2ba6faa4 [727] Fix the demo so it runs against current main, and 
harden the image (#850)
2ba6faa4 is described below

commit 2ba6faa4f0bf799182151821e2bfbef72e8bd1ed
Author: Ranga Reddy <[email protected]>
AuthorDate: Fri Aug 7 04:02:01 2026 +0530

    [727] Fix the demo so it runs against current main, and harden the image 
(#850)
    
    The demo had stopped working: the notebooks pinned dependency versions that 
no
    longer matched what xtable is built against, and the first cell provisioned 
them
    over the network on every kernel start. This makes the demo track the root 
pom,
    bakes the dependencies into the image, and removes the foot-guns that let 
it rot
    unnoticed in the first place.
    
    Demo runtime:
    
    - Derive every dependency version from the root pom. build_demo.sh writes
      demo/jars/versions.properties, and both the image's pre-fetch and the 
kernel
      predef read it, so there is one place a version is stated and the demo 
cannot
      silently drift from what xtable is tested against.
    - Bake JDK 11 and all notebook dependencies into the Jupyter image. Hudi 1.x
      requires Java 11+ while the almond base image only ships Java 8, and the
      coursier cache is warmed at build time so the first cell does not spend
      minutes resolving.
    - Move dependency loading out of the notebooks into a kernel predef, so the
      notebooks contain no dependency management.
    - Gate the Jupyter startup on the dependencies being resolvable, and 
re-fetch
      anything missing if versions changed since the image was built.
    - Run the demo in the background, split the jar build out into 
build_demo.sh,
      and add stop_demo.sh with --reset-data to restore the seed datasets.
    - Avoid an in-place Hudi table upgrade on the seed table, and make the Delta
      join cell re-runnable so the notebook can be executed more than once.
    - Create the demo namespace in the metastore automatically, qualifying the 
array
      construction as scala.Array: the notebook imports java.util._, so a bare
      Array(namespace) resolves against java.lang.reflect.Array in Ammonite.
    - Silence the harmless Presto RMI accept-loop warning via a log.properties.
    
    Hardening, from review on the PR:
    
    - Publish the notebook server on 127.0.0.1 only. It runs with authentication
      disabled, and 8888 was bound to every interface, so anyone able to reach 
the
      host had an unauthenticated Scala kernel: arbitrary code execution with
      read/write access to the mounted demo/data. Loopback keeps the printed
      http://localhost:8888/lab URL working. The other three services are left
      alone; they are pre-existing and not unauthenticated code execution.
    - Pin the base image to almondsh/almond:0.14.1 by tag and digest. The
      kernel-patching step depends on the base image's kernel path, on argv[0] 
being
      "java" and on -Xmx4g being safe to insert at argv[1], so a moving :latest
      could break the demo at build time - the same class of failure this change
      exists to fix. 0.14.1 is what :latest resolved to, same manifest digest, 
so
      behaviour is unchanged.
    - Assert in the kernel-patching step instead of silently doing nothing. 
With no
      kernel matched the glob returned an empty list, the comprehension iterated
      over nothing, and the build succeeded with an unpatched image that fails 
only
      when a notebook is run. Both the kernel path and the argv shape are 
checked.
    - Read the log4j, trino-jdbc and presto-jdbc versions from 
versions.properties
      like every other coordinate. These four were hardcoded in both
      prefetch_dependencies.sh and predef.sc, and a divergence between the two 
lists
      silently sends every kernel start to the network instead of the pre-warmed
      cache. log4j moves from 2.17.2 to the root pom's 2.22.0; 2.17.2 was 
carried
      over from the original demo (5d40c6f), not required by almond or Spark.
    - Fail in prefetch_dependencies.sh when a property is missing, rather than
      building a coordinate like "org.apache.spark:spark-sql_:" and surfacing 
it as
      a coursier resolution error. Values are read with cut -f2- so a value
      containing "=" is not truncated.
    - Remove stale xtable jars from demo/jars before copying new ones. predef.sc
      puts every jar in that directory on the kernel classpath, so after a 
version
      bump two copies of the same classes were loaded.
    
    Verified: image builds with the pinned base and patches all three Scala 
kernels;
    the kernel starts and reports log4j 2.22.0, Java 11 and Spark 3.4.2 with the
    xtable classes loaded; the notebook port is reachable on 127.0.0.1 and 
refused
    on the host's LAN address; both new asserts and the missing-property check 
fail
    loudly when their assumption is broken.
    
    Closes #727
---
 .gitignore                                         |   3 +
 .../catalog/iceberg.properties => .dockerignore}   |  13 +-
 demo/README.md                                     |   7 +-
 demo/build_demo.sh                                 |  68 +++++++
 demo/docker-compose.yaml                           |  13 +-
 demo/jupyter/Dockerfile                            |  64 ++++++
 demo/jupyter/predef.sc                             |  69 +++++++
 demo/jupyter/prefetch_dependencies.sh              |  70 +++++++
 demo/jupyter/verify_dependencies.sh                |  35 ++++
 demo/notebook/demo.ipynb                           | 222 +++++++++++++++------
 .../delta.properties => presto/log.properties}     |  12 +-
 demo/start_demo.sh                                 |  49 ++++-
 demo/{start_demo.sh => stop_demo.sh}               |  20 +-
 demo/trino/catalog/delta.properties                |   3 +-
 demo/trino/catalog/iceberg.properties              |   3 +-
 website/docs/demo/docker.md                        |  12 ++
 16 files changed, 564 insertions(+), 99 deletions(-)

diff --git a/.gitignore b/.gitignore
index d62030a3..4273535c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,6 @@ spec/generated
 
 # SDKMan
 .sdkmanrc
+demo/notebook/spark-warehouse/
+demo/notebook/metastore_db/
+demo/notebook/derby.log
diff --git a/demo/trino/catalog/iceberg.properties b/demo/.dockerignore
similarity index 80%
copy from demo/trino/catalog/iceberg.properties
copy to demo/.dockerignore
index d59b6861..b1e99cdf 100644
--- a/demo/trino/catalog/iceberg.properties
+++ b/demo/.dockerignore
@@ -14,9 +14,10 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-connector.name=iceberg
-hive.metastore.uri=thrift://hive-metastore:9083
-hive.metastore-cache-ttl=0s
-hive.metastore-refresh-interval=5s
-hive.metastore-timeout=10s
-iceberg.catalog.type=hive_metastore
\ No newline at end of file
+## Only jars/versions.properties and jupyter/ are needed in the image build
+## context; everything else is mounted at runtime.
+data
+notebook
+presto
+trino
+jars/*.jar
diff --git a/demo/README.md b/demo/README.md
index eaada899..777aa381 100644
--- a/demo/README.md
+++ b/demo/README.md
@@ -19,7 +19,12 @@
 # Running a Local Demo
 This demo was created for the 2023 Open Source Data Summit. It shows how 
XTable can be used with two existing datasets.
 
-Use `./start_demo.sh` to spin up a local notebook with a scala interpreter, 
Hive Metastore, Presto and Trino in docker containers. The script will first 
build the XTable jars required for the demo and then start the containers.  
+Use `./start_demo.sh` to spin up a local notebook with a scala interpreter, 
Hive Metastore, Presto and Trino in docker containers (in the background). The 
script builds the XTable jars required for the demo first (if they are not 
already present) and then starts the containers and prints the notebook URL. 
The jars can also be (re)built explicitly with `./build_demo.sh`, for example 
after making code changes.
+
+When you are done, use `./stop_demo.sh` to stop and remove the containers. 
Pass `--reset-data` to also restore the seed datasets under `demo/data` to 
their original state so the demo can be re-run from scratch.
+
+## Notebooks
+- `work/demo.ipynb` — the main demo: two existing datasets (Hudi and Delta) 
are synced across Hudi, Delta and Iceberg, updated from Trino, joined, and 
validated from Trino.
 
 ## Accessing Services
 ### Jupyter Notebook
diff --git a/demo/build_demo.sh b/demo/build_demo.sh
new file mode 100755
index 00000000..5e878377
--- /dev/null
+++ b/demo/build_demo.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+#
+# 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.
+#
+## Build the XTable jars required for the demo and generate the dependency
+## version properties consumed by the demo notebooks.
+
+set -e
+
+CURRENT_DIR="$( cd "$(dirname "$0")" ; pwd -P )"
+XTABLE_HOME="$( cd "$(dirname "$CURRENT_DIR")" ; pwd -P )"
+cd "$XTABLE_HOME"
+
+## Read a property from the root pom so the demo always matches the versions
+## xtable is built against.
+get_property() {
+  ./mvnw -q help:evaluate -Dexpression="$1" -DforceStdout
+}
+
+PROJECT_VERSION=$(get_property project.version)
+SCALA_BINARY_VERSION=$(get_property scala.binary.version)
+
+## JDBC drivers for the query engines the demo talks to. These have no root-pom
+## property because xtable does not depend on them, so they are declared here 
to
+## keep versions.properties the single source for every coordinate the 
notebooks
+## resolve. Keep them in step with the trino/presto image tags in
+## demo/docker-compose.yaml.
+TRINO_JDBC_VERSION=431
+PRESTO_JDBC_VERSION=0.283
+
+./mvnw install -am -pl xtable-core -DskipTests -T 2
+
+mkdir -p demo/jars
+## Drop xtable jars from a previous build first. predef.sc puts every jar in
+## demo/jars on the kernel classpath, so leaving an older version behind after 
a
+## version bump loads two copies of the same classes.
+rm -f demo/jars/xtable-*.jar
+cp 
"xtable-hudi-support/xtable-hudi-support-utils/target/xtable-hudi-support-utils-${PROJECT_VERSION}.jar"
 demo/jars
+cp "xtable-api/target/xtable-api-${PROJECT_VERSION}.jar" demo/jars
+cp 
"xtable-core/target/xtable-core_${SCALA_BINARY_VERSION}-${PROJECT_VERSION}.jar" 
demo/jars
+
+## Export the dependency versions used by the notebooks. The notebooks read
+## this file from /home/jars/versions.properties instead of hardcoding 
versions.
+{
+  cat style/text-license-header
+  echo "scala.binary.version=${SCALA_BINARY_VERSION}"
+  for prop in spark.version spark.version.prefix hudi.version delta.version 
delta.kernel.version iceberg.hive.runtime.version log4j.version; do
+    echo "${prop}=$(get_property "${prop}")"
+  done
+  echo "trino.jdbc.version=${TRINO_JDBC_VERSION}"
+  echo "presto.jdbc.version=${PRESTO_JDBC_VERSION}"
+} > demo/jars/versions.properties
+
+echo "XTable demo jars and versions.properties generated under demo/jars:"
+ls demo/jars
diff --git a/demo/docker-compose.yaml b/demo/docker-compose.yaml
index beeabaa9..c32a325e 100644
--- a/demo/docker-compose.yaml
+++ b/demo/docker-compose.yaml
@@ -37,6 +37,7 @@ services:
       - ./presto/config.properties:/opt/presto-server/etc/config.properties
       - ./presto/jvm.config:/opt/presto-server/etc/jvm.config
       - ./presto/node.properties:/opt/presto-server/etc/node.properties
+      - ./presto/log.properties:/opt/presto-server/etc/log.properties
       - ./data:/home/data
 
   hive-metastore:
@@ -54,9 +55,17 @@ services:
   jupyter:
     container_name: jupyter
     hostname: jupyter
-    image: 'almondsh/almond:latest'
+    build:
+      context: .
+      dockerfile: jupyter/Dockerfile
+    image: 'xtable-demo-jupyter'
+    ## Local demo only: the notebook server runs without authentication, so the
+    ## port is published to the loopback interface only. A Scala kernel is
+    ## arbitrary code execution with read/write access to the mounted ./data, 
so
+    ## do not widen this to 0.0.0.0 without restoring the token.
+    command: ["start-notebook.sh", "--IdentityProvider.token=", 
"--ServerApp.password="]
     ports:
-      - '8888:8888'
+      - '127.0.0.1:8888:8888'
     volumes:
       - ./notebook:/home/jovyan/work
       - ./jars:/home/jars
diff --git a/demo/jupyter/Dockerfile b/demo/jupyter/Dockerfile
new file mode 100644
index 00000000..4ae229b7
--- /dev/null
+++ b/demo/jupyter/Dockerfile
@@ -0,0 +1,64 @@
+#  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.
+
+# Jupyter image for the XTable demo, based on the almond Scala kernel image.
+# Everything the notebooks need at runtime is baked in at build time so the
+# first cell does not spend minutes provisioning:
+#   - OpenJDK 11 for the Scala kernels (Hudi 1.x jars require Java 11+)
+#   - all notebook dependencies (Spark, Hudi, Delta, Iceberg, JDBC drivers)
+#     pre-fetched into the coursier cache
+#   - the Scala kernels re-pointed at that JDK, with a bounded heap and a
+#     predef that puts every dependency on the classpath at kernel startup
+# Pinned by tag and digest. The kernel-patching step at the bottom of this file
+# depends on the base image's layout - the kernel path, argv[0] being "java" 
and
+# -Xmx4g being safe to insert at argv[1] - so a moving :latest could break the
+# demo silently at build time. 0.14.1 is the image :latest resolved to when 
this
+# was pinned, so behaviour is unchanged; bump both tag and digest together.
+FROM 
almondsh/almond:0.14.1@sha256:0407359be58529eaa8e35bcf4586c3a890123be64005600b381de88fdc3c5de7
+
+# Install a JDK 11 for the Scala kernels; the base image only ships Java 8.
+RUN mamba create -y -q -n jdk11 -c conda-forge openjdk=11 && mamba clean -afy
+
+# Pre-fetch the notebook dependencies into the coursier cache. The versions
+# come from demo/jars/versions.properties, generated from the root pom by
+# build_demo.sh, so the image always matches what xtable is built against.
+COPY jars/versions.properties /usr/local/share/xtable/versions.properties
+COPY jupyter/prefetch_dependencies.sh 
/usr/local/share/xtable/prefetch_dependencies.sh
+RUN bash /usr/local/share/xtable/prefetch_dependencies.sh 
/usr/local/share/xtable/versions.properties
+
+# Startup gate: re-verify (and complete, if versions changed since the image
+# was built) the dependency cache before the Jupyter server is started.
+COPY jupyter/verify_dependencies.sh 
/usr/local/bin/before-notebook.d/10-xtable-verify-dependencies.sh
+
+# The kernel predef loads all notebook dependencies at kernel startup, so the
+# notebooks contain no dependency management code.
+COPY jupyter/predef.sc /usr/local/share/xtable/predef.sc
+
+# Point all Scala kernels at the JDK 11 with a bounded heap (local Spark for
+# the demo fits comfortably in 4g and this keeps the kernel JVM from starving
+# the other demo containers) and wire in the predef.
+#
+# The assertions matter: without them a base image whose kernel path or argv
+# shape changed would make the glob match nothing, the comprehension iterate
+# over an empty list, and the build succeed with an unpatched image that fails
+# only when a notebook is run.
+RUN python3 -c "import glob, json; \
+paths = 
sorted(glob.glob('/home/jovyan/.local/share/jupyter/kernels/scala*/kernel.json'));
 \
+assert paths, 'no scala kernels found under 
/home/jovyan/.local/share/jupyter/kernels'; \
+kernels = [(p, json.load(open(p))) for p in paths]; \
+assert all(k['argv'][0] == 'java' for _, k in kernels), 'kernel argv[0] is not 
java: %r' % [k['argv'][0] for _, k in kernels]; \
+[(k['argv'].__setitem__(0, '/opt/conda/envs/jdk11/bin/java'), 
k['argv'].insert(1, '-Xmx4g'), k['argv'].extend(['--predef', 
'/usr/local/share/xtable/predef.sc']), k.setdefault('env', 
{}).__setitem__('JAVA_HOME', '/opt/conda/envs/jdk11'), json.dump(k, open(p, 
'w'), indent=2)) for p, k in kernels]; \
+print('patched kernels:', [p for p, _ in kernels])"
diff --git a/demo/jupyter/predef.sc b/demo/jupyter/predef.sc
new file mode 100644
index 00000000..de9be73b
--- /dev/null
+++ b/demo/jupyter/predef.sc
@@ -0,0 +1,69 @@
+/*
+ * 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.
+ */
+
+// Kernel predef for the XTable demo notebooks: loads every dependency the
+// notebooks need when the kernel starts, so the notebooks themselves contain
+// no dependency management. The artifacts are already present in the image's
+// coursier cache (pre-fetched at image build time); versions come from
+// /home/jars/versions.properties, generated from the root pom by
+// build_demo.sh.
+
+val xtableVersions = {
+  val props = new java.util.Properties()
+  val in = new java.io.FileInputStream("/home/jars/versions.properties")
+  try props.load(in)
+  finally in.close()
+  props
+}
+
+def xtableVersion(key: String): String =
+  
Option(xtableVersions.getProperty(key)).map(_.trim).filter(_.nonEmpty).getOrElse
 {
+    sys.error(s"$key missing from /home/jars/versions.properties, re-run 
build_demo.sh")
+  }
+
+{
+  val scalaBinaryVersion = xtableVersion("scala.binary.version")
+  val sparkVersionPrefix = xtableVersion("spark.version.prefix")
+
+  // Keep this list resolving to the same artifacts as the coursier fetch in
+  // prefetch_dependencies.sh; a divergence means a network resolve on every
+  // kernel start instead of a cache hit.
+  Seq(
+    ("org.apache.logging.log4j", "log4j-api", xtableVersion("log4j.version")),
+    ("org.apache.logging.log4j", "log4j-core", xtableVersion("log4j.version")),
+    ("org.apache.spark", s"spark-sql_$scalaBinaryVersion", 
xtableVersion("spark.version")),
+    ("org.apache.spark", s"spark-hive_$scalaBinaryVersion", 
xtableVersion("spark.version")),
+    ("org.apache.hudi", 
s"hudi-spark$sparkVersionPrefix-bundle_$scalaBinaryVersion", 
xtableVersion("hudi.version")),
+    ("org.apache.hudi", "hudi-java-client", xtableVersion("hudi.version")),
+    ("io.delta", s"delta-core_$scalaBinaryVersion", 
xtableVersion("delta.version")),
+    ("io.delta", "delta-kernel-api", xtableVersion("delta.kernel.version")),
+    ("io.delta", "delta-kernel-defaults", 
xtableVersion("delta.kernel.version")),
+    ("org.apache.iceberg", "iceberg-hive-runtime", 
xtableVersion("iceberg.hive.runtime.version")),
+    ("io.trino", "trino-jdbc", xtableVersion("trino.jdbc.version")),
+    ("com.facebook.presto", "presto-jdbc", 
xtableVersion("presto.jdbc.version"))
+  ).foreach { case (org, name, version) =>
+    interp.load.ivy(coursierapi.Dependency.of(org, name, version))
+  }
+
+  // Load the xtable jars built by build_demo.sh (version-agnostic).
+  new java.io.File("/home/jars").listFiles
+    .filter(_.getName.endsWith(".jar"))
+    .foreach(jar => interp.load.cp(os.Path(jar.getAbsolutePath)))
+}
+
+println("XTable demo kernel ready: Spark, Hudi, Delta, Iceberg and the XTable 
jars are on the classpath.")
diff --git a/demo/jupyter/prefetch_dependencies.sh 
b/demo/jupyter/prefetch_dependencies.sh
new file mode 100644
index 00000000..f9160b98
--- /dev/null
+++ b/demo/jupyter/prefetch_dependencies.sh
@@ -0,0 +1,70 @@
+#!/bin/bash
+#
+# 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.
+#
+## Pre-fetch the demo notebook dependencies into the coursier cache at image
+## build time so the notebooks' first cell resolves instantly. The dependency
+## versions are read from the versions.properties file passed as the first
+## argument (generated from the root pom by build_demo.sh).
+set -e
+
+PROPS_FILE="$1"
+
+## Fail on a missing or renamed key rather than substituting an empty string:
+## otherwise a typo, or build_demo.sh dropping a property, produces a 
coordinate
+## like "org.apache.spark:spark-sql_:" and a coursier resolution error that 
says
+## nothing about the real cause. Mirrors what predef.sc does.
+prop() {
+  local value
+  value=$(grep "^$1=" "${PROPS_FILE}" | cut -d= -f2-)
+  if [ -z "${value}" ]; then
+    echo "ERROR: $1 missing from ${PROPS_FILE}; re-run build_demo.sh" >&2
+    exit 1
+  fi
+  echo "${value}"
+}
+
+SCALA_BINARY_VERSION=$(prop scala.binary.version)
+SPARK_VERSION=$(prop spark.version)
+SPARK_VERSION_PREFIX=$(prop spark.version.prefix)
+HUDI_VERSION=$(prop hudi.version)
+DELTA_VERSION=$(prop delta.version)
+DELTA_KERNEL_VERSION=$(prop delta.kernel.version)
+ICEBERG_HIVE_RUNTIME_VERSION=$(prop iceberg.hive.runtime.version)
+LOG4J_VERSION=$(prop log4j.version)
+TRINO_JDBC_VERSION=$(prop trino.jdbc.version)
+PRESTO_JDBC_VERSION=$(prop presto.jdbc.version)
+
+## Every coordinate's version comes from versions.properties. This list and the
+## one in predef.sc must resolve to the same artifacts: this one warms the
+## coursier cache at image build time, that one resolves at kernel startup, so 
a
+## divergence of one version sends every kernel start back to the network -
+## exactly the slow path this image exists to avoid.
+coursier fetch --quiet \
+  "org.apache.logging.log4j:log4j-api:${LOG4J_VERSION}" \
+  "org.apache.logging.log4j:log4j-core:${LOG4J_VERSION}" \
+  "org.apache.spark:spark-sql_${SCALA_BINARY_VERSION}:${SPARK_VERSION}" \
+  "org.apache.spark:spark-hive_${SCALA_BINARY_VERSION}:${SPARK_VERSION}" \
+  
"org.apache.hudi:hudi-spark${SPARK_VERSION_PREFIX}-bundle_${SCALA_BINARY_VERSION}:${HUDI_VERSION}"
 \
+  "org.apache.hudi:hudi-java-client:${HUDI_VERSION}" \
+  "io.delta:delta-core_${SCALA_BINARY_VERSION}:${DELTA_VERSION}" \
+  "io.delta:delta-kernel-api:${DELTA_KERNEL_VERSION}" \
+  "io.delta:delta-kernel-defaults:${DELTA_KERNEL_VERSION}" \
+  "org.apache.iceberg:iceberg-hive-runtime:${ICEBERG_HIVE_RUNTIME_VERSION}" \
+  "io.trino:trino-jdbc:${TRINO_JDBC_VERSION}" \
+  "com.facebook.presto:presto-jdbc:${PRESTO_JDBC_VERSION}"
+
+echo "Notebook dependencies pre-fetched into the coursier cache."
diff --git a/demo/jupyter/verify_dependencies.sh 
b/demo/jupyter/verify_dependencies.sh
new file mode 100644
index 00000000..6e942e0a
--- /dev/null
+++ b/demo/jupyter/verify_dependencies.sh
@@ -0,0 +1,35 @@
+#!/bin/bash
+#
+# 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.
+#
+## Startup gate for the Jupyter container: verifies that every notebook
+## dependency is present in the local coursier cache (downloading anything
+## missing, e.g. after a version bump without an image rebuild) BEFORE the
+## Jupyter server starts. The image build pre-fetches everything, so when the
+## versions are unchanged this completes in a few seconds without any network
+## access.
+
+PROPS_FILE=/home/jars/versions.properties
+if [ ! -f "${PROPS_FILE}" ]; then
+  PROPS_FILE=/usr/local/share/xtable/versions.properties
+fi
+
+echo "xtable-demo: verifying notebook dependencies before starting Jupyter 
(using ${PROPS_FILE})..."
+if ! bash /usr/local/share/xtable/prefetch_dependencies.sh "${PROPS_FILE}"; 
then
+  echo "xtable-demo: ERROR - failed to resolve the notebook dependencies; not 
starting Jupyter." >&2
+  exit 1
+fi
+echo "xtable-demo: all notebook dependencies are available, starting Jupyter."
diff --git a/demo/notebook/demo.ipynb b/demo/notebook/demo.ipynb
index 3538ed3e..7ce957cb 100644
--- a/demo/notebook/demo.ipynb
+++ b/demo/notebook/demo.ipynb
@@ -5,33 +5,21 @@
    "id": "29a0a252-5a8a-4986-af24-1e2af6dfa081",
    "metadata": {},
    "source": [
-    "# Pre-Demo Setup"
+    "# Pre-Demo Setup\n",
+    "All required dependencies (Spark, Hudi, Delta Lake, Iceberg and the 
XTable jars) are loaded automatically\n",
+    "when the kernel starts, so we begin directly with the imports."
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "5f02fa4f-ca2b-49da-b2e1-9361bf001633",
-   "metadata": {
-    "scrolled": true
-   },
+   "id": "afcc578a",
+   "metadata": {},
    "outputs": [],
    "source": [
-    "import $ivy.`org.apache.logging.log4j:log4j-api:2.17.2`\n",
-    "import $ivy.`org.apache.logging.log4j:log4j-core:2.17.2`\n",
-    "import $ivy.`org.apache.spark::spark-sql:3.4.2`\n",
-    "import $ivy.`org.apache.spark:spark-hive_2.12:3.4.2`\n",
     "import org.apache.spark.sql._\n",
     "import org.apache.log4j.{Level, Logger}\n",
     "Logger.getLogger(\"org\").setLevel(Level.ERROR)\n",
-    "import $ivy.`org.apache.hudi:hudi-spark3.4-bundle_2.12:0.14.0`\n",
-    "import $ivy.`org.apache.hudi:hudi-java-client:0.14.0`\n",
-    "import $ivy.`io.delta:delta-core_2.12:2.4.0`\n",
-    "import $cp.`/home/jars/xtable-core-0.5.0-SNAPSHOT.jar`\n",
-    "import $cp.`/home/jars/xtable-api-0.5.0-SNAPSHOT.jar`\n",
-    "import $cp.`/home/jars/xtable-hudi-support-utils-0.5.0-SNAPSHOT.jar`\n",
-    "import $ivy.`org.apache.iceberg:iceberg-hive-runtime:1.4.2`\n",
-    "import $ivy.`io.trino:trino-jdbc:431`\n",
     "import java.util._\n",
     "import org.apache.xtable.conversion._\n",
     "import org.apache.xtable.model.sync._\n",
@@ -49,28 +37,29 @@
    "source": [
     "val hudiTableName = \"hudi_dimCustomer\"\n",
     "val hudiBasePath = \"file:/home/data/db/hudi_dimCustomer\"\n",
-    "val writerSchema = 
\"{\\\"type\\\":\\\"record\\\",\\\"name\\\":\\\"Sample\\\",\\\"fields\\\":[{\\\"name\\\":\\\"_c0\\\",\\\"type\\\":\\\"string\\\"},{\\\"name\\\":\\\"CustomerKey\\\",\\\"type\\\":\\\"string\\\"},{\\\"name\\\":\\\"GeographyKey\\\",\\\"type\\\":\\\"string\\\"},{\\\"name\\\":\\\"FirstName\\\",\\\"type\\\":\\\"string\\\"},{\\\"name\\\":\\\"LastName\\\",\\\"type\\\":\\\"string\\\"},{\\\"name\\\":\\\"BirthDate\\\",\\\"type\\\":\\\"string\\\"},{\\\"name\\\":\\\"MaritalStat
 [...]
+    "\n",
     "val hudiWriteOptions = new HashMap[String, String]()\n",
     "hudiWriteOptions.put(\"hoodie.table.name\", hudiTableName)\n",
     "hudiWriteOptions.put(\"hoodie.datasource.write.recordkey.field\", 
\"CustomerKey\")\n",
     "hudiWriteOptions.put(\"hoodie.datasource.write.partitionpath.field\", 
\"\")\n",
     "hudiWriteOptions.put(\"hoodie.datasource.write.precombine.field\", 
\"_c0\")\n",
     "hudiWriteOptions.put(\"hoodie.datasource.write.operation\", 
\"insert\")\n",
-    "hudiWriteOptions.put(\"hoodie.write.schema\", writerSchema)\n",
     "hudiWriteOptions.put(\"hoodie.populate.meta.fields\", \"false\")\n",
     "hudiWriteOptions.put(\"hoodie.parquet.small.file.limit\", \"1\")\n",
+    "hudiWriteOptions.put(\"hoodie.write.table.version\", \"6\")\n",
     "\n",
     "val deltaTableName = \"delta_dimGeography\"\n",
     "val deltaBasePath = \"file:/home/data/db/delta_dimGeography\"\n",
     "\n",
     "val spark = org.apache.spark.sql.SparkSession.builder()\n",
-    "    .appName(\"demo\")\n",
+    "    .appName(\"XTable Docker Demo\")\n",
     "    .config(\"spark.serializer\", 
\"org.apache.spark.serializer.KryoSerializer\")\n",
     "    .config(\"spark.sql.catalog.spark_catalog\", 
\"org.apache.spark.sql.hudi.catalog.HoodieCatalog\")\n",
     "    .config(\"spark.sql.extensions\", 
\"org.apache.spark.sql.hudi.HoodieSparkSessionExtension\")\n",
     "    .config(\"spark.kryo.registrator\", 
\"org.apache.spark.HoodieSparkKryoRegistrar\")\n",
     "    .master(\"local\")\n",
     "    .getOrCreate()\n",
+    "\n",
     "spark.sparkContext.setLogLevel(\"ERROR\")"
    ]
   },
@@ -92,7 +81,9 @@
    "id": "dd09e5d0-f71f-4000-89fe-b62fda7236da",
    "metadata": {},
    "source": [
-    "![image.png](attachment:2af13f55-97a1-415f-a354-bdf065dbf3af.png)"
+    "<p align=\"center\">\n",
+    "  <img src=\"attachment:2af13f55-97a1-415f-a354-bdf065dbf3af.png\" 
alt=\"image.png\"/>\n",
+    "</p>"
    ]
   },
   {
@@ -111,27 +102,57 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "val namespace = \"demo\" // requires that this db is already created in 
your HMS, in Trino you can run `create schema hive.demo`\n",
-    "val namespaceArray = Array(namespace)\n",
-    "val catalogOptions = new HashMap[String, String]();\n",
-    "catalogOptions.put(\"uri\", \"thrift://hive-metastore:9083\");\n",
+    "val namespace = \"demo\" // created in the metastore below if it does not 
exist yet\n",
+    "val namespaceArray = scala.Array(namespace)\n",
+    "\n",
+    "val catalogOptions = new HashMap[String, String]()\n",
+    "catalogOptions.put(\"uri\", \"thrift://hive-metastore:9083\")\n",
+    "catalogOptions.put(\"warehouse\", \"file:/home/data\")\n",
+    "\n",
     "val icebergCatalogConfig = IcebergCatalogConfig.builder()\n",
     "    .catalogImpl(\"org.apache.iceberg.hive.HiveCatalog\")\n",
     "    .catalogName(\"iceberg\")\n",
     "    .catalogOptions(catalogOptions)\n",
-    "    .build();\n",
-    "val perTableConfig = PerTableConfigImpl.builder()\n",
-    "    .tableName(hudiTableName)\n",
+    "    .build()\n",
+    "\n",
+    "// Create the namespace in the metastore if it does not already exist, so 
the\n",
+    "// demo works out of the box on a freshly started stack.\n",
+    "val hiveCatalog = new org.apache.iceberg.hive.HiveCatalog()\n",
+    "hiveCatalog.setConf(spark.sparkContext.hadoopConfiguration)\n",
+    "hiveCatalog.initialize(\"iceberg\", catalogOptions)\n",
+    "\n",
+    "val icebergNamespace = 
org.apache.iceberg.catalog.Namespace.of(namespaceArray: _*)\n",
+    "if (!hiveCatalog.namespaceExists(icebergNamespace)) {\n",
+    "  hiveCatalog.createNamespace(icebergNamespace)\n",
+    "}\n",
+    "\n",
+    "val sourceTable = SourceTable.builder()\n",
+    "    .name(hudiTableName)\n",
+    "    .formatName(TableFormat.HUDI)\n",
+    "    .basePath(hudiBasePath)\n",
+    "    .additionalProperties(new Properties())\n",
+    "    .build()\n",
+    "\n",
+    "val icebergTargetTable = TargetTable.builder()\n",
+    "    .name(hudiTableName)\n",
+    "    .formatName(TableFormat.ICEBERG)\n",
+    "    .basePath(hudiBasePath)\n",
     "    .namespace(namespaceArray)\n",
-    "    .targetTableFormats(Arrays.asList(TableFormat.ICEBERG))\n",
-    "    .tableBasePath(hudiBasePath)\n",
-    "    .icebergCatalogConfig(icebergCatalogConfig)\n",
+    "    .catalogConfig(icebergCatalogConfig)\n",
+    "    .additionalProperties(new Properties())\n",
+    "    .build()\n",
+    "\n",
+    "val conversionConfig = ConversionConfig.builder()\n",
+    "    .sourceTable(sourceTable)\n",
+    "    .targetTables(Arrays.asList(icebergTargetTable))\n",
     "    .syncMode(SyncMode.INCREMENTAL)\n",
     "    .build()\n",
+    "\n",
     "val hudiConversionSourceProvider = new HudiConversionSourceProvider()\n",
-    "hudiConversionSourceProvider.init(spark.sparkContext.hadoopConfiguration, 
Collections.emptyMap())\n",
+    
"hudiConversionSourceProvider.init(spark.sparkContext.hadoopConfiguration)\n",
+    "\n",
     "val conversionController = new 
ConversionController(spark.sparkContext.hadoopConfiguration)\n",
-    "conversionController.sync(perTableConfig, hudiConversionSourceProvider)"
+    "conversionController.sync(conversionConfig, hudiConversionSourceProvider)"
    ]
   },
   {
@@ -156,13 +177,15 @@
     "    .format(\"csv\")\n",
     "    .option(\"header\", \"true\")\n",
     "    .load(\"/home/data/DimCustomer_round2.csv\")\n",
+    "\n",
     "hudiCsvSourcePart2\n",
     "    .write\n",
     "    .format(\"hudi\")\n",
     "    .options(hudiWriteOptions)\n",
     "    .mode(\"append\")\n",
     "    .save(hudiBasePath)\n",
-    "conversionController.sync(perTableConfig, hudiConversionSourceProvider)"
+    "\n",
+    "conversionController.sync(conversionConfig, hudiConversionSourceProvider)"
    ]
   },
   {
@@ -182,11 +205,14 @@
    "outputs": [],
    "source": [
     "import java.sql._\n",
+    "\n",
     "val url = \"jdbc:trino://trino:8080/iceberg/\" + namespace + 
\"?user=admin\"\n",
     "val connection = DriverManager.getConnection(url)\n",
     "val statement = connection.createStatement()\n",
+    "\n",
     "statement.executeUpdate(\"INSERT INTO 
hudi_dimCustomer(_c0,CustomerKey,GeographyKey,FirstName,LastName,BirthDate,MaritalStatus,Gender,YearlyIncome,TotalChildren,NumberChildrenAtHome,Education,Occupation,HouseOwnerFlag,NumberCarsOwned)
 \" +\n",
     "                        \"VALUES ('18869','19146','735','Reginald','Not 
Provided','2020-07-04','S','M','1000000000.0','0.0','0.0','Puppy Obedience 
School','Branch Manager','1.0','2.0')\")\n",
+    "\n",
     "statement.close()\n",
     "connection.close()"
    ]
@@ -208,16 +234,31 @@
    "outputs": [],
    "source": [
     "val icebergConversionSourceProvider = new 
IcebergConversionSourceProvider()\n",
-    
"icebergConversionSourceProvider.init(spark.sparkContext.hadoopConfiguration, 
Collections.emptyMap())\n",
-    "val icebergSourcePerTableConfig = PerTableConfigImpl.builder()\n",
-    "    .tableName(hudiTableName)\n",
+    
"icebergConversionSourceProvider.init(spark.sparkContext.hadoopConfiguration)\n",
+    "\n",
+    "val icebergSourceTable = SourceTable.builder()\n",
+    "    .name(hudiTableName)\n",
+    "    .formatName(TableFormat.ICEBERG)\n",
+    "    .basePath(hudiBasePath)\n",
     "    .namespace(namespaceArray)\n",
-    "    .targetTableFormats(Arrays.asList(TableFormat.DELTA))\n",
-    "    .tableBasePath(hudiBasePath)\n",
-    "    .icebergCatalogConfig(icebergCatalogConfig)\n",
+    "    .catalogConfig(icebergCatalogConfig)\n",
+    "    .additionalProperties(new Properties())\n",
+    "    .build()\n",
+    "\n",
+    "val deltaTargetTable = TargetTable.builder()\n",
+    "    .name(hudiTableName)\n",
+    "    .formatName(TableFormat.DELTA)\n",
+    "    .basePath(hudiBasePath)\n",
+    "    .additionalProperties(new Properties())\n",
+    "    .build()\n",
+    "\n",
+    "val icebergSourceConversionConfig = ConversionConfig.builder()\n",
+    "    .sourceTable(icebergSourceTable)\n",
+    "    .targetTables(Arrays.asList(deltaTargetTable))\n",
     "    .syncMode(SyncMode.INCREMENTAL)\n",
     "    .build()\n",
-    "conversionController.sync(icebergSourcePerTableConfig, 
icebergConversionSourceProvider)"
+    "\n",
+    "conversionController.sync(icebergSourceConversionConfig, 
icebergConversionSourceProvider)"
    ]
   },
   {
@@ -240,15 +281,31 @@
    "source": [
     "// Sync from a Delta Lake table to Hudi\n",
     "import org.apache.xtable.delta._\n",
+    "\n",
     "val deltaConversionSourceProvider = new 
DeltaConversionSourceProvider()\n",
-    
"deltaConversionSourceProvider.init(spark.sparkContext.hadoopConfiguration, 
Collections.emptyMap())\n",
-    "val deltaSourcePerTableConfig = PerTableConfigImpl.builder()\n",
-    "    .tableName(deltaTableName)\n",
-    "    .targetTableFormats(Arrays.asList(TableFormat.HUDI))\n",
-    "    .tableBasePath(deltaBasePath)\n",
+    
"deltaConversionSourceProvider.init(spark.sparkContext.hadoopConfiguration)\n",
+    "\n",
+    "val deltaSourceTable = SourceTable.builder()\n",
+    "    .name(deltaTableName)\n",
+    "    .formatName(TableFormat.DELTA)\n",
+    "    .basePath(deltaBasePath)\n",
+    "    .additionalProperties(new Properties())\n",
+    "    .build()\n",
+    "\n",
+    "val hudiTargetTable = TargetTable.builder()\n",
+    "    .name(deltaTableName)\n",
+    "    .formatName(TableFormat.HUDI)\n",
+    "    .basePath(deltaBasePath)\n",
+    "    .additionalProperties(new Properties())\n",
+    "    .build()\n",
+    "\n",
+    "val deltaSourceConversionConfig = ConversionConfig.builder()\n",
+    "    .sourceTable(deltaSourceTable)\n",
+    "    .targetTables(Arrays.asList(hudiTargetTable))\n",
     "    .syncMode(SyncMode.INCREMENTAL)\n",
     "    .build()\n",
-    "conversionController.sync(deltaSourcePerTableConfig, 
deltaConversionSourceProvider)"
+    "\n",
+    "conversionController.sync(deltaSourceConversionConfig, 
deltaConversionSourceProvider)"
    ]
   },
   {
@@ -270,8 +327,9 @@
    "outputs": [],
    "source": [
     "// Join the two datasets as Delta tables\n",
-    "spark.sql(\"CREATE OR REPLACE TABLE hudi_dimCustomer USING DELTA LOCATION 
'/home/data/db/hudi_dimCustomer'\")\n",
-    "spark.sql(\"CREATE OR REPLACE TABLE delta_dimGeography USING DELTA 
LOCATION '/home/data/db/delta_dimGeography'\")\n",
+    
"spark.read.format(\"delta\").load(\"/home/data/db/hudi_dimCustomer\").createOrReplaceTempView(\"hudi_dimCustomer\")\n",
+    
"spark.read.format(\"delta\").load(\"/home/data/db/delta_dimGeography\").createOrReplaceTempView(\"delta_dimGeography\")\n",
+    "\n",
     "spark.sql(\"SELECT * FROM hudi_dimCustomer INNER JOIN delta_dimGeography 
ON hudi_dimCustomer.GeographyKey = delta_dimGeography.GeographyKey\")\n",
     "     .select(\"FirstName\", \"LastName\", \"CityName\").show(10)"
    ]
@@ -294,18 +352,22 @@
    "outputs": [],
    "source": [
     "// translate the Delta Lake table to Iceberg\n",
-    "val perTableConfig = PerTableConfigImpl.builder()\n",
-    "    .tableName(deltaTableName)\n",
+    "val deltaToIcebergTargetTable = TargetTable.builder()\n",
+    "    .name(deltaTableName)\n",
+    "    .formatName(TableFormat.ICEBERG)\n",
+    "    .basePath(deltaBasePath)\n",
     "    .namespace(namespaceArray)\n",
-    "    .targetTableFormats(Arrays.asList(TableFormat.ICEBERG))\n",
-    "    .tableBasePath(deltaBasePath)\n",
-    "    .icebergCatalogConfig(icebergCatalogConfig)\n",
+    "    .catalogConfig(icebergCatalogConfig)\n",
+    "    .additionalProperties(new Properties())\n",
+    "    .build()\n",
+    "\n",
+    "val deltaToIcebergConversionConfig = ConversionConfig.builder()\n",
+    "    .sourceTable(deltaSourceTable)\n",
+    "    .targetTables(Arrays.asList(deltaToIcebergTargetTable))\n",
     "    .syncMode(SyncMode.INCREMENTAL)\n",
     "    .build()\n",
-    "val deltaConversionSourceProvider = new 
DeltaConversionSourceProvider()\n",
-    
"deltaConversionSourceProvider.init(spark.sparkContext.hadoopConfiguration, 
Collections.emptyMap())\n",
-    "val conversionController = new 
ConversionController(spark.sparkContext.hadoopConfiguration)\n",
-    "conversionController.sync(perTableConfig, deltaConversionSourceProvider)"
+    "\n",
+    "conversionController.sync(deltaToIcebergConversionConfig, 
deltaConversionSourceProvider)"
    ]
   },
   {
@@ -335,16 +397,52 @@
    "id": "f25f230b-7ff2-4231-add4-4fe1906080ed",
    "metadata": {},
    "source": [
-    "![Screenshot 2023-11-17 at 10.03.52 
AM.png](attachment:858ffdfb-a437-43a3-b54f-28a6cf071a1c.png)"
+    "<p align=\"center\">\n",
+    "  <img src=\"attachment:858ffdfb-a437-43a3-b54f-28a6cf071a1c.png\" 
alt=\"Screenshot 2023-11-17 at 10.03.52 AM.png\"/>\n",
+    "</p>"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "759ab564",
+   "metadata": {},
+   "source": [
+    "## Validating the synced tables from Trino\n",
+    "Both datasets are now registered in the Hive Metastore as Iceberg tables, 
so we can validate the end-to-end\n",
+    "sync results from Trino: row counts for both tables, and the row that was 
inserted through Trino earlier and\n",
+    "round-tripped Hudi -> Iceberg -> Delta."
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
-   "id": "dec0e4ef-12da-413c-8bf2-2c859351b1c8",
+   "id": "c6744565",
    "metadata": {},
    "outputs": [],
-   "source": []
+   "source": [
+    "val validationConnection = DriverManager.getConnection(url)\n",
+    "val validationStatement = validationConnection.createStatement()\n",
+    "\n",
+    "def runQuery(query: String): Unit = {\n",
+    "  println(s\"> $query\")\n",
+    "  val rs = validationStatement.executeQuery(query)\n",
+    "  val columnCount = rs.getMetaData.getColumnCount\n",
+    "  val header = (1 to 
columnCount).map(rs.getMetaData.getColumnName).mkString(\" | \")\n",
+    "  println(header)\n",
+    "  while (rs.next()) {\n",
+    "    println((1 to columnCount).map(rs.getString).mkString(\" | \"))\n",
+    "  }\n",
+    "  rs.close()\n",
+    "  println()\n",
+    "}\n",
+    "\n",
+    "runQuery(\"SELECT COUNT(*) AS row_count FROM 
iceberg.demo.hudi_dimCustomer\")\n",
+    "runQuery(\"SELECT COUNT(*) AS row_count FROM 
iceberg.demo.delta_dimGeography\")\n",
+    "runQuery(\"SELECT CustomerKey, FirstName, LastName FROM 
iceberg.demo.hudi_dimCustomer WHERE _c0 = '18869'\")\n",
+    "\n",
+    "validationStatement.close()\n",
+    "validationConnection.close()"
+   ]
   }
  ],
  "metadata": {
@@ -359,7 +457,7 @@
    "mimetype": "text/x-scala",
    "name": "scala",
    "nbconvert_exporter": "script",
-   "version": "2.12.18"
+   "version": "2.12.20"
   }
  },
  "nbformat": 4,
diff --git a/demo/trino/catalog/delta.properties b/demo/presto/log.properties
similarity index 71%
copy from demo/trino/catalog/delta.properties
copy to demo/presto/log.properties
index 3da2a722..3ace3ffe 100644
--- a/demo/trino/catalog/delta.properties
+++ b/demo/presto/log.properties
@@ -14,10 +14,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-connector.name=delta_lake
-hive.metastore.uri=thrift://hive-metastore:9083
-hive.metastore-cache-ttl=0s
-hive.metastore-refresh-interval=5s
-hive.metastore-timeout=10s
-delta.enable-non-concurrent-writes=true
-delta.security=ALLOW_ALL
\ No newline at end of file
+## The RMI accept loop behind the JMX agent logs a harmless
+## "accept loop for ServerSocket[...] throws java.net.SocketTimeoutException"
+## warning every time its accept() call times out. It floods the demo logs and
+## reads like a failure, so only surface real errors from that logger.
+sun.rmi.transport.tcp=ERROR
diff --git a/demo/start_demo.sh b/demo/start_demo.sh
index 493c2107..711c83e7 100755
--- a/demo/start_demo.sh
+++ b/demo/start_demo.sh
@@ -15,17 +15,46 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-## Create the require jars for the demo and copy them into a directory we'll 
mount in our notebook container
+## Start the demo containers in the background. Builds the XTable jars first
+## (via build_demo.sh) if they are not present. Use stop_demo.sh to stop the
+## demo when you are done.
+
+set -e
 
 CURRENT_DIR="$( cd "$(dirname "$0")" ; pwd -P )"
-XTABLE_HOME="$( cd "$(dirname "$CURRENT_DIR")" ; pwd -P )"
-cd $XTABLE_HOME
 
-./mvnw install -am -pl xtable-core -DskipTests -T 2
-mkdir -p demo/jars
-cp 
xtable-hudi-support/xtable-hudi-support-utils/target/xtable-hudi-support-utils-0.5.0-SNAPSHOT.jar
 demo/jars
-cp xtable-api/target/xtable-api-0.5.0-SNAPSHOT.jar demo/jars
-cp xtable-core/target/xtable-core_2.12-0.5.0-SNAPSHOT.jar demo/jars
+## Validate the demo jars are built; build them if anything is missing.
+if [ ! -f "${CURRENT_DIR}/jars/versions.properties" ] || ! ls 
"${CURRENT_DIR}"/jars/xtable-core_*.jar > /dev/null 2>&1; then
+  echo "XTable demo jars not found. Building the XTable jars first..."
+  "${CURRENT_DIR}/build_demo.sh"
+fi
+
+if [ ! -f "${CURRENT_DIR}/jars/versions.properties" ] || ! ls 
"${CURRENT_DIR}"/jars/xtable-core_*.jar > /dev/null 2>&1; then
+  echo "ERROR: the XTable build did not produce the expected demo jars under 
demo/jars." >&2
+  echo "Fix the build (see output above) and re-run this script." >&2
+  exit 1
+fi
+
+cd "${CURRENT_DIR}"
+## The first start builds the notebook image (JDK 11 and all notebook
+## dependencies are baked in); later starts reuse the cached image.
+docker-compose up -d --build
+
+echo "Waiting for the Jupyter server to start..."
+JUPYTER_READY=""
+for _ in $(seq 1 150); do
+  if docker logs jupyter 2>&1 | grep -qE "Jupyter Server .* is running at"; 
then
+    JUPYTER_READY="yes"
+    break
+  fi
+  sleep 2
+done
 
-cd demo
-docker-compose up
+if [ -n "${JUPYTER_READY}" ]; then
+  echo ""
+  echo "Jupyter is running at: http://localhost:8888/lab";
+  echo "The demo notebooks are under the work/ directory."
+else
+  echo "Jupyter did not report as running yet; check its status with: docker 
logs jupyter"
+fi
+echo "Run ./stop_demo.sh when you are done (add --reset-data to restore the 
seed datasets)."
diff --git a/demo/start_demo.sh b/demo/stop_demo.sh
similarity index 66%
copy from demo/start_demo.sh
copy to demo/stop_demo.sh
index 493c2107..aa335e6d 100755
--- a/demo/start_demo.sh
+++ b/demo/stop_demo.sh
@@ -15,17 +15,19 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-## Create the require jars for the demo and copy them into a directory we'll 
mount in our notebook container
+## Stop the demo containers started by start_demo.sh. Pass --reset-data to also
+## restore the seed datasets under demo/data to their original state so the
+## demo notebook can be re-run from scratch.
 
 CURRENT_DIR="$( cd "$(dirname "$0")" ; pwd -P )"
 XTABLE_HOME="$( cd "$(dirname "$CURRENT_DIR")" ; pwd -P )"
-cd $XTABLE_HOME
 
-./mvnw install -am -pl xtable-core -DskipTests -T 2
-mkdir -p demo/jars
-cp 
xtable-hudi-support/xtable-hudi-support-utils/target/xtable-hudi-support-utils-0.5.0-SNAPSHOT.jar
 demo/jars
-cp xtable-api/target/xtable-api-0.5.0-SNAPSHOT.jar demo/jars
-cp xtable-core/target/xtable-core_2.12-0.5.0-SNAPSHOT.jar demo/jars
+cd "$CURRENT_DIR"
+docker-compose down
 
-cd demo
-docker-compose up
+if [ "$1" = "--reset-data" ]; then
+  echo "Resetting demo datasets under demo/data..."
+  cd "$XTABLE_HOME"
+  git checkout -- demo/data
+  git clean -fdxq demo/data
+fi
diff --git a/demo/trino/catalog/delta.properties 
b/demo/trino/catalog/delta.properties
index 3da2a722..e05311ff 100644
--- a/demo/trino/catalog/delta.properties
+++ b/demo/trino/catalog/delta.properties
@@ -20,4 +20,5 @@ hive.metastore-cache-ttl=0s
 hive.metastore-refresh-interval=5s
 hive.metastore-timeout=10s
 delta.enable-non-concurrent-writes=true
-delta.security=ALLOW_ALL
\ No newline at end of file
+delta.security=ALLOW_ALL
+delta.register-table-procedure.enabled=true
diff --git a/demo/trino/catalog/iceberg.properties 
b/demo/trino/catalog/iceberg.properties
index d59b6861..e114ec60 100644
--- a/demo/trino/catalog/iceberg.properties
+++ b/demo/trino/catalog/iceberg.properties
@@ -19,4 +19,5 @@ hive.metastore.uri=thrift://hive-metastore:9083
 hive.metastore-cache-ttl=0s
 hive.metastore-refresh-interval=5s
 hive.metastore-timeout=10s
-iceberg.catalog.type=hive_metastore
\ No newline at end of file
+iceberg.catalog.type=hive_metastore
+iceberg.register-table-procedure.enabled=true
diff --git a/website/docs/demo/docker.md b/website/docs/demo/docker.md
index 1ee4754c..04100c55 100644
--- a/website/docs/demo/docker.md
+++ b/website/docs/demo/docker.md
@@ -26,6 +26,18 @@ cd demo
 ./start_demo.sh
 ```
 
+The containers run in the background; the script prints the Jupyter notebook 
URL once the server is up.
+The XTable jars are built automatically on the first run; they can also be 
(re)built explicitly with the
+`build_demo.sh` script.
+
+When you are done with the demo, stop and remove the containers with the 
`stop_demo.sh` script.
+Pass `--reset-data` to also restore the seed datasets under `demo/data` to 
their original state
+so the demo can be re-run from scratch.
+
+```shell md title="shell"
+./stop_demo.sh --reset-data
+```
+
 ### Accessing Services
 #### Trino
 You can access the local Trino container by running `docker exec -it trino 
trino`

Reply via email to