feat(jni,io): default-open Session reuse + ObjectStoreRegistry single-flight#6839
feat(jni,io): default-open Session reuse + ObjectStoreRegistry single-flight#6839LuciferYang wants to merge 6 commits into
Conversation
d26a288 to
9e5b879
Compare
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
| /// | ||
| /// Steady-state should be 0 — non-zero indicates either an in-progress | ||
| /// thundering herd (expected during cold start) or a stuck builder. | ||
| pub build_locks: usize, |
There was a problem hiding this comment.
Adding these public fields changes the source shape of a public stats struct, so downstream code that constructs it with a struct literal will stop compiling.
There was a problem hiding this comment.
Good catch. ObjectStoreRegistryStats is a pub struct with pub fields and had no #[non_exhaustive], so adding build_locks/build_failures would break any external struct-literal construction. I've added #[non_exhaustive] so future field additions stay source-compatible. It's #[doc(hidden)] and only ever constructed in-crate via stats(); external code reads it (and can still ..Default::default()), never literal-constructs it. Fixed in aa80cab.
| /// | ||
| /// # Userinfo / IPv6 handling | ||
| /// | ||
| /// Overrides that build the prefix from URL authority MUST use |
There was a problem hiding this comment.
The new authority-stripping contract is not enforced across the registered authority-based providers, so URL userinfo can still remain in cache keys and be emitted by the new cache-key debug logs.
There was a problem hiding this comment.
You're right — the contract wasn't enforced everywhere. I've routed every registered authority-based provider's prefix through sanitized_authority:
huggingfacecalculate_object_store_prefix(was rawurl.authority())shared-memory— both the prefix and theshared_backend_forbackend-routing key, so the two notions of "authority identity" stay consistent- the unknown-scheme fallback in
object_store.rs
S3/GCS/OSS/Tencent already inherit the sanitized default impl (no override). Azure stays on url.authority() intentionally — it parses the userinfo position as the container name, per the trait doc — and the aws.rs --x-s3 check is a boolean test, not a cache-key/log surface. Added userinfo-stripping tests for the HF and shared-memory paths (plus a backend-consistency test). Fixed in aa80cab.
| // (guard released *before* cleanup) is encoded in | ||
| // `BuildLockSession::drop`, so the cold path can't accidentally | ||
| // invert it via `let` reordering. | ||
| let lock = self.acquire_build_lock(&cache_key); |
There was a problem hiding this comment.
A waiter cancelled while queued on the per-key build lock can leave the lock-map entry behind. Timeout-heavy callers can accumulate one stale entry per cache key even though later success, error, and panic paths clean up.
There was a problem hiding this comment.
Thanks — confirmed and fixed. The root cause was that cleanup keyed off Arc::strong_count: a waiter cancelled while parked in lock_owned().await dropped without reclaiming the entry, because the pending future still held an Arc clone, so the count never fell to the idle value.
I replaced the heuristic with an explicit BuildLockEntry::waiters count. acquire_build_lock increments it under the build_locks mutex, and the RAII BuildLockSession — now constructed before the lock_owned().await, with no .await between the increment and the construction — decrements it on every unwinding exit. So a waiter cancelled while still queued drops a live session and reclaims the entry, and cleanup no longer depends on Arc::strong_count or async drop order.
Covered by two tests: a white-box one that drops a guard: None session (the exact cancelled-while-queued state) and asserts the entry is reclaimed even with another Arc to the lock alive, and an end-to-end one that aborts a queued waiter and asserts the count drops 2 → 1 → 0. Fixed in aa80cab.
|
will respond to the review comments tomorrow. |
|
need rebase after #7384 merged |
…-flight
Coalesce concurrent cold `Dataset.open` calls in the JNI layer so 144
parallel opens against the same URI reuse one `ObjectStore` instead of
each rebuilding the credential chain and HTTP client.
`BlockingDataset::open` without an explicit `Session` now constructs a
fresh per-call `Session` whose `ObjectStoreRegistry` is the
process-wide `GLOBAL_OBJECT_STORE_REGISTRY` (a `LazyLock<Arc<...>>`).
Sharing only the registry — not the `Session` — keeps the asymmetric
caching shape: the registry coalesces expensive `ObjectStore` builds
(credential probe, IMDS, TLS handshake), while metadata/index caches
remain per-call so callers do not share eviction policy or cache size
with other tenants.
Bare-URI opens (empty storage options, no provider, no namespace
commit handler) collapse onto a single cache entry per URI: the first
caller's resolved default-credential chain becomes the credentials
used by every subsequent caller for the lifetime of that
`Arc<ObjectStore>`. This cross-tenant credential bleed is intentional
under the bare-URI invariant; callers that need per-tenant isolation
can opt out via `LANCE_JNI_DISABLE_DEFAULT_REGISTRY_SHARING={1,true,yes}`,
pass tenant-distinguishing storage options, or supply an explicit
`Session`.
The opt-out env var is parsed once into an `AtomicU8` tri-state
(`UNINIT/ENABLED/DISABLED`) on first read, with a truthy whitelist
(`1|true|yes` — anything else falls back to enabled and warns). A
`DisableSharingTestGuard` RAII helper resets the atomic for unit
tests so the env-var probe can be exercised deterministically.
`ObjectStoreRegistry::get_store` adds a per-key build-lock so concurrent
cold builds for the same `CacheKey` serialize behind one in-flight
`build()` instead of all racing the credential chain. The lock map is
`Mutex<HashMap<CacheKey, Arc<tokio::sync::Mutex<()>>>>`; each caller
acquires its slot via a `BuildLockSession` RAII guard whose `Drop`
releases the inner mutex first, then opportunistically removes the
HashMap entry if it observes `strong_count == 1`. This ordering
guarantees that panics, errors, and cancellations all reclaim the
build-lock entry without leaking a permanently-locked key.
Failures are not cached — when a cold build returns `Err`, the next
waiter retries from scratch (one-Err-per-N-waiters retry contract).
An opportunistic sweep gated by `cold_attempt_nth.is_multiple_of(64)`
amortizes HashMap shrinkage across cold-path attempts so a long-lived
process does not accumulate stale entries from short-lived URIs.
The `CacheKey` uses `sanitized_authority` derived from `Url::host()`
(IPv6 brackets preserved, port preserved, userinfo stripped) so URIs
that differ only in embedded credentials still collapse onto the same
cache slot.
- `lance-io`: hit/miss, single-flight coalescing, panic-path RAII
cleanup, error-path RAII cleanup, `sanitized_authority` over IPv6
/ userinfo / non-default port, opportunistic sweep counter.
- `lance-jni`: `parse_disable_value` truthy whitelist + warn path,
`DisableSharingTestGuard` ordering, end-to-end coalescing test
that drives 144 concurrent default-opens through the JNI layer
and asserts a single `ObjectStore` is built.
`java/README.md` documents the `LANCE_JNI_DISABLE_DEFAULT_REGISTRY_SHARING`
env var, the bare-URI credential-bleed invariant, and the three
opt-out paths (env var, tenant-distinguishing storage options,
explicit `Session`).
- ObjectStoreRegistryStats: add #[non_exhaustive] so future field additions stay source-compatible (review comment 1). - Sanitize URL authority into the cache-key prefix across every registered authority-based provider (huggingface, shared-memory prefix + backend key, and the unknown-scheme fallback), so embedded userinfo can no longer leak into cache keys or the cache-key debug logs (review comment 2). - Make per-key build-lock cleanup cancellation-safe via an explicit waiter count on BuildLockEntry: acquire increments, the RAII BuildLockSession (now constructed before the lock_owned() await) decrements on every unwinding exit. A waiter cancelled while queued no longer orphans its build_locks entry; cleanup no longer depends on Arc::strong_count or async drop order (review comment 3). Adds tests for each: userinfo stripping (huggingface + shared-memory), backend-routing consistency, and the cancelled-while-queued waiter (white-box guard-less drop + end-to-end waiter-count observation).
Merging main brought in the new GooseFS object-store provider, whose calculate_object_store_prefix used raw url.authority() — re-introducing the userinfo-in-cache-key leak that was fixed for the other authority-based providers. Route it through sanitized_authority (host[:port] preserved, userinfo stripped) and add a userinfo-stripping test. Also sync java/lance-jni/Cargo.lock (rayon) to match the merged manifests so --locked builds stay consistent.
dd40497 to
a62e577
Compare
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe changes sanitize object-store cache keys, add per-key cold-build coordination and cleanup statistics, and integrate optional process-wide registry sharing into JNI default dataset opens through an environment-controlled selection path. ChangesObject-store registry behavior
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant JNI Dataset.open
participant LanceSession
participant ObjectStoreRegistry
participant ObjectStoreProvider
JNI Dataset.open->>LanceSession: create default session
LanceSession->>ObjectStoreRegistry: get_store(cache_key)
ObjectStoreRegistry->>ObjectStoreProvider: build store once for cold key
ObjectStoreProvider-->>ObjectStoreRegistry: ObjectStore
ObjectStoreRegistry-->>LanceSession: shared Arc<ObjectStore>
LanceSession-->>JNI Dataset.open: open result
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 9
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@java/lance-jni/src/blocking_dataset.rs`:
- Around line 183-218: Update DisableSharingTestGuard and the production flag
lookup to use a test-only thread-local override before consulting
DISABLE_DEFAULT_REGISTRY_SHARING. Make DisableSharingTestGuard::set and Drop set
and restore the current thread’s override, preserving nested LIFO behavior
without mutating the process-global atomic during tests.
- Around line 3939-3983: Convert the three parse_disable_value
tests—parse_disable_value_accepts_truthy, parse_disable_value_accepts_falsy, and
parse_disable_value_returns_none_for_unrecognized—to rstest parameterized tests.
Define one case per input and expected result, use rstest’s case/parameter
attributes to report each value independently, and preserve the existing truthy,
falsy, and None expectations.
- Around line 163-180: Update select_default_open_registry to prevent global
registry reuse for unkeyed opens when credential identity is unavailable.
Require a stable tenant/credential identity before selecting
crate::GLOBAL_OBJECT_STORE_REGISTRY, or make bare-URI sharing explicitly opt-in
while preserving fresh-registry isolation otherwise; apply the same policy to
the related call site.
- Around line 3985-4069: Replace
jni_default_open_coalesces_concurrent_cold_builds’ direct registry.get_store
calls with concurrent BlockingDataset::open calls through the JNI/default-open
path, using the existing test provider setup and asserting one build plus shared
store identity. Ensure the test exercises the registry selected by
BlockingDataset::open rather than independently selecting and invoking a
registry, and remove the duplicated provider-level single-flight coverage.
In `@rust/lance-io/src/object_store/providers.rs`:
- Around line 1049-1053: Update the regression test loop over handles to extract
each result’s error instead of only checking is_err(). Assert that the error has
the InvalidInput variant and contains the exact message “simulated cold-build
failure,” while preserving the existing waiter count behavior.
- Around line 543-553: Update release_build_lock to assert that cache_key has an
existing build-lock entry and that its waiter count is greater than zero before
decrementing, using descriptive debug_assert! messages. Preserve removal when
the count reaches zero, and add a safe fallback for release-build behavior so a
missing entry or invalid count cannot underflow or be silently ignored.
- Around line 433-443: Update the sweep scheduling around self.misses and
cold_attempt_nth to use the zero-based value returned by fetch_add directly,
removing wrapping_add and adjusting the cadence condition so every 64th attempt
remains eligible. Update the related comments to describe zero-based sequencing
and eliminate the wrapping-overflow rationale; apply the same change to the
corresponding logic near the additional referenced location.
- Around line 414-421: Rename the actively used BuildLockSession binding from
_session to session in this lock acquisition flow, and update the session.guard
assignment and related references consistently. Keep the existing
cancellation-safety and Drop behavior unchanged.
- Around line 984-987: Update the documentation comment for the failure provider
near its invocation-count tracking to state that the test verifies one build per
caller, rather than claiming concurrent failures collapse into a single
underlying call. Keep the description of the simulated non-panic build failure
unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: e98ebbe7-c679-4a64-aa5d-bd8ef795a900
⛔ Files ignored due to path filters (1)
java/lance-jni/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (9)
java/README.mdjava/lance-jni/Cargo.tomljava/lance-jni/src/blocking_dataset.rsjava/lance-jni/src/lib.rsrust/lance-io/src/object_store.rsrust/lance-io/src/object_store/providers.rsrust/lance-io/src/object_store/providers/goosefs.rsrust/lance-io/src/object_store/providers/huggingface.rsrust/lance-io/src/object_store/providers/shared_memory.rs
lance-io/providers.rs: - rename `_session` -> `session` (it is read via `session.guard`) - drop `wrapping_add` in sweep scheduling; gate on the 0-based `fetch_add` value directly (`% N == N-1`), no self-owned counter arithmetic to overflow - `release_build_lock`: `debug_assert!` the entry exists and its waiter count is positive, `saturating_sub` + missing-entry no-op as the release-build fallback instead of silently underflowing - fix stale FailingProvider doc comment (failures serialize + retry per caller, they do not collapse into one call) - assert the exact `InvalidInput` variant and message in the serialized-failure test instead of bare `is_err()` lance-jni/blocking_dataset.rs: - make the test-only sharing override thread-local so parallel tests cannot observe each other's temporary setting - parameterize the `parse_disable_value` cases with `rstest` - replace the duplicated registry unit test with `open_wires_selected_registry_into_session`, which drives the real `BlockingDataset::open` path and pins that the selected registry is wired into the Session (global when sharing on, fresh when off); provider single-flight stays covered in providers.rs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
rust/lance-io/src/object_store/providers.rs (1)
574-592: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd
#[cfg_attr(coverage, coverage(off))]to test-only accessor helpers.
build_locks_lenandbuild_lock_waitersare#[cfg(test)]observability helpers used purely to assert internal state in tests; per repo convention they should be excluded from coverage instrumentation.♻️ Proposed fix
#[cfg(test)] + #[cfg_attr(coverage, coverage(off))] fn build_locks_len(&self) -> usize { self.build_locks .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .len() } /// Current waiter count for `cache_key`, or `None` if no entry exists. /// Lets tests observe the per-key acquire/release count directly — the /// signal that drives cleanup — rather than only the map's key count. #[cfg(test)] + #[cfg_attr(coverage, coverage(off))] fn build_lock_waiters(&self, cache_key: &CacheKey) -> Option<usize> {As per coding guidelines, "Exclude test utilities from coverage with
#[cfg_attr(coverage, coverage(off))]."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@rust/lance-io/src/object_store/providers.rs` around lines 574 - 592, Add #[cfg_attr(coverage, coverage(off))] to both test-only accessor methods, build_locks_len and build_lock_waiters, while preserving their existing #[cfg(test)] attributes and behavior.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@rust/lance-io/src/object_store/providers.rs`:
- Around line 574-592: Add #[cfg_attr(coverage, coverage(off))] to both
test-only accessor methods, build_locks_len and build_lock_waiters, while
preserving their existing #[cfg(test)] attributes and behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: ddce404b-c3f3-4a62-8a7f-73f2289c18a0
⛔ Files ignored due to path filters (1)
java/lance-jni/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (3)
java/lance-jni/Cargo.tomljava/lance-jni/src/blocking_dataset.rsrust/lance-io/src/object_store/providers.rs
…ontract a MUST The registry keys its ObjectStore cache solely on `provider_id()`, so it is the only isolation boundary between providers. Spell out the security consequence on the trait method: a provider that vends principal-specific credentials MUST encode that identity in its ID, because two providers returning the same ID share one cached ObjectStore (and thus one resolved credential chain). Doc-only; no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Closes #6838
Coalesces concurrent cold
Dataset::openagainst the same URI so N parallel opens reuse oneObjectStoreinstead of each rebuilding the credential chain and HTTP client.Two changes, both required:
lance-io— registry single-flight.ObjectStoreRegistry::get_storegains a per-CacheKeytokio::sync::Mutexso concurrent cold builds serialize behind one in-flightbuild(). The lock map entry is freed via aBuildLockSessionRAII guard that drops the inner mutex first, then removes the HashMap entry if it observesstrong_count == 1, making panics / errors / cancellations all reclaim the slot. Failures are not cached (one-Err-per-N-waiters retry); an opportunistic sweep gated oncold_attempt_nth % 64 == 0amortizes HashMap shrinkage.lance-jni— default-open registry sharing.BlockingDataset::openwithout an explicitSessionnow uses a process-wideLazyLock<Arc<ObjectStoreRegistry>>(theSessionitself is still per-call so metadata/index caches stay isolated). This is what makes thelance-iofix actually coalesce across JNI default opens — without it each open had its own registry and had nothing to coalesce against.Bare-URI opens (empty storage options, no provider, no namespace handler) collapse onto one cache entry per URI: the first caller's resolved default-credential chain becomes the credentials used by every subsequent caller for the lifetime of that
Arc<ObjectStore>. This is documented injava/README.mdalong with three opt-outs:LANCE_JNI_DISABLE_DEFAULT_REGISTRY_SHARING={1,true,yes}SessionBenchmark
144 concurrent opens against the same URI, semaphore-gated to 12, S3 → MinIO :9000,
BENCH_SHARED_SESSION=1, run via the standalone reproducer in the Tests section below:ad9f38227no-sharedmode (each open creates its ownSession) is unchanged between branches, as expected.Tests
lance-io: hit/miss, single-flight coalescing, panic + error RAII cleanup,sanitized_authority(IPv6 / userinfo / non-default port), opportunistic sweep counter.lance-jni: env-var truthy whitelist + warn path,DisableSharingTestGuardordering, end-to-end test driving 144 concurrent default-opens through the JNI layer and asserting a singleObjectStorebuild.rust/lance/examples/concurrent_open_bench.rs:concurrent_open_bench.rsSummary by CodeRabbit
New Features
Bug Fixes
Documentation