Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion core/build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,13 @@ libraryDependencies ++= akka ++ typesafeConfig ++ http ++
json4s ++ mockito ++ avro ++ cloudConnectors ++ repl :+ "com.google.code.gson" % "gson" % Versions.gson :+
"com.typesafe.scala-logging" %% "scala-logging" % Versions.scalaLogging :+
"io.delta" %% "delta-standalone" % Versions.delta :+
"org.scalatest" %% "scalatest" % Versions.scalatest % Test
"org.scalatest" %% "scalatest" % Versions.scalatest % Test :+
// #258: the isolation specs capture the empty-provider-list WARN through logback's ListAppender
// (house pattern: SlicedScrollCompletenessSpec). Test scope only - the published module carries no
// logging backend. Declared here rather than inherited from `licensing % "test->test"`; test logging
// (root WARN) comes from licensing's TestLoggingConfigurator, a logback Configurator SPI that applies
// only where no logback XML config exists - see its scaladoc for why it is not a logback-test.xml.
"ch.qos.logback" % "logback-classic" % Versions.logback % Test

// Issue #183: run the very same test suite on an arbitrary JDK without changing the compile JDK.
// sbt -Dtest.jdk.home=/Library/Java/JavaVirtualMachines/zulu-25.jdk/Contents/Home \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ import java.time.Duration
* Socket operation timeout
* @param metrics
* Metrics and monitoring configuration
* @param watcher
* Credentials for the watcher component (if applicable)
* @param includeDocumentId
* When enabled, result rows surface the Elasticsearch document id as an `_id` column (disabled
* by default)
* @param scroll
* Paged row extraction settings (`elastic.scroll`: page size and the ceiling on concurrent PIT
* slices, #238)
* @param watcher
* Credentials for the watcher component (if applicable)
* @param includeDocumentId
* When enabled, result rows surface the Elasticsearch document id as an `_id` column (disabled
* by default)
* @param scroll
* Paged row extraction settings (`elastic.scroll`: page size and the ceiling on concurrent PIT
* slices, #238)
*/
case class ElasticConfig(
credentials: ElasticCredentials = ElasticCredentials(),
Expand All @@ -55,12 +55,22 @@ case class ElasticConfig(
metrics: MetricsConfig,
watcher: ElasticCredentials,
includeDocumentId: Boolean = false,
scroll: ScrollSettings = ScrollSettings())
scroll: ScrollSettings = ScrollSettings()
)

object ElasticConfig extends StrictLogging {

/** The `elastic.*` defaults shipped in this jar (`softnetwork-elastic.conf`), resolved against
* the classloader that loaded this class rather than the thread context classloader: under a
* host-owned blind TCCL `ConfigFactory.load(name)` finds nothing and every client creation fails
* on configuration before it can even look for a provider (#258).
*/
private def defaults: Config =
ConfigFactory.load(classOf[ElasticConfig].getClassLoader, "softnetwork-elastic.conf")

def apply(config: Config): ElasticConfig = {
Configs[ElasticConfig]
.get(config.withFallback(ConfigFactory.load("softnetwork-elastic.conf")), "elastic")
.get(config.withFallback(defaults), "elastic")
.toEither match {
case Left(configError) =>
logger.error(s"Something went wrong with the provided arguments $configError")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,18 @@ case class ElasticConfig(
)

object ElasticConfig extends StrictLogging {

/** The `elastic.*` defaults shipped in this jar (`softnetwork-elastic.conf`), resolved against
* the classloader that loaded this class rather than the thread context classloader: under a
* host-owned blind TCCL `ConfigFactory.load(name)` finds nothing and every client creation fails
* on configuration before it can even look for a provider (#258).
*/
private def defaults: Config =
ConfigFactory.load(classOf[ElasticConfig].getClassLoader, "softnetwork-elastic.conf")

def apply(config: Config): ElasticConfig = {
ConfigReader[ElasticConfig]
.read(config.withFallback(ConfigFactory.load("softnetwork-elastic.conf")), "elastic")
.read(config.withFallback(defaults), "elastic")
.toEither match {
case Left(configError) =>
logger.error(s"Something went wrong with the provided arguments $configError")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,22 @@ class ExtensionRegistry(
*/
lazy val extensions: Seq[ExtensionSpi] = {
val scanStart = System.nanoTime()
val loader = ServiceLoader.load(classOf[ExtensionSpi])
// #258: resolve providers against the classloader that loaded this library, never the thread
// context classloader - under a host-owned blind loader a single-arg load found nothing and the
// registry silently ran with no extension at all (#157's silent-wrong-answer mode).
val spiClass = classOf[ExtensionSpi]
val discovered = ServiceLoader.load(spiClass, spiClass.getClassLoader).iterator().asScala.toList
if (discovered.isEmpty) {
logger.warn(
s"No ${spiClass.getName} provider found through ${spiClass.getClassLoader}: no SQL " +
"extension will be available - not even the core DDL/DQL extensions shipped in " +
"softclient4es-core, let alone cross-index JOIN or materialized views. Providers are " +
"resolved against the classloader that loaded softclient4es-core, never the thread context " +
"classloader: the extension jars must be on that same classpath (#258)."
)
}

val loaded = loader.iterator().asScala.toSeq.flatMap { ext =>
val loaded = discovered.flatMap { ext =>
logger.info(
s"🔌 Discovered extension: ${ext.extensionName} v${ext.version} (priority: ${ext.priority})"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import org.slf4j.{Logger, LoggerFactory}
import java.util.ServiceLoader
import java.util.concurrent.ConcurrentHashMap
import scala.jdk.CollectionConverters._
import scala.sys.ShutdownHookThread
import scala.util.control.NonFatal

/** Factory for creating Elasticsearch clients with optional metrics and monitoring.
Expand All @@ -39,16 +40,22 @@ object ElasticClientFactory {

private val logger: Logger = LoggerFactory.getLogger(getClass)

// #258: resolve providers against the classloader that loaded this library, never the thread
// context classloader. This val is latched at class initialisation, so a single-arg load froze
// whichever TCCL the first caller happened to carry - under a host-owned blind loader (Tableau,
// plugin containers, app servers) that meant a permanent and misleading "No ElasticClientSpi
// implementation found" although the provider sat right beside this class on the classpath.
private[this] val factories: ServiceLoader[ElasticClientSpi] =
ServiceLoader.load(classOf[ElasticClientSpi])
ServiceLoader.load(classOf[ElasticClientSpi], classOf[ElasticClientSpi].getClassLoader)

// Use String key (URL) instead of Config for reliable caching
private[this] val clientsByUrl = new ConcurrentHashMap[String, ElasticClientApi]()
private[this] val metricsClientsByUrl = new ConcurrentHashMap[String, MetricsElasticClient]()
private[this] val monitoredClientsByUrl = new ConcurrentHashMap[String, MonitoredElasticClient]()

// Shutdown hook to close all clients
sys.addShutdownHook {
// Shutdown hook to close all clients. Named so that a test which defines a fresh copy of this
// object (ElasticClientFactoryIsolationSpec, #258) can remove the hook that copy registered.
private[spi] val shutdownHook: ShutdownHookThread = sys.addShutdownHook {
logger.info("JVM shutdown detected, closing all Elasticsearch clients")
shutdown()
}
Expand All @@ -74,7 +81,15 @@ object ElasticClientFactory {
.map(_.client(config))
.toSeq
.headOption
.getOrElse(throw new IllegalStateException("No ElasticClientSpi implementation found"))
.getOrElse(
// The leading substring is pinned by the jdbc/arrow isolation specs - append, never replace.
throw new IllegalStateException(
"No ElasticClientSpi implementation found through " +
s"${classOf[ElasticClientSpi].getClassLoader}: the client jar must be on the same " +
"classpath as softclient4es-core - providers are resolved against the classloader that " +
"loaded softclient4es-core, never the thread context classloader (#258)"
)
)
}
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/*
* Copyright 2025 SOFTNETWORK
*
* Licensed 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 app.softnetwork.elastic.client

import app.softnetwork.elastic.licensing.ClassLoaderIsolation
import com.typesafe.config.{Config, ConfigFactory}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

/** #258 (found by the ElasticClientFactory isolation spec): `ElasticConfig.apply` supplies the
* `elastic.*` defaults by loading `softnetwork-elastic.conf` itself, and
* `ConfigFactory.load(name)` resolves that resource through the thread context classloader. Under
* a host-owned blind TCCL the factory therefore failed on configuration BEFORE its `ServiceLoader`
* ever ran. The resource must resolve against the classloader that loaded softclient4es-core,
* whatever the TCCL.
*/
class ElasticConfigIsolationSpec extends AnyFlatSpec with Matchers {

private val defaultsResource = "softnetwork-elastic.conf"

behavior of "ElasticConfig defaults resolution (#258)"

it should "resolve the softnetwork-elastic.conf defaults under a blind context classloader" in {
// Built BEFORE the swap (ConfigFactory.load() is itself TCCL-bound). Precondition, asserted not
// assumed: the application config carries NO `elastic.*` key of its own, so every value below
// has to come from the defaults resource.
val application: Config = ConfigFactory.load()
application.hasPath("elastic") shouldBe false

val blind = ClassLoaderIsolation.blindLoader()
blind.getResource(defaultsResource) shouldBe null

val expected = ElasticConfig(application)
val underBlindTccl =
ClassLoaderIsolation.withContextClassLoader(blind)(ElasticConfig(application))

underBlindTccl shouldBe expected
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* Copyright 2025 SOFTNETWORK
*
* Licensed 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 app.softnetwork.elastic.client

import app.softnetwork.elastic.client.extensions.{CoreDdlExtension, CoreDqlExtension}
import app.softnetwork.elastic.licensing.ClassLoaderIsolation.RedefiningClassLoader
import app.softnetwork.elastic.licensing.{
ClassLoaderIsolation,
LicenseRefreshStrategy,
LogbackCapture,
NopRefreshStrategy
}
import ch.qos.logback.classic.Level
import com.typesafe.config.{Config, ConfigFactory}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

import java.net.URL

/** #258 - `ExtensionRegistry` resolves its `ExtensionSpi` providers against the interface's own
* classloader, never the thread context classloader, and WARNs when it finds none (the silent "no
* JOIN / MV extension" mode of #157).
*
* Each spec instantiates a FRESH copy of the registry class defined by a
* [[RedefiningClassLoader]], so the `lazy val extensions` it forces is that class's first touch
* whatever ran before.
*/
class ExtensionRegistryIsolationSpec extends AnyFlatSpec with Matchers {

private val registryClass = classOf[ExtensionRegistry].getName
private val spiName = classOf[ExtensionSpi].getName
private val servicesResource = "META-INF/services/" + spiName

// ExtensionRegistry logs through LoggerFactory.getLogger(getClass): the logger is its class name
private val registryLogger = registryClass

// Built BEFORE any blind context classloader is installed: ConfigFactory.load() is TCCL-bound.
private val config: Config = ConfigFactory.load()
private val strategy: LicenseRefreshStrategy = new NopRefreshStrategy()

private def extensionsOfFreshRegistry(loader: ClassLoader): Seq[_] = {
val cls = loader.loadClass(registryClass)
// a distinct Class => a distinct, unforced lazy val: this IS the registry's first touch
cls should not be theSameInstanceAs(classOf[ExtensionRegistry])
val registry = cls
.getConstructor(classOf[Config], classOf[LicenseRefreshStrategy])
.newInstance(config, strategy)
.asInstanceOf[AnyRef]
cls.getMethod("extensions").invoke(registry).asInstanceOf[Seq[_]]
}

behavior of "ExtensionRegistry provider discovery (#258)"

it should "discover the extensions shipped in softclient4es-core on first touch under a blind context classloader" in {
val parent = getClass.getClassLoader
// precondition, asserted not assumed: core's own jar registers CoreDdlExtension/CoreDqlExtension
parent.getResources(servicesResource).hasMoreElements shouldBe true
val blind = ClassLoaderIsolation.blindLoader()
blind.getResources(servicesResource).hasMoreElements shouldBe false

val isolating =
new RedefiningClassLoader(parent, Seq(registryClass), Set.empty, Array.empty[URL])
val (extensions, events) = LogbackCapture.capture(registryLogger, Level.INFO) {
ClassLoaderIsolation.withContextClassLoader(blind)(extensionsOfFreshRegistry(isolating))
}
val ids = extensions.collect { case e: ExtensionSpi => e.extensionId }
ids should contain allOf (new CoreDdlExtension().extensionId, new CoreDqlExtension().extensionId)
events.filter(_.level == "WARN") shouldBe empty
}

it should "WARN naming ExtensionSpi and load nothing when no provider is visible to the interface's own classloader" in {
val parent = getClass.getClassLoader
// The context classloader is deliberately left as it is - one that CAN see core's registration.
// Post-fix that must not matter (AD-S5-1: a provider visible only through the TCCL is no longer
// honoured), and this WARN is what makes that narrowing visible. Pre-fix the single-arg load
// consults the TCCL and dies on `CoreDdlExtension not a subtype` of the redefined interface.
val isolating = new RedefiningClassLoader(
parent,
Seq(registryClass, spiName),
Set(servicesResource),
Array.empty[URL]
)
// the interface now belongs to the isolating loader, which hides its registration
isolating.loadClass(spiName).getClassLoader shouldBe theSameInstanceAs(isolating)
isolating.getResources(servicesResource).hasMoreElements shouldBe false

val (extensions, events) =
LogbackCapture.capture(registryLogger, Level.INFO)(extensionsOfFreshRegistry(isolating))
extensions shouldBe empty
val warnings = events.filter(_.level == "WARN")
warnings should have size 1
warnings.head.message should include(spiName)
}
}
Loading
Loading