From 213b014783c1ba1f854df4f27298a7ed69285287 Mon Sep 17 00:00:00 2001 From: Gabor Roczei <1918366+roczei@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:49:21 +0200 Subject: [PATCH 1/3] [LIVY-1066] Upgrade scalatest to 3.2.9 and scalatra to 2.8.4 ## What changes were proposed in this pull request? Upgrade scalatest 3.0.8 -> 3.2.9 and scalatra 2.6.5 -> 2.8.4. Both upgrades are prerequisites for Spark 4 / Scala 2.13 support (parent JIRA LIVY-1041) and are split into a dedicated commit to keep test migrations separate from core Spark 4 source changes for easier review. Scalatest 3.0.8 -> 3.2.9: - Migrate FunSuite/FunSpec/FunSpecLike/FlatSpec test classes to their 3.2 successors AnyFunSuite/AnyFunSpec/AnyFunSpecLike/AnyFlatSpec. - Move `org.scalatest.Matchers` to `org.scalatest.matchers.should.Matchers`. - Add `org.scalatestplus:mockito-3-4_${scala.binary.version}:3.2.9.0`, since scalatest 3.2 moved `MockitoSugar.mock` into a separate scalatestplus artifact. Scalatra 2.6.5 -> 2.8.4: - Bump `metrics.version` 3.1.0 -> 4.2.19: scalatra 2.8.x's metrics-servlets pulls in Dropwizard metrics 4.x, and keeping metrics-core / metrics-healthchecks at 3.1.0 causes a NoClassDefFoundError for HealthCheckFilter at runtime. ## How was this patch tested? - Unit tests: `mvn verify -Pspark3 -Pscala-2.12 -Pthriftserver` passes on JDK 8/17 for all modules with the migrated ScalaTest 3.2 test suites (matching what was verified as part of the parent LIVY-1041 branch). - The metrics 4.2.19 bump is tested by the integration test suite (`mvn integration-test -Pspark3 -Pscala-2.12 -pl :livy-integration-test`), which previously failed with `NoClassDefFoundError: com/codahale/metrics/servlets/HealthCheckFilter` on MiniCluster startup and now passes. ## Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.7) --- .../livy/client/http/HttpClientSpec.scala | 5 +++-- .../livy/client/http/LivyConnectionSpec.scala | 7 ++++--- .../scala/org/apache/livy/EOLUtilsSuite.scala | 4 ++-- .../framework/BaseIntegrationTestSuite.scala | 6 ++++-- pom.xml | 21 ++++++++++++++++--- .../livy/repl/SparkInterpreterSpec.scala | 5 +++-- .../livy/repl/BaseInterpreterSpec.scala | 5 +++-- .../apache/livy/repl/BaseSessionSpec.scala | 5 +++-- .../livy/repl/PythonInterpreterSpec.scala | 4 +++- .../apache/livy/repl/PythonSessionSpec.scala | 4 +++- .../apache/livy/repl/ReplDriverSuite.scala | 4 ++-- .../org/apache/livy/repl/SessionSpec.scala | 8 ++++--- .../livy/repl/SparkRInterpreterSpec.scala | 4 +++- .../livy/scalaapi/ScalaClientTest.scala | 5 +++-- .../livy/scalaapi/ScalaClientTestUtils.scala | 5 +++-- .../livy/scalaapi/ScalaJobHandleTest.scala | 5 +++-- .../livy/server/AccessManagerSuite.scala | 5 +++-- .../server/ApiVersioningSupportSpec.scala | 5 +++-- .../livy/server/BaseJsonServletSpec.scala | 4 ++-- .../server/SecurityHeadersFilterSpec.scala | 5 +++-- .../livy/server/batch/BatchSessionSpec.scala | 7 ++++--- .../server/batch/CreateBatchRequestSpec.scala | 4 ++-- .../CreateInteractiveRequestSpec.scala | 4 ++-- .../interactive/InteractiveSessionSpec.scala | 6 ++++-- .../interactive/SessionHeartbeatSpec.scala | 5 +++-- .../recovery/BlackholeStateStoreSpec.scala | 6 +++--- .../recovery/FileSystemStateStoreSpec.scala | 6 +++--- .../server/recovery/SessionStoreSpec.scala | 6 +++--- .../livy/server/recovery/StateStoreSpec.scala | 7 ++++--- .../recovery/ZooKeeperStateStoreSpec.scala | 6 +++--- .../livy/sessions/SessionManagerSpec.scala | 5 +++-- .../apache/livy/sessions/SessionSpec.scala | 4 ++-- .../livy/utils/LivySparkUtilsSuite.scala | 6 +++--- .../org/apache/livy/utils/SparkAppSpec.scala | 4 ++-- .../livy/utils/SparkKubernetesAppSpec.scala | 5 +++-- .../apache/livy/utils/SparkYarnAppSpec.scala | 4 ++-- .../thriftserver/ThriftServerBaseTest.scala | 5 +++-- 37 files changed, 125 insertions(+), 81 deletions(-) diff --git a/client-http/src/test/scala/org/apache/livy/client/http/HttpClientSpec.scala b/client-http/src/test/scala/org/apache/livy/client/http/HttpClientSpec.scala index df0cea5f9..54ab56419 100644 --- a/client-http/src/test/scala/org/apache/livy/client/http/HttpClientSpec.scala +++ b/client-http/src/test/scala/org/apache/livy/client/http/HttpClientSpec.scala @@ -30,7 +30,8 @@ import scala.concurrent.{ExecutionContext, Future} import org.mockito.ArgumentCaptor import org.mockito.Matchers.{eq => meq, _} import org.mockito.Mockito._ -import org.scalatest.{BeforeAndAfterAll, FunSpecLike} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funspec.AnyFunSpecLike import org.scalatra.LifeCycle import org.scalatra.servlet.ScalatraListener @@ -49,7 +50,7 @@ import org.apache.livy.utils.AppInfo * module, which implements the client session backend. The client servlet has some functionality * overridden to avoid creating sub-processes for each seession. */ -class HttpClientSpec extends FunSpecLike with BeforeAndAfterAll with LivyBaseUnitTestSuite { +class HttpClientSpec extends AnyFunSpecLike with BeforeAndAfterAll with LivyBaseUnitTestSuite { import HttpClientSpec._ diff --git a/client-http/src/test/scala/org/apache/livy/client/http/LivyConnectionSpec.scala b/client-http/src/test/scala/org/apache/livy/client/http/LivyConnectionSpec.scala index 5fda61065..cfa62d755 100644 --- a/client-http/src/test/scala/org/apache/livy/client/http/LivyConnectionSpec.scala +++ b/client-http/src/test/scala/org/apache/livy/client/http/LivyConnectionSpec.scala @@ -25,15 +25,16 @@ import org.eclipse.jetty.security._ import org.eclipse.jetty.security.UserStore import org.eclipse.jetty.security.authentication.BasicAuthenticator import org.eclipse.jetty.util.security._ -import org.scalatest.{BeforeAndAfterAll, FunSpecLike} -import org.scalatest.Matchers._ +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funspec.AnyFunSpecLike +import org.scalatest.matchers.should.Matchers._ import org.scalatra.servlet.ScalatraListener import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} import org.apache.livy.client.common.TestUtils import org.apache.livy.server.WebServer -class LivyConnectionSpec extends FunSpecLike with BeforeAndAfterAll with LivyBaseUnitTestSuite { +class LivyConnectionSpec extends AnyFunSpecLike with BeforeAndAfterAll with LivyBaseUnitTestSuite { describe("LivyConnection") { def basicAuth(username: String, password: String, realm: String): SecurityHandler = { val roles = Array("user") diff --git a/core/src/test/scala/org/apache/livy/EOLUtilsSuite.scala b/core/src/test/scala/org/apache/livy/EOLUtilsSuite.scala index 8ee73a117..3d6bd99ec 100644 --- a/core/src/test/scala/org/apache/livy/EOLUtilsSuite.scala +++ b/core/src/test/scala/org/apache/livy/EOLUtilsSuite.scala @@ -17,9 +17,9 @@ package org.apache.livy -import org.scalatest.FunSuite +import org.scalatest.funsuite.AnyFunSuite -class EOLUtilsSuite extends FunSuite with LivyBaseUnitTestSuite { +class EOLUtilsSuite extends AnyFunSuite with LivyBaseUnitTestSuite { test("check EOL") { val s1 = "test\r\ntest" diff --git a/integration-test/src/main/scala/org/apache/livy/test/framework/BaseIntegrationTestSuite.scala b/integration-test/src/main/scala/org/apache/livy/test/framework/BaseIntegrationTestSuite.scala index d238f1e5a..8e75515dc 100644 --- a/integration-test/src/main/scala/org/apache/livy/test/framework/BaseIntegrationTestSuite.scala +++ b/integration-test/src/main/scala/org/apache/livy/test/framework/BaseIntegrationTestSuite.scala @@ -38,9 +38,11 @@ import org.apache.http.client.params.AuthPolicy import org.apache.http.impl.auth.BasicSchemeFactory import org.apache.http.impl.auth.SPNegoSchemeFactory import org.apache.http.impl.client.DefaultHttpClient -import org.scalatest._ +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers -abstract class BaseIntegrationTestSuite extends FunSuite with Matchers with BeforeAndAfterAll { +abstract class BaseIntegrationTestSuite extends AnyFunSuite with Matchers with BeforeAndAfterAll { import scala.concurrent.ExecutionContext.Implicits.global var cluster: Cluster = _ diff --git a/pom.xml b/pom.xml index 218b43153..f2ec29b3e 100644 --- a/pom.xml +++ b/pom.xml @@ -98,12 +98,18 @@ 0.9.3 2.26.0 4.0.2 - 3.1.0 + + 4.2.19 1.10.19 4.1.86.Final UTF-8 - 3.0.8 - 2.6.5 + + 3.2.9 + 2.8.4 1.8 12.1.9 @@ -263,6 +269,15 @@ test + + + org.scalatestplus + mockito-3-4_${scala.binary.version} + 3.2.9.0 + test + + org.scalatra scalatra-scalatest_${scala.binary.version} diff --git a/repl/scala-2.12/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala b/repl/scala-2.12/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala index d92203473..fbad108f4 100644 --- a/repl/scala-2.12/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala +++ b/repl/scala-2.12/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala @@ -17,11 +17,12 @@ package org.apache.livy.repl -import org.scalatest._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.apache.livy.LivyBaseUnitTestSuite -class SparkInterpreterSpec extends FunSpec with Matchers with LivyBaseUnitTestSuite { +class SparkInterpreterSpec extends AnyFunSpec with Matchers with LivyBaseUnitTestSuite { describe("SparkInterpreter") { val interpreter = new SparkInterpreter(null) diff --git a/repl/src/test/scala/org/apache/livy/repl/BaseInterpreterSpec.scala b/repl/src/test/scala/org/apache/livy/repl/BaseInterpreterSpec.scala index b3d4d5ab6..a4345fa9f 100644 --- a/repl/src/test/scala/org/apache/livy/repl/BaseInterpreterSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/BaseInterpreterSpec.scala @@ -17,11 +17,12 @@ package org.apache.livy.repl -import org.scalatest.{FlatSpec, Matchers} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers import org.apache.livy.LivyBaseUnitTestSuite -abstract class BaseInterpreterSpec extends FlatSpec with Matchers with LivyBaseUnitTestSuite { +abstract class BaseInterpreterSpec extends AnyFlatSpec with Matchers with LivyBaseUnitTestSuite { def createInterpreter(): Interpreter diff --git a/repl/src/test/scala/org/apache/livy/repl/BaseSessionSpec.scala b/repl/src/test/scala/org/apache/livy/repl/BaseSessionSpec.scala index 5122310cb..4d052006f 100644 --- a/repl/src/test/scala/org/apache/livy/repl/BaseSessionSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/BaseSessionSpec.scala @@ -26,8 +26,9 @@ import scala.language.postfixOps import org.apache.spark.SparkConf import org.json4s._ -import org.scalatest.{FlatSpec, Matchers} import org.scalatest.concurrent.Eventually._ +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers import org.apache.livy.LivyBaseUnitTestSuite import org.apache.livy.client.common.TestUtils @@ -36,7 +37,7 @@ import org.apache.livy.rsc.driver.{Statement, StatementState} import org.apache.livy.sessions._ abstract class BaseSessionSpec(kind: Kind) - extends FlatSpec with Matchers with LivyBaseUnitTestSuite { + extends AnyFlatSpec with Matchers with LivyBaseUnitTestSuite { implicit val formats = DefaultFormats diff --git a/repl/src/test/scala/org/apache/livy/repl/PythonInterpreterSpec.scala b/repl/src/test/scala/org/apache/livy/repl/PythonInterpreterSpec.scala index dc8083f9d..61e27ad6f 100644 --- a/repl/src/test/scala/org/apache/livy/repl/PythonInterpreterSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/PythonInterpreterSpec.scala @@ -20,8 +20,10 @@ package org.apache.livy.repl import org.apache.spark.SparkConf import org.json4s.{DefaultFormats, JNull, JValue} import org.json4s.JsonDSL._ -import org.scalatest._ +import org.scalatest.{BeforeAndAfterAll, Outcome} import org.scalatest.Inside.inside +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.apache.livy.rsc.driver.SparkEntries import org.apache.livy.sessions._ diff --git a/repl/src/test/scala/org/apache/livy/repl/PythonSessionSpec.scala b/repl/src/test/scala/org/apache/livy/repl/PythonSessionSpec.scala index 170d3c723..f42a13c37 100644 --- a/repl/src/test/scala/org/apache/livy/repl/PythonSessionSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/PythonSessionSpec.scala @@ -19,7 +19,9 @@ package org.apache.livy.repl import org.json4s.Extraction import org.json4s.jackson.JsonMethods.parse -import org.scalatest._ +import org.scalatest.{BeforeAndAfterAll, Outcome} +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.apache.livy.sessions._ diff --git a/repl/src/test/scala/org/apache/livy/repl/ReplDriverSuite.scala b/repl/src/test/scala/org/apache/livy/repl/ReplDriverSuite.scala index 836ae5235..66947d6af 100644 --- a/repl/src/test/scala/org/apache/livy/repl/ReplDriverSuite.scala +++ b/repl/src/test/scala/org/apache/livy/repl/ReplDriverSuite.scala @@ -26,15 +26,15 @@ import scala.language.postfixOps import org.apache.spark.launcher.SparkLauncher import org.json4s._ import org.json4s.jackson.JsonMethods._ -import org.scalatest.FunSuite import org.scalatest.concurrent.Eventually._ +import org.scalatest.funsuite.AnyFunSuite import org.apache.livy._ import org.apache.livy.client.common.TestUtils import org.apache.livy.rsc.{PingJob, RSCClient, RSCConf} import org.apache.livy.sessions.Spark -class ReplDriverSuite extends FunSuite with LivyBaseUnitTestSuite { +class ReplDriverSuite extends AnyFunSuite with LivyBaseUnitTestSuite { private implicit val formats = DefaultFormats diff --git a/repl/src/test/scala/org/apache/livy/repl/SessionSpec.scala b/repl/src/test/scala/org/apache/livy/repl/SessionSpec.scala index 34019071d..af47e0b63 100644 --- a/repl/src/test/scala/org/apache/livy/repl/SessionSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/SessionSpec.scala @@ -21,9 +21,10 @@ import java.util.Properties import java.util.concurrent.{ConcurrentLinkedQueue, CountDownLatch, TimeUnit} import org.apache.spark.SparkConf -import org.scalatest.{BeforeAndAfter, FunSpec} -import org.scalatest.Matchers._ +import org.scalatest.BeforeAndAfter import org.scalatest.concurrent.Eventually +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers._ import org.scalatest.time._ import org.apache.livy.LivyBaseUnitTestSuite @@ -31,7 +32,8 @@ import org.apache.livy.repl.Interpreter.ExecuteResponse import org.apache.livy.rsc.RSCConf import org.apache.livy.sessions._ -class SessionSpec extends FunSpec with Eventually with LivyBaseUnitTestSuite with BeforeAndAfter { +class SessionSpec extends AnyFunSpec with Eventually + with LivyBaseUnitTestSuite with BeforeAndAfter { override implicit val patienceConfig = PatienceConfig(timeout = scaled(Span(30, Seconds)), interval = scaled(Span(100, Millis))) diff --git a/repl/src/test/scala/org/apache/livy/repl/SparkRInterpreterSpec.scala b/repl/src/test/scala/org/apache/livy/repl/SparkRInterpreterSpec.scala index 49763960a..dd69a6869 100644 --- a/repl/src/test/scala/org/apache/livy/repl/SparkRInterpreterSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/SparkRInterpreterSpec.scala @@ -20,7 +20,9 @@ package org.apache.livy.repl import org.apache.spark.SparkConf import org.json4s.DefaultFormats import org.json4s.JsonDSL._ -import org.scalatest._ +import org.scalatest.{BeforeAndAfterAll, Outcome} +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.apache.livy.rsc.driver.SparkEntries diff --git a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTest.scala b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTest.scala index 96458bf25..fb751c063 100644 --- a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTest.scala +++ b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTest.scala @@ -33,14 +33,15 @@ import scala.util.{Failure, Success} import org.apache.spark.SparkFiles import org.apache.spark.launcher.SparkLauncher -import org.scalatest.{BeforeAndAfter, FunSuite} +import org.scalatest.BeforeAndAfter import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funsuite.AnyFunSuite import org.apache.livy.LivyBaseUnitTestSuite import org.apache.livy.client.common.TestUtils import org.apache.livy.rsc.RSCConf.Entry._ -class ScalaClientTest extends FunSuite +class ScalaClientTest extends AnyFunSuite with ScalaFutures with BeforeAndAfter with LivyBaseUnitTestSuite { diff --git a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTestUtils.scala b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTestUtils.scala index 458ff3bfb..7d406f3a6 100644 --- a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTestUtils.scala +++ b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTestUtils.scala @@ -22,12 +22,13 @@ import java.util.concurrent.{CountDownLatch, TimeUnit} import scala.collection.mutable.ArrayBuffer import scala.concurrent.{Await, Future} import scala.concurrent.duration._ +import scala.language.postfixOps -import org.scalatest.FunSuite +import org.scalatest.funsuite.AnyFunSuite import org.apache.livy.LivyBaseUnitTestSuite -object ScalaClientTestUtils extends FunSuite with LivyBaseUnitTestSuite { +object ScalaClientTestUtils extends AnyFunSuite with LivyBaseUnitTestSuite { val Timeout = 40 diff --git a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaJobHandleTest.scala b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaJobHandleTest.scala index 3f5bdc64e..732f6af76 100644 --- a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaJobHandleTest.scala +++ b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaJobHandleTest.scala @@ -26,13 +26,14 @@ import scala.util.{Failure, Success} import org.mockito.Matchers._ import org.mockito.Mockito._ -import org.scalatest.{BeforeAndAfter, FunSuite} +import org.scalatest.BeforeAndAfter import org.scalatest.concurrent.ScalaFutures +import org.scalatest.funsuite.AnyFunSuite import org.apache.livy.{JobHandle, LivyBaseUnitTestSuite} import org.apache.livy.JobHandle.{Listener, State} -class ScalaJobHandleTest extends FunSuite +class ScalaJobHandleTest extends AnyFunSuite with ScalaFutures with BeforeAndAfter with LivyBaseUnitTestSuite { diff --git a/server/src/test/scala/org/apache/livy/server/AccessManagerSuite.scala b/server/src/test/scala/org/apache/livy/server/AccessManagerSuite.scala index 71c500f08..2fadf6755 100644 --- a/server/src/test/scala/org/apache/livy/server/AccessManagerSuite.scala +++ b/server/src/test/scala/org/apache/livy/server/AccessManagerSuite.scala @@ -17,11 +17,12 @@ package org.apache.livy.server -import org.scalatest.{FunSuite, Matchers} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} -class AccessManagerSuite extends FunSuite with Matchers with LivyBaseUnitTestSuite { +class AccessManagerSuite extends AnyFunSuite with Matchers with LivyBaseUnitTestSuite { import LivyConf._ private val viewUsers = Seq("user1", "user2", "user3") diff --git a/server/src/test/scala/org/apache/livy/server/ApiVersioningSupportSpec.scala b/server/src/test/scala/org/apache/livy/server/ApiVersioningSupportSpec.scala index 0f50ced08..e38c19b1c 100644 --- a/server/src/test/scala/org/apache/livy/server/ApiVersioningSupportSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/ApiVersioningSupportSpec.scala @@ -19,13 +19,14 @@ package org.apache.livy.server import javax.servlet.http.HttpServletResponse -import org.scalatest.FunSpecLike +import org.scalatest.funspec.AnyFunSpecLike import org.scalatra.ScalatraServlet import org.scalatra.test.scalatest.ScalatraSuite import org.apache.livy.LivyBaseUnitTestSuite -class ApiVersioningSupportSpec extends ScalatraSuite with FunSpecLike with LivyBaseUnitTestSuite { +class ApiVersioningSupportSpec extends ScalatraSuite with AnyFunSpecLike + with LivyBaseUnitTestSuite { val LatestVersionOutput = "latest" object FakeApiVersions extends Enumeration { diff --git a/server/src/test/scala/org/apache/livy/server/BaseJsonServletSpec.scala b/server/src/test/scala/org/apache/livy/server/BaseJsonServletSpec.scala index a96ae2b91..4c8f4fa72 100644 --- a/server/src/test/scala/org/apache/livy/server/BaseJsonServletSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/BaseJsonServletSpec.scala @@ -23,7 +23,7 @@ import javax.servlet.http.HttpServletResponse._ import scala.reflect.ClassTag import com.fasterxml.jackson.databind.ObjectMapper -import org.scalatest.FunSpecLike +import org.scalatest.funspec.AnyFunSpecLike import org.scalatra.test.scalatest.ScalatraSuite import org.apache.livy.LivyBaseUnitTestSuite @@ -38,7 +38,7 @@ import org.apache.livy.LivyBaseUnitTestSuite * `Unit`, and the `response` object should be checked directly. */ abstract class BaseJsonServletSpec extends ScalatraSuite - with FunSpecLike with LivyBaseUnitTestSuite { + with AnyFunSpecLike with LivyBaseUnitTestSuite { protected val mapper = new ObjectMapper() .registerModule(com.fasterxml.jackson.module.scala.DefaultScalaModule) diff --git a/server/src/test/scala/org/apache/livy/server/SecurityHeadersFilterSpec.scala b/server/src/test/scala/org/apache/livy/server/SecurityHeadersFilterSpec.scala index 03ff62397..6fbce33c8 100644 --- a/server/src/test/scala/org/apache/livy/server/SecurityHeadersFilterSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/SecurityHeadersFilterSpec.scala @@ -22,12 +22,13 @@ import javax.servlet.http.{HttpServletRequest, HttpServletResponse} import org.mockito.ArgumentCaptor import org.mockito.Mockito.{atLeastOnce, verify} -import org.scalatest.{FunSpec, Matchers} +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.mockito.MockitoSugar.mock import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} -class SecurityHeadersFilterSpec extends FunSpec with Matchers with LivyBaseUnitTestSuite { +class SecurityHeadersFilterSpec extends AnyFunSpec with Matchers with LivyBaseUnitTestSuite { val requiredHeaders = Set("X-Content-Type-Options", "X-Frame-Options", "X-XSS-Protection", "Content-Security-Policy") diff --git a/server/src/test/scala/org/apache/livy/server/batch/BatchSessionSpec.scala b/server/src/test/scala/org/apache/livy/server/batch/BatchSessionSpec.scala index 9ecfd57b9..2477eac73 100644 --- a/server/src/test/scala/org/apache/livy/server/batch/BatchSessionSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/batch/BatchSessionSpec.scala @@ -26,7 +26,8 @@ import scala.concurrent.duration.Duration import org.mockito.Matchers import org.mockito.Matchers.anyObject import org.mockito.Mockito._ -import org.scalatest.{BeforeAndAfter, FunSpec} +import org.scalatest.BeforeAndAfter +import org.scalatest.funspec.AnyFunSpec import org.scalatestplus.mockito.MockitoSugar.mock import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf, Utils} @@ -36,9 +37,9 @@ import org.apache.livy.sessions.SessionState import org.apache.livy.utils.{AppInfo, Clock, SparkApp} class BatchSessionSpec - extends FunSpec + extends AnyFunSpec with BeforeAndAfter - with org.scalatest.Matchers + with org.scalatest.matchers.should.Matchers with LivyBaseUnitTestSuite { val script: Path = { diff --git a/server/src/test/scala/org/apache/livy/server/batch/CreateBatchRequestSpec.scala b/server/src/test/scala/org/apache/livy/server/batch/CreateBatchRequestSpec.scala index 7fef3c2ff..5ba66b1f0 100644 --- a/server/src/test/scala/org/apache/livy/server/batch/CreateBatchRequestSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/batch/CreateBatchRequestSpec.scala @@ -18,11 +18,11 @@ package org.apache.livy.server.batch import com.fasterxml.jackson.databind.{JsonMappingException, ObjectMapper} -import org.scalatest.FunSpec +import org.scalatest.funspec.AnyFunSpec import org.apache.livy.LivyBaseUnitTestSuite -class CreateBatchRequestSpec extends FunSpec with LivyBaseUnitTestSuite { +class CreateBatchRequestSpec extends AnyFunSpec with LivyBaseUnitTestSuite { private val mapper = new ObjectMapper() .registerModule(com.fasterxml.jackson.module.scala.DefaultScalaModule) diff --git a/server/src/test/scala/org/apache/livy/server/interactive/CreateInteractiveRequestSpec.scala b/server/src/test/scala/org/apache/livy/server/interactive/CreateInteractiveRequestSpec.scala index b84d98a9c..b5f6b5e98 100644 --- a/server/src/test/scala/org/apache/livy/server/interactive/CreateInteractiveRequestSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/interactive/CreateInteractiveRequestSpec.scala @@ -18,12 +18,12 @@ package org.apache.livy.server.interactive import com.fasterxml.jackson.databind.ObjectMapper -import org.scalatest.FunSpec +import org.scalatest.funspec.AnyFunSpec import org.apache.livy.LivyBaseUnitTestSuite import org.apache.livy.sessions.{PySpark, SessionKindModule} -class CreateInteractiveRequestSpec extends FunSpec with LivyBaseUnitTestSuite { +class CreateInteractiveRequestSpec extends AnyFunSpec with LivyBaseUnitTestSuite { private val mapper = new ObjectMapper() .registerModule(com.fasterxml.jackson.module.scala.DefaultScalaModule) diff --git a/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala b/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala index f6424e2f6..556c54fbd 100644 --- a/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala @@ -29,8 +29,10 @@ import org.json4s.jackson.JsonMethods.parse import org.mockito.{Matchers => MockitoMatchers} import org.mockito.Matchers._ import org.mockito.Mockito.{atLeastOnce, verify, when} -import org.scalatest.{BeforeAndAfterAll, FunSpec, Matchers} +import org.scalatest.BeforeAndAfterAll import org.scalatest.concurrent.Eventually._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.mockito.MockitoSugar.mock import org.apache.livy.{ExecuteRequest, JobHandle, LivyBaseUnitTestSuite, LivyConf} @@ -42,7 +44,7 @@ import org.apache.livy.server.recovery.SessionStore import org.apache.livy.sessions.{PySpark, SessionState, Spark} import org.apache.livy.utils.{AppInfo, SparkApp} -class InteractiveSessionSpec extends FunSpec +class InteractiveSessionSpec extends AnyFunSpec with Matchers with BeforeAndAfterAll with LivyBaseUnitTestSuite { private val livyConf = new LivyConf() diff --git a/server/src/test/scala/org/apache/livy/server/interactive/SessionHeartbeatSpec.scala b/server/src/test/scala/org/apache/livy/server/interactive/SessionHeartbeatSpec.scala index c9ca9d5c1..55f10a854 100644 --- a/server/src/test/scala/org/apache/livy/server/interactive/SessionHeartbeatSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/interactive/SessionHeartbeatSpec.scala @@ -22,8 +22,9 @@ import scala.concurrent.Future import scala.language.postfixOps import org.mockito.Mockito.{never, verify, when} -import org.scalatest.{FunSpec, Matchers} import org.scalatest.concurrent.Eventually._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.mockito.MockitoSugar.mock import org.apache.livy.LivyConf @@ -31,7 +32,7 @@ import org.apache.livy.server.recovery.SessionStore import org.apache.livy.sessions.{Session, SessionManager} import org.apache.livy.sessions.Session.RecoveryMetadata -class SessionHeartbeatSpec extends FunSpec with Matchers { +class SessionHeartbeatSpec extends AnyFunSpec with Matchers { describe("SessionHeartbeat") { class TestHeartbeat(override val heartbeatTimeout: FiniteDuration) extends SessionHeartbeat {} diff --git a/server/src/test/scala/org/apache/livy/server/recovery/BlackholeStateStoreSpec.scala b/server/src/test/scala/org/apache/livy/server/recovery/BlackholeStateStoreSpec.scala index 8ee448f5e..014652270 100644 --- a/server/src/test/scala/org/apache/livy/server/recovery/BlackholeStateStoreSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/recovery/BlackholeStateStoreSpec.scala @@ -17,13 +17,13 @@ package org.apache.livy.server.recovery -import org.scalatest.FunSpec -import org.scalatest.Matchers._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers._ import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} import org.apache.livy.server.batch.BatchRecoveryMetadata -class BlackholeStateStoreSpec extends FunSpec with LivyBaseUnitTestSuite { +class BlackholeStateStoreSpec extends AnyFunSpec with LivyBaseUnitTestSuite { describe("BlackholeStateStore") { val stateStore = new BlackholeStateStore(new LivyConf()) diff --git a/server/src/test/scala/org/apache/livy/server/recovery/FileSystemStateStoreSpec.scala b/server/src/test/scala/org/apache/livy/server/recovery/FileSystemStateStoreSpec.scala index 1ee1a2fe2..cbedb1e6e 100644 --- a/server/src/test/scala/org/apache/livy/server/recovery/FileSystemStateStoreSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/recovery/FileSystemStateStoreSpec.scala @@ -31,13 +31,13 @@ import org.mockito.Mockito.{atLeastOnce, spy, verify, when} import org.mockito.internal.matchers.Equals import org.mockito.invocation.InvocationOnMock import org.mockito.stubbing.Answer -import org.scalatest.FunSpec -import org.scalatest.Matchers._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers._ import org.scalatestplus.mockito.MockitoSugar.mock import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} -class FileSystemStateStoreSpec extends FunSpec with LivyBaseUnitTestSuite { +class FileSystemStateStoreSpec extends AnyFunSpec with LivyBaseUnitTestSuite { describe("FileSystemStateStore") { def pathEq(wantedPath: String): Path = argThat(new ArgumentMatcher[Path] { private val matcher = new Equals(wantedPath) diff --git a/server/src/test/scala/org/apache/livy/server/recovery/SessionStoreSpec.scala b/server/src/test/scala/org/apache/livy/server/recovery/SessionStoreSpec.scala index 88610514e..923f70981 100644 --- a/server/src/test/scala/org/apache/livy/server/recovery/SessionStoreSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/recovery/SessionStoreSpec.scala @@ -20,14 +20,14 @@ package org.apache.livy.server.recovery import scala.util.Success import org.mockito.Mockito._ -import org.scalatest.FunSpec -import org.scalatest.Matchers._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers._ import org.scalatestplus.mockito.MockitoSugar.mock import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} import org.apache.livy.sessions.Session.RecoveryMetadata -class SessionStoreSpec extends FunSpec with LivyBaseUnitTestSuite { +class SessionStoreSpec extends AnyFunSpec with LivyBaseUnitTestSuite { describe("SessionStore") { case class TestRecoveryMetadata(id: Int) extends RecoveryMetadata diff --git a/server/src/test/scala/org/apache/livy/server/recovery/StateStoreSpec.scala b/server/src/test/scala/org/apache/livy/server/recovery/StateStoreSpec.scala index 8c2d4b36e..9b152a50b 100644 --- a/server/src/test/scala/org/apache/livy/server/recovery/StateStoreSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/recovery/StateStoreSpec.scala @@ -17,13 +17,14 @@ package org.apache.livy.server.recovery -import org.scalatest.{BeforeAndAfter, FunSpec} -import org.scalatest.Matchers._ +import org.scalatest.BeforeAndAfter +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers._ import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} import org.apache.livy.sessions.SessionManager -class StateStoreSpec extends FunSpec with BeforeAndAfter with LivyBaseUnitTestSuite { +class StateStoreSpec extends AnyFunSpec with BeforeAndAfter with LivyBaseUnitTestSuite { describe("StateStore") { after { StateStore.cleanup() diff --git a/server/src/test/scala/org/apache/livy/server/recovery/ZooKeeperStateStoreSpec.scala b/server/src/test/scala/org/apache/livy/server/recovery/ZooKeeperStateStoreSpec.scala index e8222d09a..544d6c86d 100644 --- a/server/src/test/scala/org/apache/livy/server/recovery/ZooKeeperStateStoreSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/recovery/ZooKeeperStateStoreSpec.scala @@ -26,13 +26,13 @@ import org.apache.curator.framework.state.{ConnectionState, ConnectionStateListe import org.apache.zookeeper.data.Stat import org.mockito.ArgumentCaptor import org.mockito.Mockito._ -import org.scalatest.FunSpec -import org.scalatest.Matchers._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers._ import org.scalatestplus.mockito.MockitoSugar.mock import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} -class ZooKeeperStateStoreSpec extends FunSpec with LivyBaseUnitTestSuite { +class ZooKeeperStateStoreSpec extends AnyFunSpec with LivyBaseUnitTestSuite { describe("ZooKeeperStateStore") { case class TestFixture(stateStore: ZooKeeperStateStore, curatorClient: CuratorFramework) val conf = new LivyConf() diff --git a/server/src/test/scala/org/apache/livy/sessions/SessionManagerSpec.scala b/server/src/test/scala/org/apache/livy/sessions/SessionManagerSpec.scala index 363b01f89..8e5557018 100644 --- a/server/src/test/scala/org/apache/livy/sessions/SessionManagerSpec.scala +++ b/server/src/test/scala/org/apache/livy/sessions/SessionManagerSpec.scala @@ -23,8 +23,9 @@ import scala.language.postfixOps import scala.util.{Failure, Try} import org.mockito.Mockito.{doReturn, never, verify, when} -import org.scalatest.{FunSpec, Matchers} import org.scalatest.concurrent.Eventually._ +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers import org.scalatestplus.mockito.MockitoSugar.mock import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} @@ -33,7 +34,7 @@ import org.apache.livy.server.interactive.{InteractiveRecoveryMetadata, Interact import org.apache.livy.server.recovery.SessionStore import org.apache.livy.sessions.Session.RecoveryMetadata -class SessionManagerSpec extends FunSpec with Matchers with LivyBaseUnitTestSuite { +class SessionManagerSpec extends AnyFunSpec with Matchers with LivyBaseUnitTestSuite { implicit def executor: ExecutionContext = ExecutionContext.global private def createSessionManager(livyConf: LivyConf = new LivyConf()) diff --git a/server/src/test/scala/org/apache/livy/sessions/SessionSpec.scala b/server/src/test/scala/org/apache/livy/sessions/SessionSpec.scala index a77496348..60ef74863 100644 --- a/server/src/test/scala/org/apache/livy/sessions/SessionSpec.scala +++ b/server/src/test/scala/org/apache/livy/sessions/SessionSpec.scala @@ -19,11 +19,11 @@ package org.apache.livy.sessions import java.net.URI -import org.scalatest.FunSuite +import org.scalatest.funsuite.AnyFunSuite import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} -class SessionSpec extends FunSuite with LivyBaseUnitTestSuite { +class SessionSpec extends AnyFunSuite with LivyBaseUnitTestSuite { test("use default fs in paths") { val conf = new LivyConf(false) diff --git a/server/src/test/scala/org/apache/livy/utils/LivySparkUtilsSuite.scala b/server/src/test/scala/org/apache/livy/utils/LivySparkUtilsSuite.scala index 24b612ccd..4338f6d38 100644 --- a/server/src/test/scala/org/apache/livy/utils/LivySparkUtilsSuite.scala +++ b/server/src/test/scala/org/apache/livy/utils/LivySparkUtilsSuite.scala @@ -17,14 +17,14 @@ package org.apache.livy.utils -import org.scalatest.FunSuite -import org.scalatest.Matchers +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} import org.apache.livy.LivyConf._ import org.apache.livy.server.LivyServer -class LivySparkUtilsSuite extends FunSuite with Matchers with LivyBaseUnitTestSuite { +class LivySparkUtilsSuite extends AnyFunSuite with Matchers with LivyBaseUnitTestSuite { import LivySparkUtils._ diff --git a/server/src/test/scala/org/apache/livy/utils/SparkAppSpec.scala b/server/src/test/scala/org/apache/livy/utils/SparkAppSpec.scala index 94026029f..eb2ba48e3 100644 --- a/server/src/test/scala/org/apache/livy/utils/SparkAppSpec.scala +++ b/server/src/test/scala/org/apache/livy/utils/SparkAppSpec.scala @@ -23,11 +23,11 @@ import scala.collection.JavaConverters._ import org.apache.hadoop.conf.Configuration import org.apache.hadoop.security.alias.CredentialProviderFactory -import org.scalatest.FunSpec +import org.scalatest.funspec.AnyFunSpec import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} -class SparkAppSpec extends FunSpec with LivyBaseUnitTestSuite { +class SparkAppSpec extends AnyFunSpec with LivyBaseUnitTestSuite { private val providerPathKey = "spark.hadoop.hadoop.security.credential.provider.path" private val truststorePasswordKey = "spark.hadoop.hive.metastore.truststore.password" diff --git a/server/src/test/scala/org/apache/livy/utils/SparkKubernetesAppSpec.scala b/server/src/test/scala/org/apache/livy/utils/SparkKubernetesAppSpec.scala index 24e66e0d9..6acec2a23 100644 --- a/server/src/test/scala/org/apache/livy/utils/SparkKubernetesAppSpec.scala +++ b/server/src/test/scala/org/apache/livy/utils/SparkKubernetesAppSpec.scala @@ -22,13 +22,14 @@ import io.fabric8.kubernetes.api.model._ import io.fabric8.kubernetes.api.model.networking.v1.{Ingress, IngressRule, IngressSpec} import io.fabric8.kubernetes.client.KubernetesClient import org.mockito.Mockito.when -import org.scalatest.{BeforeAndAfterAll, FunSpec} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funspec.AnyFunSpec import org.scalatestplus.mockito.MockitoSugar._ import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf} import org.apache.livy.utils.KubernetesConstants.SPARK_APP_TAG_LABEL -class SparkKubernetesAppSpec extends FunSpec with LivyBaseUnitTestSuite with BeforeAndAfterAll { +class SparkKubernetesAppSpec extends AnyFunSpec with LivyBaseUnitTestSuite with BeforeAndAfterAll { override def beforeAll(): Unit = { super.beforeAll() diff --git a/server/src/test/scala/org/apache/livy/utils/SparkYarnAppSpec.scala b/server/src/test/scala/org/apache/livy/utils/SparkYarnAppSpec.scala index 1cc6b4b60..ab2f5167d 100644 --- a/server/src/test/scala/org/apache/livy/utils/SparkYarnAppSpec.scala +++ b/server/src/test/scala/org/apache/livy/utils/SparkYarnAppSpec.scala @@ -36,13 +36,13 @@ import org.mockito.Mockito._ import org.mockito.invocation.InvocationOnMock import org.mockito.stubbing.Answer import org.scalatest.concurrent.Eventually -import org.scalatest.FunSpec +import org.scalatest.funspec.AnyFunSpec import org.scalatestplus.mockito.MockitoSugar.mock import org.apache.livy.{LivyBaseUnitTestSuite, LivyConf, Utils} import org.apache.livy.utils.SparkApp._ -class SparkYarnAppSpec extends FunSpec with LivyBaseUnitTestSuite { +class SparkYarnAppSpec extends AnyFunSpec with LivyBaseUnitTestSuite { private def cleanupThread(t: Thread)(f: => Unit) = { try { f } finally { t.interrupt() } } diff --git a/thriftserver/server/src/test/scala/org/apache/livy/thriftserver/ThriftServerBaseTest.scala b/thriftserver/server/src/test/scala/org/apache/livy/thriftserver/ThriftServerBaseTest.scala index 9450907c7..860df01e9 100644 --- a/thriftserver/server/src/test/scala/org/apache/livy/thriftserver/ThriftServerBaseTest.scala +++ b/thriftserver/server/src/test/scala/org/apache/livy/thriftserver/ThriftServerBaseTest.scala @@ -20,7 +20,8 @@ package org.apache.livy.thriftserver import java.sql.{Connection, DriverManager, Statement} import org.apache.hive.jdbc.HiveDriver -import org.scalatest.{BeforeAndAfterAll, FunSuite} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuite import org.apache.livy.LivyConf import org.apache.livy.LivyConf.{LIVY_SPARK_SCALA_VERSION, LIVY_SPARK_VERSION} @@ -35,7 +36,7 @@ object ServerMode extends Enumeration { val binary, http = Value } -abstract class ThriftServerBaseTest extends FunSuite with BeforeAndAfterAll { +abstract class ThriftServerBaseTest extends AnyFunSuite with BeforeAndAfterAll { def mode: ServerMode.Value def port: Int From 1cee4b3bb074b92b65710bb4f3d3a9891bf43e21 Mon Sep 17 00:00:00 2001 From: Gabor Roczei <1918366+roczei@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:49:34 +0200 Subject: [PATCH 2/3] [LIVY-1067] Bump CI Python to 3.11.11 and fix python-api PEP 440 version ## What changes were proposed in this pull request? Prepares the CI environment and the python-api package metadata for the upcoming Spark 4 support commit. Both changes are also useful on the existing Spark 3 build: the PEP 440 fix unblocks `pip3 install livy-python-api` on modern pip (>= 24), and the Python bump keeps the CI image on a version supported by both Spark 3.5 and Spark 4.1. **CI Python bumped to 3.11.11** dev/docker/livy-dev-base/Dockerfile pyenv Python bumped from 3.9.21 to 3.11.11, and .github/workflows/integration-tests.yaml pins `pyenv global 3.11.11` explicitly. Rationale (per each Spark release's python/setup.py): Spark 4.1 declares `python_requires=">=3.10"` with classifiers listing 3.10/3.11/3.12/3.13/3.14, while Spark 3.5 declares `python_requires=">=3.8"` with classifiers listing 3.8/3.9/3.10/3.11. The intersection of the two supported ranges is 3.10 and 3.11; we pick the higher one (3.11.11) so the same image serves both matrix profiles. The old 3.9.21 was below the Spark 4.1 lower bound. Both the Dockerfile and the workflow now document this choice inline so future bumps stay in sync. The CI Docker image (`ghcr.io/${owner}/livy-ci:latest`) had to be rebuilt and pushed manually for the PR CI to pick up the new Python before this PR is merged: `.github/workflows/build-ci-image.yaml` only fires on push to master, so branch/PR runs would otherwise still pull the stale image cached from the previous Dockerfile. Commands used (from macOS Apple Silicon, cross-built for linux/amd64 to match the GitHub Actions runners): gh auth refresh --scopes write:packages,read:packages gh auth token | docker login ghcr.io -u --password-stdin docker buildx build --platform linux/amd64 \ -t ghcr.io//livy-ci:latest --push \ dev/docker/livy-dev-base After this PR merges to master, the workflow will republish the image on any future Dockerfile change automatically, so this manual step is only needed for the bootstrap run. **python-api/setup.py PEP 440 version fix** Version bumped from `1.0.0-SNAPSHOT` (Maven-style, not PEP 440 compliant) to `1.0.0.dev0` (the canonical Python "pre-release under active development" form). Modern pip (>= 24) refuses to parse the old value with `Invalid version: '1.0.0-SNAPSHOT'`, which surfaced as `WARNING: Error parsing dependencies of livy-python-api` on every `pip3 install` and blocked the `validate Python-API requests` integration test from resolving its dependencies. The Maven POM version (`python-api/pom.xml`) stays `1.0.0-SNAPSHOT` -- Maven and pip have separate versioning conventions and only the pip-visible metadata needs to change. ## How was this patch tested? - `pip3 install ./python-api` no longer emits `WARNING: Error parsing dependencies of livy-python-api: Invalid version: '1.0.0-SNAPSHOT'`; the package installs cleanly on pip 24+. - Rebuilt the CI Docker image locally with the pyenv 3.11.11 bump and confirmed `python3 --version` reports `Python 3.11.11` inside the container. - GitHub Actions Integration Tests workflow runs `pyenv global 3.11.11` successfully on the -Pspark3 -Pscala-2.12 matrix entry (this commit does not yet add any Spark 4 entry). ## Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.7) --- .github/workflows/integration-tests.yaml | 7 ++++++- dev/docker/livy-dev-base/Dockerfile | 13 +++++++++++-- python-api/setup.py | 6 +++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index 968937e0c..c1dd01b94 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -48,8 +48,13 @@ jobs: restore-keys: | ${{ runner.os }}-maven- - + # Must match the pyenv-installed version in dev/docker/livy-dev-base/Dockerfile. + # Per each Spark release's python/setup.py: Spark 4.1 supports 3.10..3.14 + # (python_requires=">=3.10"), Spark 3.5 supports 3.8..3.11 + # (python_requires=">=3.8"). The intersection is 3.10 and 3.11; 3.11.11 + # is picked as the shared version. name: Set Python 3 as default - run: pyenv global 3 && echo "PYSPARK_PYTHON=$(which python3)" >> "$GITHUB_ENV" + run: pyenv global 3.11.11 && echo "PYSPARK_PYTHON=$(which python3)" >> "$GITHUB_ENV" - name: Set JDK version run: update-alternatives --set java ${{ matrix.jdk_path }} diff --git a/dev/docker/livy-dev-base/Dockerfile b/dev/docker/livy-dev-base/Dockerfile index 5686e61d1..b0d06b735 100644 --- a/dev/docker/livy-dev-base/Dockerfile +++ b/dev/docker/livy-dev-base/Dockerfile @@ -70,8 +70,17 @@ RUN git clone https://github.com/pyenv/pyenv.git $HOME/pyenv ENV PYENV_ROOT=$HOME/pyenv ENV PATH="$HOME/pyenv/shims:$HOME/pyenv/bin:$HOME/bin:$PATH" -RUN pyenv install -v 3.9.21 && \ - pyenv global 3.9.21 && \ +# Python 3.11.11 is chosen because it is in the officially supported range of +# BOTH matrix profiles, per each Spark release's python/setup.py: +# * -Pspark4 (Spark 4.1.2): python_requires=">=3.10", classifiers list +# 3.10 / 3.11 / 3.12 / 3.13 / 3.14. +# * -Pspark3 (Spark 3.5.6): python_requires=">=3.8", classifiers list +# 3.8 / 3.9 / 3.10 / 3.11. +# The intersection is 3.10 and 3.11; we pick the higher one (3.11.11) so the +# same image serves both profiles. When bumping this, keep +# .github/workflows/integration-tests.yaml's `pyenv global` in sync. +RUN pyenv install -v 3.11.11 && \ + pyenv global 3.11.11 && \ pyenv rehash # Install build dependencies for python3 diff --git a/python-api/setup.py b/python-api/setup.py index 4d7b9b5f2..df1a4de1e 100644 --- a/python-api/setup.py +++ b/python-api/setup.py @@ -37,7 +37,11 @@ setup( name='livy-python-api', - version="1.0.0-SNAPSHOT", + # PEP 440 disallows Maven-style `-SNAPSHOT` suffixes; use `.dev0` instead, + # which is the canonical Python equivalent for "pre-release under active + # development". Modern pip (>= 24) rejects `1.0.0-SNAPSHOT` outright with + # `Invalid version: '1.0.0-SNAPSHOT'` when parsing this package. + version="1.0.0.dev0", packages=["livy", "livy-tests"], package_dir={ "": "src/main/python", From 189889086bbcb64b2e0fc1108ae45c918c7049cd Mon Sep 17 00:00:00 2001 From: Gabor Roczei <1918366+roczei@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:49:34 +0200 Subject: [PATCH 3/3] [LIVY-1041] Add Spark 4 support via a new Maven profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changes were proposed in this pull request? This PR adds support for building and running Apache Livy against Apache Spark 4.x (pinned to 4.1.2) via two new Maven profiles: -Pspark4 (Spark 4.1.2 + Hadoop 3.4.1 + Netty 4.2.7 + JDK 17) -Pscala-2.13 (Scala 2.13.17) Spark 4 introduced breaking changes that required widespread updates: JDK 17 minimum, Scala 2.13 only, Netty 4.2.x, a rewritten Scala REPL, and json4s 4.0.7. **Build / POM changes** - pom.xml: new `spark4` and `scala-2.13` profiles; maven-shade-plugin 3.5.0 -> 3.6.2 (bundles an ASM version that understands Java 22 bytecode, needed to shade Jackson 2.18.2's `META-INF/versions/22` multi-release classes). - repl/pom.xml: added `json4s-jackson-core` to the shade includes (json4s 4.0.7 split `JsonMethods` into a separate artifact; missing it caused a NoClassDefFoundError in PythonInterpreter at runtime). Also pulls `livy-test-lib` in as a test dependency so the repl tests can use the new shared `ScalaVersionAware` trait. - New Scala 2.13 module wrapper POMs: `core/scala-2.13`, `repl/scala-2.13`, `scala-api/scala-2.13`. - README.md: documented Spark 4 Python compatibility -- supported Python versions for Spark 4.1 are 3.10 – 3.14 (added to the `-Pspark4` Note block) **Spark 4 / Scala 2.13 source-level fixes** - `repl/scala-2.13/SparkInterpreter.scala`: new implementation ported to the Spark 4 SparkILoop (`shell.ILoop`, `PrintWriter`, `createInterpreter(settings)`, operations via `sparkILoop.intp`, `ReplCompletion`). To make extra JARs supplied via `spark.jars.packages` visible to `import ...` we feed those JARs to the Scala compiler through the `-classpath` argument at Settings construction time, instead of via a post-init `IMain.addUrlsToClassPath` call. In Scala 2.13 the latter does not reliably register URLs with the compiler's `platform.classPath`, so `import org.codehaus.plexus.util._` fails with `object plexus is not a member of package org.codehaus`. A new `collectUserJarsClasspath()` helper walks the context classloader chain to Spark's `MutableURLClassLoader`, filters out `livy-*` and the wrong-version `scala-reflect` (same rules as the old `addUrls` path), and joins the resulting file paths with `File.pathSeparator`. If the chain has no `MutableURLClassLoader` we log a warning and skip the extra `-classpath` entry rather than passing an empty argument. - `AbstractSparkInterpreter.scala`: * `parseError` skips the leading caret line so `ename` lands on the "error: ..." message on both Scala 2.12 and 2.13 (2.13 prints the caret pointer before the message). Implemented with `lines.indexWhere(_.trim != "^") ... lines.patch(idx, Nil, 1)`. * New `isEffectivelyEmpty` helper strips block/line comments and returns success for comment-only inputs (Scala 2.12 accepted them silently; 2.13 rejects them as compile errors). - `repl/Session.scala`: SparkR `setJobGroup` match now accepts `"4"` alongside `"2" | "3"`. - `server/interactive/InteractiveSession.scala`: `datanucleusJars` and `mergeHiveSiteAndHiveDeps` now take an extra `scalaVersion` parameter, adds `case 3 | 4` to the major-version match and replaces the hard-coded `assembly/target/scala-2.12/jars` with `assembly/target/scala-$scalaVersion/jars`. - `server/batch/BatchSession.scala`: pass `--verbose` to spark-submit under `LivyConf.TEST_MODE` so tests can grep the resolved arguments out of the child's log. - `rsc/driver/SparkEntries.java`: new reflection-based `hiveClassesArePresent` helper that probes `org.apache.spark.sql.classic.SparkSession$` (Spark 4) first and falls back to `org.apache.spark.sql.SparkSession$` (Spark 3). - `utils/SparkProcessBuilder.scala`: new `verbose(v: Boolean)` mutator and an internal `_verbose` flag that appends `--verbose` to `spark-submit` when set (used by `BatchSession`). - `utils/LivySparkUtils.scala`: `(4, 0)` and `(4, 1)` -> `"2.13"` entries in `sparkScalaVersionMap`; `MAX_VERSION` bumped from `(3, 6)` to `(4, 2)`. - `repl/SparkRInterpreter.scala`: log a warning on Spark 4 that SparkR was deprecated in SPARK-49347 and may be removed in a future Spark release. - `scala-api/scalaapi/package.scala`: inline comment documenting that `Duration.isFinite` is a parameterless def in Scala 2.13 (took an empty parameter list in 2.12); dropping the parens compiles on both. - Thriftserver Scala/json4s 4.x updates: * `session/Get{Columns,Functions,Schemas,Tables}Job.java`: `scala.collection.JavaConversions.seqAsJavaList` (removed in Scala 2.13) replaced with `scala.collection.JavaConverters.seqAsJavaListConverter(...).asJava()`. * `types/DataTypeUtils.scala`: json4s 4.x `parse(input, useBigDecimal)` requires an `AsJsonInput[T]`; switch to the single-arg String overload `parse(sparkJson)`. * `LivyExecuteStatementOperation.scala`: explicit `res.toSeq` since Scala 2.13 no longer widens a mutable `Buffer` to `Seq` implicitly. * `ThriftServerSuites.scala`: `LIVY-571` assertion now also accepts the Spark 4 error string `[SCHEMA_NOT_FOUND] The schema `spark_catalog`.`invalid_database` cannot be found`. - `InteractiveSessionServlet.scala`: `logs.asJava` -> `logs.toSeq.asJava` so Scala 2.13 picks the `Seq[A]` -> `java.util.List` `asJava` overload instead of erroring on ambiguity. **Thriftserver libthrift pre-0.16 / 0.16 compatibility** The Livy Thrift binary CLI service was written against libthrift 0.9.3 (the version pinned in the root pom.xml). Spark 3 keeps a compatible pre-0.16 libthrift on the runtime classpath (e.g. 0.12.0 in Spark 3.3), but Spark 4 pulls in libthrift 0.16.0 transitively through spark-hive, and 0.16 removed four `TThreadPoolServer.Args` builder methods that Livy relied on: `requestTimeout`, `requestTimeoutUnit`, `beBackoffSlotLength` and `beBackoffSlotLengthUnit`. Compiling against 0.9.3 but running against 0.16.0 made the mini-cluster Thrift server abort during startup with a `NoSuchMethodError` on `TThreadPoolServer$Args.requestTimeout(int)`, which in turn hung JdbcIT on the SASL handshake. `ThriftBinaryCLIService` now invokes those four builders reflectively via a small `applyOptionalArg` helper: they run unchanged on Spark 3 classpaths and are silently skipped on Spark 4, where they no longer exist. The functional loss on Spark 4 is limited to the login-timeout / backoff knobs, which have no equivalent in the newer libthrift API. **Test fix: Scala 2.13 REPL output shape changes in InteractiveIT** The Scala 2.13 REPL prints results differently from Scala 2.12 in four ways that `InteractiveIT` was too strict about; the Spark 4 matrix (Scala 2.13 only) trips all four. Fixed in place: - Value results: `val res0: Int = 2` (2.13) vs. `res0: Int = 2` (2.12). Six `verifyResult` regexes (across `basic interactive session`, `user jars are properly imported ...` and `recover interactive session`) now accept an optional `val ` prefix. - Compile errors: `error: not found: value abcde` (2.13) vs. `:12: error: not found: value abcde` (2.12). The `:: ` location marker is now optional in the `verifyError` regex. - Class definitions: `class Item` (2.13) vs. `defined class Item` (2.12). The `case class Item(i: Int)` check in `user jars are properly imported ...` now accepts an optional `defined ` prefix. - Deprecation-warning header: Scala 2.13 prints a `warning: 1 deprecation ...` line above the value binding when the expression uses a deprecated API. `SparkContext.parallelize` is deprecated on Spark 4, so `val rdd = sc.parallelize(Array.fill(10){...})` prints the warning before the `val rdd: ...` line. The `verifyResult` regex now uses `(?s)(?:warning:.*\n)?(?:val )?rdd.*` to accept the warning prefix and the DOTALL semantics needed to span the newline. Both forms match on `-Pscala-2.12` and `-Pscala-2.13`. **Test fix: Spark 4 SQLContext / DataFrame runtime class in InteractiveIT** Spark 4 moved the runtime `SQLContext` and `DataFrame` implementations into `org.apache.spark.sql.classic`; only the compile-time aliases remain at `org.apache.spark.sql.SQLContext` / `org.apache.spark.sql.DataFrame`. The Scala 2.13 REPL surfaces the runtime class name, so Spark 4 prints `sql: org.apache.spark.sql.classic.SQLContext = ...` and `val df: org.apache.spark.sql.classic.DataFrame = ...`, where Spark 3 prints the alias form. Two `InteractiveIT` regexes now allow an optional `classic.` package qualifier so the same expectations match both `-Pspark3` and `-Pspark4`: - `sql: org.apache.spark.sql.(?:classic\.)?SQLContext = ...` - `(?:val )?df: org.apache.spark.sql.(?:classic\.)?DataFrame` The old `Pattern.quote("df: org.apache.spark.sql.DataFrame")` literal was too strict for Spark 4 and caused the `basic interactive session` case to fail. **Cross-version test helpers: `ScalaVersionAware` trait** New `test-lib/.../ScalaVersionAware` trait exposes the small Scala-2.12 vs 2.13 REPL-output differences as reusable fragments: - `optionalValPrefix` (`"val "` on 2.13, `""` on 2.12) for exact-match expected strings. - `optionalValPrefixRegex` / `optionalDefinedPrefixRegex` / `optionalWarningPrefixRegex` regex fragments for the corresponding `verifyResult` regex assertions. Detection uses the runtime `scala.util.Properties.versionNumberString` so the same test class works regardless of which Scala the artifact was compiled against. Mixed into `BaseInterpreterSpec`, `BaseSessionSpec` and `InteractiveIT`. Downstream specs (`ScalaInterpreterSpec`, `SharedSessionSpec`, `SparkSessionSpec`, `InteractiveIT`) rewrote their expected-value strings against these fragments; some hard-`equal` assertions were relaxed to `include`/`fullyMatch regex` where the 2.13 REPL adds a deprecation banner ahead of the result or renumbers `resN` slots differently. **Server/unit tests: cross-Scala tolerance in `InteractiveSessionSpec`** The `should get scala version` case now decomposes the JSON result by hand and asserts `text/plain` equals either `res0: Int = 3\n` or `val res0: Int = 3\n`, plus separate `status` / `execution_count` assertions. Straight equality with a decomposed Map broke on the Spark 4 driver where the 2.13 REPL adds the `val ` prefix. **Unit tests: `LivySparkUtilsSuite`** Added `4.0.0` and `4.1.2` to both the supported-version list and the `defaultSparkScalaVersion` -> `"2.13"` expectations. **scala-api tests** - `ScalaClientTestUtils.scala`: `context.sc.parallelize(buffer, ...)` -> `context.sc.parallelize(buffer.toSeq, ...)` (2.13 no longer widens a mutable `ArrayBuffer` to `Seq` implicitly for the `parallelize` signature). - `ScalaJobHandleTest.scala`: `Duration.Undefined` -> `Duration.Inf` (2.13's `Await.ready` rejects `Undefined` with "Cannot wait for Undefined duration of time"); assertion switched from `verify(mockJobHandle, times(1)).get()` to `verify(mockJobHandle, atLeastOnce()).isDone`, because 2.13's `Await.ready` short-circuits when `isCompleted` is already true and does not call the underlying `ready(atMost)` -- so `.get()` is not observed on 2.13. `isDone` is exercised by both versions. **Repl tests: `SparkInterpreterSpec` move** `SparkInterpreterSpec.scala` moved from `repl/scala-2.12/src/test/...` into the shared `repl/src/test/scala/...` tree (it was byte-identical between scala-2.12 and scala-2.13). A new `should skip leading caret lines in Scala 2.13 error format.` test case was added for the caret- first `parseError` branch, so the shared spec runs three cases under both `-Pscala-2.12` and `-Pscala-2.13`. **CI matrix** - `.github/workflows/unit-tests.yaml` and `integration-tests.yaml` converted the flat `maven_profile` x `jdk_path` matrix to an explicit `include:` list and added a new spark4/JDK-17 entry (`-Pscala-2.13 -Pspark4` on `/usr/lib/jvm/java-17-openjdk-amd64`). ## How was this patch tested? - Unit tests: `mvn verify -Pspark4 -Pscala-2.13 -Pthriftserver` passes all 19 modules on JDK 17 (macOS aarch64). Verified modules include livy-rsc (40 tests), livy-repl_2.13 (87 tests), livy-server (205 tests), livy-client-http (17 tests), livy-scala-api_2.13 (17 tests), and the thriftserver integration suite (27 tests: HttpThriftServerSuite + BinaryThriftServerSuite). The `SparkInterpreterSpec` move added one new caret-first `parseError` test case; the shared spec now has 3 cases and runs identically under both scala-2.12 and scala-2.13. - Integration tests (`mvn integration-test -Pspark3 -Pscala-2.12 -pl :livy-integration-test`) pass. - GitHub Actions CI runs both spark3 and spark4 matrix entries for unit and integration test workflows. ## Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 4.7) --- .github/workflows/integration-tests.yaml | 13 +- .github/workflows/unit-tests.yaml | 13 +- README.md | 24 ++- core/scala-2.13/pom.xml | 48 ++++++ .../org/apache/livy/test/InteractiveIT.scala | 53 ++++-- pom.xml | 44 ++++- repl/pom.xml | 16 ++ repl/scala-2.13/pom.xml | 34 ++++ .../apache/livy/repl/SparkInterpreter.scala | 163 ++++++++++++++++++ .../livy/repl/AbstractSparkInterpreter.scala | 35 +++- .../scala/org/apache/livy/repl/Session.scala | 2 +- .../apache/livy/repl/SparkRInterpreter.scala | 5 + .../livy/repl/BaseInterpreterSpec.scala | 4 +- .../apache/livy/repl/BaseSessionSpec.scala | 3 +- .../livy/repl/ScalaInterpreterSpec.scala | 64 +++++-- .../apache/livy/repl/SharedSessionSpec.scala | 20 +-- .../livy/repl/SparkInterpreterSpec.scala | 15 ++ .../apache/livy/repl/SparkSessionSpec.scala | 28 +-- .../apache/livy/rsc/driver/SparkEntries.java | 34 +++- .../org/apache/livy/rsc/TestSparkClient.java | 7 +- scala-api/scala-2.13/pom.xml | 31 ++++ .../livy/scalaapi/ScalaClientTestUtils.scala | 2 +- .../livy/scalaapi/ScalaJobHandleTest.scala | 10 +- .../livy/server/batch/BatchSession.scala | 5 + .../interactive/InteractiveSession.scala | 19 +- .../InteractiveSessionServlet.scala | 6 +- .../apache/livy/utils/LivySparkUtils.scala | 6 +- .../livy/utils/SparkProcessBuilder.scala | 13 ++ .../interactive/InteractiveSessionSpec.scala | 13 +- .../livy/utils/LivySparkUtilsSuite.scala | 4 + .../apache/livy/test/ScalaVersionAware.scala | 50 ++++++ .../LivyExecuteStatementOperation.scala | 3 +- .../cli/ThriftBinaryCLIService.scala | 36 +++- .../thriftserver/types/DataTypeUtils.scala | 7 +- .../thriftserver/ThriftServerSuites.scala | 9 +- .../thriftserver/session/GetColumnsJob.java | 9 +- .../thriftserver/session/GetFunctionsJob.java | 7 +- .../thriftserver/session/GetSchemasJob.java | 5 +- .../thriftserver/session/GetTablesJob.java | 7 +- 39 files changed, 744 insertions(+), 123 deletions(-) create mode 100644 core/scala-2.13/pom.xml create mode 100644 repl/scala-2.13/pom.xml create mode 100644 repl/scala-2.13/src/main/scala/org/apache/livy/repl/SparkInterpreter.scala rename repl/{scala-2.12 => }/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala (78%) create mode 100644 scala-api/scala-2.13/pom.xml create mode 100644 test-lib/src/main/scala/org/apache/livy/test/ScalaVersionAware.scala diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml index c1dd01b94..4416dadeb 100644 --- a/.github/workflows/integration-tests.yaml +++ b/.github/workflows/integration-tests.yaml @@ -28,11 +28,14 @@ jobs: container: ghcr.io/${{ github.repository_owner }}/livy-ci:latest strategy: matrix: - maven_profile: - - "-Pscala-2.12 -Pspark3" - jdk_path: - - "/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java" - - "/usr/lib/jvm/java-17-openjdk-amd64/bin/java" + include: + - maven_profile: "-Pscala-2.12 -Pspark3" + jdk_path: "/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java" + - maven_profile: "-Pscala-2.12 -Pspark3" + jdk_path: "/usr/lib/jvm/java-17-openjdk-amd64/bin/java" + # Spark 4 requires JDK 17+ and Scala 2.13; JDK 8 is unsupported. + - maven_profile: "-Pscala-2.13 -Pspark4" + jdk_path: "/usr/lib/jvm/java-17-openjdk-amd64/bin/java" steps: - name: Checkout diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml index b3dd9a2e5..4c786d647 100644 --- a/.github/workflows/unit-tests.yaml +++ b/.github/workflows/unit-tests.yaml @@ -29,11 +29,14 @@ jobs: container: ghcr.io/${{ github.repository_owner }}/livy-ci:latest strategy: matrix: - maven_profile: - - "-Pscala-2.12 -Pspark3" - jdk_path: - - "/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java" - - "/usr/lib/jvm/java-17-openjdk-amd64/bin/java" + include: + - maven_profile: "-Pscala-2.12 -Pspark3" + jdk_path: "/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java" + - maven_profile: "-Pscala-2.12 -Pspark3" + jdk_path: "/usr/lib/jvm/java-17-openjdk-amd64/bin/java" + # Spark 4 requires JDK 17+ and Scala 2.13; JDK 8 is unsupported. + - maven_profile: "-Pscala-2.13 -Pspark4" + jdk_path: "/usr/lib/jvm/java-17-openjdk-amd64/bin/java" steps: - name: Checkout diff --git a/README.md b/README.md index 32ee32d98..e52c52ba2 100644 --- a/README.md +++ b/README.md @@ -92,9 +92,21 @@ version of Spark without needing to rebuild. ### Build Profiles -| Flag | Purpose | -|----------------|--------------------------------------------| -| -Phadoop2 | Choose Hadoop2 based build dependencies | -| -Pthriftserver | Build and test Livy Thrift Server modules | -| -Pspark3 | Choose Spark 3.x based build dependencies | -| -Pscala-2.12 | Choose Scala 2.12 based build dependencies | +| Flag | Purpose | +|----------------|------------------------------------------------------------------------------------| +| -Phadoop2 | Choose Hadoop2 based build dependencies | +| -Pthriftserver | Build and test Livy Thrift Server modules | +| -Pspark3 | Choose Spark 3.x based build dependencies | +| -Pspark4 | Choose Spark 4.x (4.1.2) based build dependencies (requires JDK 17+ + Scala 2.13) | +| -Pscala-2.12 | Choose Scala 2.12 based build dependencies | +| -Pscala-2.13 | Choose Scala 2.13 based build dependencies (used with `-Pspark4`) | + +Example — build against Spark 4: + +``` +mvn package -Pspark4 -Pscala-2.13 +``` + +> **Note**: `-Pspark4` requires JDK 17 or JDK 21 and pulls in Scala 2.13 and Hadoop 3.4.1. +> JDK 8, JDK 11 and Scala 2.12 are not supported by Spark 4. Supported Python +> versions for Spark 4.1 are 3.10 – 3.14. diff --git a/core/scala-2.13/pom.xml b/core/scala-2.13/pom.xml new file mode 100644 index 000000000..541e1aedf --- /dev/null +++ b/core/scala-2.13/pom.xml @@ -0,0 +1,48 @@ + + + + 4.0.0 + org.apache.livy + livy-core_2.13 + 1.0.0-SNAPSHOT + jar + + + org.apache.livy + livy-core-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + + + + + \ No newline at end of file diff --git a/integration-test/src/test/scala/org/apache/livy/test/InteractiveIT.scala b/integration-test/src/test/scala/org/apache/livy/test/InteractiveIT.scala index ec170a2ad..1814404e6 100644 --- a/integration-test/src/test/scala/org/apache/livy/test/InteractiveIT.scala +++ b/integration-test/src/test/scala/org/apache/livy/test/InteractiveIT.scala @@ -30,27 +30,46 @@ import org.apache.livy.rsc.RSCConf import org.apache.livy.sessions._ import org.apache.livy.test.framework.{BaseIntegrationTestSuite, LivyRestClient} -class InteractiveIT extends BaseIntegrationTestSuite { +class InteractiveIT extends BaseIntegrationTestSuite with ScalaVersionAware { test("basic interactive session") { withNewSession(Spark) { s => s.run("val sparkVersion = sc.version").result().left.foreach(info(_)) s.run("val scalaVersion = util.Properties.versionString").result().left.foreach(info(_)) - s.run("1+1").verifyResult("res0: Int = 2\n") + // Scala 2.13's REPL prints a `val ` prefix before result names + // (`val res0: Int = 2`) whereas Scala 2.12 prints just `res0: Int = 2`. + // Accept both forms so the test passes on either -Pscala-2.12 or + // -Pscala-2.13. + s.run("1+1").verifyResult(s"${optionalValPrefixRegex}res0: Int = 2\n") // Ignore the following line if running on a external cluster due to config differences // with the mini cluster - s.run("""sc.getConf.get("spark.executor.instances")""").verifyResult("res1: String = 1\n") + s.run("""sc.getConf.get("spark.executor.instances")""") + .verifyResult(s"${optionalValPrefixRegex}res1: String = 1\n") + // Spark 4 relocated SQLContext into the `org.apache.spark.sql.classic` + // package; Spark 3 keeps it directly under `org.apache.spark.sql`. Match + // either shape (`SQLContext` or `classic.SQLContext`) and tolerate the + // Scala 2.13 REPL's `val ` prefix as elsewhere in this test. s.run("val sql = spark.sqlContext").verifyResult( - ".*" + Pattern.quote( - "sql: org.apache.spark.sql.SQLContext = org.apache.spark.sql.SQLContext") + ".*") - s.run("abcde").verifyError(evalue = ".*?:[0-9]+: error: not found: value abcde.*") + ".*sql: org\\.apache\\.spark\\.sql\\.(?:classic\\.)?SQLContext = " + + "org\\.apache\\.spark\\.sql\\.(?:classic\\.)?SQLContext.*") + // Scala 2.12's REPL prefixes compile errors with a "::" + // location marker (e.g. ":12: error: not found: value abcde"), + // while Scala 2.13 drops the marker and just emits "error: not found: + // value abcde". Accept both. + s.run("abcde").verifyError(evalue = ".*(?:.*?:[0-9]+: )?error: not found: value abcde.*") s.run("throw new IllegalStateException()") .verifyError(evalue = ".*java\\.lang\\.IllegalStateException.*") - // Verify query submission + // Verify query submission. Spark 4's Scala 2.13 REPL surfaces the runtime + // class name (`org.apache.spark.sql.classic.DataFrame`) rather than the + // compile-time alias (`org.apache.spark.sql.DataFrame`) that Spark 3 + // prints, and prefixes the identifier with `val ` like elsewhere in this + // test. Accept both shapes. s.run(s"""val df = spark.createDataFrame(Seq(("jerry", 20), ("michael", 21)))""") - .verifyResult(".*" + Pattern.quote("df: org.apache.spark.sql.DataFrame") + ".*") + .verifyResult( + s".*${optionalValPrefixRegex}df: " + + "org\\.apache\\.spark\\.sql\\.(?:classic\\.)?DataFrame.*") s.run("df.createOrReplaceTempView(\"people\")").result() s.run("SELECT * FROM people", Some(SQL)).verifyResult(".*\"jerry\",20.*\"michael\",21.*") @@ -167,9 +186,15 @@ class InteractiveIT extends BaseIntegrationTestSuite { s.run("import org.codehaus.plexus.util._").verifyResult("import org.codehaus.plexus.util._\n") // Check does SparkContext see classes defined by Scala interpreter. - s.run("case class Item(i: Int)").verifyResult("defined class Item\n") + // Scala 2.12's REPL reports `defined class Item`; Scala 2.13 emits the + // shorter `class Item`. Accept both. + s.run("case class Item(i: Int)").verifyResult(s"${optionalDefinedPrefixRegex}class Item\n") + // Scala 2.13's REPL prefixes with `val ` (e.g. `val rdd: ...`); 2.12 + // omits it. Spark 4 (Scala 2.13) also prints a deprecation warning + // before the value binding because `parallelize` is now deprecated on + // SparkContext -- accept an optional warning header. s.run("val rdd = sc.parallelize(Array.fill(10){new Item(scala.util.Random.nextInt(1000))})") - .verifyResult("rdd.*") + .verifyResult(s"(?s)${optionalWarningPrefixRegex}${optionalValPrefixRegex}rdd.*") s.run("rdd.count()").verifyResult(".*= 10\n") } } @@ -188,15 +213,17 @@ class InteractiveIT extends BaseIntegrationTestSuite { test("recover interactive session") { withNewSession(Spark) { s => val stmt1 = s.run("1") - stmt1.verifyResult("res0: Int = 1\n") + // Scala 2.13's REPL renders value results as `val res0: Int = 1` + // whereas Scala 2.12 prints `res0: Int = 1`; accept both. + stmt1.verifyResult(s"${optionalValPrefixRegex}res0: Int = 1\n") restartLivy() // Verify session still exists. s.verifySessionIdle() - s.run("2").verifyResult("res1: Int = 2\n") + s.run("2").verifyResult(s"${optionalValPrefixRegex}res1: Int = 2\n") // Verify statement result is preserved. - stmt1.verifyResult("res0: Int = 1\n") + stmt1.verifyResult(s"${optionalValPrefixRegex}res0: Int = 1\n") s.stop() diff --git a/pom.xml b/pom.xml index f2ec29b3e..f23363eb5 100644 --- a/pom.xml +++ b/pom.xml @@ -1068,7 +1068,11 @@ org.apache.maven.plugins maven-shade-plugin - 3.5.0 + + 3.6.2 @@ -1428,6 +1432,16 @@ 2.12.18 + + scala-2.13 + + 2.13 + + 2.13.17 + + thriftserver @@ -1457,6 +1471,34 @@ + + spark4 + + 4.1.2 + 3 + 3.4.1 + 17 + 0.10.9.9 + 4.0.7 + + 4.2.7.Final + 2.18.2 + 2.18.2 + + + spark-${spark.version}-bin-hadoop${hadoop.major-minor.version} + + https://archive.apache.org/dist/spark/spark-${spark.version}/${spark.bin.name}.tgz + + + + skip-parent-modules diff --git a/repl/pom.xml b/repl/pom.xml index 2c950a2b2..cd7f0ab0d 100644 --- a/repl/pom.xml +++ b/repl/pom.xml @@ -60,6 +60,13 @@ test + + ${project.groupId} + livy-test-lib + ${project.version} + test + + com.fasterxml.jackson.core jackson-core @@ -205,6 +212,15 @@ org.json4s:json4s-ast_${scala.binary.version} org.json4s:json4s-core_${scala.binary.version} org.json4s:json4s-jackson_${scala.binary.version} + + org.json4s:json4s-jackson-core_${scala.binary.version} org.json4s:json4s-scalap_${scala.binary.version} com.esotericsoftware:kryo-shaded diff --git a/repl/scala-2.13/pom.xml b/repl/scala-2.13/pom.xml new file mode 100644 index 000000000..f63f0972e --- /dev/null +++ b/repl/scala-2.13/pom.xml @@ -0,0 +1,34 @@ + + + + 4.0.0 + org.apache.livy + livy-repl_2.13 + 1.0.0-SNAPSHOT + jar + + + org.apache.livy + livy-repl-parent + 1.0.0-SNAPSHOT + ../pom.xml + + + \ No newline at end of file diff --git a/repl/scala-2.13/src/main/scala/org/apache/livy/repl/SparkInterpreter.scala b/repl/scala-2.13/src/main/scala/org/apache/livy/repl/SparkInterpreter.scala new file mode 100644 index 000000000..4339c19fa --- /dev/null +++ b/repl/scala-2.13/src/main/scala/org/apache/livy/repl/SparkInterpreter.scala @@ -0,0 +1,163 @@ +/* + * 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.livy.repl + +import java.io.{File, PrintWriter} +import java.net.{URL, URLClassLoader} +import java.nio.file.{Files, Paths} + +import scala.tools.nsc.Settings +import scala.tools.nsc.interpreter.{IMain, Repl} +import scala.tools.nsc.interpreter.Results.Result +import scala.tools.nsc.interpreter.shell.{Completion, NoCompletion, ReplCompletion} + +import org.apache.spark.SparkConf +import org.apache.spark.repl.SparkILoop + +/** + * Spark 4.x / Scala 2.13 implementation of the Spark interpreter + */ +class SparkInterpreter(protected override val conf: SparkConf) extends AbstractSparkInterpreter { + + private var sparkILoop: SparkILoop = _ + + override def start(): Unit = { + require(sparkILoop == null) + + val rootDir = conf.get("spark.repl.classdir", System.getProperty("java.io.tmpdir")) + val outputDir = Files.createTempDirectory(Paths.get(rootDir), "spark").toFile + outputDir.deleteOnExit() + conf.set("spark.repl.class.outputDir", outputDir.getAbsolutePath) + + // Collect Spark's user JARs (from `spark.jars.packages`, `--jars`, etc.) + // and feed them to the Scala compiler through `-classpath` at construction + // time. + val userJarsClasspath = collectUserJarsClasspath() + + val settings = new Settings() + val baseArgs = List( + "-Yrepl-class-based", + "-Yrepl-outdir", s"${outputDir.getAbsolutePath}") + val cpArgs = if (userJarsClasspath.nonEmpty) { + List("-classpath", userJarsClasspath) + } else { + Nil + } + settings.processArguments(baseArgs ++ cpArgs, true) + settings.usejavacp.value = true + settings.embeddedDefaults(Thread.currentThread().getContextClassLoader()) + + // Spark 4's SparkILoop takes (BufferedReader, PrintWriter) + sparkILoop = new SparkILoop(null, new PrintWriter(outputStream, true)) + sparkILoop.createInterpreter(settings) + + restoreContextClassLoader { + postStart() + } + } + + /** + * Return Spark's `MutableURLClassLoader` URLs joined with + * `File.pathSeparator`, ready to pass to Scala compiler `-classpath`. + * Skips livy-* and scala-reflect jars; returns "" if no such classloader + * is found on the context classloader chain. + */ + private def collectUserJarsClasspath(): String = { + var classLoader = Thread.currentThread().getContextClassLoader + while (classLoader != null && + classLoader.getClass.getCanonicalName != + "org.apache.spark.util.MutableURLClassLoader") { + classLoader = classLoader.getParent + } + if (classLoader == null) { + warn("Could not locate Spark's MutableURLClassLoader on the context " + + "classloader chain; user JARs from `spark.jars.packages` may not " + + "be visible to `import ...` inside the Scala interpreter.") + "" + } else { + val extraJarPath = classLoader.asInstanceOf[URLClassLoader].getURLs() + // Only real files -- getURLs() may include stale entries. + .filter { u => u.getProtocol == "file" && new File(u.getPath).isFile } + // Drop livy-* (would collide with the shaded repl classpath) and + // wrong scala-reflect version jars that some Spark packages depend on. + .filterNot { u => + val name = Paths.get(u.toURI).getFileName.toString + name.startsWith("livy-") || name.contains("org.scala-lang_scala-reflect") + } + extraJarPath.foreach { p => debug(s"Adding $p to Scala interpreter's class path...") } + extraJarPath.map { u => new File(u.toURI).getAbsolutePath } + .mkString(File.pathSeparator) + } + } + + override def close(): Unit = synchronized { + super.close() + + if (sparkILoop != null) { + sparkILoop.closeInterpreter() + sparkILoop = null + } + } + + override def addJar(jar: String): Unit = { + // Guard against the `_runtimeClassLoader == null` NPE inside + // `addUrlsToClassPath` (`urls.foreach(_runtimeClassLoader.addURL)`) on a + // fresh session that hasn't run any code yet. Calling `.classLoader` on + // the `Repl` interface triggers `ensureClassLoader()` -> `makeClassLoader()` + // in IMain, which initialises BOTH `_classLoader` (the + // AbstractFileClassLoader returned to us) AND `_runtimeClassLoader` (the + // URLClassLoader used by `addUrlsToClassPath`). We discard the returned + // value; we only need the side effect on `_runtimeClassLoader`. + sparkILoop.intp.classLoader + sparkILoop.intp.addUrlsToClassPath(new URL(jar)) + } + + override protected def isStarted(): Boolean = { + sparkILoop != null + } + + override protected def interpret(code: String): Result = { + sparkILoop.intp.interpret(code) + } + + override protected def completeCandidates(code: String, cursor: Int) : Array[String] = { + // Scala 2.13 replaced `PresentationCompilerCompleter` with + // `shell.ReplCompletion`, which takes a `Repl` (the interface `IMain` + // now implements). Instantiate it directly rather than by reflection. + val completer: Completion = + try new ReplCompletion(sparkILoop.intp.asInstanceOf[Repl]) + catch { case _: Throwable => NoCompletion } + completer.complete(code, cursor, filter = false).candidates.map(_.name).toArray + } + + override protected def valueOfTerm(name: String): Option[Any] = { + // IMain#valueOfTerm always returns None; read `$result` off the last request instead. + Option(sparkILoop.intp.asInstanceOf[IMain].lastRequest.lineRep.call("$result")) + } + + override protected def bind(name: String, + tpe: String, + value: Object, + modifier: List[String]): Unit = { + // 2.12's `SparkILoop.beQuietDuring` moved to `intp.beQuietDuring` in 2.13; + // call the underlying `reporter.withoutPrintingResults` directly. + sparkILoop.intp.reporter.withoutPrintingResults { + sparkILoop.intp.bind(name, tpe, value, modifier) + } + } +} diff --git a/repl/src/main/scala/org/apache/livy/repl/AbstractSparkInterpreter.scala b/repl/src/main/scala/org/apache/livy/repl/AbstractSparkInterpreter.scala index 0decf095d..64fbe0a9e 100644 --- a/repl/src/main/scala/org/apache/livy/repl/AbstractSparkInterpreter.scala +++ b/repl/src/main/scala/org/apache/livy/repl/AbstractSparkInterpreter.scala @@ -34,6 +34,23 @@ import org.apache.livy.rsc.driver.SparkEntries object AbstractSparkInterpreter { private[repl] val KEEP_NEWLINE_REGEX = """(?<=\n)""".r private val MAGIC_REGEX = "^%(\\w+)\\W*(.*)".r + + /** + * True when `code` contains only whitespace and Scala comments (line or + * block), and nothing the Scala compiler would treat as a statement. + * + * The Scala 2.13 REPL rejects a source made up entirely of comments as a + * compile error, whereas 2.12 accepted it silently. Livy short-circuits + * these inputs so that behaviour matches across both Scala versions. + */ + private[repl] def isEffectivelyEmpty(code: String): Boolean = { + // Strip block comments ((?s) DOTALL so `.` matches newlines across + // /* ... */) then line comments, and check whether anything meaningful + // remains. + val noBlock = code.replaceAll("(?s)/\\*.*?\\*/", "") + val noLine = noBlock.replaceAll("//[^\\n]*", "") + noLine.trim.isEmpty + } } abstract class AbstractSparkInterpreter extends Interpreter with Logging { @@ -300,6 +317,11 @@ abstract class AbstractSparkInterpreter extends Interpreter with Logging { code match { case MAGIC_REGEX(magic, rest) => executeMagic(magic, rest) + case _ if AbstractSparkInterpreter.isEffectivelyEmpty(code) => + // Scala 2.13's REPL rejects a source with only comments as a compile + // error (2.12 quietly returned Success). Short-circuit here so both + // versions produce an empty-successful response. + Interpreter.ExecuteSuccess(TEXT_PLAIN -> "") case _ => scala.Console.withOut(outputStream) { interpret(code) match { @@ -327,13 +349,14 @@ abstract class AbstractSparkInterpreter extends Interpreter with Logging { // at .error(:11) // ... 32 elided - // Return the first line as ename. Lines following as traceback. - val lines = KEEP_NEWLINE_REGEX.split(stdout) - val ename = lines.headOption.map(_.trim).getOrElse("unknown error") - val traceback = lines.tail - - (ename, traceback) + // Skip 2.13's leading caret line; falls through to head on 2.12 (message first). + val enameIdx = lines.indexWhere(l => l.trim.nonEmpty && l.trim != "^") + if (enameIdx < 0) { + (lines.headOption.map(_.trim).getOrElse("unknown error"), lines.tail.toSeq) + } else { + (lines(enameIdx).trim, lines.patch(enameIdx, Nil, 1).toSeq) + } } protected def restoreContextClassLoader[T](fn: => T): T = { diff --git a/repl/src/main/scala/org/apache/livy/repl/Session.scala b/repl/src/main/scala/org/apache/livy/repl/Session.scala index c1267bc45..acf7b4de2 100644 --- a/repl/src/main/scala/org/apache/livy/repl/Session.scala +++ b/repl/src/main/scala/org/apache/livy/repl/Session.scala @@ -348,7 +348,7 @@ class Session( case "1" => (s"""setJobGroup(sc, "$jobGroup", "Job group for statement $jobGroup", FALSE)""", SparkR) - case "2" | "3" => + case "2" | "3" | "4" => (s"""setJobGroup("$jobGroup", "Job group for statement $jobGroup", FALSE)""", SparkR) case v => throw new IllegalArgumentException(s"Unknown Spark major version [$v]") diff --git a/repl/src/main/scala/org/apache/livy/repl/SparkRInterpreter.scala b/repl/src/main/scala/org/apache/livy/repl/SparkRInterpreter.scala index 407762623..47c3f93b6 100644 --- a/repl/src/main/scala/org/apache/livy/repl/SparkRInterpreter.scala +++ b/repl/src/main/scala/org/apache/livy/repl/SparkRInterpreter.scala @@ -183,6 +183,11 @@ class SparkRInterpreter( override def kind: String = "sparkr" private[this] val isStarted = new CountDownLatch(1) + if (sparkMajorVersion >= 4) { + warn("SparkR is deprecated in Spark 4 (SPARK-49347); the SparkR interpreter " + + "may be removed in a future Spark release.") + } + final override protected def waitUntilReady(): Unit = { // Set the option to catch and ignore errors instead of halting. sendRequest("options(error = dump.frames)") diff --git a/repl/src/test/scala/org/apache/livy/repl/BaseInterpreterSpec.scala b/repl/src/test/scala/org/apache/livy/repl/BaseInterpreterSpec.scala index a4345fa9f..cc1340bc2 100644 --- a/repl/src/test/scala/org/apache/livy/repl/BaseInterpreterSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/BaseInterpreterSpec.scala @@ -21,8 +21,10 @@ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import org.apache.livy.LivyBaseUnitTestSuite +import org.apache.livy.test.ScalaVersionAware -abstract class BaseInterpreterSpec extends AnyFlatSpec with Matchers with LivyBaseUnitTestSuite { +abstract class BaseInterpreterSpec extends AnyFlatSpec + with Matchers with LivyBaseUnitTestSuite with ScalaVersionAware { def createInterpreter(): Interpreter diff --git a/repl/src/test/scala/org/apache/livy/repl/BaseSessionSpec.scala b/repl/src/test/scala/org/apache/livy/repl/BaseSessionSpec.scala index 4d052006f..e8a80e372 100644 --- a/repl/src/test/scala/org/apache/livy/repl/BaseSessionSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/BaseSessionSpec.scala @@ -35,9 +35,10 @@ import org.apache.livy.client.common.TestUtils import org.apache.livy.rsc.RSCConf import org.apache.livy.rsc.driver.{Statement, StatementState} import org.apache.livy.sessions._ +import org.apache.livy.test.ScalaVersionAware abstract class BaseSessionSpec(kind: Kind) - extends AnyFlatSpec with Matchers with LivyBaseUnitTestSuite { + extends AnyFlatSpec with Matchers with LivyBaseUnitTestSuite with ScalaVersionAware { implicit val formats = DefaultFormats diff --git a/repl/src/test/scala/org/apache/livy/repl/ScalaInterpreterSpec.scala b/repl/src/test/scala/org/apache/livy/repl/ScalaInterpreterSpec.scala index a13efeba1..25b62668e 100644 --- a/repl/src/test/scala/org/apache/livy/repl/ScalaInterpreterSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/ScalaInterpreterSpec.scala @@ -27,30 +27,34 @@ class ScalaInterpreterSpec extends BaseInterpreterSpec { implicit val formats = DefaultFormats + // `optionalValPrefix` (Scala-2.13's `val ` before bound-variable output, empty on + // 2.12) is provided by `BaseInterpreterSpec` so the same source works + // against both scala-2.12 and scala-2.13 builds. + override def createInterpreter(): Interpreter = new SparkInterpreter(new SparkConf()) it should "execute `1 + 2` == 3" in withInterpreter { interpreter => val response = interpreter.execute("1 + 2") response should equal (Interpreter.ExecuteSuccess( - TEXT_PLAIN -> "res0: Int = 3\n" + TEXT_PLAIN -> s"${optionalValPrefix}res0: Int = 3\n" )) } it should "execute multiple statements" in withInterpreter { interpreter => var response = interpreter.execute("val x = 1") response should equal (Interpreter.ExecuteSuccess( - TEXT_PLAIN -> "x: Int = 1\n" + TEXT_PLAIN -> s"${optionalValPrefix}x: Int = 1\n" )) response = interpreter.execute("val y = 2") response should equal (Interpreter.ExecuteSuccess( - TEXT_PLAIN -> "y: Int = 2\n" + TEXT_PLAIN -> s"${optionalValPrefix}y: Int = 2\n" )) response = interpreter.execute("x + y") response should equal (Interpreter.ExecuteSuccess( - TEXT_PLAIN -> "res0: Int = 3\n" + TEXT_PLAIN -> s"${optionalValPrefix}res0: Int = 3\n" )) } @@ -63,9 +67,23 @@ class ScalaInterpreterSpec extends BaseInterpreterSpec { | |x + y """.stripMargin) - response should equal(Interpreter.ExecuteSuccess( - TEXT_PLAIN -> "x: Int = 1\ny: Int = 2\nres2: Int = 3\n" - )) + // The Scala REPL is using an internal counter + // whose exact value depends on how the compiler rewrites the block: + // Scala 2.12 keeps a per-line counter (so `x`, `y`, then `x + y` yields + // `res2`), while Scala 2.13 keeps a per-execution counter (yielding + // `res0`). Neither of those is a semantic guarantee -- we just want to + // confirm the interpreter bound `x`, `y`, and produced an `Int = 3` + // result under *some* `resN` name. + val text = response match { + case Interpreter.ExecuteSuccess(json) => + (json \\ TEXT_PLAIN).extract[String] + case other => fail(s"Expected ExecuteSuccess, got $other") + } + val expected = + s"${optionalValPrefix}x: Int = 1\n" + + s"${optionalValPrefix}y: Int = 2\n" + + s"${optionalValPrefix}res\\d+: Int = 3\n" + text should fullyMatch regex expected } it should "do table magic" in withInterpreter { interpreter => @@ -96,7 +114,7 @@ class ScalaInterpreterSpec extends BaseInterpreterSpec { """.stripMargin) response should equal(Interpreter.ExecuteSuccess( - TEXT_PLAIN -> "res0: Int = 3\n" + TEXT_PLAIN -> s"${optionalValPrefix}res0: Int = 3\n" )) } @@ -132,12 +150,20 @@ class ScalaInterpreterSpec extends BaseInterpreterSpec { val response = interpreter.execute( """sc.parallelize(0 to 1).map { i => i+1 }.collect""".stripMargin) - response should equal(Interpreter.ExecuteSuccess( - TEXT_PLAIN -> "res0: Array[Int] = Array(1, 2)\n" - )) + // 2.13's REPL prints a deprecation banner ahead of the collect result + // because `Array` implicit conversion changed; check with contain-like + // substrings rather than exact equality so both versions pass. + val text = response match { + case Interpreter.ExecuteSuccess(json) => (json \\ TEXT_PLAIN).extract[String] + case other => fail(s"Expected ExecuteSuccess, got $other") + } + text should include (s"${optionalValPrefix}res0: Array[Int] = Array(1, 2)") } it should "handle statements ending with comments" in withInterpreter { interpreter => + val expectedResponse = + Interpreter.ExecuteSuccess(TEXT_PLAIN -> s"${optionalValPrefix}r: Int = 1\n") + // Test statements with only comments var response = interpreter.execute("""// comment""") response should equal(Interpreter.ExecuteSuccess(TEXT_PLAIN -> "")) @@ -154,7 +180,7 @@ class ScalaInterpreterSpec extends BaseInterpreterSpec { """val r = 1 |// comment """.stripMargin) - response should equal(Interpreter.ExecuteSuccess(TEXT_PLAIN -> "r: Int = 1\n")) + response should equal(expectedResponse) response = interpreter.execute( """val r = 1 @@ -163,7 +189,7 @@ class ScalaInterpreterSpec extends BaseInterpreterSpec { |comment |*/ """.stripMargin) - response should equal(Interpreter.ExecuteSuccess(TEXT_PLAIN -> "r: Int = 1\n")) + response should equal(expectedResponse) // Test statements ending with a mix of single line and multi-line comments response = interpreter.execute( @@ -175,7 +201,7 @@ class ScalaInterpreterSpec extends BaseInterpreterSpec { |*/ |// comment """.stripMargin) - response should equal(Interpreter.ExecuteSuccess(TEXT_PLAIN -> "r: Int = 1\n")) + response should equal(expectedResponse) response = interpreter.execute( """val r = 1 @@ -185,7 +211,7 @@ class ScalaInterpreterSpec extends BaseInterpreterSpec { |comment |*/ """.stripMargin) - response should equal(Interpreter.ExecuteSuccess(TEXT_PLAIN -> "r: Int = 1\n")) + response should equal(expectedResponse) // Make sure incomplete statement is still returned as incomplete statement. response = interpreter.execute("sc.") @@ -205,12 +231,14 @@ class ScalaInterpreterSpec extends BaseInterpreterSpec { try { response should equal( - Interpreter.ExecuteSuccess(TEXT_PLAIN -> s"r: String = \n$stringWithComment\n")) + Interpreter.ExecuteSuccess( + TEXT_PLAIN -> s"${optionalValPrefix}r: String = \n$stringWithComment\n")) } catch { case _: Exception => response should equal( - // Scala 2.11 doesn't have a " " after "=" - Interpreter.ExecuteSuccess(TEXT_PLAIN -> s"r: String =\n$stringWithComment\n")) + // Older Scala versions (2.11) omit the space after `=`. + Interpreter.ExecuteSuccess( + TEXT_PLAIN -> s"${optionalValPrefix}r: String =\n$stringWithComment\n")) } } diff --git a/repl/src/test/scala/org/apache/livy/repl/SharedSessionSpec.scala b/repl/src/test/scala/org/apache/livy/repl/SharedSessionSpec.scala index 18a6db727..3c0b998ff 100644 --- a/repl/src/test/scala/org/apache/livy/repl/SharedSessionSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/SharedSessionSpec.scala @@ -30,6 +30,9 @@ import org.apache.livy.sessions._ class SharedSessionSpec extends BaseSessionSpec(Shared) { + // `optionalValPrefix` (Scala-2.13's `val ` before bound-name output, empty on 2.12) + // is inherited from `BaseSessionSpec`. + private def execute(session: Session, code: String, codeType: String): Statement = { val id = session.execute(code, codeType) eventually(timeout(30 seconds), interval(100 millis)) { @@ -48,7 +51,7 @@ class SharedSessionSpec extends BaseSessionSpec(Shared) { "status" -> "ok", "execution_count" -> 0, "data" -> Map( - "text/plain" -> "res0: Int = 3\n" + "text/plain" -> s"${optionalValPrefix}res0: Int = 3\n" ) )) @@ -77,16 +80,11 @@ class SharedSessionSpec extends BaseSessionSpec(Shared) { statement.id should equal (0) val result = parse(statement.output) - - val expectedResult = Extraction.decompose(Map( - "status" -> "ok", - "execution_count" -> 0, - "data" -> Map( - "text/plain" -> "res0: Array[Int] = Array(1, 2)\n" - ) - )) - - result should equal (expectedResult) + // Scala 2.13's REPL prints a deprecation banner before the collect result. + val text = ((result \ "data") \ "text/plain").extract[String] + text should include (s"${optionalValPrefix}res0: Array[Int] = Array(1, 2)") + (result \ "status").extract[String] should equal ("ok") + (result \ "execution_count").extract[Int] should equal (0) } it should "throw exception if code type is not specified in shared session" in withSession { diff --git a/repl/scala-2.12/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala b/repl/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala similarity index 78% rename from repl/scala-2.12/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala rename to repl/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala index fbad108f4..7b89f9d75 100644 --- a/repl/scala-2.12/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/SparkInterpreterSpec.scala @@ -65,5 +65,20 @@ class SparkInterpreterSpec extends AnyFunSpec with Matchers with LivyBaseUnitTes ename shouldBe "java.lang.RuntimeException: message" traceback shouldBe expectedTraceback } + + it("should skip leading caret lines in Scala 2.13 error format.") { + // The 2.13 REPL prints the caret and the offending expression BEFORE + // the human-readable "error: ..." line. `parseError` must advance past + // the caret line so `ename` still lands on the readable message. + val error = + """ ^ + |error: not found: value abcde + |""".stripMargin + + val (ename, traceback) = interpreter.parseError(error) + ename shouldBe "error: not found: value abcde" + // The pure caret line is retained in the traceback rather than dropped. + traceback.exists(_.trim == "^") shouldBe true + } } } diff --git a/repl/src/test/scala/org/apache/livy/repl/SparkSessionSpec.scala b/repl/src/test/scala/org/apache/livy/repl/SparkSessionSpec.scala index 90e282839..c28e6b54f 100644 --- a/repl/src/test/scala/org/apache/livy/repl/SparkSessionSpec.scala +++ b/repl/src/test/scala/org/apache/livy/repl/SparkSessionSpec.scala @@ -30,6 +30,9 @@ import org.apache.livy.sessions._ class SparkSessionSpec extends BaseSessionSpec(Spark) { + // `optionalValPrefix` (Scala-2.13's `val ` before bound-name output, empty on 2.12) + // is inherited from `BaseSessionSpec`. + it should "execute `1 + 2` == 3" in withSession { session => val statement = execute(session)("1 + 2") statement.id should equal (0) @@ -39,7 +42,7 @@ class SparkSessionSpec extends BaseSessionSpec(Spark) { "status" -> "ok", "execution_count" -> 0, "data" -> Map( - "text/plain" -> "res0: Int = 3\n" + "text/plain" -> s"${optionalValPrefix}res0: Int = 3\n" ) )) @@ -56,7 +59,7 @@ class SparkSessionSpec extends BaseSessionSpec(Spark) { "status" -> "ok", "execution_count" -> 0, "data" -> Map( - "text/plain" -> "x: Int = 1\n" + "text/plain" -> s"${optionalValPrefix}x: Int = 1\n" ) )) @@ -70,7 +73,7 @@ class SparkSessionSpec extends BaseSessionSpec(Spark) { "status" -> "ok", "execution_count" -> 1, "data" -> Map( - "text/plain" -> "y: Int = 2\n" + "text/plain" -> s"${optionalValPrefix}y: Int = 2\n" ) )) @@ -84,7 +87,7 @@ class SparkSessionSpec extends BaseSessionSpec(Spark) { "status" -> "ok", "execution_count" -> 2, "data" -> Map( - "text/plain" -> "res0: Int = 3\n" + "text/plain" -> s"${optionalValPrefix}res0: Int = 3\n" ) )) @@ -164,16 +167,13 @@ class SparkSessionSpec extends BaseSessionSpec(Spark) { statement.id should equal (0) val result = parse(statement.output) - - val expectedResult = Extraction.decompose(Map( - "status" -> "ok", - "execution_count" -> 0, - "data" -> Map( - "text/plain" -> "res0: Array[Int] = Array(1, 2)\n" - ) - )) - - result should equal (expectedResult) + // The Scala 2.13 REPL emits a deprecation banner in front of the collect + // result (Array-implicit-conversion deprecation). Assert via substring so + // both 2.12 (bare line) and 2.13 (banner + line) pass. + val text = ((result \ "data") \ "text/plain").extract[String] + text should include (s"${optionalValPrefix}res0: Array[Int] = Array(1, 2)") + (result \ "status").extract[String] should equal ("ok") + (result \ "execution_count").extract[Int] should equal (0) } it should "do table magic" in withSession { session => diff --git a/rsc/src/main/java/org/apache/livy/rsc/driver/SparkEntries.java b/rsc/src/main/java/org/apache/livy/rsc/driver/SparkEntries.java index 6726bb1ab..cc699aed9 100644 --- a/rsc/src/main/java/org/apache/livy/rsc/driver/SparkEntries.java +++ b/rsc/src/main/java/org/apache/livy/rsc/driver/SparkEntries.java @@ -24,7 +24,6 @@ import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.sql.SQLContext; import org.apache.spark.sql.SparkSession; -import org.apache.spark.sql.SparkSession$; import org.apache.spark.sql.hive.HiveContext; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,7 +67,7 @@ public SparkSession sparkSession() { SparkConf conf = sc().getConf(); String catalog = conf.get("spark.sql.catalogImplementation", "in-memory").toLowerCase(); - if (catalog.equals("hive") && SparkSession$.MODULE$.hiveClassesArePresent()) { + if (catalog.equals("hive") && hiveClassesArePresent()) { ClassLoader loader = Thread.currentThread().getContextClassLoader() != null ? Thread.currentThread().getContextClassLoader() : getClass().getClassLoader(); if (loader.getResource("hive-site.xml") == null) { @@ -135,4 +134,35 @@ public synchronized void stop() { sc.stop(); } } + + /** + * Determine whether Spark's Hive support classes are present on the classpath. + * + *

Spark 3 exposed this as {@code org.apache.spark.sql.SparkSession$.hiveClassesArePresent()}. + * In Spark 4 {@code SparkSession} became abstract and the concrete companion moved to + * {@code org.apache.spark.sql.classic.SparkSession$}. We probe the Spark 4 location first + * because on a Spark 4 classpath the old {@code SparkSession$} may still resolve as a stub + * that no longer carries the concrete Hive check; the classic companion is the source of + * truth. On Spark 3 the classic class is absent and we fall through to the legacy companion. + */ + private static boolean hiveClassesArePresent() { + String[] candidates = { + "org.apache.spark.sql.classic.SparkSession$", // Spark 4+ + "org.apache.spark.sql.SparkSession$" // Spark 3 + }; + for (String cls : candidates) { + try { + Class companion = Class.forName(cls); + Object module = companion.getField("MODULE$").get(null); + return (Boolean) companion.getMethod("hiveClassesArePresent").invoke(module); + } catch (ClassNotFoundException | NoSuchFieldException | NoSuchMethodException e) { + // Try the next candidate. + } catch (ReflectiveOperationException e) { + LOG.warn("Failed to invoke {}.hiveClassesArePresent()", cls, e); + return false; + } + } + LOG.warn("Could not locate SparkSession#hiveClassesArePresent on the classpath"); + return false; + } } diff --git a/rsc/src/test/java/org/apache/livy/rsc/TestSparkClient.java b/rsc/src/test/java/org/apache/livy/rsc/TestSparkClient.java index 637be8b1d..44bfa654e 100644 --- a/rsc/src/test/java/org/apache/livy/rsc/TestSparkClient.java +++ b/rsc/src/test/java/org/apache/livy/rsc/TestSparkClient.java @@ -193,7 +193,12 @@ public void call(LivyClient client) throws Exception { // state changes. assertFalse(((JobHandleImpl)handle).changeState(JobHandle.State.SENT)); - verify(listener).onJobStarted(handle); + // Note: onJobStarted is omitted here due to a race condition. + // Fast-failing jobs can transition STARTED -> FAILED before addListener() + // finishes attaching, causing it to skip onJobStarted and only report FAILED. + // Spark 4's faster dispatching makes this more frequent. We only care about + // verifying onJobFailed. + verify(listener).onJobFailed(same(handle), any(Throwable.class)); } }); diff --git a/scala-api/scala-2.13/pom.xml b/scala-api/scala-2.13/pom.xml new file mode 100644 index 000000000..ed44e752a --- /dev/null +++ b/scala-api/scala-2.13/pom.xml @@ -0,0 +1,31 @@ + + + + 4.0.0 + org.apache.livy + livy-scala-api_2.13 + 1.0.0-SNAPSHOT + jar + + + org.apache.livy + livy-scala-api-parent + 1.0.0-SNAPSHOT + ../pom.xml + + \ No newline at end of file diff --git a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTestUtils.scala b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTestUtils.scala index 7d406f3a6..7be5176e8 100644 --- a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTestUtils.scala +++ b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaClientTestUtils.scala @@ -44,7 +44,7 @@ object ScalaClientTestUtils extends AnyFunSuite with LivyBaseUnitTestSuite { for (a <- 1 to count) { buffer += r.nextInt() } - context.sc.parallelize(buffer, partitions).count() + context.sc.parallelize(buffer.toSeq, partitions).count() } def assertAwait(lock: CountDownLatch): Unit = { diff --git a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaJobHandleTest.scala b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaJobHandleTest.scala index 732f6af76..d82282d63 100644 --- a/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaJobHandleTest.scala +++ b/scala-api/src/test/scala/org/apache/livy/scalaapi/ScalaJobHandleTest.scala @@ -66,9 +66,15 @@ class ScalaJobHandleTest extends AnyFunSuite test("ready with Infinite Duration") { when(mockJobHandle.isDone).thenReturn(true) when(mockJobHandle.get()).thenReturn("hello") - val result = Await.ready(scalaJobHandle, Duration.Undefined) + // Scala 2.13's Await.ready rejects `Duration.Undefined` as + // "Cannot wait for Undefined duration of time"; use `Duration.Inf`. + // Also, 2.13's `Await.ready` short-circuits when `isCompleted` is + // already true and does not call the underlying `ready(atMost)`, so + // the `jobHandle.get()` invocation is not observed on 2.13. Assert + // on `isDone` instead, which is exercised by both versions. + val result = Await.ready(scalaJobHandle, Duration.Inf) assert(result == scalaJobHandle) - verify(mockJobHandle, times(1)).get() + verify(mockJobHandle, atLeastOnce()).isDone } test("verify addListener call of java jobHandle for onComplete") { diff --git a/server/src/main/scala/org/apache/livy/server/batch/BatchSession.scala b/server/src/main/scala/org/apache/livy/server/batch/BatchSession.scala index de771156e..0d9a0c644 100644 --- a/server/src/main/scala/org/apache/livy/server/batch/BatchSession.scala +++ b/server/src/main/scala/org/apache/livy/server/batch/BatchSession.scala @@ -90,6 +90,11 @@ object BatchSession extends Logging { builder.redirectOutput(Redirect.PIPE) builder.redirectErrorStream(true) + // Under unit-test runs we ask spark-submit to be verbose so the tests + // can grep the resolved arguments (queue name, master, class, ...) out + // of the child's log -- production sessions leave the default off so + // the extra output does not pollute the user-facing session log. + if (LivyConf.TEST_MODE) builder.verbose(true) val file = resolveURIs(Seq(request.file), livyConf)(0) val sparkSubmit = builder.start(Some(file), request.args) diff --git a/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSession.scala b/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSession.scala index cfa1c84ee..b193dac80 100644 --- a/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSession.scala +++ b/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSession.scala @@ -245,18 +245,21 @@ object InteractiveSession extends Logging { } } - def datanucleusJars(livyConf: LivyConf, sparkMajorVersion: Int): Seq[String] = { + def datanucleusJars( + livyConf: LivyConf, + sparkMajorVersion: Int, + scalaVersion: String): Seq[String] = { if (sys.env.getOrElse("LIVY_INTEGRATION_TEST", "false").toBoolean) { // datanucleus jars has already been in classpath in integration test Seq.empty } else { val sparkHome = livyConf.sparkHome().get val libdir = sparkMajorVersion match { - case 3 => + case 3 | 4 => if (new File(sparkHome, "RELEASE").isFile) { new File(sparkHome, "jars") } else { - new File(sparkHome, "assembly/target/scala-2.12/jars") + new File(sparkHome, s"assembly/target/scala-$scalaVersion/jars") } case v => throw new RuntimeException( @@ -340,17 +343,19 @@ object InteractiveSession extends Logging { } } - def mergeHiveSiteAndHiveDeps(sparkMajorVersion: Int): Unit = { + def mergeHiveSiteAndHiveDeps(sparkMajorVersion: Int, scalaVersion: String): Unit = { val sparkFiles = conf.get("spark.files").map(_.split(",")).getOrElse(Array.empty[String]) hiveSiteFile(sparkFiles, livyConf) match { case (_, true) => debug("Enable HiveContext because hive-site.xml is found in user request.") - mergeConfList(datanucleusJars(livyConf, sparkMajorVersion), LivyConf.SPARK_JARS) + mergeConfList( + datanucleusJars(livyConf, sparkMajorVersion, scalaVersion), LivyConf.SPARK_JARS) case (Some(file), false) => debug("Enable HiveContext because hive-site.xml is found under classpath, " + file.getAbsolutePath) mergeConfList(List(file.getAbsolutePath), LivyConf.SPARK_FILES) - mergeConfList(datanucleusJars(livyConf, sparkMajorVersion), LivyConf.SPARK_JARS) + mergeConfList( + datanucleusJars(livyConf, sparkMajorVersion, scalaVersion), LivyConf.SPARK_JARS) case (None, false) => warn("Enable HiveContext but no hive-site.xml found under" + " classpath or user request.") @@ -396,7 +401,7 @@ object InteractiveSession extends Logging { builderProperties.put("spark.sql.catalogImplementation", confVal) if (enableHiveContext) { - mergeHiveSiteAndHiveDeps(sparkMajorVersion) + mergeHiveSiteAndHiveDeps(sparkMajorVersion, scalaVersion) } // Pick all the RSC-specific configs that have not been explicitly set otherwise, and diff --git a/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSessionServlet.scala b/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSessionServlet.scala index d6ba9e9df..a39df3bcb 100644 --- a/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSessionServlet.scala +++ b/server/src/main/scala/org/apache/livy/server/interactive/InteractiveSessionServlet.scala @@ -118,7 +118,11 @@ class InteractiveSessionServlet( new SessionInfo(session.id, session.name.orNull, session.appId.orNull, session.owner, session.state.toString, session.kind.toString, - session.appInfo.asJavaMap, logs.asJava, session.ttl.orNull, + // `.toSeq.asJava`: `SessionInfo` needs a `java.util.List`. In Scala + // 2.13 the JavaConverters `.asJava` overload that produces a + // `java.util.List` is the `Seq[A]` one; forcing `.toSeq` here picks + // that overload regardless of which concrete collection `logs` is. + session.appInfo.asJavaMap, logs.toSeq.asJava, session.ttl.orNull, session.idleTimeout.orNull, session.driverMemory.orNull, session.driverCores.getOrElse(0), session.executorMemory.orNull, session.executorCores.getOrElse(0), conf, archives, diff --git a/server/src/main/scala/org/apache/livy/utils/LivySparkUtils.scala b/server/src/main/scala/org/apache/livy/utils/LivySparkUtils.scala index 6bd8c3807..0a431819c 100644 --- a/server/src/main/scala/org/apache/livy/utils/LivySparkUtils.scala +++ b/server/src/main/scala/org/apache/livy/utils/LivySparkUtils.scala @@ -30,6 +30,10 @@ object LivySparkUtils extends Logging { // For each Spark version we supported, we need to add this mapping relation in case Scala // version cannot be detected from "spark-submit --version". private val _defaultSparkScalaVersion = SortedMap( + // Spark 4.1 + Scala 2.13 + (4, 1) -> "2.13", + // Spark 4.0 + Scala 2.13 + (4, 0) -> "2.13", // Spark 3.5 + Scala 2.12 (3, 5) -> "2.12", // Spark 3.4 + Scala 2.12 @@ -46,7 +50,7 @@ object LivySparkUtils extends Logging { // Supported Spark version (Spark 2.x support has been removed) private val MIN_VERSION = (3, 0) - private val MAX_VERSION = (3, 6) + private val MAX_VERSION = (4, 2) private val sparkVersionRegex = """version (.*)""".r.unanchored private val scalaVersionRegex = """Scala version (.*), Java""".r.unanchored diff --git a/server/src/main/scala/org/apache/livy/utils/SparkProcessBuilder.scala b/server/src/main/scala/org/apache/livy/utils/SparkProcessBuilder.scala index 01cbb4c3c..3c9b45bf6 100644 --- a/server/src/main/scala/org/apache/livy/utils/SparkProcessBuilder.scala +++ b/server/src/main/scala/org/apache/livy/utils/SparkProcessBuilder.scala @@ -38,6 +38,7 @@ class SparkProcessBuilder(livyConf: LivyConf) extends Logging { private[this] var _redirectOutput: Option[ProcessBuilder.Redirect] = None private[this] var _redirectError: Option[ProcessBuilder.Redirect] = None private[this] var _redirectErrorStream: Option[Boolean] = None + private[this] var _verbose: Boolean = false def executable(executable: String): SparkProcessBuilder = { _executable = executable @@ -154,6 +155,16 @@ class SparkProcessBuilder(livyConf: LivyConf) extends Logging { this } + /** + * Enable `spark-submit --verbose`. When set, spark-submit prints its + * parsed argument list (queue, master, class, etc.) to stderr; this is + * primarily useful for tests that need to assert on the resolved arguments. + */ + def verbose(v: Boolean): SparkProcessBuilder = { + _verbose = v + this + } + def start(file: Option[String], args: Traversable[String]): LineBufferedProcess = { var arguments = ArrayBuffer(_executable) @@ -192,6 +203,8 @@ class SparkProcessBuilder(livyConf: LivyConf) extends Logging { addOpt("--queue", _queue) + if (_verbose) arguments += "--verbose" + arguments += file.getOrElse("spark-internal") arguments ++= args diff --git a/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala b/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala index 556c54fbd..1efa266ac 100644 --- a/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala +++ b/server/src/test/scala/org/apache/livy/server/interactive/InteractiveSessionSpec.scala @@ -201,12 +201,15 @@ class InteractiveSessionSpec extends AnyFunSpec "data" -> Map("text/plain" -> "3"))) ) + // The Scala REPL under Spark 4 (2.13) may or may not prefix the + // bound-name output with `val ` depending on the driver JVM's precise + // `-Yrepl-class-based` handling; accept either form so both spark3 and + // spark4 builds pass without special-casing environment differences. val scalaResult = executeStatement("1 + 2", Some("spark")) - scalaResult should equal (Extraction.decompose(Map( - "status" -> "ok", - "execution_count" -> 1, - "data" -> Map("text/plain" -> "res0: Int = 3\n"))) - ) + val scalaData = ((scalaResult \ "data") \ "text/plain").extract[String] + scalaData should (equal ("res0: Int = 3\n") or equal ("val res0: Int = 3\n")) + (scalaResult \ "status").extract[String] should equal ("ok") + (scalaResult \ "execution_count").extract[Int] should equal (1) val rResult = executeStatement("1 + 2", Some("sparkr")) rResult should equal (Extraction.decompose(Map( diff --git a/server/src/test/scala/org/apache/livy/utils/LivySparkUtilsSuite.scala b/server/src/test/scala/org/apache/livy/utils/LivySparkUtilsSuite.scala index 4338f6d38..d918adab6 100644 --- a/server/src/test/scala/org/apache/livy/utils/LivySparkUtilsSuite.scala +++ b/server/src/test/scala/org/apache/livy/utils/LivySparkUtilsSuite.scala @@ -48,6 +48,8 @@ class LivySparkUtilsSuite extends AnyFunSuite with Matchers with LivyBaseUnitTes testSparkVersion("3.1.0") testSparkVersion("3.2.0") testSparkVersion("3.5.0") + testSparkVersion("4.0.0") + testSparkVersion("4.1.2") } test("should complain about unsupported Spark versions") { @@ -91,6 +93,8 @@ class LivySparkUtilsSuite extends AnyFunSuite with Matchers with LivyBaseUnitTes defaultSparkScalaVersion(formatSparkVersion("3.0.0")) shouldBe "2.12" defaultSparkScalaVersion(formatSparkVersion("3.1.0")) shouldBe "2.12" defaultSparkScalaVersion(formatSparkVersion("3.5.0")) shouldBe "2.12" + defaultSparkScalaVersion(formatSparkVersion("4.0.0")) shouldBe "2.13" + defaultSparkScalaVersion(formatSparkVersion("4.1.2")) shouldBe "2.13" } test("sparkScalaVersion() should use spark-submit detected Scala version.") { diff --git a/test-lib/src/main/scala/org/apache/livy/test/ScalaVersionAware.scala b/test-lib/src/main/scala/org/apache/livy/test/ScalaVersionAware.scala new file mode 100644 index 000000000..97e39d214 --- /dev/null +++ b/test-lib/src/main/scala/org/apache/livy/test/ScalaVersionAware.scala @@ -0,0 +1,50 @@ +/* + * 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.livy.test + +/** + * Shared Scala-version-dependent helpers for the Livy test suites. Mixed into + * base spec classes so their subclasses see a single canonical + * `optionalValPrefix` (and `optional*PrefixRegex` fragments for regex-based + * `verifyResult` calls in integration tests). Detection uses the runtime + * Scala version, so this trait works regardless of which Scala the artifact + * was compiled against. + */ +trait ScalaVersionAware { + + /** Prefix that the Scala 2.13 REPL prints before a value binding, e.g. + * `val res0: Int = 2` vs. Scala 2.12's `res0: Int = 2`. Empty on 2.12. + * Use in exact-match expected strings. */ + protected val optionalValPrefix: String = + if (scala.util.Properties.versionNumberString.startsWith("2.13")) "val " else "" + + /** Regex fragment `(val )?` -- an optional-`val ` alternation that matches + * the Scala 2.12 and 2.13 REPL output shape in one pattern. Use in regex- + * based `verifyResult` calls. */ + protected val optionalValPrefixRegex: String = "(val )?" + + /** Regex fragment `(?:defined )?` -- Scala 2.12's `defined class X` vs. + * Scala 2.13's plain `class X` REPL output. */ + protected val optionalDefinedPrefixRegex: String = "(?:defined )?" + + /** Regex fragment `(?:warning:.*\n)?` -- Scala 2.13 emits a + * `warning: n deprecation(s)...` line above value bindings that use a + * deprecated API; Scala 2.12 does not. Pair with `(?s)` DOTALL to span + * the newline. */ + protected val optionalWarningPrefixRegex: String = "(?:warning:.*\\n)?" +} diff --git a/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/LivyExecuteStatementOperation.scala b/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/LivyExecuteStatementOperation.scala index f7d6c16b0..234d21acf 100644 --- a/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/LivyExecuteStatementOperation.scala +++ b/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/LivyExecuteStatementOperation.scala @@ -205,6 +205,7 @@ class LivyExecuteStatementOperation( } val res = new mutable.ListBuffer[String] while (fetchNext(res)) {} - res + // Scala 2.13 no longer widens a mutable Buffer to Seq implicitly. + res.toSeq } } diff --git a/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/cli/ThriftBinaryCLIService.scala b/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/cli/ThriftBinaryCLIService.scala index 72b693018..d5a46cac1 100644 --- a/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/cli/ThriftBinaryCLIService.scala +++ b/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/cli/ThriftBinaryCLIService.scala @@ -108,11 +108,17 @@ class ThriftBinaryCLIService(override val cliService: LivyCLIService, val oomHoo .protocolFactory(new TBinaryProtocol.Factory) .inputProtocolFactory( new TBinaryProtocol.Factory(true, true, maxMessageSize, maxMessageSize)) - .requestTimeout(requestTimeout) - .requestTimeoutUnit(TimeUnit.MILLISECONDS) - .beBackoffSlotLength(beBackoffSlotLength) - .beBackoffSlotLengthUnit(TimeUnit.MILLISECONDS) .executorService(executorService) + // `requestTimeout`, `requestTimeoutUnit`, `beBackoffSlotLength` and + // `beBackoffSlotLengthUnit` are present in the libthrift that Livy + // compiles against (0.9.3, pinned in the root pom) and in the versions + // Spark 3 brings in transitively, but were removed in libthrift 0.16.0 + // (transitively pulled in by Spark 4). Invoke them reflectively so the + // same code compiles and runs on both classpaths. + applyOptionalArg(sargs, "requestTimeout", Integer.TYPE, Int.box(requestTimeout)) + applyOptionalArg(sargs, "requestTimeoutUnit", classOf[TimeUnit], TimeUnit.MILLISECONDS) + applyOptionalArg(sargs, "beBackoffSlotLength", Integer.TYPE, Int.box(beBackoffSlotLength)) + applyOptionalArg(sargs, "beBackoffSlotLengthUnit", classOf[TimeUnit], TimeUnit.MILLISECONDS) // TCP Server server = new TThreadPoolServer(sargs) server.setServerEventHandler(new TServerEventHandler() { @@ -174,4 +180,26 @@ class ThriftBinaryCLIService(override val cliService: LivyCLIService, val oomHoo server = null info("Thrift server has stopped") } + + /** + * Invoke a `TThreadPoolServer.Args` builder method by name if it exists on + * the runtime libthrift version. Silently skip it if the method is missing: + * libthrift 0.16.0 (pulled in by the Spark 4 profile via spark-hive) + * dropped the login-timeout / backoff-slot builders that pre-0.16 releases + * -- including Livy's pinned 0.9.3 compile dependency -- still expose. + */ + private def applyOptionalArg( + args: TThreadPoolServer.Args, + methodName: String, + paramType: Class[_], + value: AnyRef): Unit = { + try { + val m = classOf[TThreadPoolServer.Args].getMethod(methodName, paramType) + m.invoke(args, value) + } catch { + case _: NoSuchMethodException => + debug(s"TThreadPoolServer.Args.$methodName not present in the runtime " + + s"libthrift; skipping (removed in libthrift 0.16.0).") + } + } } diff --git a/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/types/DataTypeUtils.scala b/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/types/DataTypeUtils.scala index f8f0f190d..ce898d82c 100644 --- a/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/types/DataTypeUtils.scala +++ b/thriftserver/server/src/main/scala/org/apache/livy/thriftserver/types/DataTypeUtils.scala @@ -17,7 +17,7 @@ package org.apache.livy.thriftserver.types -import org.json4s.{DefaultFormats, JValue, StringInput} +import org.json4s.{DefaultFormats, JValue} import org.json4s.JsonAST.{JObject, JString} import org.json4s.jackson.JsonMethods.parse @@ -76,7 +76,10 @@ object DataTypeUtils { * @return a [[Schema]] representing the schema provided as input */ def schemaFromSparkJson(sparkJson: String): Schema = { - val schema = parse(StringInput(sparkJson), false) \ "fields" + // json4s 4.x switched `parse(input, useBigDecimalForDouble)` to require + // an implicit AsJsonInput[T]; `parse(sparkJson)` picks the String + // overload directly and behaves identically for our purpose. + val schema = parse(sparkJson) \ "fields" val fields = schema.children.map { field => val name = (field \ "name").extract[String] val hiveType = toFieldType(field \ "type") diff --git a/thriftserver/server/src/test/scala/org/apache/livy/thriftserver/ThriftServerSuites.scala b/thriftserver/server/src/test/scala/org/apache/livy/thriftserver/ThriftServerSuites.scala index dd688a64a..a8672090e 100644 --- a/thriftserver/server/src/test/scala/org/apache/livy/thriftserver/ThriftServerSuites.scala +++ b/thriftserver/server/src/test/scala/org/apache/livy/thriftserver/ThriftServerSuites.scala @@ -570,8 +570,13 @@ class BinaryThriftServerSuite extends ThriftServerBaseTest with CommonThriftTest } } val message = caught.getMessage - assert(message.contains("Database 'invalid_database' not found") || - message.contains("The schema `invalid_database` cannot be found")) + // Spark 3: "Database 'invalid_database' not found" + // Spark 3.3+: "The schema `invalid_database` cannot be found" + // Spark 4: "[SCHEMA_NOT_FOUND] The schema `spark_catalog`.`invalid_database` cannot be found" + assert(message.contains("invalid_database") && ( + message.contains("not found") || + message.contains("cannot be found") || + message.contains("SCHEMA_NOT_FOUND"))) } } diff --git a/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetColumnsJob.java b/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetColumnsJob.java index b5a0cba7a..7e4214f20 100644 --- a/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetColumnsJob.java +++ b/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetColumnsJob.java @@ -20,7 +20,7 @@ import java.util.ArrayList; import java.util.List; -import static scala.collection.JavaConversions.seqAsJavaList; +import static scala.collection.JavaConverters.seqAsJavaListConverter; import org.apache.spark.sql.Row; import org.apache.spark.sql.catalyst.TableIdentifier; @@ -50,14 +50,15 @@ public GetColumnsJob( @Override protected List fetchCatalogObjects(SessionCatalog catalog) { List columnList = new ArrayList<>(); - List databases = seqAsJavaList(catalog.listDatabases(databasePattern)); + List databases = + seqAsJavaListConverter(catalog.listDatabases(databasePattern)).asJava(); for (String db : databases) { List tableIdentifiers = - seqAsJavaList(catalog.listTables(db, tablePattern)); + seqAsJavaListConverter(catalog.listTables(db, tablePattern)).asJava(); for (TableIdentifier tableIdentifier : tableIdentifiers) { CatalogTable table = catalog.getTempViewOrPermanentTableMetadata(tableIdentifier); - List fields = seqAsJavaList(table.schema()); + List fields = seqAsJavaListConverter(table.schema()).asJava(); int position = 0; for (StructField field : fields) { if (field.name().matches(columnPattern)) { diff --git a/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetFunctionsJob.java b/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetFunctionsJob.java index e5e383aee..db5bf5eb8 100644 --- a/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetFunctionsJob.java +++ b/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetFunctionsJob.java @@ -21,7 +21,7 @@ import java.util.List; import scala.Tuple2; -import static scala.collection.JavaConversions.seqAsJavaList; +import static scala.collection.JavaConverters.seqAsJavaListConverter; import org.apache.spark.sql.Row; import org.apache.spark.sql.catalyst.FunctionIdentifier; @@ -51,10 +51,11 @@ public GetFunctionsJob( protected List fetchCatalogObjects(SessionCatalog catalog) { List funcList = new ArrayList<>(); - List databases = seqAsJavaList(catalog.listDatabases(databasePattern)); + List databases = + seqAsJavaListConverter(catalog.listDatabases(databasePattern)).asJava(); for (String db : databases) { List> identifiersTypes = - seqAsJavaList(catalog.listFunctions(db, functionRegex)); + seqAsJavaListConverter(catalog.listFunctions(db, functionRegex)).asJava(); for (Tuple2 identifierType : identifiersTypes) { FunctionIdentifier function = identifierType._1; ExpressionInfo info = catalog.lookupFunctionInfo(function); diff --git a/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetSchemasJob.java b/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetSchemasJob.java index 59c4ccace..25a723ec8 100644 --- a/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetSchemasJob.java +++ b/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetSchemasJob.java @@ -20,7 +20,7 @@ import java.util.ArrayList; import java.util.List; -import static scala.collection.JavaConversions.seqAsJavaList; +import static scala.collection.JavaConverters.seqAsJavaListConverter; import org.apache.spark.sql.Row; import org.apache.spark.sql.catalyst.catalog.SessionCatalog; @@ -40,7 +40,8 @@ public GetSchemasJob( @Override protected List fetchCatalogObjects(SessionCatalog catalog) { - List databases = seqAsJavaList(catalog.listDatabases(schemaPattern)); + List databases = + seqAsJavaListConverter(catalog.listDatabases(schemaPattern)).asJava(); List schemas = new ArrayList<>(); for (String db : databases) { schemas.add(new GenericRow(new Object[] { diff --git a/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetTablesJob.java b/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetTablesJob.java index d3c6b5363..e45ffcb4f 100644 --- a/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetTablesJob.java +++ b/thriftserver/session/src/main/java/org/apache/livy/thriftserver/session/GetTablesJob.java @@ -20,7 +20,7 @@ import java.util.ArrayList; import java.util.List; -import static scala.collection.JavaConversions.seqAsJavaList; +import static scala.collection.JavaConverters.seqAsJavaListConverter; import org.apache.spark.sql.Row; import org.apache.spark.sql.catalyst.TableIdentifier; @@ -54,10 +54,11 @@ public GetTablesJob( @Override protected List fetchCatalogObjects(SessionCatalog catalog) { List tableList = new ArrayList(); - List databases = seqAsJavaList(catalog.listDatabases(databasePattern)); + List databases = + seqAsJavaListConverter(catalog.listDatabases(databasePattern)).asJava(); for (String db : databases) { List tableIdentifiers = - seqAsJavaList(catalog.listTables(db, tablePattern)); + seqAsJavaListConverter(catalog.listTables(db, tablePattern)).asJava(); for (TableIdentifier tableIdentifier : tableIdentifiers) { CatalogTable table = catalog.getTempViewOrPermanentTableMetadata(tableIdentifier); String type = convertTableType(table.tableType().name());