Skip to content

fix(core): a cached client is shared only by callers with the same identity (SCHEMA-14) - #458

Merged
fupelaqu merged 1 commit into
mainfrom
feature/SCHEMA-14
Nov 7, 2026
Merged

fupelaqu merged 1 commit into
mainfrom
feature/SCHEMA-14

Conversation

@fupelaqu

@fupelaqu fupelaqu commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Summary

ElasticClientFactory now shares a cached client only between callers whose effective elastic.* configuration is equal (SCHEMA-14). On 0.24.0 and main, the factory's three client caches (base, metrics, monitored) are keyed by the cluster URL alone (scheme://host:port): the auth method, user, password, API key and bearer token are ignored, so a caller configured with other credentials for the same URL receives the client built for the first caller, and that caller's privileges. The JDBC driver and ADBC build one client per connection through the factory, so two users of one cluster in one JVM were affected. The fix also stops one caller's close() from closing a client other callers still use, and stops monitored clients from leaking an actor system and a JVM shutdown hook each.

What changes

  • One cache key. ClientKey is the URL plus an HMAC-SHA256 fingerprint of the whole effective elastic.* configuration (credentials, timeouts, scroll and schema-cache settings), computed by one function for the three caches and the custom-collector paths. The HMAC key is random per JVM, and the encoding is length-prefixed per value, so two configurations cannot collide by moving a separator and a leaked fingerprint is no offline password oracle. ClientKey.toString prints the URL only. The configuration is parsed once per create…() call (main parsed it twice).
  • Equal keys still share. create, createWithMetrics and createWithMonitoring return the same cached instance for an equal configuration, as main does per URL; createWithCustomMetrics and createMonitoredWithCustomMetrics still build a new wrapper per call, over the cached base client of that configuration.
  • close() releases one share. A cached instance counts its holders: close() releases the caller's share and never closes the transport, or stops the monitor, that another caller still holds. Public return types are unchanged (package-private SharedClient, SharedMetricsClient, SharedMonitoredClient; the custom paths return a per-call idempotent lease).
  • Idle clients are closed. A client nobody holds stays cached for 10 minutes, so a caller that comes back reuses it. At most once a minute, a client creation sweeps the caches and really closes each client idle for longer, once, outside the map lock (no sweep or close ever runs inside a ConcurrentHashMap remapping function). Without this, every rotated API key or token, and every user, would keep a client and its IO threads until JVM exit. shutdown() and clearCache() keep their meaning.
  • One monitoring actor system. Monitored clients share one actor system owned by the factory, created lazily and terminated by shutdown(); MonitoredElasticClient.shutdown() removes its own JVM shutdown hook. Main created one actor system and one hook per monitored client and never released them.
  • Provider lookup is serialised. Two configurations on one URL can now build clients at the same time, so the ServiceLoader iteration runs under a lock (a ServiceLoader is not safe for concurrent iteration).
  • Logs and statistics. Creation and eviction lines name the URL and the auth method kind only; getCacheStats keeps its three counts (now one entry per distinct configuration).
  • Docs: the client lifecycle section gains "Sharing and Closing" and the cache-statistics meaning.

Release notes

  • Security fix: a client from ElasticClientFactory is no longer shared with a caller that configured other credentials (or other client settings) for the same cluster URL. 0.24.0 handed such a caller the first caller's client and privileges; the JDBC driver and ADBC, which open one client per connection, were affected.
  • Behaviour changes:
    • close() releases the caller's share; a client is really closed after 10 minutes without a holder (at the next client creation) or by shutdown().
    • Metrics, monitoring reports and schema caches belong to each configuration, hence to each identity; a new identity's first query pays the client's first-use costs.
    • One monitoring actor system per factory, terminated by shutdown().
    • clearCache() leaves the clients handed out before it usable but no longer managed: their close() does nothing and shutdown() does not close them (test use).
  • No downstream rebuild: the new classes are package-private, MonitoredElasticClient gains private members only, no public signature changes and no case class changes arity.

Acceptance evidence

  • Two identities, two clients — RUN on ES 8.18.3 with security on (new JavaClientFactoryIdentitySpec): through every factory entry point and in both creation orders, a read-only user (basic credentials, and an API key restricted to a reader role) is refused on a protected index and on the version endpoint and answered on an open index, while the admin on the same URL is answered everywhere; a wrong password is refused with 401. Control on main: the second client answered exactly as the first.
  • The same identity still shares one client — unit: one build per 1000 create/close cycles, as on main.
  • Close semantics — unit + RUN: one caller's close leaves the other caller's client and monitor working (two JDBC-shaped connections of two users, each surviving the other's close, on ES 8); a client idle past the timeout is closed once; the share count never drops below zero. The contract is one close() per create…(), as the docs say; the JDBC driver, ADBC and the Flight producer each close once.
  • No secret in logs or statistics — unit with a log capture at the most verbose level (with a canary proving the capture sees the factory's lines): no user, password, key, token or fingerprint in any line, in getCacheStats, or in any toString.
  • Cost — creating a client through the factory: 0.60 ms per call on the branch vs 1.11 ms on main (warm JIT; the configuration is now parsed once).
  • Consumer-shaped cells — the JDBC driver's and ADBC's exact configuration shapes go through the factory in core's tests (RUN on ES 8).
  • Suites: core 1640/0; ES 8 security-on leg (*JavaClientFactoryIdentitySpec *JavaClientSpec *JavaClientSchemaConsumerSpec) 98/0; Docker-free client units on es6 REST, es7, es8 and es9 green; Scala 2.12 + 2.13 compile; CI lint line clean on the committed bytes.

Downstream

The real drivers were run against this fix before opening the PR: 0.24.0 plus this commit (the cherry-pick applies cleanly; the factory, MonitoredElasticClient, ElasticConfig and ElasticCredentials are unchanged since 0.24.0) was published locally under a throwaway version. The JDBC driver (through DriverManager) and ADBC each ran one two-user test on ES 8 with security on — both users, both orders, one user closing while the other keeps answering — and each first checked that it had loaded the patched factory: JDBC 5/0, ADBC 5/0. Their class-loader isolation suites passed (13/0, 8/0). The throwaway artifacts were deleted. No change is needed in extensions, JDBC or arrow.

Recorded, not fixed

  • The key covers elastic.* only: the licence mode, the extension registry and the akka settings are still read from the raw configuration of whoever first created a client for that key (as main does per URL).
  • Calling shutdown() directly on a shared monitored client stops monitoring for every holder (its scaladoc now steers callers to close()).
  • Using a client after its last close() and its eviction re-opens a transport outside the cache.
  • On Scala 2.13 the provider lookup still builds every provider's client before taking the first (expression kept as on main).
  • ElasticConfig.apply logs the configuration error on a parse failure, which may echo a value.

Test plan

  • core ClientKeySpec, ElasticClientCacheSpec, ElasticClientFactoryIsolationSpec (65/0) and core/test (1640/0)
  • ES 8.18.3, security on: JavaClientFactoryIdentitySpec, JavaClientSpec, JavaClientSchemaConsumerSpec (98/0)
  • Docker-free client units: es8, es6 REST, es7, es9
  • + compile (2.12 and 2.13)
  • JDBC driver and ADBC two-user tests against 0.24.0 + this fix on ES 8 with security on (5/0 each)
  • CI: all ES legs (the identity matrix on ES 6/7/9; the embedded ES 6.8 Jest companion suite, which cannot start on arm64 hosts)

🤖 Generated with Claude Code

…entity (SCHEMA-14)

Story SCHEMA-14. Reviewed: r0:clean, r1:clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fupelaqu
fupelaqu marked this pull request as ready for review November 7, 2026 10:02
@fupelaqu
fupelaqu merged commit 45aee28 into main Nov 7, 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

Development

Successfully merging this pull request may close these issues.

1 participant