Skip to content

fix(spi): resolve ServiceLoader providers against the SPI interface's classloader, never the thread context classloader - #287

Merged
fupelaqu merged 4 commits into
mainfrom
feature/BIDC-5
Sep 6, 2026
Merged

fix(spi): resolve ServiceLoader providers against the SPI interface's classloader, never the thread context classloader#287
fupelaqu merged 4 commits into
mainfrom
feature/BIDC-5

Conversation

@fupelaqu

@fupelaqu fupelaqu commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #258

Story BIDC-5 (BI defect-closure epic, run 2). Branch feature/BIDC-5 off origin/main e55bd937: 15ee612d (story), 2525aceb (review follow-up R5-1..R5-7), plus a style(core) scalafmt commit for the scala-2.12 ElasticConfig variant (never in scope of the default-2.13 scalafmtAll), and f330ec30 (second look, R5-10: the capture appender stamps the thread name itself). Version line 0.23.0-SNAPSHOT verified, untouched.

What

The three ServiceLoader lookups named in #258 now use the two-arg form, anchored on the SPI interface's own classloader:

Site Before After
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) single-arg two-arg + WARN naming ExtensionSpi on an empty provider list
licensing/.../LicenseRefreshStrategyFactory.scala (CAS-cached) single-arg two-arg + WARN naming LicenseManagerSpi before the NopRefreshStrategy fallback

Found by the regression harness and fixed in the same PR: ElasticConfig.apply supplied the elastic.* defaults through ConfigFactory.load("softnetwork-elastic.conf"), which is also TCCL-bound — under a blind TCCL ElasticClientFactory.create(config) died on No configuration setting found for key 'elastic' BEFORE its ServiceLoader ever ran. Now ConfigFactory.load(classOf[ElasticConfig].getClassLoader, "softnetwork-elastic.conf") in both the scala-2.12 and scala-2.13 variants, with its own two-sided spec.

ServiceLoader.load( census at e55bd937, 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 from core via test->test) is a parent-first URLClassLoader with 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)) — a null TCCL does not reproduce the ServiceLoader defect. No fork and no build change for core/licensing, which run their tests in the sbt JVM (the five ES client modules fork in their own build.sbt).

Two-sided oracles per site: factory — a test stub provider served ONLY from a synthetic META-INF/services directory on the redefining loader (never on the real test classpath: every es{N} module inherits core % "test->test"); registry — core's own core-ddl/core-dql ids; licensing — the License strategy initialized line (Community's strategy IS a NopRefreshStrategy, so the class is no oracle).

Falsification, both directions:

  • RED before the fix (harness against the untouched single-arg sites): licensing 0/2, core 0/4 — provider NOT discovered: … IllegalStateException: No ElasticClientSpi implementation found (the issue's literal symptom), List() did not contain all of ("core-ddl", "core-dql"), no License strategy initialized line (Nop fallback), ConfigException … key 'elastic'.
  • GREEN after the fix: 2/2 + 4/4.
  • RED again with the two-arg load reverted at the three sites (WARNs kept): licensing 0/2, core 1/4 — the only green being ElasticConfigIsolationSpec, whose fix was not reverted. Restored, green.

AC 2 (WARN on empty): ExtensionRegistryIsolationSpec / LicenseRefreshStrategyFactoryIsolationSpec second cases capture the WARN through logback's ListAppender (house pattern) with the interface's services resource hidden on the redefining loader.

Test logging wiring (why not logback-test.xml)

Neither core nor licensing had an slf4j backend on its test classpath. logback-classic % Test is added to both. It is configured by a ch.qos.logback.classic.spi.Configurator service (TestLoggingConfigurator, root WARN, one console appender) instead of a logback-test.xml, because persistence-core-testkit ships the logback.xml the ES integration suites log through (INFO → STDOUT) and a logback-test.xml in this test tree would override it on every es{N} test classpath via test->test. Measured on the logback 1.5.32 source: service configurators run BEFORE the internal XML search, so the configurator repeats DefaultJoranConfigurator's search and DEFERS whenever an XML configuration is visible — pinned by TestLoggingConfiguratorSpec. Visible side effect: core/licensing unit 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 forked es8java JVM under sbt17 with -Dlogback.debug=true listed TestLoggingConfigurator as the only service configurator → INVOKE_NEXT_IF_ANYDefaultJoranConfiguratorFound resource [logback.xml] in persistence-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). TestLoggingConfiguratorSpec also 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 SubstituteLogger to other threads while the first thread is still binding, and licensing runs its suites in parallel. LogbackCapture therefore waits (bounded) for the real LoggerContext instead of pattern-matching whatever LoggerFactory.getLogger returns.

Release note (AC 4 — semantics change, AD-S5-1)

  • SPI providers visible only through the thread context classloader are no longer honoured at ElasticClientFactory, ExtensionRegistry and LicenseRefreshStrategyFactory: providers must be loadable by the classloader that loaded softclient4es-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 at e55bd937: no known consumer relies on the old behaviour — the REPL launcher uses a flat -cp, the JDBC/ADBC drivers' scoped swap installs the driver jar's own loader, plain apps have one classpath.
  • An empty provider list now logs a WARN naming the SPI interface at the two previously silent sites. ElasticClientFactory keeps failing loudly; its message now names the classloader consulted and the remedy — the leading No ElasticClientSpi implementation found substring is unchanged (the jdbc/arrow ContextClassLoaderIsolationSpecs pin it).
  • ElasticConfig's built-in defaults (softnetwork-elastic.conf) resolve against the core jar's classloader as well — which also removes the ConfigException.BugOrBroken that ConfigFactory.load(String) throws under a null context classloader (config 1.4.x checkedContextClassLoader). CliConfig.builtinLayer (REPL, owns its JVM) stays TCCL-bound by design.

Docs: documentation/client/common_principles.md gains 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 commit

  • R5-1 (MEDIUM, doc)common_principles.md no longer promises a WARN from ElasticClientFactory; the defaults sentence is scoped to ElasticConfig (not CliConfig.builtinLayer).
  • R5-5 — the factory's exception message appends the consulted classloader and the remedy after the pinned substring; new spec case asserts it.
  • R5-2LogbackCapture keeps only events logged by the capturing thread (the real factory shares the logger name and licensing runs suites in parallel).
  • R5-3 — "no fork anywhere" corrected everywhere it was written (spec, PR body, memory): only core/licensing are in-process; the ES client modules fork.
  • R5-4TestLoggingConfiguratorSpec pins ServiceLoader discovery of the configurator through the services file.
  • R5-6 — harness wording aligned: "parent-first with a child-defined name set".
  • R5-7 — release note carries the null-TCCL BugOrBroken nuance.
  • R5-8 / R5-9 — out of repo / pre-existing: recorded in the findings log as hand-offs (jdbc/arrow DriverClassLoader scaladoc reason 3 at the 0.23.0 core bump; persistence-core 0.9.0 POM ships logback-classic in 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/compile and ++ 2.12.20 licensing/Test/compile green.
  • scalafmtCheckAll green. core/Test/headerCheck / licensing/Test/headerCheck fail only on pre-existing headerless/ELv2 test files (20 in core, …DriverModeSpec in licensing) — every new file passes.
  • ES integration suites: not run by the dev agent (no client-module code changed); the reviewer's forked es8java probe above is the execution evidence for the logging deferral.

🤖 Generated with Claude Code

fupelaqu and others added 4 commits September 6, 2026 03:15
… 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
fupelaqu marked this pull request as ready for review September 6, 2026 10:22
@fupelaqu
fupelaqu merged commit e2dc7c5 into main Sep 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant