diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml
index 816d9f7be90..d647afabf81 100644
--- a/.github/workflows/core.yml
+++ b/.github/workflows/core.yml
@@ -92,7 +92,7 @@ jobs:
matrix:
java: [ 11 ]
env:
- INTERPRETERS: 'hbase,jdbc,file,flink-cmd,cassandra,elasticsearch,bigquery,livy,groovy,java,neo4j,sparql,mongodb,influxdb,shell'
+ INTERPRETERS: 'hbase,jdbc,file,flink-cmd,cassandra,elasticsearch,bigquery,livy,groovy,java,neo4j,sparql,mongodb,influxdb,shell,spark-connect'
steps:
- name: Checkout
uses: actions/checkout@v5
diff --git a/pom.xml b/pom.xml
index a8e8a7f8f88..e9e2ee8669e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -60,6 +60,7 @@
zeppelin-jupyter-interpreter-shaded
groovy
spark
+ spark-connect
spark-submit
markdown
mongodb
diff --git a/spark-connect/README.md b/spark-connect/README.md
new file mode 100644
index 00000000000..afd4a6fb6e9
--- /dev/null
+++ b/spark-connect/README.md
@@ -0,0 +1,255 @@
+# Spark Connect Interpreter for Apache Zeppelin
+
+## What is Spark Connect?
+
+Spark Connect (Spark 3.5+) is a new client-server architecture for Apache Spark that decouples the Spark client from the Spark cluster. Unlike the traditional `spark` interpreter which requires running Spark in the same JVM process, Spark Connect is a **thin gRPC client** that communicates with a remote Spark cluster via the `sc://host:port` connection string.
+
+## Why Use Spark Connect?
+
+- **No local Spark installation** — Zeppelin doesn't need the full Spark distribution on its host
+- **Remote cluster support** — Connect to any Spark 3.5+ cluster over the network
+- **Token authentication** — Support for token-based auth and SSL
+- **Multi-user isolation** — Per-user session quotas prevent resource exhaustion
+- **Cleaner deployments** — Simpler Docker images, reduced memory footprint
+
+## Differences from the Legacy Spark Interpreter
+
+| Feature | Spark Interpreter | Spark Connect Interpreter |
+|---------|-------------------|---------------------------|
+| **Architecture** | In-process SparkContext | Remote gRPC client |
+| **Installation** | Requires full Spark on host | Only Spark Connect client JAR needed |
+| **Scala support** | Yes (via embedded Scala interpreter) | No (client-only protocol) |
+| **R support** | Yes (SparkR) | No (not supported) |
+| **ZeppelinContext** | Full support (`z.show()`, Angular) | Returns `null` |
+| **Multi-user** | Global shared SparkContext | Isolated sessions per user |
+| **Session quota** | Not enforced | Per-user quota (default: 5) |
+
+## Prerequisites
+
+1. **Spark 3.5.x cluster** running the Spark Connect server
+ ```bash
+ # Start a Spark Connect server on port 15002
+ spark-shell --master --conf spark.connect.grpc.binding.port=15002
+ ```
+
+2. **Python 3.x** (for PySpark/IPySpark support)
+
+## Configuration Properties
+
+The Spark Connect interpreter supports the following configuration properties in the Zeppelin UI:
+
+### Connection Settings
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `zeppelin.spark.connect.url` | string | `sc://localhost:15002` | Spark Connect server URL |
+| `zeppelin.spark.connect.token` | string | `` | Optional token for authentication (redacted in logs) |
+| `zeppelin.spark.connect.use_ssl` | checkbox | false | Enable SSL/TLS for connection |
+| `zeppelin.spark.connect.user_id` | string | `` | User ID to report to Spark Connect server |
+
+### Session Management
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `zeppelin.spark.connect.maxSessionsPerUser` | number | 5 | Maximum concurrent sessions per user |
+
+### Execution
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `zeppelin.spark.maxResult` | number | 10000 | Maximum result rows to fetch |
+| `zeppelin.spark.concurrentSQL` | checkbox | false | Allow concurrent SQL execution (within notebook) |
+| `zeppelin.spark.concurrentSQL.max` | number | 10 | Max concurrent SQL threads |
+
+### PySpark
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `zeppelin.python` | string | `python` | Python executable path |
+| `zeppelin.pyspark.useIPython` | checkbox | true | Use IPython if available |
+
+## Usage
+
+### SQL Mode (Default)
+
+```sql
+%spark-connect
+
+SELECT * FROM my_table LIMIT 10
+```
+
+### SQL with Concurrent Execution
+
+```sql
+%spark-connect.sql
+
+-- This uses the concurrentSQL scheduler
+SELECT COUNT(*) FROM large_table
+```
+
+### PySpark
+
+```python
+%spark-connect.pyspark
+
+df = spark.sql("SELECT * FROM my_table")
+df.show()
+```
+
+### IPython PySpark
+
+```python
+%spark-connect.ipyspark
+
+# Full IPython REPL with Spark
+df = spark.sql("SELECT COUNT(*) FROM table")
+df.collect()
+```
+
+## Architecture
+
+### SparkConnectInterpreter
+- Core interpreter managing the remote Spark session
+- Enforces per-user session quota via `ConcurrentHashMap`
+- Uses `NotebookLockManager` for per-notebook sequential execution
+- Delegates SQL parsing to `SqlSplitter` for multi-statement support
+
+### SparkConnectSqlInterpreter
+- SQL-only frontend with optional concurrent scheduler
+- Shares the Spark session with `SparkConnectInterpreter`
+
+### PySparkConnectInterpreter
+- Uses PySpark's native Spark Connect client (`SparkSession.builder.remote(...)`)
+- Forwards the connection URI to Python via the `SPARK_REMOTE` env var; no Py4j bridge to the Java SparkSession
+- Python opens an **independent** Spark Connect session against the same gRPC server as the Java/SQL interpreter — cross-language sharing flows through catalog tables/views, not in-memory session state
+- Supports the same Python executable resolution as Spark's own PySpark
+
+### IPySparkConnectInterpreter
+- IPython variant of PySpark
+- Same native-client model as `PySparkConnectInterpreter`
+
+### SparkConnectUtils
+- Stateless utilities for:
+ - Building Spark Connect URIs with token/SSL/user_id params
+ - Formatting DataFrames as Zeppelin `%table` output
+ - Streaming large result sets to avoid memory overflow
+
+### NotebookLockManager
+- Per-notebook `ReentrantLock` registry (fair FIFO ordering)
+- Ensures sequential query execution within a single notebook
+- Prevents concurrent modifications to shared notebook state
+
+## Session Isolation and Multi-User Support
+
+Each user gets **isolated Spark sessions** tracked in a global `ConcurrentHashMap`:
+- Username extracted from Zeppelin auth (falls back to `"anonymous"`)
+- Per-user quota enforced (`maxSessionsPerUser`, default 5)
+- Prevents runaway session proliferation
+
+Within a notebook, a fair `ReentrantLock` ensures:
+- Only one query executes at a time (even with `concurrentSQL=true`)
+- FIFO ordering prevents starvation
+
+## Testing
+
+### Unit Tests (No Spark Server Required)
+
+Tests for `SparkConnectUtils` utility class:
+```bash
+mvn test -pl spark-connect -Dtest=SparkConnectUtilsTest
+```
+
+### Integration Tests (Requires Spark Connect Server)
+
+Full interpreter tests with a live Spark server:
+```bash
+SPARK_CONNECT_TEST_REMOTE=sc://localhost:15002 \
+mvn test -pl spark-connect
+```
+
+Only integration tests are executed when `SPARK_CONNECT_TEST_REMOTE` is set.
+
+## Limitations
+
+1. **No Scala interpreter** — Spark Connect is a client-only protocol; embedded Scala REPL not supported
+2. **No R support** — `%spark.r` and `%spark.ir` not available
+3. **No ZeppelinContext** — `z.show()`, Angular widgets, and other Zeppelin-specific features return `null`
+4. **Spark 3.5.x only** — The gRPC protocol is version-locked to Spark 3.5
+5. **No progress tracking** — Job progress API always returns 0
+6. **Python and Java/SQL use separate Spark Connect sessions** — they connect to the same Spark Connect server, so data is shared through catalog tables (e.g. `createOrReplaceTempView` on a Hive/managed table, or persisting via `spark.sql("CREATE TABLE ...")`), but **session-local** temp views created in Python are not visible to `%spark-connect` SQL paragraphs, and vice versa
+
+## Dependency Shading
+
+The module uses Maven Shade Plugin to relocate conflicting dependencies:
+- `io.netty` → `org.apache.zeppelin.spark.connect.io.netty`
+- `com.google` → `org.apache.zeppelin.spark.connect.com.google`
+- `io.grpc` → `org.apache.zeppelin.spark.connect.io.grpc`
+
+This prevents classpath conflicts with Zeppelin Server's own Netty and other interpreters.
+
+## Examples
+
+### Connect to a Remote Spark Cluster
+
+Configure in Zeppelin UI:
+- **URL**: `sc://spark-server.example.com:15002`
+- **Token**: `your-auth-token` (if required)
+- **Use SSL**: Enable if cluster uses TLS
+
+### Run Multi-Statement SQL
+
+```sql
+%spark-connect
+
+CREATE OR REPLACE TEMP VIEW my_view AS
+ SELECT * FROM source_table WHERE year = 2024;
+
+SELECT COUNT(*) FROM my_view;
+```
+
+### PySpark with Pandas
+
+```python
+%spark-connect.pyspark
+
+# Create a Spark DataFrame and convert to Pandas
+import pandas as pd
+df_spark = spark.sql("SELECT * FROM my_table")
+df_pandas = df_spark.toPandas()
+print(df_pandas.head())
+```
+
+### Inspect DataFrame Schema
+
+```python
+%spark-connect.pyspark
+
+df = spark.sql("SELECT * FROM events LIMIT 1")
+df.explain() # Logical and physical plan
+df.printSchema() # Column names and types
+```
+
+## Troubleshooting
+
+### Connection Refused
+
+- Ensure Spark Connect server is running: `spark-shell --master --conf spark.connect.grpc.binding.port=15002`
+- Verify network connectivity and firewall rules
+- Check `zeppelin.spark.connect.url` configuration
+
+### Authentication Failures
+
+- Verify `zeppelin.spark.connect.token` matches server token requirements
+- Enable `zeppelin.spark.connect.use_ssl` if cluster uses TLS
+
+### Out of Memory
+
+- Use `zeppelin.spark.maxResult` to limit rows fetched
+- Use `spark.sql(...).limit(n)` in queries to reduce data transfer
+- Enable `zeppelin.spark.connect.use_ssl` to stream results instead of collecting
+
+## References
+
+- [Apache Spark Connect Documentation](https://spark.apache.org/docs/latest/spark-connect-overview.html)
+- [Spark Connect Protocol](https://spark.apache.org/docs/latest/spark-connect-introduction.html)
+- [Zeppelin Interpreter Development](https://zeppelin.apache.org/docs/latest/usage/interpreter/interpreter_binding_mode.html)
diff --git a/spark-connect/pom.xml b/spark-connect/pom.xml
new file mode 100644
index 00000000000..cb2a5bd9dc3
--- /dev/null
+++ b/spark-connect/pom.xml
@@ -0,0 +1,130 @@
+
+
+
+
+ 4.0.0
+
+
+ zeppelin-interpreter-parent
+ org.apache.zeppelin
+ 0.11.2
+ ../zeppelin-interpreter-parent/pom.xml
+
+
+ spark-connect-interpreter
+ jar
+ Zeppelin: Spark Connect Interpreter
+ Zeppelin Spark Connect support via gRPC client
+
+
+ spark-connect
+ 3.5.3
+ 2.12
+
+
+
+
+ org.apache.spark
+ spark-connect-client-jvm_${spark.scala.binary.version}
+ ${spark.connect.version}
+
+
+
+ org.apache.zeppelin
+ zeppelin-python
+ ${project.version}
+
+
+
+ org.apache.commons
+ commons-lang3
+
+
+
+ org.mockito
+ mockito-core
+ test
+
+
+
+
+
+
+ maven-resources-plugin
+
+
+ maven-shade-plugin
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
+
+
+
+ reference.conf
+
+
+
+
+ org.apache.zeppelin:zeppelin-interpreter-shaded
+
+
+
+
+ io.netty
+ org.apache.zeppelin.spark.connect.io.netty
+
+
+ com.google
+ org.apache.zeppelin.spark.connect.com.google
+
+
+ io.grpc
+ org.apache.zeppelin.spark.connect.io.grpc
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-checkstyle-plugin
+
+
+
+
+
+
+ spark-connect-3.5
+
+ true
+
+
+ 3.5.3
+
+
+
+
+
diff --git a/spark-connect/src/main/java/org/apache/zeppelin/spark/IPySparkConnectInterpreter.java b/spark-connect/src/main/java/org/apache/zeppelin/spark/IPySparkConnectInterpreter.java
new file mode 100644
index 00000000000..73e5fb23211
--- /dev/null
+++ b/spark-connect/src/main/java/org/apache/zeppelin/spark/IPySparkConnectInterpreter.java
@@ -0,0 +1,113 @@
+/*
+ * 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.zeppelin.spark;
+
+import org.apache.zeppelin.interpreter.InterpreterContext;
+import org.apache.zeppelin.interpreter.InterpreterException;
+import org.apache.zeppelin.python.IPythonInterpreter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Properties;
+
+/**
+ * PySpark Connect Interpreter which uses IPython underlying.
+ * Uses PySpark's native Spark Connect client — IPython opens its own
+ * Spark Connect session pointed at the same gRPC server as the Java/SQL
+ * interpreter. No Py4j bridge.
+ */
+public class IPySparkConnectInterpreter extends IPythonInterpreter {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(IPySparkConnectInterpreter.class);
+
+ private PySparkConnectInterpreter pySparkConnectInterpreter;
+ private boolean opened = false;
+ private InterpreterContext curIntpContext;
+
+ public IPySparkConnectInterpreter(Properties property) {
+ super(property);
+ }
+
+ @Override
+ public synchronized void open() throws InterpreterException {
+ if (opened) {
+ return;
+ }
+
+ this.pySparkConnectInterpreter =
+ getInterpreterInTheSameSessionByClassName(PySparkConnectInterpreter.class, false);
+
+ if (pySparkConnectInterpreter != null) {
+ setProperty("zeppelin.python", pySparkConnectInterpreter.getPythonExec());
+ }
+ setAdditionalPythonInitFile("python/zeppelin_sparkconnect.py");
+ super.open();
+ opened = true;
+ }
+
+ @Override
+ protected Map setupKernelEnv() throws IOException {
+ Map envs = super.setupKernelEnv();
+ String remote = SparkConnectUtils.buildConnectionString(getProperties(), getUserName());
+ envs.put("SPARK_REMOTE", remote);
+ LOGGER.info("Set SPARK_REMOTE for IPython native Spark Connect client: {}",
+ remote.replaceAll("token=[^;]*", "token=[REDACTED]")
+ .replaceAll("user_id=[^;]*", "user_id=[REDACTED]"));
+ return envs;
+ }
+
+ @Override
+ public org.apache.zeppelin.interpreter.InterpreterResult interpret(String st,
+ InterpreterContext context) throws InterpreterException {
+ InterpreterContext.set(context);
+ this.curIntpContext = context;
+ String setInptContextStmt = "intp.setInterpreterContextInPython()";
+ org.apache.zeppelin.interpreter.InterpreterResult result =
+ super.interpret(setInptContextStmt, context);
+ if (result.code().equals(org.apache.zeppelin.interpreter.InterpreterResult.Code.ERROR)) {
+ return new org.apache.zeppelin.interpreter.InterpreterResult(
+ org.apache.zeppelin.interpreter.InterpreterResult.Code.ERROR,
+ "Fail to setCurIntpContext");
+ }
+
+ return super.interpret(st, context);
+ }
+
+ public void setInterpreterContextInPython() {
+ InterpreterContext.set(curIntpContext);
+ }
+
+ @Override
+ public void close() throws InterpreterException {
+ LOGGER.info("Close IPySparkConnectInterpreter (opened={})", opened);
+ try {
+ super.close();
+ } finally {
+ opened = false;
+ pySparkConnectInterpreter = null;
+ LOGGER.info("IPySparkConnectInterpreter closed and state reset — ready for re-open");
+ }
+ }
+
+ @Override
+ public int getProgress(InterpreterContext context) throws InterpreterException {
+ return 0;
+ }
+}
diff --git a/spark-connect/src/main/java/org/apache/zeppelin/spark/NotebookLockManager.java b/spark-connect/src/main/java/org/apache/zeppelin/spark/NotebookLockManager.java
new file mode 100644
index 00000000000..8f39e937cc9
--- /dev/null
+++ b/spark-connect/src/main/java/org/apache/zeppelin/spark/NotebookLockManager.java
@@ -0,0 +1,63 @@
+/*
+ * 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.zeppelin.spark;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Shared utility class for managing notebook-level locks.
+ * Ensures that only one query executes at a time per notebook,
+ * regardless of which interpreter (SparkConnectInterpreter or SparkConnectSqlInterpreter) is used.
+ */
+public class NotebookLockManager {
+
+ // Locks per notebook to ensure one query at a time per notebook
+ private static final ConcurrentHashMap notebookLocks =
+ new ConcurrentHashMap<>();
+
+ /**
+ * Get or create a lock for the specified notebook.
+ * Uses fair locking to ensure FIFO ordering of query execution.
+ *
+ * @param noteId The notebook ID
+ * @return The lock for this notebook
+ */
+ public static ReentrantLock getNotebookLock(String noteId) {
+ return notebookLocks.computeIfAbsent(noteId,
+ k -> new ReentrantLock(true)); // Fair lock for FIFO ordering
+ }
+
+ /**
+ * Remove the lock for a notebook (cleanup when notebook is closed).
+ *
+ * @param noteId The notebook ID
+ */
+ public static void removeNotebookLock(String noteId) {
+ notebookLocks.remove(noteId);
+ }
+
+ /**
+ * Get the number of active notebook locks (for monitoring/debugging).
+ *
+ * @return The number of active locks
+ */
+ public static int getActiveLockCount() {
+ return notebookLocks.size();
+ }
+}
diff --git a/spark-connect/src/main/java/org/apache/zeppelin/spark/PySparkConnectInterpreter.java b/spark-connect/src/main/java/org/apache/zeppelin/spark/PySparkConnectInterpreter.java
new file mode 100644
index 00000000000..672fe7678d9
--- /dev/null
+++ b/spark-connect/src/main/java/org/apache/zeppelin/spark/PySparkConnectInterpreter.java
@@ -0,0 +1,180 @@
+/*
+ * 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.zeppelin.spark;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.zeppelin.interpreter.InterpreterContext;
+import org.apache.zeppelin.interpreter.InterpreterException;
+import org.apache.zeppelin.python.PythonInterpreter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Properties;
+
+/**
+ * PySpark interpreter for Spark Connect.
+ * Uses PySpark's native Spark Connect client — Python opens its own
+ * Spark Connect session pointed at the same gRPC server as the Java/SQL
+ * interpreter. No Py4j bridge.
+ */
+public class PySparkConnectInterpreter extends PythonInterpreter {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PySparkConnectInterpreter.class);
+
+ private InterpreterContext curIntpContext;
+
+ public PySparkConnectInterpreter(Properties property) {
+ super(property);
+ }
+
+ @Override
+ public void open() throws InterpreterException {
+ setProperty("zeppelin.python.useIPython",
+ getProperty("zeppelin.pyspark.connect.useIPython", "true"));
+
+ String pythonExec = getPythonExec();
+ LOGGER.info("Python executable resolved: {}", pythonExec);
+
+ super.open();
+
+ if (!useIPython()) {
+ try {
+ bootstrapInterpreter("python/zeppelin_sparkconnect.py");
+ } catch (IOException e) {
+ LOGGER.error("Fail to bootstrap spark connect", e);
+ throw new InterpreterException("Fail to bootstrap spark connect", e);
+ }
+ }
+ }
+
+ @Override
+ public void close() throws InterpreterException {
+ LOGGER.info("Close PySparkConnectInterpreter");
+ super.close();
+ }
+
+ @Override
+ protected org.apache.zeppelin.python.IPythonInterpreter getIPythonInterpreter()
+ throws InterpreterException {
+ return getInterpreterInTheSameSessionByClassName(IPySparkConnectInterpreter.class, false);
+ }
+
+ @Override
+ public org.apache.zeppelin.interpreter.InterpreterResult interpret(String st,
+ InterpreterContext context) throws InterpreterException {
+ curIntpContext = context;
+ return super.interpret(st, context);
+ }
+
+ @Override
+ protected void preCallPython(InterpreterContext context) {
+ callPython(new PythonInterpretRequest(
+ "intp.setInterpreterContextInPython()", false, false));
+ }
+
+ public void setInterpreterContextInPython() {
+ InterpreterContext.set(curIntpContext);
+ }
+
+ @Override
+ protected Map setupPythonEnv() throws IOException {
+ Map env = super.setupPythonEnv();
+
+ String pythonExec = getPythonExec();
+ env.put("PYSPARK_PYTHON", pythonExec);
+ LOGGER.info("Set PYSPARK_PYTHON: {}", pythonExec);
+
+ String remote = SparkConnectUtils.buildConnectionString(getProperties(), getUserName());
+ env.put("SPARK_REMOTE", remote);
+ LOGGER.info("Set SPARK_REMOTE for PySpark native Spark Connect client: {}",
+ remote.replaceAll("token=[^;]*", "token=[REDACTED]")
+ .replaceAll("user_id=[^;]*", "user_id=[REDACTED]"));
+
+ setupCondaLibraryPath(env, pythonExec);
+
+ LOGGER.info("LD_LIBRARY_PATH: {}", env.get("LD_LIBRARY_PATH"));
+ return env;
+ }
+
+ /**
+ * Get Python executable following Spark's PySpark detection pattern exactly:
+ * 1. spark.pyspark.driver.python (from Spark Connect properties)
+ * 2. spark.pyspark.python (from Spark Connect properties)
+ * 3. PYSPARK_DRIVER_PYTHON (environment variable)
+ * 4. PYSPARK_PYTHON (environment variable)
+ * 5. zeppelin.python (Zeppelin property) - if set, validate it
+ * 6. Default to "python" (let system PATH handle it, just like Spark does)
+ */
+ @Override
+ protected String getPythonExec() {
+ String driverPython = getProperty("spark.pyspark.driver.python", "");
+ if (StringUtils.isNotBlank(driverPython)) {
+ LOGGER.info("Using Python executable from spark.pyspark.driver.python: {}", driverPython);
+ return driverPython;
+ }
+
+ String pysparkPython = getProperty("spark.pyspark.python", "");
+ if (StringUtils.isNotBlank(pysparkPython)) {
+ LOGGER.info("Using Python executable from spark.pyspark.python: {}", pysparkPython);
+ return pysparkPython;
+ }
+
+ String envDriverPython = System.getenv("PYSPARK_DRIVER_PYTHON");
+ if (StringUtils.isNotBlank(envDriverPython)) {
+ LOGGER.info("Using Python executable from PYSPARK_DRIVER_PYTHON: {}", envDriverPython);
+ return envDriverPython;
+ }
+
+ String envPysparkPython = System.getenv("PYSPARK_PYTHON");
+ if (StringUtils.isNotBlank(envPysparkPython)) {
+ LOGGER.info("Using Python executable from PYSPARK_PYTHON: {}", envPysparkPython);
+ return envPysparkPython;
+ }
+
+ String zeppelinPython = getProperty("zeppelin.python", "");
+ if (StringUtils.isNotBlank(zeppelinPython)) {
+ LOGGER.info("Using Python executable from zeppelin.python property: {}", zeppelinPython);
+ return zeppelinPython;
+ }
+
+ LOGGER.info("No Python executable configured, defaulting to 'python' (will use system PATH)");
+ return "python";
+ }
+
+ private void setupCondaLibraryPath(Map env, String pythonExec) {
+ if (pythonExec != null && pythonExec.contains("/conda/")) {
+ int binIndex = pythonExec.indexOf("/bin/");
+ if (binIndex > 0) {
+ String condaBase = pythonExec.substring(0, binIndex);
+ String condaLib = condaBase + "/lib";
+ java.io.File libDir = new java.io.File(condaLib);
+ if (libDir.exists() && libDir.isDirectory()) {
+ String ldLibraryPath = env.getOrDefault("LD_LIBRARY_PATH", "");
+ if (ldLibraryPath.isEmpty()) {
+ env.put("LD_LIBRARY_PATH", condaLib);
+ } else if (!ldLibraryPath.contains(condaLib)) {
+ env.put("LD_LIBRARY_PATH", condaLib + ":" + ldLibraryPath);
+ }
+ LOGGER.info("Added conda lib directory to LD_LIBRARY_PATH: {}", condaLib);
+ }
+ }
+ }
+ }
+}
diff --git a/spark-connect/src/main/java/org/apache/zeppelin/spark/SparkConnectInterpreter.java b/spark-connect/src/main/java/org/apache/zeppelin/spark/SparkConnectInterpreter.java
new file mode 100644
index 00000000000..b8ae477a0e8
--- /dev/null
+++ b/spark-connect/src/main/java/org/apache/zeppelin/spark/SparkConnectInterpreter.java
@@ -0,0 +1,343 @@
+/*
+ * 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.zeppelin.spark;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.apache.zeppelin.interpreter.AbstractInterpreter;
+import org.apache.zeppelin.interpreter.InterpreterContext;
+import org.apache.zeppelin.interpreter.InterpreterException;
+import org.apache.zeppelin.interpreter.InterpreterResult;
+import org.apache.zeppelin.interpreter.InterpreterResult.Code;
+import org.apache.zeppelin.interpreter.ZeppelinContext;
+import org.apache.zeppelin.interpreter.thrift.InterpreterCompletion;
+import org.apache.zeppelin.interpreter.util.SqlSplitter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Spark Connect interpreter for Zeppelin.
+ * Connects to a remote Spark cluster via the Spark Connect gRPC protocol.
+ *
+ * Session & Concurrency Model:
+ *
+ * - Max Spark Connect sessions per user is capped
+ * ({@code zeppelin.spark.connect.maxSessionsPerUser}, default 5).
+ * Each interpreter instance creates one session; Zeppelin's binding mode
+ * (per-user / per-note / scoped / isolated) controls how many instances exist.
+ * - Notebooks are unlimited -- users may open as many as they like.
+ * - Within a single notebook only one query executes at a time
+ * (per-notebook fair lock via {@link NotebookLockManager}).
+ *
+ */
+public class SparkConnectInterpreter extends AbstractInterpreter {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(SparkConnectInterpreter.class);
+
+ /** user -> number of active SparkSession instances owned by that user. */
+ private static final ConcurrentHashMap userSessionCount =
+ new ConcurrentHashMap<>();
+
+ private static final int DEFAULT_MAX_SESSIONS_PER_USER = 5;
+
+ private SparkSession sparkSession;
+ private SqlSplitter sqlSplitter;
+ private int maxResult;
+ private String currentUser;
+ private volatile boolean sessionSlotAcquired = false;
+
+ public SparkConnectInterpreter(Properties properties) {
+ super(properties);
+ }
+
+ @Override
+ public synchronized void open() throws InterpreterException {
+ if (sparkSession != null) {
+ LOGGER.warn("open() called but sparkSession is already active — skipping. " +
+ "Call close() first to restart the interpreter.");
+ return;
+ }
+
+ try {
+ currentUser = getUserName();
+ if (StringUtils.isBlank(currentUser)) {
+ currentUser = "anonymous";
+ }
+
+ int maxSessions = Integer.parseInt(
+ getProperty("zeppelin.spark.connect.maxSessionsPerUser",
+ String.valueOf(DEFAULT_MAX_SESSIONS_PER_USER)));
+
+ if (!acquireSessionSlot(currentUser, maxSessions)) {
+ throw new InterpreterException(
+ String.format("User '%s' already has %d active Spark Connect sessions " +
+ "(max %d). Close an existing interpreter before opening a new one.",
+ currentUser, maxSessions, maxSessions));
+ }
+ sessionSlotAcquired = true;
+
+ LOGGER.info("Opening SparkConnectInterpreter for user: {} (session slot {}/{})",
+ currentUser, userSessionCount.getOrDefault(currentUser, 0), maxSessions);
+
+ String remoteUrl = SparkConnectUtils.buildConnectionString(getProperties(), currentUser);
+ LOGGER.info("Connecting to Spark Connect server at: {}",
+ remoteUrl.replaceAll("token=[^;]*", "token=[REDACTED]")
+ .replaceAll("user_id=[^;]*", "user_id=[REDACTED]"));
+
+ // Clear the thread-local active session on the Spark Connect client so that
+ // getOrCreate() creates a fresh remote session rather than reusing the previous
+ // closed one. We intentionally do NOT call clearDefaultSession() because that
+ // is a JVM-global operation and would disrupt other interpreter instances that
+ // are concurrently active in the same process.
+ try {
+ SparkSession.clearActiveSession();
+ LOGGER.info("Cleared thread-local active Spark session (safe for multi-interpreter)");
+ } catch (Exception e) {
+ LOGGER.warn("Could not clear active Spark session (non-fatal): {}", e.getMessage());
+ }
+
+ SparkSession.Builder builder = SparkSession.builder().remote(remoteUrl);
+
+ String appName = getProperty("spark.app.name", "Zeppelin Spark Connect");
+ if (StringUtils.isNotBlank(appName)) {
+ builder.appName(appName);
+ }
+
+ String grpcMaxMsgSize = getProperty(
+ "spark.connect.grpc.maxMessageSize", "134217728");
+ builder.config("spark.connect.grpc.maxMessageSize", grpcMaxMsgSize);
+
+ for (Object key : getProperties().keySet()) {
+ String keyStr = key.toString();
+ String value = getProperties().getProperty(keyStr);
+ if (StringUtils.isNotBlank(value)
+ && keyStr.startsWith("spark.")
+ && !keyStr.equals("spark.remote")
+ && !keyStr.equals("spark.connect.token")
+ && !keyStr.equals("spark.connect.use_ssl")
+ && !keyStr.equals("spark.app.name")
+ && !keyStr.equals("spark.connect.grpc.maxMessageSize")) {
+ builder.config(keyStr, value);
+ }
+ }
+
+ sparkSession = builder.getOrCreate();
+ LOGGER.info("Spark Connect session established for user: {}", currentUser);
+
+ maxResult = Integer.parseInt(getProperty("zeppelin.spark.maxResult", "1000"));
+ sqlSplitter = new SqlSplitter();
+ } catch (InterpreterException ie) {
+ throw ie;
+ } catch (Exception e) {
+ if (sessionSlotAcquired) {
+ releaseSessionSlot(currentUser);
+ sessionSlotAcquired = false;
+ }
+ LOGGER.error("Failed to connect to Spark Connect server", e);
+ throw new InterpreterException("Failed to connect to Spark Connect server: "
+ + e.getMessage(), e);
+ }
+ }
+
+ @Override
+ public void close() throws InterpreterException {
+ LOGGER.info("Closing SparkConnectInterpreter for user: {} (sparkSession={})",
+ currentUser, sparkSession != null ? "active" : "null");
+ if (sparkSession != null) {
+ try {
+ sparkSession.close();
+ LOGGER.info("Spark Connect session closed for user: {}", currentUser);
+ } catch (Exception e) {
+ LOGGER.warn("Error closing Spark Connect session", e);
+ } finally {
+ sparkSession = null;
+ }
+ } else {
+ LOGGER.info("close() called but no active sparkSession — nothing to tear down");
+ }
+ if (sessionSlotAcquired) {
+ releaseSessionSlot(currentUser);
+ sessionSlotAcquired = false;
+ }
+ }
+
+ @Override
+ public ZeppelinContext getZeppelinContext() {
+ return null;
+ }
+
+ @Override
+ public InterpreterResult internalInterpret(String st, InterpreterContext context)
+ throws InterpreterException {
+ if (sparkSession == null) {
+ return new InterpreterResult(Code.ERROR,
+ "Spark Connect session is not initialized. Check connection settings.");
+ }
+
+ String noteId = context.getNoteId();
+ if (StringUtils.isBlank(noteId)) {
+ return new InterpreterResult(Code.ERROR,
+ "Note ID is missing from interpreter context.");
+ }
+
+ // Per-notebook lock: only one query at a time inside a notebook
+ ReentrantLock notebookLock = NotebookLockManager.getNotebookLock(noteId);
+ notebookLock.lock();
+ try {
+ List sqls = sqlSplitter.splitSql(st);
+ int limit = Integer.parseInt(context.getLocalProperties().getOrDefault("limit",
+ String.valueOf(maxResult)));
+
+ boolean useStreaming = Boolean.parseBoolean(
+ getProperty("zeppelin.spark.connect.streamResults", "false"));
+
+ String curSql = null;
+ try {
+ for (String sql : sqls) {
+ curSql = sql;
+ if (StringUtils.isBlank(sql)) {
+ continue;
+ }
+ Dataset df = sparkSession.sql(sql);
+ if (useStreaming) {
+ SparkConnectUtils.streamDataFrame(df, limit, context.out);
+ } else {
+ String result = SparkConnectUtils.showDataFrame(df, limit);
+ context.out.write(result);
+ }
+ }
+ context.out.flush();
+ } catch (Exception e) {
+ return handleSqlException(e, curSql, context);
+ }
+
+ return new InterpreterResult(Code.SUCCESS);
+ } finally {
+ notebookLock.unlock();
+ }
+ }
+
+ // ---- session-slot helpers (static, shared across all instances) ----
+
+ /**
+ * Try to claim one session slot for the user.
+ * @return true if a slot was available and claimed
+ */
+ private static synchronized boolean acquireSessionSlot(String user, int maxSessions) {
+ int current = userSessionCount.getOrDefault(user, 0);
+ if (current >= maxSessions) {
+ LOGGER.warn("User {} already has {} active Spark Connect sessions (max {})",
+ user, current, maxSessions);
+ return false;
+ }
+ userSessionCount.put(user, current + 1);
+ LOGGER.info("Acquired session slot for user {}. Active sessions: {}/{}",
+ user, current + 1, maxSessions);
+ return true;
+ }
+
+ /**
+ * Release one session slot for the user.
+ */
+ private static synchronized void releaseSessionSlot(String user) {
+ if (user == null) {
+ return;
+ }
+ int current = userSessionCount.getOrDefault(user, 0);
+ if (current <= 1) {
+ userSessionCount.remove(user);
+ } else {
+ userSessionCount.put(user, current - 1);
+ }
+ LOGGER.info("Released session slot for user {}. Remaining sessions: {}",
+ user, Math.max(0, current - 1));
+ }
+
+ /** Visible for testing. */
+ static int getActiveSessionCount(String user) {
+ return userSessionCount.getOrDefault(user, 0);
+ }
+
+ private InterpreterResult handleSqlException(Exception e, String sql,
+ InterpreterContext context) {
+ try {
+ LOGGER.error("Error executing SQL: {}", sql, e);
+ context.out.write("\nError in SQL: " + sql + "\n");
+ if (Boolean.parseBoolean(getProperty("zeppelin.spark.sql.stacktrace", "true"))) {
+ if (e.getCause() != null) {
+ context.out.write(ExceptionUtils.getStackTrace(e.getCause()));
+ } else {
+ context.out.write(ExceptionUtils.getStackTrace(e));
+ }
+ } else {
+ String msg = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
+ context.out.write(msg
+ + "\nSet zeppelin.spark.sql.stacktrace = true to see full stacktrace");
+ }
+ context.out.flush();
+ } catch (IOException ex) {
+ LOGGER.error("Failed to write error output", ex);
+ }
+ return new InterpreterResult(Code.ERROR);
+ }
+
+ @Override
+ public void cancel(InterpreterContext context) throws InterpreterException {
+ if (sparkSession != null) {
+ try {
+ sparkSession.interruptAll();
+ } catch (Exception e) {
+ LOGGER.warn("Error interrupting Spark Connect session", e);
+ }
+ }
+ }
+
+ @Override
+ public FormType getFormType() {
+ return FormType.SIMPLE;
+ }
+
+ @Override
+ public int getProgress(InterpreterContext context) throws InterpreterException {
+ return 0;
+ }
+
+ @Override
+ public List completion(String buf, int cursor,
+ InterpreterContext interpreterContext) throws InterpreterException {
+ return new ArrayList<>();
+ }
+
+ public SparkSession getSparkSession() {
+ return sparkSession;
+ }
+
+ public int getMaxResult() {
+ return maxResult;
+ }
+}
diff --git a/spark-connect/src/main/java/org/apache/zeppelin/spark/SparkConnectSqlInterpreter.java b/spark-connect/src/main/java/org/apache/zeppelin/spark/SparkConnectSqlInterpreter.java
new file mode 100644
index 00000000000..e642455d9aa
--- /dev/null
+++ b/spark-connect/src/main/java/org/apache/zeppelin/spark/SparkConnectSqlInterpreter.java
@@ -0,0 +1,204 @@
+/*
+ * 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.zeppelin.spark;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.apache.zeppelin.interpreter.AbstractInterpreter;
+import org.apache.zeppelin.interpreter.InterpreterContext;
+import org.apache.zeppelin.interpreter.InterpreterException;
+import org.apache.zeppelin.interpreter.InterpreterResult;
+import org.apache.zeppelin.interpreter.InterpreterResult.Code;
+import org.apache.zeppelin.interpreter.ZeppelinContext;
+import org.apache.zeppelin.interpreter.thrift.InterpreterCompletion;
+import org.apache.zeppelin.interpreter.util.SqlSplitter;
+import org.apache.zeppelin.scheduler.Scheduler;
+import org.apache.zeppelin.scheduler.SchedulerFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * Spark Connect SQL interpreter for Zeppelin.
+ * Delegates to SparkConnectInterpreter for the SparkSession, providing
+ * dedicated SQL execution with concurrent query support.
+ */
+public class SparkConnectSqlInterpreter extends AbstractInterpreter {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(SparkConnectSqlInterpreter.class);
+
+ private SparkConnectInterpreter sparkConnectInterpreter;
+ private SqlSplitter sqlSplitter;
+
+ public SparkConnectSqlInterpreter(Properties properties) {
+ super(properties);
+ }
+
+ @Override
+ public void open() throws InterpreterException {
+ this.sparkConnectInterpreter =
+ getInterpreterInTheSameSessionByClassName(SparkConnectInterpreter.class);
+ this.sqlSplitter = new SqlSplitter();
+ }
+
+ @Override
+ public void close() throws InterpreterException {
+ sparkConnectInterpreter = null;
+ }
+
+ @Override
+ protected boolean isInterpolate() {
+ return Boolean.parseBoolean(getProperty("zeppelin.spark.sql.interpolation", "false"));
+ }
+
+ @Override
+ public ZeppelinContext getZeppelinContext() {
+ return null;
+ }
+
+ @Override
+ public InterpreterResult internalInterpret(String st, InterpreterContext context)
+ throws InterpreterException {
+ SparkSession sparkSession = sparkConnectInterpreter.getSparkSession();
+ if (sparkSession == null) {
+ return new InterpreterResult(Code.ERROR,
+ "Spark Connect session is not initialized. Check connection settings.");
+ }
+
+ // Get noteId from context for notebook-level synchronization
+ String noteId = context.getNoteId();
+ if (StringUtils.isBlank(noteId)) {
+ return new InterpreterResult(Code.ERROR,
+ "Note ID is missing from interpreter context.");
+ }
+
+ // Get or create lock for this notebook to ensure sequential execution
+ // This ensures one query at a time per notebook, even with concurrentSQL enabled
+ ReentrantLock notebookLock = NotebookLockManager.getNotebookLock(noteId);
+
+ // Acquire lock to ensure only one query executes at a time for this notebook
+ notebookLock.lock();
+ try {
+ List sqls = sqlSplitter.splitSql(st);
+ int maxResult = Integer.parseInt(context.getLocalProperties().getOrDefault("limit",
+ String.valueOf(sparkConnectInterpreter.getMaxResult())));
+
+ boolean useStreaming = Boolean.parseBoolean(
+ getProperty("zeppelin.spark.connect.streamResults", "false"));
+
+ String curSql = null;
+ try {
+ for (String sql : sqls) {
+ curSql = sql;
+ if (StringUtils.isBlank(sql)) {
+ continue;
+ }
+ Dataset df = sparkSession.sql(sql);
+ if (useStreaming) {
+ SparkConnectUtils.streamDataFrame(df, maxResult, context.out);
+ } else {
+ String result = SparkConnectUtils.showDataFrame(df, maxResult);
+ context.out.write(result);
+ }
+ }
+ context.out.flush();
+ } catch (Exception e) {
+ try {
+ LOGGER.error("Error executing SQL: {}", curSql, e);
+ context.out.write("\nError in SQL: " + curSql + "\n");
+ if (Boolean.parseBoolean(getProperty("zeppelin.spark.sql.stacktrace", "true"))) {
+ if (e.getCause() != null) {
+ context.out.write(ExceptionUtils.getStackTrace(e.getCause()));
+ } else {
+ context.out.write(ExceptionUtils.getStackTrace(e));
+ }
+ } else {
+ String msg = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
+ context.out.write(msg
+ + "\nSet zeppelin.spark.sql.stacktrace = true to see full stacktrace");
+ }
+ context.out.flush();
+ } catch (IOException ex) {
+ LOGGER.error("Failed to write error output", ex);
+ }
+ return new InterpreterResult(Code.ERROR);
+ }
+
+ return new InterpreterResult(Code.SUCCESS);
+ } finally {
+ notebookLock.unlock();
+ }
+ }
+
+ @Override
+ public void cancel(InterpreterContext context) throws InterpreterException {
+ SparkSession sparkSession = sparkConnectInterpreter.getSparkSession();
+ if (sparkSession != null) {
+ try {
+ sparkSession.interruptAll();
+ } catch (Exception e) {
+ LOGGER.warn("Error interrupting Spark Connect session", e);
+ }
+ }
+ }
+
+ @Override
+ public FormType getFormType() {
+ return FormType.SIMPLE;
+ }
+
+ @Override
+ public int getProgress(InterpreterContext context) throws InterpreterException {
+ return 0;
+ }
+
+ @Override
+ public List completion(String buf, int cursor,
+ InterpreterContext interpreterContext) throws InterpreterException {
+ return new ArrayList<>();
+ }
+
+ @Override
+ public Scheduler getScheduler() {
+ if (concurrentSQL()) {
+ int maxConcurrency = Integer.parseInt(
+ getProperty("zeppelin.spark.concurrentSQL.max", "10"));
+ return SchedulerFactory.singleton().createOrGetParallelScheduler(
+ SparkConnectSqlInterpreter.class.getName() + this.hashCode(), maxConcurrency);
+ } else {
+ try {
+ return getInterpreterInTheSameSessionByClassName(
+ SparkConnectInterpreter.class, false).getScheduler();
+ } catch (InterpreterException e) {
+ throw new RuntimeException("Failed to get scheduler", e);
+ }
+ }
+ }
+
+ private boolean concurrentSQL() {
+ return Boolean.parseBoolean(getProperty("zeppelin.spark.concurrentSQL"));
+ }
+}
diff --git a/spark-connect/src/main/java/org/apache/zeppelin/spark/SparkConnectUtils.java b/spark-connect/src/main/java/org/apache/zeppelin/spark/SparkConnectUtils.java
new file mode 100644
index 00000000000..ffad1fbb789
--- /dev/null
+++ b/spark-connect/src/main/java/org/apache/zeppelin/spark/SparkConnectUtils.java
@@ -0,0 +1,191 @@
+/*
+ * 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.zeppelin.spark;
+
+import org.apache.commons.lang3.StringUtils;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Properties;
+
+public class SparkConnectUtils {
+
+ static final int DEFAULT_TRUNCATE_LENGTH = 256;
+
+ private SparkConnectUtils() {
+ }
+
+ /**
+ * Build the Spark Connect connection string from interpreter properties.
+ * Format: sc://hostname:port[/;param1=val1;param2=val2]
+ *
+ * Supports: token, use_ssl, user_id, and any extra params already in the URI.
+ * Examples:
+ * sc://localhost:15002
+ * sc://localhost:15002/;use_ssl=true;token=abc123;user_id=alice
+ * sc://ranking-cluster-m:8080
+ */
+ public static String buildConnectionString(Properties properties) {
+ return buildConnectionString(properties, null);
+ }
+
+ /**
+ * Build the Spark Connect connection string, including user_id so the Spark Connect
+ * server can attribute the session to the correct user in its own UI.
+ *
+ * @param properties interpreter properties
+ * @param userName the authenticated Zeppelin username; ignored if blank
+ */
+ public static String buildConnectionString(Properties properties, String userName) {
+ String remote = properties.getProperty("spark.remote", "sc://localhost:15002");
+ StringBuilder params = new StringBuilder();
+
+ String token = properties.getProperty("spark.connect.token", "");
+ if (StringUtils.isNotBlank(token)) {
+ params.append(";token=").append(token);
+ }
+
+ boolean useSsl = Boolean.parseBoolean(
+ properties.getProperty("spark.connect.use_ssl", "false"));
+ if (useSsl) {
+ params.append(";use_ssl=true");
+ }
+
+ if (StringUtils.isNotBlank(userName) && !remote.contains("user_id=")) {
+ params.append(";user_id=").append(userName);
+ }
+
+ if (params.length() > 0) {
+ if (remote.contains(";")) {
+ remote = remote + params;
+ } else {
+ remote = remote + "/" + params;
+ }
+ }
+ return remote;
+ }
+
+ /**
+ * Convert a Dataset to Zeppelin's %table format string.
+ * Applies limit before collecting to prevent OOM on the driver.
+ * Truncates cell values to avoid excessively wide output.
+ */
+ public static String showDataFrame(Dataset df, int maxResult) {
+ return showDataFrame(df, maxResult, DEFAULT_TRUNCATE_LENGTH);
+ }
+
+ public static String showDataFrame(Dataset df, int maxResult, int truncateLength) {
+ StructType schema = df.schema();
+ StructField[] fields = schema.fields();
+
+ int effectiveLimit = Math.max(1, Math.min(maxResult, 100_000));
+
+ int estimatedRowSize = Math.max(fields.length * 20, 100);
+ int estimatedTotalBytes = estimatedRowSize * effectiveLimit;
+ StringBuilder sb = new StringBuilder(Math.min(estimatedTotalBytes, 10 * 1024 * 1024));
+ sb.append("%table ");
+
+ for (int i = 0; i < fields.length; i++) {
+ if (i > 0) {
+ sb.append('\t');
+ }
+ sb.append(replaceReservedChars(fields[i].name()));
+ }
+ sb.append('\n');
+
+ List rows = df.limit(effectiveLimit).collectAsList();
+ for (Row row : rows) {
+ for (int i = 0; i < row.length(); i++) {
+ if (i > 0) {
+ sb.append('\t');
+ }
+ Object value = row.get(i);
+ String cellStr = value == null ? "null" : value.toString();
+ if (truncateLength > 0 && cellStr.length() > truncateLength) {
+ cellStr = cellStr.substring(0, truncateLength) + "...";
+ }
+ sb.append(replaceReservedChars(cellStr));
+ }
+ sb.append('\n');
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * Stream a Dataset as Zeppelin %table format directly to an OutputStream,
+ * avoiding building the entire result in memory.
+ * Preferred for large result sets.
+ */
+ public static void streamDataFrame(Dataset df, int maxResult, OutputStream out)
+ throws IOException {
+ streamDataFrame(df, maxResult, DEFAULT_TRUNCATE_LENGTH, out);
+ }
+
+ public static void streamDataFrame(Dataset df, int maxResult,
+ int truncateLength, OutputStream out) throws IOException {
+ StructType schema = df.schema();
+ StructField[] fields = schema.fields();
+
+ int effectiveLimit = Math.max(1, Math.min(maxResult, 100_000));
+
+ StringBuilder header = new StringBuilder("%table ");
+ for (int i = 0; i < fields.length; i++) {
+ if (i > 0) {
+ header.append('\t');
+ }
+ header.append(replaceReservedChars(fields[i].name()));
+ }
+ header.append('\n');
+ out.write(header.toString().getBytes(StandardCharsets.UTF_8));
+
+ Iterator it = df.limit(effectiveLimit).toLocalIterator();
+ StringBuilder rowBuf = new StringBuilder(256);
+ while (it.hasNext()) {
+ rowBuf.setLength(0);
+ Row row = it.next();
+ for (int i = 0; i < row.length(); i++) {
+ if (i > 0) {
+ rowBuf.append('\t');
+ }
+ Object value = row.get(i);
+ String cellStr = value == null ? "null" : value.toString();
+ if (truncateLength > 0 && cellStr.length() > truncateLength) {
+ cellStr = cellStr.substring(0, truncateLength) + "...";
+ }
+ rowBuf.append(replaceReservedChars(cellStr));
+ }
+ rowBuf.append('\n');
+ out.write(rowBuf.toString().getBytes(StandardCharsets.UTF_8));
+ }
+ out.flush();
+ }
+
+ static String replaceReservedChars(String str) {
+ if (str == null) {
+ return "null";
+ }
+ return str.replace('\t', ' ').replace('\n', ' ');
+ }
+}
diff --git a/spark-connect/src/main/resources/interpreter-setting.json b/spark-connect/src/main/resources/interpreter-setting.json
new file mode 100644
index 00000000000..78d68fda936
--- /dev/null
+++ b/spark-connect/src/main/resources/interpreter-setting.json
@@ -0,0 +1,187 @@
+[
+ {
+ "group": "spark-connect",
+ "name": "spark-connect",
+ "className": "org.apache.zeppelin.spark.SparkConnectInterpreter",
+ "defaultInterpreter": true,
+ "properties": {
+ "spark.remote": {
+ "envName": "SPARK_REMOTE",
+ "propertyName": "spark.remote",
+ "defaultValue": "sc://localhost:15002",
+ "description": "Spark Connect server URI (e.g. sc://localhost:15002 or sc://dataproc-master:8080)",
+ "type": "string"
+ },
+ "spark.app.name": {
+ "envName": null,
+ "propertyName": "spark.app.name",
+ "defaultValue": "Zeppelin Spark Connect",
+ "description": "Spark application name",
+ "type": "string"
+ },
+ "zeppelin.spark.maxResult": {
+ "envName": null,
+ "propertyName": "zeppelin.spark.maxResult",
+ "defaultValue": "1000",
+ "description": "Max number of rows to display",
+ "type": "number"
+ },
+ "zeppelin.spark.sql.stacktrace": {
+ "envName": null,
+ "propertyName": "zeppelin.spark.sql.stacktrace",
+ "defaultValue": true,
+ "description": "Show full exception stacktrace for SQL errors",
+ "type": "checkbox"
+ },
+ "spark.connect.grpc.maxMessageSize": {
+ "envName": null,
+ "propertyName": "spark.connect.grpc.maxMessageSize",
+ "defaultValue": "134217728",
+ "description": "Max gRPC message size in bytes (default 128MB). Increase for large result sets.",
+ "type": "number"
+ },
+ "zeppelin.spark.connect.streamResults": {
+ "envName": null,
+ "propertyName": "zeppelin.spark.connect.streamResults",
+ "defaultValue": false,
+ "description": "Stream query results row-by-row instead of building full result in memory. Recommended for large result sets.",
+ "type": "checkbox"
+ },
+ "zeppelin.spark.connect.maxSessionsPerUser": {
+ "envName": null,
+ "propertyName": "zeppelin.spark.connect.maxSessionsPerUser",
+ "defaultValue": "5",
+ "description": "Maximum number of Spark Connect sessions (SparkSession instances) per user. Each interpreter instance creates one session.",
+ "type": "number"
+ }
+ },
+ "editor": {
+ "language": "sql",
+ "editOnDblClick": false,
+ "completionKey": "TAB",
+ "completionSupport": false
+ }
+ },
+ {
+ "group": "spark-connect",
+ "name": "sql",
+ "className": "org.apache.zeppelin.spark.SparkConnectSqlInterpreter",
+ "properties": {
+ "zeppelin.spark.concurrentSQL": {
+ "envName": null,
+ "propertyName": "zeppelin.spark.concurrentSQL",
+ "defaultValue": true,
+ "description": "Execute multiple SQL concurrently",
+ "type": "checkbox"
+ },
+ "zeppelin.spark.concurrentSQL.max": {
+ "envName": null,
+ "propertyName": "zeppelin.spark.concurrentSQL.max",
+ "defaultValue": "10",
+ "description": "Max concurrent SQL executions",
+ "type": "number"
+ },
+ "zeppelin.spark.sql.stacktrace": {
+ "envName": null,
+ "propertyName": "zeppelin.spark.sql.stacktrace",
+ "defaultValue": true,
+ "description": "Show full exception stacktrace for SQL errors",
+ "type": "checkbox"
+ }
+ },
+ "editor": {
+ "language": "sql",
+ "editOnDblClick": false,
+ "completionKey": "TAB",
+ "completionSupport": false
+ }
+ },
+ {
+ "group": "spark-connect",
+ "name": "pyspark",
+ "className": "org.apache.zeppelin.spark.PySparkConnectInterpreter",
+ "properties": {
+ "spark.remote": {
+ "envName": "SPARK_REMOTE",
+ "propertyName": "spark.remote",
+ "defaultValue": "sc://localhost:15002",
+ "description": "Spark Connect server URI (e.g. sc://localhost:15002 or sc://dataproc-master:8080)",
+ "type": "string"
+ },
+ "spark.connect.token": {
+ "envName": "SPARK_CONNECT_TOKEN",
+ "propertyName": "spark.connect.token",
+ "defaultValue": "",
+ "description": "Authentication token for Spark Connect (optional)",
+ "type": "string"
+ },
+ "spark.connect.use_ssl": {
+ "envName": "SPARK_CONNECT_USE_SSL",
+ "propertyName": "spark.connect.use_ssl",
+ "defaultValue": false,
+ "description": "Use SSL for Spark Connect connection",
+ "type": "checkbox"
+ },
+ "spark.app.name": {
+ "envName": null,
+ "propertyName": "spark.app.name",
+ "defaultValue": "Zeppelin Spark Connect",
+ "description": "Spark application name",
+ "type": "string"
+ },
+ "spark.pyspark.driver.python": {
+ "envName": "PYSPARK_DRIVER_PYTHON",
+ "propertyName": "spark.pyspark.driver.python",
+ "defaultValue": "",
+ "description": "Python executable to use for PySpark driver (highest priority). Follows Spark's PySpark detection pattern.",
+ "type": "string"
+ },
+ "spark.pyspark.python": {
+ "envName": "PYSPARK_PYTHON",
+ "propertyName": "spark.pyspark.python",
+ "defaultValue": "",
+ "description": "Python executable to use for PySpark (second priority). Can also be set via PYSPARK_PYTHON environment variable.",
+ "type": "string"
+ },
+ "zeppelin.python": {
+ "envName": "ZEPPELIN_PYTHON",
+ "propertyName": "zeppelin.python",
+ "defaultValue": "",
+ "description": "Python executable command (optional fallback). If not set, defaults to 'python' and uses system PATH (matching Spark's PySpark behavior).",
+ "type": "string"
+ },
+ "zeppelin.pyspark.connect.useIPython": {
+ "envName": null,
+ "propertyName": "zeppelin.pyspark.connect.useIPython",
+ "defaultValue": true,
+ "description": "Use IPython if available",
+ "type": "checkbox"
+ },
+ "zeppelin.spark.maxResult": {
+ "envName": null,
+ "propertyName": "zeppelin.spark.maxResult",
+ "defaultValue": "1000",
+ "description": "Max number of rows to display",
+ "type": "number"
+ }
+ },
+ "editor": {
+ "language": "python",
+ "editOnDblClick": false,
+ "completionKey": "TAB",
+ "completionSupport": true
+ }
+ },
+ {
+ "group": "spark-connect",
+ "name": "ipyspark",
+ "className": "org.apache.zeppelin.spark.IPySparkConnectInterpreter",
+ "properties": {},
+ "editor": {
+ "language": "python",
+ "editOnDblClick": false,
+ "completionKey": "TAB",
+ "completionSupport": true
+ }
+ }
+]
diff --git a/spark-connect/src/main/resources/python/zeppelin_sparkconnect.py b/spark-connect/src/main/resources/python/zeppelin_sparkconnect.py
new file mode 100644
index 00000000000..f73f6883b71
--- /dev/null
+++ b/spark-connect/src/main/resources/python/zeppelin_sparkconnect.py
@@ -0,0 +1,38 @@
+#
+# 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.
+#
+
+# Bootstrap a native PySpark Spark Connect session. The Java interpreter
+# populates SPARK_REMOTE with the connection URI (including any token /
+# use_ssl / user_id params). Java/SQL and Python connect as independent
+# sessions to the same Spark Connect server; cross-language sharing
+# happens through catalog tables.
+
+import os
+import warnings
+
+from pyspark.sql import SparkSession
+
+warnings.filterwarnings(action='ignore', module='pyspark.util')
+
+_remote = os.environ.get("SPARK_REMOTE")
+if not _remote:
+ raise RuntimeError(
+ "SPARK_REMOTE env var not set. The Java interpreter is expected "
+ "to populate it from the 'spark.remote' interpreter property.")
+
+spark = SparkSession.builder.remote(_remote).getOrCreate()
+sqlContext = sqlc = spark
diff --git a/spark-connect/src/test/java/org/apache/zeppelin/spark/PySparkConnectInterpreterTest.java b/spark-connect/src/test/java/org/apache/zeppelin/spark/PySparkConnectInterpreterTest.java
new file mode 100644
index 00000000000..f477229a1d5
--- /dev/null
+++ b/spark-connect/src/test/java/org/apache/zeppelin/spark/PySparkConnectInterpreterTest.java
@@ -0,0 +1,147 @@
+/*
+ * 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.zeppelin.spark;
+
+import org.apache.zeppelin.display.AngularObjectRegistry;
+import org.apache.zeppelin.interpreter.Interpreter;
+import org.apache.zeppelin.interpreter.InterpreterContext;
+import org.apache.zeppelin.interpreter.InterpreterException;
+import org.apache.zeppelin.interpreter.InterpreterGroup;
+import org.apache.zeppelin.interpreter.InterpreterOutput;
+import org.apache.zeppelin.interpreter.InterpreterResult;
+import org.apache.zeppelin.interpreter.remote.RemoteInterpreterEventClient;
+import org.apache.zeppelin.resource.LocalResourcePool;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Integration tests for PySparkConnectInterpreter.
+ * Requires a running Spark Connect server.
+ * Set SPARK_CONNECT_TEST_REMOTE env var (e.g. sc://localhost:15002) to enable.
+ */
+@EnabledIfEnvironmentVariable(named = "SPARK_CONNECT_TEST_REMOTE", matches = ".+")
+public class PySparkConnectInterpreterTest {
+
+ private static PySparkConnectInterpreter interpreter;
+ private static SparkConnectInterpreter sparkConnectInterpreter;
+ private static InterpreterGroup intpGroup;
+
+ @BeforeAll
+ public static void setUp() throws Exception {
+ String remote = System.getenv("SPARK_CONNECT_TEST_REMOTE");
+ Properties p = new Properties();
+ p.setProperty("spark.remote", remote);
+ p.setProperty("spark.app.name", "ZeppelinPySparkConnectTest");
+ p.setProperty("zeppelin.spark.maxResult", "100");
+ p.setProperty("zeppelin.pyspark.connect.useIPython", "false");
+ p.setProperty("zeppelin.python", "python");
+
+ intpGroup = new InterpreterGroup();
+
+ // Create SparkConnectInterpreter first (required dependency)
+ sparkConnectInterpreter = new SparkConnectInterpreter(p);
+ sparkConnectInterpreter.setInterpreterGroup(intpGroup);
+ intpGroup.put("session_1", new LinkedList());
+ intpGroup.get("session_1").add(sparkConnectInterpreter);
+ sparkConnectInterpreter.open();
+
+ // Create PySparkConnectInterpreter
+ interpreter = new PySparkConnectInterpreter(p);
+ interpreter.setInterpreterGroup(intpGroup);
+ intpGroup.get("session_1").add(interpreter);
+ interpreter.open();
+ }
+
+ @AfterAll
+ public static void tearDown() throws InterpreterException {
+ if (interpreter != null) {
+ interpreter.close();
+ }
+ if (sparkConnectInterpreter != null) {
+ sparkConnectInterpreter.close();
+ }
+ }
+
+ private static InterpreterContext getInterpreterContext() {
+ return InterpreterContext.builder()
+ .setNoteId("noteId")
+ .setParagraphId("paragraphId")
+ .setParagraphTitle("title")
+ .setAngularObjectRegistry(new AngularObjectRegistry(intpGroup.getId(), null))
+ .setResourcePool(new LocalResourcePool("id"))
+ .setInterpreterOut(new InterpreterOutput())
+ .setIntpEventClient(mock(RemoteInterpreterEventClient.class))
+ .build();
+ }
+
+ @Test
+ void testSimpleQuery() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = interpreter.interpret(
+ "df = spark.sql(\"SELECT 1 AS id, 'hello' AS message\")\ndf.show()", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ String output = context.out.toInterpreterResultMessage().get(0).getData();
+ assertTrue(output.contains("id") || output.contains("message") || output.contains("hello"),
+ "Output should contain query results: " + output);
+ }
+
+ @Test
+ void testDataFrameVariable() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = interpreter.interpret(
+ "df = spark.sql(\"SELECT 1 AS id, 'test' AS name\")\nprint(type(df))", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ String output = context.out.toInterpreterResultMessage().get(0).getData();
+ assertTrue(output.contains("DataFrame") || output.contains("pyspark.sql.connect"),
+ "Output should indicate native PySpark Connect DataFrame type: " + output);
+ }
+
+ @Test
+ void testDeltaTableQuery() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ // Test the exact query from user
+ InterpreterResult result = interpreter.interpret(
+ "df = spark.sql(\"select * from gold.delta_test\")", context);
+ // This might fail if table doesn't exist, but should not crash
+ // We check that interpreter handled it gracefully
+ assertTrue(result.code() == InterpreterResult.Code.SUCCESS
+ || result.code() == InterpreterResult.Code.ERROR,
+ "Should handle query execution (success or error): " + result.code());
+ }
+
+ @Test
+ void testSparkVariableAvailable() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = interpreter.interpret(
+ "print('Spark session:', type(spark))", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ String output = context.out.toInterpreterResultMessage().get(0).getData();
+ assertTrue(output.contains("SparkSession") || output.contains("spark"),
+ "Spark session should be available: " + output);
+ }
+}
diff --git a/spark-connect/src/test/java/org/apache/zeppelin/spark/SparkConnectInterpreterTest.java b/spark-connect/src/test/java/org/apache/zeppelin/spark/SparkConnectInterpreterTest.java
new file mode 100644
index 00000000000..0b3cbc8213b
--- /dev/null
+++ b/spark-connect/src/test/java/org/apache/zeppelin/spark/SparkConnectInterpreterTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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.zeppelin.spark;
+
+import org.apache.zeppelin.display.AngularObjectRegistry;
+import org.apache.zeppelin.interpreter.Interpreter;
+import org.apache.zeppelin.interpreter.InterpreterContext;
+import org.apache.zeppelin.interpreter.InterpreterException;
+import org.apache.zeppelin.interpreter.InterpreterGroup;
+import org.apache.zeppelin.interpreter.InterpreterOutput;
+import org.apache.zeppelin.interpreter.InterpreterResult;
+import org.apache.zeppelin.interpreter.remote.RemoteInterpreterEventClient;
+import org.apache.zeppelin.resource.LocalResourcePool;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Integration tests for SparkConnectInterpreter.
+ * Requires a running Spark Connect server.
+ * Set SPARK_CONNECT_TEST_REMOTE env var (e.g. sc://localhost:15002) to enable.
+ */
+@EnabledIfEnvironmentVariable(named = "SPARK_CONNECT_TEST_REMOTE", matches = ".+")
+public class SparkConnectInterpreterTest {
+
+ private static SparkConnectInterpreter interpreter;
+ private static InterpreterGroup intpGroup;
+
+ @BeforeAll
+ public static void setUp() throws Exception {
+ String remote = System.getenv("SPARK_CONNECT_TEST_REMOTE");
+ Properties p = new Properties();
+ p.setProperty("spark.remote", remote);
+ p.setProperty("spark.app.name", "ZeppelinSparkConnectTest");
+ p.setProperty("zeppelin.spark.maxResult", "100");
+ p.setProperty("zeppelin.spark.sql.stacktrace", "true");
+
+ intpGroup = new InterpreterGroup();
+ interpreter = new SparkConnectInterpreter(p);
+ interpreter.setInterpreterGroup(intpGroup);
+ intpGroup.put("session_1", new LinkedList());
+ intpGroup.get("session_1").add(interpreter);
+
+ interpreter.open();
+ }
+
+ @AfterAll
+ public static void tearDown() throws InterpreterException {
+ if (interpreter != null) {
+ interpreter.close();
+ }
+ }
+
+ private static InterpreterContext getInterpreterContext() {
+ return InterpreterContext.builder()
+ .setNoteId("noteId")
+ .setParagraphId("paragraphId")
+ .setParagraphTitle("title")
+ .setAngularObjectRegistry(new AngularObjectRegistry(intpGroup.getId(), null))
+ .setResourcePool(new LocalResourcePool("id"))
+ .setInterpreterOut(new InterpreterOutput())
+ .setIntpEventClient(mock(RemoteInterpreterEventClient.class))
+ .build();
+ }
+
+ @Test
+ void testSparkSessionCreated() {
+ assertNotNull(interpreter.getSparkSession());
+ }
+
+ @Test
+ void testSimpleQuery() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = interpreter.interpret(
+ "SELECT 1 AS id, 'hello' AS message", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ String output = context.out.toInterpreterResultMessage().get(0).getData();
+ assertTrue(output.contains("id"));
+ assertTrue(output.contains("message"));
+ assertTrue(output.contains("hello"));
+ }
+
+ @Test
+ void testMultipleStatements() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = interpreter.interpret(
+ "SELECT 1 AS a; SELECT 2 AS b", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ }
+
+ @Test
+ void testInvalidSQL() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = interpreter.interpret(
+ "SELECT FROM WHERE INVALID", context);
+ assertEquals(InterpreterResult.Code.ERROR, result.code());
+ }
+
+ @Test
+ void testMaxResultLimit() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ context.getLocalProperties().put("limit", "5");
+ InterpreterResult result = interpreter.interpret(
+ "SELECT id FROM range(100)", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ String output = context.out.toInterpreterResultMessage().get(0).getData();
+ String[] lines = output.split("\n");
+ // header + 5 data rows
+ assertTrue(lines.length <= 7);
+ }
+
+ @Test
+ void testDDL() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = interpreter.interpret(
+ "CREATE OR REPLACE TEMP VIEW test_view AS SELECT 1 AS id, 'test' AS name", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+
+ context = getInterpreterContext();
+ result = interpreter.interpret("SELECT * FROM test_view", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ String output = context.out.toInterpreterResultMessage().get(0).getData();
+ assertTrue(output.contains("test"));
+ }
+
+ @Test
+ void testFormType() {
+ assertEquals(Interpreter.FormType.SIMPLE, interpreter.getFormType());
+ }
+
+ @Test
+ void testProgress() throws InterpreterException {
+ InterpreterContext context = getInterpreterContext();
+ assertEquals(0, interpreter.getProgress(context));
+ }
+}
diff --git a/spark-connect/src/test/java/org/apache/zeppelin/spark/SparkConnectSqlInterpreterTest.java b/spark-connect/src/test/java/org/apache/zeppelin/spark/SparkConnectSqlInterpreterTest.java
new file mode 100644
index 00000000000..acee3ed7a44
--- /dev/null
+++ b/spark-connect/src/test/java/org/apache/zeppelin/spark/SparkConnectSqlInterpreterTest.java
@@ -0,0 +1,164 @@
+/*
+ * 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.zeppelin.spark;
+
+import org.apache.zeppelin.display.AngularObjectRegistry;
+import org.apache.zeppelin.interpreter.Interpreter;
+import org.apache.zeppelin.interpreter.InterpreterContext;
+import org.apache.zeppelin.interpreter.InterpreterException;
+import org.apache.zeppelin.interpreter.InterpreterGroup;
+import org.apache.zeppelin.interpreter.InterpreterOutput;
+import org.apache.zeppelin.interpreter.InterpreterResult;
+import org.apache.zeppelin.interpreter.remote.RemoteInterpreterEventClient;
+import org.apache.zeppelin.resource.LocalResourcePool;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
+
+import java.io.IOException;
+import java.util.LinkedList;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Integration tests for SparkConnectSqlInterpreter.
+ * Requires a running Spark Connect server.
+ * Set SPARK_CONNECT_TEST_REMOTE env var (e.g. sc://localhost:15002) to enable.
+ */
+@EnabledIfEnvironmentVariable(named = "SPARK_CONNECT_TEST_REMOTE", matches = ".+")
+public class SparkConnectSqlInterpreterTest {
+
+ private static SparkConnectInterpreter connectInterpreter;
+ private static SparkConnectSqlInterpreter sqlInterpreter;
+ private static InterpreterGroup intpGroup;
+
+ @BeforeAll
+ public static void setUp() throws Exception {
+ String remote = System.getenv("SPARK_CONNECT_TEST_REMOTE");
+ Properties p = new Properties();
+ p.setProperty("spark.remote", remote);
+ p.setProperty("spark.app.name", "ZeppelinSparkConnectSqlTest");
+ p.setProperty("zeppelin.spark.maxResult", "100");
+ p.setProperty("zeppelin.spark.concurrentSQL", "true");
+ p.setProperty("zeppelin.spark.concurrentSQL.max", "10");
+ p.setProperty("zeppelin.spark.sql.stacktrace", "true");
+ p.setProperty("zeppelin.spark.sql.interpolation", "false");
+
+ intpGroup = new InterpreterGroup();
+ connectInterpreter = new SparkConnectInterpreter(p);
+ connectInterpreter.setInterpreterGroup(intpGroup);
+
+ sqlInterpreter = new SparkConnectSqlInterpreter(p);
+ sqlInterpreter.setInterpreterGroup(intpGroup);
+
+ intpGroup.put("session_1", new LinkedList());
+ intpGroup.get("session_1").add(connectInterpreter);
+ intpGroup.get("session_1").add(sqlInterpreter);
+
+ connectInterpreter.open();
+ sqlInterpreter.open();
+ }
+
+ @AfterAll
+ public static void tearDown() throws InterpreterException {
+ if (sqlInterpreter != null) {
+ sqlInterpreter.close();
+ }
+ if (connectInterpreter != null) {
+ connectInterpreter.close();
+ }
+ }
+
+ private static InterpreterContext getInterpreterContext() {
+ return InterpreterContext.builder()
+ .setNoteId("noteId")
+ .setParagraphId("paragraphId")
+ .setParagraphTitle("title")
+ .setAngularObjectRegistry(new AngularObjectRegistry(intpGroup.getId(), null))
+ .setResourcePool(new LocalResourcePool("id"))
+ .setInterpreterOut(new InterpreterOutput())
+ .setIntpEventClient(mock(RemoteInterpreterEventClient.class))
+ .build();
+ }
+
+ @Test
+ void testSimpleQuery() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = sqlInterpreter.interpret(
+ "SELECT 1 AS id, 'hello' AS message", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ String output = context.out.toInterpreterResultMessage().get(0).getData();
+ assertTrue(output.contains("id"));
+ assertTrue(output.contains("message"));
+ assertTrue(output.contains("hello"));
+ }
+
+ @Test
+ void testMultipleStatements() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = sqlInterpreter.interpret(
+ "SELECT 1 AS a; SELECT 2 AS b", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ assertEquals(2, context.out.toInterpreterResultMessage().size());
+ }
+
+ @Test
+ void testInvalidSQL() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = sqlInterpreter.interpret(
+ "SELECT FROM WHERE INVALID", context);
+ assertEquals(InterpreterResult.Code.ERROR, result.code());
+ assertTrue(context.out.toInterpreterResultMessage().get(0).getData().length() > 0);
+ }
+
+ @Test
+ void testMaxResultLimit() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ context.getLocalProperties().put("limit", "3");
+ InterpreterResult result = sqlInterpreter.interpret(
+ "SELECT id FROM range(100)", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ String output = context.out.toInterpreterResultMessage().get(0).getData();
+ String[] lines = output.split("\n");
+ // header + 3 data rows
+ assertTrue(lines.length <= 5);
+ }
+
+ @Test
+ void testCreateAndQuery() throws InterpreterException, IOException {
+ InterpreterContext context = getInterpreterContext();
+ InterpreterResult result = sqlInterpreter.interpret(
+ "CREATE OR REPLACE TEMP VIEW sql_test AS SELECT 42 AS answer", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+
+ context = getInterpreterContext();
+ result = sqlInterpreter.interpret("SELECT * FROM sql_test", context);
+ assertEquals(InterpreterResult.Code.SUCCESS, result.code());
+ String output = context.out.toInterpreterResultMessage().get(0).getData();
+ assertTrue(output.contains("42"));
+ }
+
+ @Test
+ void testFormType() {
+ assertEquals(Interpreter.FormType.SIMPLE, sqlInterpreter.getFormType());
+ }
+}
diff --git a/spark-connect/src/test/java/org/apache/zeppelin/spark/SparkConnectUtilsTest.java b/spark-connect/src/test/java/org/apache/zeppelin/spark/SparkConnectUtilsTest.java
new file mode 100644
index 00000000000..ebafd1a9bef
--- /dev/null
+++ b/spark-connect/src/test/java/org/apache/zeppelin/spark/SparkConnectUtilsTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.zeppelin.spark;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class SparkConnectUtilsTest {
+
+ @Test
+ void testBuildConnectionStringDefault() {
+ Properties props = new Properties();
+ String result = SparkConnectUtils.buildConnectionString(props);
+ assertEquals("sc://localhost:15002", result);
+ }
+
+ @Test
+ void testBuildConnectionStringCustomRemote() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://spark-server.example.com:15002");
+ String result = SparkConnectUtils.buildConnectionString(props);
+ assertEquals("sc://spark-server.example.com:15002", result);
+ }
+
+ @Test
+ void testBuildConnectionStringWithToken() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://spark-server.example.com:15002");
+ props.setProperty("spark.connect.token", "my-secret-token");
+ String result = SparkConnectUtils.buildConnectionString(props);
+ assertEquals("sc://spark-server.example.com:15002/;token=my-secret-token", result);
+ }
+
+ @Test
+ void testBuildConnectionStringWithTokenAndExistingParams() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://host:15002/;use_ssl=true");
+ props.setProperty("spark.connect.token", "tok123");
+ String result = SparkConnectUtils.buildConnectionString(props);
+ assertEquals("sc://host:15002/;use_ssl=true;token=tok123", result);
+ }
+
+ @Test
+ void testBuildConnectionStringEmptyToken() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://host:15002");
+ props.setProperty("spark.connect.token", "");
+ String result = SparkConnectUtils.buildConnectionString(props);
+ assertEquals("sc://host:15002", result);
+ }
+
+ @Test
+ void testBuildConnectionStringWithUseSsl() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://ranking-cluster-m:8080");
+ props.setProperty("spark.connect.use_ssl", "true");
+ String result = SparkConnectUtils.buildConnectionString(props);
+ assertEquals("sc://ranking-cluster-m:8080/;use_ssl=true", result);
+ }
+
+ @Test
+ void testBuildConnectionStringWithSslAndToken() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://cluster:8080");
+ props.setProperty("spark.connect.use_ssl", "true");
+ props.setProperty("spark.connect.token", "abc123");
+ String result = SparkConnectUtils.buildConnectionString(props);
+ assertEquals("sc://cluster:8080/;token=abc123;use_ssl=true", result);
+ }
+
+ @Test
+ void testBuildConnectionStringDataprocTunnel() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://localhost:15002");
+ String result = SparkConnectUtils.buildConnectionString(props);
+ assertEquals("sc://localhost:15002", result);
+ }
+
+ @Test
+ void testBuildConnectionStringDataprocDirect() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://ranking-cluster-m:8080");
+ String result = SparkConnectUtils.buildConnectionString(props);
+ assertEquals("sc://ranking-cluster-m:8080", result);
+ }
+
+ @Test
+ void testBuildConnectionStringWithUserName() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://cluster:8080");
+ String result = SparkConnectUtils.buildConnectionString(props, "alice");
+ assertEquals("sc://cluster:8080/;user_id=alice", result);
+ }
+
+ @Test
+ void testBuildConnectionStringWithUserNameAndToken() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://cluster:8080");
+ props.setProperty("spark.connect.token", "tok");
+ String result = SparkConnectUtils.buildConnectionString(props, "bob");
+ assertEquals("sc://cluster:8080/;token=tok;user_id=bob", result);
+ }
+
+ @Test
+ void testBuildConnectionStringUserIdAlreadyInUrl() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://cluster:8080/;user_id=preexisting");
+ String result = SparkConnectUtils.buildConnectionString(props, "alice");
+ assertEquals("sc://cluster:8080/;user_id=preexisting", result);
+ }
+
+ @Test
+ void testBuildConnectionStringNullUserName() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://cluster:8080");
+ String result = SparkConnectUtils.buildConnectionString(props, null);
+ assertEquals("sc://cluster:8080", result);
+ }
+
+ @Test
+ void testBuildConnectionStringBlankUserName() {
+ Properties props = new Properties();
+ props.setProperty("spark.remote", "sc://cluster:8080");
+ String result = SparkConnectUtils.buildConnectionString(props, " ");
+ assertEquals("sc://cluster:8080", result);
+ }
+
+ @Test
+ void testReplaceReservedChars() {
+ assertEquals("hello world", SparkConnectUtils.replaceReservedChars("hello\tworld"));
+ assertEquals("hello world", SparkConnectUtils.replaceReservedChars("hello\nworld"));
+ assertEquals("null", SparkConnectUtils.replaceReservedChars(null));
+ assertEquals("normal", SparkConnectUtils.replaceReservedChars("normal"));
+ assertEquals("a b c", SparkConnectUtils.replaceReservedChars("a\tb\nc"));
+ }
+}