Summary
Three ServiceLoader lookups in this repo use the single-arg ServiceLoader.load(cls) form, which resolves through the thread context classloader and latches the result. Any embedder that first touches one of these objects while a foreign/blind context classloader is installed (Tableau, plugin/OSGi-style hosts, app servers) gets an empty provider list — permanently for that classloader — and two of the three sites degrade silently.
Found specifying and implementing Epic 20 Story 20.1 (jdbc#32 / arrow#166). The in-process JDBC and ADBC drivers now guard themselves with a context-classloader swap around every entry point, which makes these latches harmless for those two drivers — but any other embedder of softclient4es-core can still latch a blind loader. Filing pre-approved by the lead (2026-09-02, story 20.1 OQ-1).
The three latch sites
| Site |
Shape |
Empty provider list ⇒ |
core/src/main/scala/app/softnetwork/elastic/client/spi/ElasticClientFactory.scala:42-43 |
single-arg ServiceLoader.load(classOf[ElasticClientSpi]) in a private[this] val on an object ⇒ latched at class-initialisation; reused by :71-77 |
IllegalStateException("No ElasticClientSpi implementation found") (:77) — loud, but misleading: the provider IS on the classpath, just not visible to the latched loader |
licensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategyFactory.scala:112 |
single-arg ServiceLoader.load(classOf[LicenseManagerSpi]); resolved strategy CAS-latched JVM-wide at :129 |
falls through to new NopRefreshStrategy() (:124-128) — no warn, no exception. Licence refresh silently OFF for the lifetime of the JVM |
core/src/main/scala/app/softnetwork/elastic/client/ExtensionRegistry.scala:37-39 |
single-arg ServiceLoader.load(classOf[ExtensionSpi]) inside a lazy val ⇒ latched at first force |
no JOIN / MV extension — issue #157's silent-wrong-answer mode: a JOIN query returns wrong results instead of erroring |
ElasticClientFactory also registers sys.addShutdownHook at object init (:50-54) — a fourth thread whose context classloader is inherited from whoever touched the object first.
Proposed fix
Use the two-arg form at all three sites, anchoring on the SPI interface's own classloader:
ServiceLoader.load(classOf[ElasticClientSpi], classOf[ElasticClientSpi].getClassLoader)
(and the analogous change for LicenseManagerSpi and ExtensionSpi). This makes provider discovery independent of whichever thread happens to initialise the object first. The two silent sites should additionally log a WARN when the provider list is empty, so a future packaging mistake is not invisible.
Scope note
This is a follow-up to Story 20.1 — deliberately no elasticsql code change was made in that story (AD-3: one mechanism, one review surface; the drivers' swap covers them for the driver venues). The latches remain reachable by any other in-process embedder of softclient4es-core.
Refs: SOFTNETWORK-APP/softclient4es-jdbc#32, SOFTNETWORK-APP/softclient4es-arrow#166.
Summary
Three
ServiceLoaderlookups in this repo use the single-argServiceLoader.load(cls)form, which resolves through the thread context classloader and latches the result. Any embedder that first touches one of these objects while a foreign/blind context classloader is installed (Tableau, plugin/OSGi-style hosts, app servers) gets an empty provider list — permanently for that classloader — and two of the three sites degrade silently.Found specifying and implementing Epic 20 Story 20.1 (jdbc#32 / arrow#166). The in-process JDBC and ADBC drivers now guard themselves with a context-classloader swap around every entry point, which makes these latches harmless for those two drivers — but any other embedder of softclient4es-core can still latch a blind loader. Filing pre-approved by the lead (2026-09-02, story 20.1 OQ-1).
The three latch sites
core/src/main/scala/app/softnetwork/elastic/client/spi/ElasticClientFactory.scala:42-43ServiceLoader.load(classOf[ElasticClientSpi])in aprivate[this] valon an object ⇒ latched at class-initialisation; reused by:71-77IllegalStateException("No ElasticClientSpi implementation found")(:77) — loud, but misleading: the provider IS on the classpath, just not visible to the latched loaderlicensing/src/main/scala/app/softnetwork/elastic/licensing/LicenseRefreshStrategyFactory.scala:112ServiceLoader.load(classOf[LicenseManagerSpi]); resolved strategy CAS-latched JVM-wide at:129new NopRefreshStrategy()(:124-128) — no warn, no exception. Licence refresh silently OFF for the lifetime of the JVMcore/src/main/scala/app/softnetwork/elastic/client/ExtensionRegistry.scala:37-39ServiceLoader.load(classOf[ExtensionSpi])inside alazy val⇒ latched at first forceElasticClientFactoryalso registerssys.addShutdownHookat object init (:50-54) — a fourth thread whose context classloader is inherited from whoever touched the object first.Proposed fix
Use the two-arg form at all three sites, anchoring on the SPI interface's own classloader:
(and the analogous change for
LicenseManagerSpiandExtensionSpi). This makes provider discovery independent of whichever thread happens to initialise the object first. The two silent sites should additionally log a WARN when the provider list is empty, so a future packaging mistake is not invisible.Scope note
This is a follow-up to Story 20.1 — deliberately no elasticsql code change was made in that story (AD-3: one mechanism, one review surface; the drivers' swap covers them for the driver venues). The latches remain reachable by any other in-process embedder of softclient4es-core.
Refs: SOFTNETWORK-APP/softclient4es-jdbc#32, SOFTNETWORK-APP/softclient4es-arrow#166.