fix(spi): resolve ServiceLoader providers against the SPI interface's classloader, never the thread context classloader - #287
Merged
Merged
Conversation
… classloader, never the TCCL
ElasticClientFactory, ExtensionRegistry and LicenseRefreshStrategyFactory use the two-arg ServiceLoader.load(cls, cls.getClassLoader); the two silent sites WARN naming the SPI interface when the provider list is empty. Found by the harness and fixed alongside: ElasticConfig resolved its softnetwork-elastic.conf defaults through the thread context classloader too, so under a blind TCCL the factory failed on configuration before discovery ever ran (both Scala-version variants).
First-touch isolation specs through a child-first redefining classloader (red pre-fix, green post-fix, red again with the two-arg load reverted). logback-classic in Test scope on core and licensing, configured by a DEFERRING Configurator SPI: logback 1.5 consults service configurators before its XML search, and persistence-core-testkit's logback.xml must keep winning on the es{N} test classpaths. LogbackCapture waits for the real LoggerContext (slf4j 2 hands other threads a SubstituteLogger during binding; licensing runs its suites in parallel). Release note: SPI providers visible only through the thread context classloader are no longer honoured at these sites. documentation/client/common_principles.md gains a classloader-resolution section.
Story BIDC-5
Closes #258
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
R5-1/R5-5: ElasticClientFactory's exception now names the classloader it consulted and the remedy, appended after the pinned substring No ElasticClientSpi implementation found (jdbc/arrow isolation specs include() it); new spec case asserts the message. documentation/client/common_principles.md no longer promises a WARN from the factory (only ExtensionRegistry and LicenseRefreshStrategyFactory WARN) and scopes the defaults claim to ElasticConfig, CliConfig.builtinLayer staying TCCL-bound by design. R5-2: LogbackCapture keeps only the events logged by the capturing thread - the real factory shares the logger name and licensing runs its suites in parallel. R5-4: TestLoggingConfiguratorSpec pins that logback discovers TestLoggingConfigurator through the META-INF/services registration (a typo there would degrade silently to BasicConfigurator). R5-6: harness scaladoc wording aligned with the spec and PR body (parent-first with a child-defined name set). R5-3/R5-7 are record corrections (fork is per-module: the five ES client modules fork, core/licensing do not; null-TCCL ConfigException.BugOrBroken nuance) carried by the spec, PR body and memory. Story BIDC-5 Closes #258 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Every earlier scalafmtAll ran under the default Scala 2.13, so the scala-2.12 source directory was never in scope; a 2.12-scoped scalafmtCheck flagged it. Output of scalafmtAll, no semantic change. Story BIDC-5 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
logback 1.5 captures an event's thread name lazily on the first getThreadName() and ListAppender.append only stores the event, so the calling-thread filter used to depend on a root OutputStreamAppender having forced the stamp. The capture appender now calls prepareForDeferredProcessing() on the logging thread, whatever the root configuration. R5-11 (design-gate transcript pointers to R5-3/R5-6) and R5-12 (memory line citation :74 on origin/main, :80 on this branch) are record-only. Story BIDC-5 Closes #258 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
fupelaqu
marked this pull request as ready for review
September 6, 2026 10:22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #258
Story BIDC-5 (BI defect-closure epic, run 2). Branch
feature/BIDC-5offorigin/maine55bd937:15ee612d(story),2525aceb(review follow-up R5-1..R5-7), plus astyle(core)scalafmt commit for thescala-2.12ElasticConfigvariant (never in scope of the default-2.13scalafmtAll), andf330ec30(second look, R5-10: the capture appender stamps the thread name itself). Version line0.23.0-SNAPSHOTverified, untouched.What
The three
ServiceLoaderlookups named in #258 now use the two-arg form, anchored on the SPI interface's own classloader:core/.../client/spi/ElasticClientFactory.scala(val, latched at class init)ServiceLoader.load(classOf[ElasticClientSpi])load(classOf[ElasticClientSpi], classOf[ElasticClientSpi].getClassLoader)core/.../client/ExtensionRegistry.scala(lazy val extensions)ExtensionSpion an empty provider listlicensing/.../LicenseRefreshStrategyFactory.scala(CAS-cached)LicenseManagerSpibefore theNopRefreshStrategyfallbackFound by the regression harness and fixed in the same PR:
ElasticConfig.applysupplied theelastic.*defaults throughConfigFactory.load("softnetwork-elastic.conf"), which is also TCCL-bound — under a blind TCCLElasticClientFactory.create(config)died onNo configuration setting found for key 'elastic'BEFORE itsServiceLoaderever ran. NowConfigFactory.load(classOf[ElasticConfig].getClassLoader, "softnetwork-elastic.conf")in both thescala-2.12andscala-2.13variants, with its own two-sided spec.ServiceLoader.load(census ate55bd937, whole repo, main + test, all modules: 3 production sites (these) + 1 test-only assertion (LicenseManagerSpiSpec, deliberately left single-arg). No fourth production site.Regression tests — first touch under a genuinely isolating loader (AC 3)
All three sites are once-per-JVM latches, so a test that merely installs an isolating TCCL in sbt's shared test JVM passes with or without the fix. The harness (
licensing/src/test/.../ClassLoaderIsolation.scala, reachable fromcoreviatest->test) is a parent-firstURLClassLoaderwith a child-defined name set: it re-defines the latch-holding classes from their own bytecode so the object under test runs its initialiser INSIDE the spec's window, whatever ran before; the TCCL installed is a non-null blind loader (new URLClassLoader(Array.empty, null)) — anullTCCL does not reproduce theServiceLoaderdefect. Noforkand no build change forcore/licensing, which run their tests in the sbt JVM (the five ES client modules fork in their ownbuild.sbt).Two-sided oracles per site: factory — a test stub provider served ONLY from a synthetic
META-INF/servicesdirectory on the redefining loader (never on the real test classpath: every es{N} module inheritscore % "test->test"); registry — core's owncore-ddl/core-dqlids; licensing — theLicense strategy initializedline (Community's strategy IS aNopRefreshStrategy, so the class is no oracle).Falsification, both directions:
provider NOT discovered: … IllegalStateException: No ElasticClientSpi implementation found(the issue's literal symptom),List() did not contain all of ("core-ddl", "core-dql"), noLicense strategy initializedline (Nop fallback),ConfigException … key 'elastic'.loadreverted at the three sites (WARNs kept): licensing 0/2, core 1/4 — the only green beingElasticConfigIsolationSpec, whose fix was not reverted. Restored, green.AC 2 (WARN on empty):
ExtensionRegistryIsolationSpec/LicenseRefreshStrategyFactoryIsolationSpecsecond cases capture the WARN through logback'sListAppender(house pattern) with the interface's services resource hidden on the redefining loader.Test logging wiring (why not
logback-test.xml)Neither
corenorlicensinghad an slf4j backend on its test classpath.logback-classic % Testis added to both. It is configured by ach.qos.logback.classic.spi.Configuratorservice (TestLoggingConfigurator, root WARN, one console appender) instead of alogback-test.xml, becausepersistence-core-testkitships thelogback.xmlthe ES integration suites log through (INFO → STDOUT) and alogback-test.xmlin this test tree would override it on every es{N} test classpath viatest->test. Measured on the logback 1.5.32 source: service configurators run BEFORE the internal XML search, so the configurator repeatsDefaultJoranConfigurator's search and DEFERS whenever an XML configuration is visible — pinned byTestLoggingConfiguratorSpec. Visible side effect:core/licensingunit tests now print WARN/ERROR lines that were NOP-swallowed before (~76 per leg, all legitimate error-path logs).ES venue verified by execution (independent review,
.review-BIDC-5.md"Suites run"): a forkedes8javaJVM undersbt17with-Dlogback.debug=truelistedTestLoggingConfiguratoras the only service configurator →INVOKE_NEXT_IF_ANY→DefaultJoranConfigurator→Found resource [logback.xml]inpersistence-core-testkit_2.13-0.9.0.jar, then the suites' FILE/ASYNC/STDOUT appenders (5/5 tests). es6/es7/es9 not executed (same jars, same forked shape).TestLoggingConfiguratorSpecalso pins that logback discovers the configurator through the services file.One race the full run exposed (green alone, green on the 2.12 leg, red on the 2.13 leg): slf4j 2 hands a
SubstituteLoggerto other threads while the first thread is still binding, andlicensingruns its suites in parallel.LogbackCapturetherefore waits (bounded) for the realLoggerContextinstead of pattern-matching whateverLoggerFactory.getLoggerreturns.Release note (AC 4 — semantics change, AD-S5-1)
ElasticClientFactory,ExtensionRegistryandLicenseRefreshStrategyFactory: providers must be loadable by the classloader that loadedsoftclient4es-core/softclient4es-licensing(a flat classpath or one shaded jar both qualify). This is what lets the library run inside hosts that own the context classloader (Tableau, plugin containers, application servers). Audit ate55bd937: no known consumer relies on the old behaviour — the REPL launcher uses a flat-cp, the JDBC/ADBC drivers'scopedswap installs the driver jar's own loader, plain apps have one classpath.ElasticClientFactorykeeps failing loudly; its message now names the classloader consulted and the remedy — the leadingNo ElasticClientSpi implementation foundsubstring is unchanged (the jdbc/arrowContextClassLoaderIsolationSpecs pin it).ElasticConfig's built-in defaults (softnetwork-elastic.conf) resolve against the core jar's classloader as well — which also removes theConfigException.BugOrBrokenthatConfigFactory.load(String)throws under a null context classloader (config 1.4.xcheckedContextClassLoader).CliConfig.builtinLayer(REPL, owns its JVM) stays TCCL-bound by design.Docs:
documentation/client/common_principles.mdgains a "Classloader resolution" section (scoped to the client factory path; the two WARN sites and the factory's loud failure are described as the code behaves). Web twin to mirror (not in this repo):softclient4es-web/src/content/docs/architecture/overview.mdx"SPI Pattern".Review follow-up (independent review
.review-BIDC-5.md, approve-with-fixes: 0 HIGH, 1 MEDIUM, 8 LOW) — second commitcommon_principles.mdno longer promises a WARN fromElasticClientFactory; the defaults sentence is scoped toElasticConfig(notCliConfig.builtinLayer).LogbackCapturekeeps only events logged by the capturing thread (the real factory shares the logger name andlicensingruns suites in parallel).forkanywhere" corrected everywhere it was written (spec, PR body, memory): onlycore/licensingare in-process; the ES client modules fork.TestLoggingConfiguratorSpecpins ServiceLoader discovery of the configurator through the services file.BugOrBrokennuance.DriverClassLoaderscaladoc reason 3 at the 0.23.0 core bump;persistence-core 0.9.0POM shipslogback-classicin Compile).Verification
sbt "+ licensing/test" "+ core/test": green on 2.12 and 2.13 — after the review follow-up licensing 153/153 and core 873/873 per leg (first commit: 152 / 872), on the formatted tree;++ 2.12.20 core/Test/compileand++ 2.12.20 licensing/Test/compilegreen.scalafmtCheckAllgreen.core/Test/headerCheck/licensing/Test/headerCheckfail only on pre-existing headerless/ELv2 test files (20 in core,…DriverModeSpecin licensing) — every new file passes.es8javaprobe above is the execution evidence for the logging deferral.🤖 Generated with Claude Code