This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new ae1a05046c [#10983] test(lance): add Lance compatibility matrix
automation (#11119)
ae1a05046c is described below
commit ae1a05046c8ccc786dbce90ee3d60fda10f21d2f
Author: Qi Yu <[email protected]>
AuthorDate: Tue Jun 2 16:58:34 2026 +0800
[#10983] test(lance): add Lance compatibility matrix automation (#11119)
### What changes were proposed in this pull request?
This PR is stacked on #11060 and should be merged after #11060.
It adds a separate Lance compatibility matrix validation path:
- Add `:lance:lance-rest-server:lanceSparkMatrixTest` to run
`LanceSparkRESTServiceIT` against multiple `lance-spark-bundle`
versions.
- Add `:clients:client-python:lanceRayMatrixTest` and
`scripts/run_lance_ray_matrix.py` to run the Lance Ray integration test
against multiple `lance-ray` versions with isolated per-version
virtualenvs.
- Add a standalone `Lance Compatibility Matrix Test` GitHub Actions
workflow, triggered by `workflow_dispatch` and weekly schedule only.
- Document how to reproduce the Lance Spark and Lance Ray matrix
locally.
### Why are the changes needed?
The default PR validation should keep using a single pinned, known-good
Lance dependency combination so normal PR checks stay stable and
reasonably fast.
The full compatibility matrix is still useful for catching Lance
ecosystem compatibility drift, but it should run as a separate
automation path instead of being part of every PR validation.
Fix: #10983
### Does this PR introduce _any_ user-facing change?
No user-facing API change.
This adds developer/CI validation tooling and documentation for Lance
compatibility testing.
### How was this patch tested?
- `./gradlew :lance:lance-rest-server:spotlessApply
:clients:client-python:pylint -PskipITs`
- `./gradlew :lance:lance-rest-server:test -PskipITs`
- `git diff --cached --check`
The full multi-version matrix was not run locally because it downloads
and runs multiple external Lance dependency combinations.
---
.../workflows/lance-compatibility-matrix-test.yml | 96 ++++++
clients/client-python/build.gradle.kts | 73 +++++
clients/client-python/requirements-dev.txt | 8 +
clients/client-python/requirements-lance.txt | 8 +-
.../client-python/scripts/run_lance_ray_matrix.py | 355 +++++++++++++++++++++
.../tests/integration/test_lance_ray.py | 41 ++-
docs/lance-rest-integration.md | 57 +++-
lance/lance-rest-server/build.gradle.kts | 131 +++++++-
8 files changed, 727 insertions(+), 42 deletions(-)
diff --git a/.github/workflows/lance-compatibility-matrix-test.yml
b/.github/workflows/lance-compatibility-matrix-test.yml
new file mode 100644
index 0000000000..44d615f127
--- /dev/null
+++ b/.github/workflows/lance-compatibility-matrix-test.yml
@@ -0,0 +1,96 @@
+name: Lance Compatibility Matrix Test
+
+on:
+ workflow_dispatch:
+ inputs:
+ lance_spark_versions:
+ description: "Comma-separated lance-spark-bundle versions"
+ required: false
+ default: "0.2.0,0.4.0"
+ lance_ray_versions:
+ description: "Comma-separated lance-ray versions"
+ required: false
+ default: "0.4.2,0.3.0"
+ schedule:
+ # Run weekly on main so compatibility drift is caught outside PR
validation.
+ - cron: "0 18 * * 0"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lance-spark-matrix:
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ env:
+ LANCE_SPARK_VERSIONS: ${{ github.event.inputs.lance_spark_versions ||
'0.2.0,0.4.0' }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ java-version: 17
+ distribution: "temurin"
+ cache: "gradle"
+
+ - name: Free up disk space
+ run: dev/ci/util_free_space.sh
+
+ - name: Run Lance Spark compatibility matrix
+ id: lanceSparkMatrixTest
+ run: |
+ ./gradlew :lance:lance-rest-server:lanceSparkMatrixTest \
+ -PlanceSparkBundleVersions="${LANCE_SPARK_VERSIONS}" \
+ -PskipDockerTests=true \
+ -PskipWeb=true
+
+ - name: Upload Lance Spark matrix reports
+ uses: actions/upload-artifact@v7
+ if: ${{ failure() && steps.lanceSparkMatrixTest.outcome == 'failure' }}
+ with:
+ name: lance-spark-matrix-reports
+ path: |
+ lance/lance-rest-server/build/reports/lance-spark-matrix
+ lance/lance-rest-server/build/test-results/lance-spark-matrix
+ build/reports
+
+ lance-ray-matrix:
+ runs-on: ubuntu-latest
+ timeout-minutes: 120
+ env:
+ LANCE_RAY_VERSIONS: ${{ github.event.inputs.lance_ray_versions ||
'0.4.2,0.3.0' }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-java@v4
+ with:
+ java-version: 17
+ distribution: "temurin"
+ cache: "gradle"
+
+ - name: Free up disk space
+ run: dev/ci/util_free_space.sh
+
+ - name: Package Gravitino
+ run: ./gradlew compileDistribution -PskipWeb=true -x test
+
+ - name: Run Lance Ray compatibility matrix
+ id: lanceRayMatrixTest
+ run: |
+ ./gradlew :clients:client-python:lanceRayMatrixTest \
+ -PlanceRayVersions="${LANCE_RAY_VERSIONS}" \
+ -PskipDockerTests=true \
+ -PskipWeb=true
+
+ - name: Upload Lance Ray matrix reports
+ uses: actions/upload-artifact@v7
+ if: ${{ failure() && steps.lanceRayMatrixTest.outcome == 'failure' }}
+ with:
+ name: lance-ray-matrix-reports
+ path: |
+ clients/client-python/build/lance-ray-matrix
+ build/reports
+ integration-test/build/integration-test.log
+ distribution/package/logs/gravitino-server.out
+ distribution/package/logs/gravitino-server.log
diff --git a/clients/client-python/build.gradle.kts
b/clients/client-python/build.gradle.kts
index 8c80ae04d2..a0caf0d523 100644
--- a/clients/client-python/build.gradle.kts
+++ b/clients/client-python/build.gradle.kts
@@ -239,6 +239,79 @@ tasks {
finalizedBy(unitCoverageReport)
}
+ // Run tests/integration/test_lance_ray.py against multiple lance-ray
+ // versions. Each version is exercised inside its own venv under
+ // build/lance-ray-matrix/.venv-<version>/ (cached across runs).
+ // Override the matrix with `-PlanceRayVersions=0.4.2,0.3.0`.
+ // Override the bootstrap interpreter with
`-PlanceRayPython=/path/to/python`.
+ register("lanceRayMatrixTest") {
+ group = "verification"
+ description =
+ "Run tests/integration/test_lance_ray.py against multiple lance-ray " +
+ "versions. Override with -PlanceRayVersions=<csv> (default: " +
+ "tracks docs/lance-rest-integration.md Compatibility Matrix)."
+
+ val versions = project.findProperty("lanceRayVersions") as? String
+ val keepGoing = project.hasProperty("lanceRayKeepGoing")
+ // Bootstrap interpreter resolution (lowest to highest priority):
+ // 1. system `python3` on PATH (final fallback)
+ // 2. miniforge plugin's conda env (set up by `pythonPlugin {}` block
+ // above), if its `python` exists. This keeps the matrix consistent
+ // with the python toolchain the rest of the python client uses.
+ // 3. `-PlanceRayPython=/path/to/python` explicit override.
+ //
+ // The matrix script provisions its own per-version venvs from this
+ // interpreter, so all that's required is a Python 3 with the `venv`
+ // module — the choice does not affect what lance-ray sees at runtime.
+ val pythonVersion = project.rootProject.extra["pythonVersion"].toString()
+ val osDir = when {
+ org.gradle.internal.os.OperatingSystem.current().isMacOsX -> "MacOSX"
+ org.gradle.internal.os.OperatingSystem.current().isLinux -> "Linux"
+ else -> null
+ }
+ val condaPython = osDir?.let {
+
file("${project.rootDir}/.gradle/python/$it/Miniforge3/envs/python-$pythonVersion/bin/python")
+ }
+ val explicitPython =
+ (project.findProperty("lanceRayPython") as? String)?.takeIf {
it.isNotBlank() }
+ val pythonExecutable = when {
+ explicitPython != null -> explicitPython
+ condaPython != null && condaPython.exists() -> condaPython.absolutePath
+ else -> "python3"
+ }
+ val script = projectDir.resolve("scripts/run_lance_ray_matrix.py")
+ val gravitinoHome = file("${project.rootDir}/distribution/package")
+
+ doLast {
+ gravitinoServer("start")
+ try {
+ val args = mutableListOf(
+ pythonExecutable,
+ script.absolutePath,
+ "--python",
+ pythonExecutable,
+ "--gravitino-home",
+ gravitinoHome.absolutePath,
+ )
+ if (!versions.isNullOrBlank()) {
+ args += listOf("--versions", versions)
+ }
+ if (keepGoing) {
+ args += "--keep-going"
+ }
+ val proc = ProcessBuilder(args)
+ .inheritIO()
+ .start()
+ val exit = proc.waitFor()
+ if (exit != 0) {
+ throw GradleException("lance-ray matrix failed with exit code $exit")
+ }
+ } finally {
+ gravitinoServer("stop")
+ }
+ }
+ }
+
register("test", VenvTask::class) {
val skipUTs = project.hasProperty("skipTests")
val skipITs = project.hasProperty("skipITs")
diff --git a/clients/client-python/requirements-dev.txt
b/clients/client-python/requirements-dev.txt
index e4335e2a98..83a37fad97 100644
--- a/clients/client-python/requirements-dev.txt
+++ b/clients/client-python/requirements-dev.txt
@@ -33,3 +33,11 @@ jwcrypto==1.5.6
sphinx==7.1.2
furo==2024.8.6
banks==2.4.1
+
+# Lance integration deps. Pinned so the default integration test runs against
+# a single, known-good (server-side `lance-namespace-core` 0.7.5+) combination.
+# The multi-version matrix (`:clients:client-python:lanceRayMatrixTest`) keeps
+# its own per-version venvs and does not consume these pins.
+ray==2.55.1
+lance-ray==0.4.2
+lance-namespace==0.7.5
diff --git a/clients/client-python/requirements-lance.txt
b/clients/client-python/requirements-lance.txt
index 482aeef75f..58192eab05 100644
--- a/clients/client-python/requirements-lance.txt
+++ b/clients/client-python/requirements-lance.txt
@@ -16,9 +16,9 @@
# under the License.
# Lance integration deps. `lance-ray` owns the compatible `lance-namespace`
-# dependency, so do not pin `lance-namespace` here separately. Installed via
-# the `lance` extra (e.g. `pip install -e .[lance]`) so the heavy ray/pylance
-# native wheels don't slow down the default `dev` install used by lint and
-# unit-test tasks.
+# dependency, so do not pin `lance-namespace` here separately.
+# Installed via the `lance` extra (`pip install -e .[lance]`).
+# The same pins are also included in requirements-dev.txt so that the default
+# integration-test run works out of the box with `pip install -r
requirements-dev.txt`.
ray==2.55.1
lance-ray==0.4.2
diff --git a/clients/client-python/scripts/run_lance_ray_matrix.py
b/clients/client-python/scripts/run_lance_ray_matrix.py
new file mode 100644
index 0000000000..3a0fc94842
--- /dev/null
+++ b/clients/client-python/scripts/run_lance_ray_matrix.py
@@ -0,0 +1,355 @@
+#!/usr/bin/env python3
+# 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.
+
+"""Run ``tests/integration/test_lance_ray.py`` against multiple lance-ray
+versions to validate the supported range advertised in the Compatibility
+Matrix (``docs/lance-rest-integration.md``).
+
+For each version we provision a dedicated venv under
+``clients/client-python/build/lance-ray-matrix/.venv-<version>/`` and install
+``ray``, ``lance-ray==<version>``, ``lance-namespace``, ``requests``, plus the
+in-tree ``apache-gravitino`` distribution (editable). The unittest itself is
+launched per-version with ``python -m unittest -v
+tests.integration.test_lance_ray``; results are collected into a pass/fail
+table at the end.
+
+The caller is responsible for starting the Gravitino server (with the
+auxiliary lance-rest service enabled). The Gradle wrapper task
+``:clients:client-python:lanceRayMatrixTest`` handles that. For ad-hoc local
+use::
+
+ distribution/package/bin/gravitino.sh start
+ python3 clients/client-python/scripts/run_lance_ray_matrix.py \
+ --versions 0.4.2,0.3.0 \
+ --gravitino-home distribution/package
+ distribution/package/bin/gravitino.sh stop
+
+Each test class will append its own metalake binding to ``gravitino.conf`` and
+restart the server itself. The matrix runner opts into keeping that binding
+between versions, so back-to-back runs avoid unnecessary Gravitino restarts.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import os
+import subprocess
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from typing import List
+
+# Default version set tracks what the Compatibility Matrix in
+# docs/lance-rest-integration.md claims to support. Keep these in sync.
+DEFAULT_VERSIONS = ["0.4.2", "0.3.0"]
+
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+PYTHON_CLIENT_DIR = REPO_ROOT / "clients" / "client-python"
+DEFAULT_MATRIX_DIR = PYTHON_CLIENT_DIR / "build" / "lance-ray-matrix"
+DEFAULT_GRAVITINO_HOME = REPO_ROOT / "distribution" / "package"
+LANCE_REST_CONF = Path("conf") / "gravitino.conf"
+LANCE_REST_METALAKE_BINDING = (
+ "gravitino.lance-rest.gravitino-metalake = lance_ray_test_metalake"
+)
+
+
+@dataclass
+class VersionResult:
+ version: str
+ status: str # "ok", "fail", "setup-error"
+ details: str
+
+
+def parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ p.add_argument(
+ "--versions",
+ default=",".join(DEFAULT_VERSIONS),
+ help="Comma-separated list of lance-ray versions to test. "
+ f"Default: {','.join(DEFAULT_VERSIONS)}",
+ )
+ p.add_argument(
+ "--python",
+ default=sys.executable,
+ help="Path to the host python interpreter used to bootstrap each "
+ "version's venv. Default: %(default)s",
+ )
+ p.add_argument(
+ "--matrix-dir",
+ default=str(DEFAULT_MATRIX_DIR),
+ help="Directory under which per-version venvs are created and "
+ "cached across runs. Default: %(default)s",
+ )
+ p.add_argument(
+ "--gravitino-home",
+ default=str(DEFAULT_GRAVITINO_HOME),
+ help="Path to the built Gravitino distribution package. The "
+ "lance-rest aux service must be enabled there. Default: %(default)s",
+ )
+ p.add_argument(
+ "--ray-spec",
+ default="ray==2.55.1",
+ help="Pip spec for ray. Pinned by default so the matrix is "
+ "reproducible across runs and does not drift with PyPI. Override "
+ "(e.g. 'ray') to let pip pick a compatible version per lance-ray. "
+ "Default: %(default)s",
+ )
+ p.add_argument(
+ "--lance-namespace-spec",
+ default="lance-namespace==0.7.5",
+ help="Pip spec for lance-namespace. Pinned by default so the "
+ "matrix is reproducible and does not drift with PyPI. Override "
+ "(e.g. 'lance-namespace') for latest. Default: %(default)s",
+ )
+ p.add_argument(
+ "--keep-going",
+ action="store_true",
+ help="Continue to the next version after a failure instead of "
+ "stopping on the first failed run.",
+ )
+ return p.parse_args()
+
+
+def run(cmd: List[str], **kwargs) -> subprocess.CompletedProcess:
+ print(f"[matrix] $ {' '.join(cmd)}")
+ return subprocess.run(cmd, check=False, **kwargs)
+
+
+def ensure_venv(python: str, venv_dir: Path) -> Path:
+ """Create the venv if it doesn't already exist. Returns the venv python
path."""
+ venv_python = venv_dir / "bin" / "python"
+ if not venv_python.exists():
+ venv_dir.parent.mkdir(parents=True, exist_ok=True)
+ rc = run([python, "-m", "venv", str(venv_dir)]).returncode
+ if rc != 0:
+ raise RuntimeError(f"Failed to create venv at {venv_dir}")
+ return venv_python
+
+
+def _deps_sentinel(venv_dir: Path, version: str, ray_spec: str,
lance_namespace_spec: str) -> Path:
+ """Return the path to the sentinel file that marks a fully-installed venv.
+
+ The sentinel encodes all dep specs so any change in pinned versions forces
+ a reinstall, while an identical set of specs skips all pip invocations.
+ """
+ key = f"{version}|{ray_spec}|{lance_namespace_spec}"
+ digest = hashlib.sha1(key.encode(), usedforsecurity=False).hexdigest()[:12]
+ return venv_dir / f".deps-installed-{digest}"
+
+
+def install_deps(
+ venv_python: Path,
+ venv_dir: Path,
+ version: str,
+ ray_spec: str,
+ lance_namespace_spec: str,
+) -> None:
+ """Install all deps into the venv. Skips every pip call when a sentinel
+ file proves the identical set of packages was already installed — this
+ makes repeated matrix runs fast when the venv directory is cached (e.g.
+ in CI artifact caches or local re-runs).
+ """
+ sentinel = _deps_sentinel(venv_dir, version, ray_spec,
lance_namespace_spec)
+ if sentinel.exists():
+ print(f"[matrix] venv for lance-ray=={version} already populated,
skipping pip install")
+ return
+
+ rc = run(
+ [
+ str(venv_python),
+ "-m",
+ "pip",
+ "install",
+ "--upgrade",
+ "pip",
+ "wheel",
+ ]
+ ).returncode
+ if rc != 0:
+ raise RuntimeError("pip upgrade failed in venv")
+
+ rc = run(
+ [
+ str(venv_python),
+ "-m",
+ "pip",
+ "install",
+ ray_spec,
+ f"lance-ray=={version}",
+ lance_namespace_spec,
+ "requests",
+ ]
+ ).returncode
+ if rc != 0:
+ raise RuntimeError(f"Failed to install lance-ray=={version} deps")
+
+ rc = run(
+ [
+ str(venv_python),
+ "-m",
+ "pip",
+ "install",
+ "-e",
+ str(PYTHON_CLIENT_DIR),
+ ]
+ ).returncode
+ if rc != 0:
+ raise RuntimeError("Failed to install apache-gravitino in editable
mode")
+
+ sentinel.touch()
+
+
+def generate_version_ini(venv_python: Path) -> None:
+ # The python client reads gravitino/version.ini at runtime. It is
+ # produced by scripts/generate_version.py and is gitignored, so we
+ # regenerate it here to make the matrix runnable on fresh checkouts.
+ script = PYTHON_CLIENT_DIR / "scripts" / "generate_version.py"
+ rc = run(
+ [str(venv_python), str(script)],
+ cwd=str(PYTHON_CLIENT_DIR),
+ ).returncode
+ if rc != 0:
+ raise RuntimeError("Failed to generate version.ini for python client")
+
+
+def run_unittest(venv_python: Path, gravitino_home: Path) -> int:
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(PYTHON_CLIENT_DIR)
+ env["GRAVITINO_HOME"] = str(gravitino_home)
+ env["START_EXTERNAL_GRAVITINO"] = "true"
+ env["LANCE_RAY_KEEP_GRAVITINO_CONF"] = "true"
+ cmd = [
+ str(venv_python),
+ "-m",
+ "unittest",
+ "-v",
+ "tests.integration.test_lance_ray",
+ ]
+ print(f"[matrix] $ PYTHONPATH=... GRAVITINO_HOME=... {' '.join(cmd)}")
+ return subprocess.run(
+ cmd, cwd=str(PYTHON_CLIENT_DIR), env=env, check=False
+ ).returncode
+
+
+def count_lance_rest_binding(conf_path: Path) -> int:
+ if not conf_path.exists():
+ return 0
+ with conf_path.open(encoding="utf-8") as file:
+ return sum(1 for line in file if line.strip() ==
LANCE_REST_METALAKE_BINDING)
+
+
+def restore_lance_rest_binding_count(conf_path: Path, original_count: int) ->
None:
+ if not conf_path.exists():
+ return
+
+ lines = conf_path.read_text(encoding="utf-8").splitlines(keepends=True)
+ current_count = sum(
+ 1 for line in lines if line.strip() == LANCE_REST_METALAKE_BINDING
+ )
+ surplus_count = current_count - original_count
+ if surplus_count <= 0:
+ return
+
+ filtered_lines = []
+ removed_count = 0
+ for line in reversed(lines):
+ if (
+ removed_count < surplus_count
+ and line.strip() == LANCE_REST_METALAKE_BINDING
+ ):
+ removed_count += 1
+ continue
+ filtered_lines.append(line)
+
+ conf_path.write_text("".join(reversed(filtered_lines)), encoding="utf-8")
+ print(
+ "[matrix] removed "
+ f"{removed_count} lance-rest binding line(s) from {conf_path}"
+ )
+
+
+def main() -> int:
+ args = parse_args()
+ versions = [v.strip() for v in args.versions.split(",") if v.strip()]
+ if not versions:
+ print("--versions must contain at least one entry", file=sys.stderr)
+ return 2
+
+ matrix_dir = Path(args.matrix_dir).resolve()
+ gravitino_home = Path(args.gravitino_home).resolve()
+ if not (gravitino_home / "bin" / "gravitino.sh").exists():
+ print(
+ f"GRAVITINO_HOME={gravitino_home} does not look like a "
+ "Gravitino distribution package (missing bin/gravitino.sh). "
+ "Run `./gradlew compileDistribution -PskipWeb=true -x test`
first.",
+ file=sys.stderr,
+ )
+ return 2
+
+ conf_path = gravitino_home / LANCE_REST_CONF
+ original_binding_count = count_lance_rest_binding(conf_path)
+ results: List[VersionResult] = []
+ try:
+ for version in versions:
+ print(f"\n========== lance-ray=={version} ==========")
+ venv_dir = matrix_dir / f".venv-{version}"
+ try:
+ venv_python = ensure_venv(args.python, venv_dir)
+ install_deps(
+ venv_python,
+ venv_dir,
+ version,
+ args.ray_spec,
+ args.lance_namespace_spec,
+ )
+ generate_version_ini(venv_python)
+ except Exception as e: # pylint: disable=broad-exception-caught
+ print(f"[matrix] {version}: setup failed: {e}",
file=sys.stderr)
+ results.append(VersionResult(version, "setup-error", str(e)))
+ if not args.keep_going:
+ break
+ continue
+
+ rc = run_unittest(venv_python, gravitino_home)
+ if rc == 0:
+ print(f"[matrix] {version}: PASS")
+ results.append(VersionResult(version, "ok", "tests passed"))
+ else:
+ print(f"[matrix] {version}: FAIL (exit={rc})")
+ results.append(VersionResult(version, "fail", f"unittest exit
{rc}"))
+ if not args.keep_going:
+ break
+ finally:
+ restore_lance_rest_binding_count(conf_path, original_binding_count)
+
+ print("\n========== summary ==========")
+ width = max(len(r.version) for r in results) if results else 0
+ for r in results:
+ print(f" lance-ray=={r.version.ljust(width)} {r.status:11s}
{r.details}")
+
+ any_fail = any(r.status != "ok" for r in results)
+ return 1 if any_fail else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/clients/client-python/tests/integration/test_lance_ray.py
b/clients/client-python/tests/integration/test_lance_ray.py
index d94ee69d8f..593db2e694 100644
--- a/clients/client-python/tests/integration/test_lance_ray.py
+++ b/clients/client-python/tests/integration/test_lance_ray.py
@@ -44,6 +44,7 @@ LANCE_REST_BASE_URL =
f"http://localhost:{LANCE_REST_PORT}/lance"
# standalone lance-rest conf file.
MAIN_CONF_FILE = "conf/gravitino.conf"
LANCE_REST_METALAKE_KEY = "gravitino.lance-rest.gravitino-metalake"
+KEEP_GRAVITINO_CONF_ENV = "LANCE_RAY_KEEP_GRAVITINO_CONF"
def _missing_lance_ray_deps() -> Optional[str]:
@@ -64,9 +65,9 @@ _MISSING_LANCE_RAY_DEPS = _missing_lance_ray_deps()
@unittest.skipIf(
_MISSING_LANCE_RAY_DEPS is not None,
f"lance-ray test deps not installed: {_MISSING_LANCE_RAY_DEPS}. "
- "Install with: pip install -e .[lance] (or pip install ray lance-ray "
- "lance-namespace). Requires the Gravitino server to expose a lance-rest "
- "auxiliary service backed by lance-namespace-core >= 0.7.5.",
+ "Install with: pip install -r clients/client-python/requirements-dev.txt "
+ "(or pip install -e .[lance]). Requires the Gravitino server to expose a "
+ "lance-rest auxiliary service backed by lance-namespace-core >= 0.7.5.",
)
class TestLanceRayIntegration(IntegrationTestEnv):
"""End-to-end test for the lance-ray Python client against a
Gravitino-backed
@@ -74,9 +75,11 @@ class TestLanceRayIntegration(IntegrationTestEnv):
``read_lance`` flow from the upstream lance-ray docs.
"""
- # Metalake name is fixed because the lance-rest aux service binds to a
- # single metalake from gravitino.conf. The per-test table name still gets
- # a random suffix to keep individual test methods isolated.
+ # Metalake name is fixed (not randomized) so back-to-back runs in the
+ # same Gravitino process can detect that the lance-rest aux service is
+ # already bound and skip the costly server restart. The per-test table
+ # name still gets a random suffix to keep individual test methods
+ # isolated.
METALAKE_NAME: str = "lance_ray_test_metalake"
CATALOG_NAME: str = "lance_catalog"
SCHEMA_NAME: str = "schema"
@@ -99,8 +102,8 @@ class TestLanceRayIntegration(IntegrationTestEnv):
# Bind the lance-rest aux service to our test metalake. If the same
# binding is already present (e.g. an earlier run in the same Gradle
# session left it there), skip the conf write and the restart. This
- # avoids appending the same conf entry twice if a prior failed run
- # already left the binding behind.
+ # avoids restarting Gravitino in the middle of the IT suite when the
+ # test class is replayed, which would briefly disrupt other ITs.
if not cls._lance_metalake_already_bound():
cls._append_conf(cls._lance_rest_config(), cls.main_conf_path)
cls.appended_lance_rest_conf = True
@@ -119,7 +122,7 @@ class TestLanceRayIntegration(IntegrationTestEnv):
# so a skipped run leaves no fixtures behind.
skip_reason = cls._check_lance_namespace_compat()
if skip_reason is not None:
- cls._reset_lance_rest_conf()
+ cls._reset_lance_rest_conf_if_needed()
raise unittest.SkipTest(skip_reason)
cls.gravitino_admin_client =
GravitinoAdminClient("http://localhost:8090")
@@ -180,7 +183,7 @@ class TestLanceRayIntegration(IntegrationTestEnv):
failures.append(("drop metalake", e))
try:
- cls._reset_lance_rest_conf()
+ cls._reset_lance_rest_conf_if_needed()
except Exception as e: # pylint: disable=broad-exception-caught
failures.append(("reset lance-rest conf", e))
@@ -263,9 +266,19 @@ class TestLanceRayIntegration(IntegrationTestEnv):
return {LANCE_REST_METALAKE_KEY: cls.METALAKE_NAME}
@classmethod
- def _reset_lance_rest_conf(cls) -> None:
+ def _should_keep_lance_rest_conf(cls) -> bool:
+ return os.environ.get(KEEP_GRAVITINO_CONF_ENV, "").lower() == "true"
+
+ @classmethod
+ def _reset_lance_rest_conf_if_needed(cls) -> None:
if not cls.appended_lance_rest_conf or cls.main_conf_path is None:
return
+ if cls._should_keep_lance_rest_conf():
+ logger.info(
+ "Keeping lance-rest Gravitino conf because %s=true",
+ KEEP_GRAVITINO_CONF_ENV,
+ )
+ return
cls._reset_conf(cls._lance_rest_config(), cls.main_conf_path)
cls.appended_lance_rest_conf = False
cls.restart_server()
@@ -289,10 +302,8 @@ class TestLanceRayIntegration(IntegrationTestEnv):
def test_write_read_filter_via_lance_ray(self):
# Imports are deferred so the skipIf decorator handles missing deps
- # cleanly without import errors at module load time. The lance/ray
- # extras live in `requirements-lance.txt` (and `setup.py`'s `lance`
- # extra), so they aren't present in the default `dev` install used by
- # pylint — silence the resulting import-error.
+ # cleanly without import errors at module load time. The matrix runner
+ # also swaps lance-ray versions in isolated venvs, so keep imports
local.
# pylint: disable=import-outside-toplevel,import-error
import ray
from lance_ray import read_lance, write_lance
diff --git a/docs/lance-rest-integration.md b/docs/lance-rest-integration.md
index 8b87183de7..d0416fbb56 100644
--- a/docs/lance-rest-integration.md
+++ b/docs/lance-rest-integration.md
@@ -23,20 +23,63 @@ The following table outlines the tested compatibility
between Gravitino versions
| Gravitino Version (Lance REST) | Supported lance-spark Versions | Supported
lance-ray Versions |
|--------------------------------|--------------------------------|-----------------------------------------------|
| 1.1.1 - 1.2.1 | 0.0.10 - 0.0.15 | 0.0.6 -
0.0.8 |
-| 1.3.0 | 0.1.0 - 0.4.0 | 0.3.0 -
0.4.2, 0.2.0 supports with conditions |
+| 1.3.0 | {0.2.0, 0.4.0} | 0.3.0 -
0.4.2 (0.2.0 conditionally supported) |
:::note
-- These version ranges show which versions are expected to work together.
+- These version entries show which versions are expected to work together.
- For Gravitino 1.3.0, the explicitly verified release versions are
- `lance-spark` {0.1.0, 0.1.1, 0.2.0, 0.4.0} and `lance-ray`
- {0.3.0, 0.4.2}. By default, lance-ray 0.2.0 and earlier are *not* supported
on 1.3.0
- because pip resolves them with an older `lance-namespace` whose request
- schema is incompatible with the upgraded server-side `lance-namespace-core`
- (0.7.5+). But if you can still use lance-ray 0.2.0 with Gravitino 1.3.0 by
pining pylance to 3.x or 4.x;
+ `lance-spark` {0.2.0, 0.4.0} and `lance-ray` {0.3.0, 0.4.2}. `lance-ray`
+ 0.2.0 is conditionally supported only with the conditions described below.
+
+- **`lance-spark` 0.1.0 and 0.1.1 are not supported on Gravitino 1.3.0.**
+ Those bundles create tables by calling the legacy
+ `POST /lance/v1/table/{id}/create-empty` endpoint, which 1.3.0 no longer
+ exposes — the table-declaration path was consolidated onto
+ `POST /lance/v1/table/{id}/declare` (`LanceTableOperations#declareTable`)
+ when the deprecated `createEmptyTable` API was removed during the
+ `lance-namespace-core` 0.7.5 upgrade. Running 0.1.x against 1.3.0 surfaces
+ as `404 Not Found` on every table-creation flow; the small set of
+ list/describe-only tests still works, but any write path will fail.
+- **`lance-ray` 0.1.0 is not supported on Gravitino 1.3.0.** It exposes
+ `write_lance(... namespace=<LanceNamespace>)`, whereas the 0.2.0+ test path
+ uses the new `write_lance(... namespace_impl="rest",
+ namespace_properties={...})` signature. Calling it on 0.1.0 raises
+ `TypeError: write_lance() got an unexpected keyword argument
'namespace_impl'`.
+- **`lance-ray` 0.2.0 is conditionally supported on Gravitino 1.3.0.** It
+ matches the new signature, but at runtime
+ `lance_ray.utils.create_storage_options_provider` does
+ `from lance import LanceNamespaceStorageOptionsProvider`, which no longer
+ exists in the `pylance` 6.0.0 wheel that `lance-namespace==0.7.5` pulls in,
+ raising `ImportError: cannot import name
+ 'LanceNamespaceStorageOptionsProvider' from 'lance'`. You can use
+ `lance-ray` 0.2.0 with Gravitino 1.3.0 by pinning `pylance` to 3.x or 4.x.
- Before using in production, please test the exact connector versions in your
own environment.
- The Lance ecosystem is changing quickly, so some versions may introduce
breaking changes.
:::
+#### Reproducing the matrix locally
+
+Both connectors ship with a multi-version integration test driver so the
+matrix can be re-verified (and extended) without ad-hoc scripting:
+
+```bash
+# lance-spark — runs LanceSparkRESTServiceIT once per bundle version.
+# The default list intentionally omits 0.1.0 / 0.1.1: those bundles call the
+# removed /create-empty endpoint and will fail with 404 against 1.3.0+.
+./gradlew :lance:lance-rest-server:lanceSparkMatrixTest \
+ -PlanceSparkBundleVersions=0.2.0,0.4.0 \
+ -PskipDockerTests=true
+# Per-version JUnit reports land under
+# lance/lance-rest-server/build/reports/lance-spark-matrix/<version>/.
+
+# lance-ray — provisions a venv per version under
+# clients/client-python/build/lance-ray-matrix/.venv-<version>/ and runs
+# tests/integration/test_lance_ray.py against each. The Gradle wrapper
+# below starts / stops Gravitino automatically.
+./gradlew :clients:client-python:lanceRayMatrixTest \
+ -PlanceRayVersions=0.4.2,0.3.0
+```
+
### Why Maintain a Compatibility Matrix?
The Lance ecosystem is under active development, with frequent updates to APIs
and features. Gravitino's Lance REST service depends on specific connector
behaviors to ensure reliable operation. Using incompatible versions may result
in:
diff --git a/lance/lance-rest-server/build.gradle.kts
b/lance/lance-rest-server/build.gradle.kts
index 72f8577cc6..127eac64d5 100644
--- a/lance/lance-rest-server/build.gradle.kts
+++ b/lance/lance-rest-server/build.gradle.kts
@@ -28,13 +28,34 @@ val scalaVersion: String =
project.properties["scalaVersion"] as? String ?:
extra["defaultScalaVersion"].toString()
val sparkVersion: String = libs.versions.spark35.get()
val scalaCollectionCompatVersion: String =
libs.versions.scala.collection.compat.get()
-val lanceSparkBundleVersion = "0.4.0"
+// Comma-separated list of lance-spark-bundle versions to test against.
+// The default is the latest supported version; the integration test matrix
+// (`:lance:lance-rest-server:lanceSparkMatrixTest`) covers every version in
+// this list. Override via `-PlanceSparkBundleVersions=0.2.0,0.4.0`.
+val lanceSparkBundleVersions: List<String> =
+ ((project.properties["lanceSparkBundleVersions"] as? String) ?: "0.4.0")
+ .split(",").map { it.trim() }.filter { it.isNotEmpty() }.distinct()
+if (lanceSparkBundleVersions.isEmpty()) {
+ throw GradleException("lanceSparkBundleVersions must contain at least one
version")
+}
+val primaryLanceSparkBundleVersion: String = lanceSparkBundleVersions.first()
val lanceSparkBundleJarPathProperty = "gravitino.lance.spark.bundle.jar"
-val lanceSparkBundleDir = layout.buildDirectory.dir("lance-spark-bundle")
-val lanceSparkBundle by configurations.creating {
- isCanBeConsumed = false
- isCanBeResolved = true
- isTransitive = false
+
+fun lanceSparkBundleConfigName(version: String): String =
+ "lanceSparkBundle_" + version.replace(".", "_").replace("-", "_")
+fun lanceSparkBundleDirFor(version: String) =
+ layout.buildDirectory.dir("lance-spark-bundle/$version")
+fun lanceSparkPrepareTaskName(version: String): String =
+ "prepareLanceSparkBundle_" + version.replace(".", "_").replace("-", "_")
+fun lanceSparkTestTaskName(version: String): String =
+ "testLanceSparkBundle_" + version.replace(".", "_").replace("-", "_")
+
+lanceSparkBundleVersions.forEach { version ->
+ configurations.create(lanceSparkBundleConfigName(version)) {
+ isCanBeConsumed = false
+ isCanBeResolved = true
+ isTransitive = false
+ }
}
dependencies {
@@ -84,10 +105,12 @@ dependencies {
testImplementation(project(":integration-test-common", "testArtifacts"))
testImplementation(libs.lance)
- add(
- lanceSparkBundle.name,
- "org.lance:lance-spark-bundle-3.5_2.12:$lanceSparkBundleVersion"
- )
+ lanceSparkBundleVersions.forEach { version ->
+ add(
+ lanceSparkBundleConfigName(version),
+ "org.lance:lance-spark-bundle-3.5_2.12:$version"
+ )
+ }
testImplementation("org.scala-lang.modules:scala-collection-compat_$scalaVersion:$scalaCollectionCompatVersion")
testImplementation("org.apache.spark:spark-sql_$scalaVersion:$sparkVersion")
{
@@ -123,10 +146,17 @@ tasks {
from(configurations.runtimeClasspath)
into("build/libs")
}
- val prepareLanceSparkBundle by registering(Sync::class) {
- from(lanceSparkBundle)
- into(lanceSparkBundleDir)
+ // One Sync task per lance-spark-bundle version. Each task lays down its
+ // bundle jar under build/lance-spark-bundle/<version>/ so per-version Test
+ // tasks pick up the right jar without colliding.
+ lanceSparkBundleVersions.forEach { version ->
+ register<Sync>(lanceSparkPrepareTaskName(version)) {
+ from(configurations.getByName(lanceSparkBundleConfigName(version)))
+ into(lanceSparkBundleDirFor(version))
+ }
}
+ val primaryPrepareLanceSparkBundle =
+ named(lanceSparkPrepareTaskName(primaryLanceSparkBundleVersion))
jar {
finalizedBy(copyDepends)
@@ -157,13 +187,14 @@ tasks {
}
test {
- dependsOn(prepareLanceSparkBundle)
+ dependsOn(primaryPrepareLanceSparkBundle)
+ val primaryBundleDir =
lanceSparkBundleDirFor(primaryLanceSparkBundleVersion)
doFirst {
val bundleJar =
- lanceSparkBundleDir.get().asFile.listFiles()?.singleOrNull {
it.extension == "jar" }
+ primaryBundleDir.get().asFile.listFiles()?.singleOrNull { it.extension
== "jar" }
?: throw GradleException(
- "Expected exactly one Lance Spark bundle jar in
${lanceSparkBundleDir.get().asFile}"
+ "Expected exactly one Lance Spark bundle jar in
${primaryBundleDir.get().asFile}"
)
systemProperty(lanceSparkBundleJarPathProperty, bundleJar.absolutePath)
}
@@ -173,6 +204,74 @@ tasks {
dependsOn(":catalogs:catalog-lakehouse-generic:jar")
}
}
+
+ // Per-version Test task that only runs LanceSparkRESTServiceIT against a
+ // specific lance-spark-bundle. Each task downloads its bundle through the
+ // matching Sync task and points the IT JVM at it via system property.
+ lanceSparkBundleVersions.forEach { version ->
+ register<Test>(lanceSparkTestTaskName(version)) {
+ group = "verification"
+ description =
+ "Run LanceSparkRESTServiceIT against lance-spark-bundle $version"
+
+ dependsOn(named(lanceSparkPrepareTaskName(version)))
+ dependsOn(named("jar"))
+ val versionTestMode = project.properties["testMode"] as? String ?:
"embedded"
+ if (versionTestMode == "embedded") {
+ dependsOn(":catalogs:catalog-lakehouse-generic:jar")
+ }
+
+ testClassesDirs = sourceSets["test"].output.classesDirs
+ classpath = sourceSets["test"].runtimeClasspath
+ useJUnitPlatform()
+ filter { includeTestsMatching("*LanceSparkRESTServiceIT*") }
+
+ val versionBundleDir = lanceSparkBundleDirFor(version)
+ doFirst {
+ val bundleJar =
+ versionBundleDir.get().asFile.listFiles()?.singleOrNull {
it.extension == "jar" }
+ ?: throw GradleException(
+ "Expected exactly one Lance Spark bundle jar in " +
+ "${versionBundleDir.get().asFile} for version $version"
+ )
+ systemProperty(lanceSparkBundleJarPathProperty, bundleJar.absolutePath)
+ println("[lance-spark-matrix] running IT against bundle $version ->
${bundleJar.name}")
+ }
+
+ // Send per-version reports to a separate directory so a matrix run
+ // doesn't overwrite results across versions.
+ val versionSlug = version.replace(".", "_").replace("-", "_")
+ reports {
+ html.outputLocation.set(
+ layout.buildDirectory.dir("reports/lance-spark-matrix/$versionSlug")
+ )
+ junitXml.outputLocation.set(
+
layout.buildDirectory.dir("test-results/lance-spark-matrix/$versionSlug")
+ )
+ }
+ }
+ }
+
+ register("lanceSparkMatrixTest") {
+ group = "verification"
+ description =
+ "Run LanceSparkRESTServiceIT against every version in
-PlanceSparkBundleVersions " +
+ "(default: $primaryLanceSparkBundleVersion). Reports land under " +
+ "build/reports/lance-spark-matrix/<version>/."
+ dependsOn(
+ lanceSparkBundleVersions.map { named(lanceSparkTestTaskName(it)) }
+ )
+ }
+
+ // Force serial execution: each version spins up an embedded MiniGravitino
on a
+ // dynamically chosen port. findAvailablePort is a scan, not an atomic
OS-level
+ // reservation (TOCTOU), so concurrent tasks can race to bind the same port
and
+ // produce intermittent "address already in use" failures under --parallel.
+ lanceSparkBundleVersions.zipWithNext { a, b ->
+ named(lanceSparkTestTaskName(b)) {
+ mustRunAfter(named(lanceSparkTestTaskName(a)))
+ }
+ }
}
tasks.test {