Skip to content

fix(namespace): avoid write starvation under heavy reads - #9285

Merged
zhangyue19921010 merged 1 commit into
lance-format:mainfrom
rerun-io:upstream/namespace-write-starvation
Sep 30, 2026
Merged

zhangyue19921010 merged 1 commit into
lance-format:mainfrom
rerun-io:upstream/namespace-write-starvation

Conversation

@amunra

@amunra amunra commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

DatasetConsistencyWrapper (the cached manifest dataset behind the directory namespace) guards its Dataset with a tokio::sync::RwLock and exposes get() / get_refreshed() for readers and get_mut() for writers. get_mut() called reload() and only then requested the write lock. reload() itself takes a read lock to probe for a successor version and releases it before anything else happens, so from the lock's point of view the writer was just another reader until the moment it finally called .write().await.

tokio's RwLock is write-preferring: once a writer is queued, no new readers are admitted. That protection never engaged for get_mut() while it was reloading, because during that phase it held only a read lock and no write request was queued yet. Every reader that arrived while get_mut() was still reloading was admitted alongside it, and the writer then had to wait for all of them before its write request was served; the fairness guarantee only covered the tail of the operation. get_mut() is a public method of the wrapper; the namespace's own write operations currently go through get_refreshed() and the copy-on-write manifest rewrite path, so this affects external callers of get_mut() rather than any in-tree operation.

Change

get_mut() now acquires the write lock first and reloads the dataset while holding it, so write-fairness covers the whole operation: as soon as a writer calls get_mut(), new readers queue behind it.

The reload body is factored into reload_under_write_lock(), which takes the already-held RwLockWriteGuard, probes has_successor_version() and calls checkout_latest() if needed. The read path (get() / get_refreshed() -> reload()) is unchanged in behaviour: it still checks under a read lock and only upgrades to a write lock when a successor version exists, then reuses the same helper instead of duplicating the probe-and-checkout logic.

The cost is that every get_mut() now performs the successor probe while holding the write lock rather than under a read lock. Writers already had to take the write lock immediately afterwards, and has_successor_version() is a single object-store existence check on the manifest table, so the extra exclusive hold is short and only on the write path.

Testing

New unit test test_get_mut_is_served_before_later_readers in rust/lance-namespace-impls/src/dir/manifest.rs. It holds the wrapper's lock exclusively, starts get_mut() on one task and then, on a second task, a plain read-lock acquisition that keeps its permit until told to release it; it then releases the exclusive lock and asserts under a bounded timeout that get_mut() completes before the late reader is served, and that the reader completes once released. On a current-thread runtime the two tasks park on the lock in spawn order and the late reader holds a single permit from admission to release, so the outcome depends only on which lock get_mut() requests first: with the old ordering it queued as a reader, the late reader was admitted alongside it, and the writer's subsequent write request blocked on that reader until the timeout; with the new ordering it is queued as a writer and served first. Verified both ways, five runs each, with the fix locally reverted and restored.

The rest of the lance-namespace-impls suite (which exercises get() / get_refreshed() through the directory namespace operations and get_mut() in one existing unit test) passes unchanged, along with cargo fmt --all -- --check and cargo clippy --all --tests --benches -- -D warnings.

Compatibility

Internal locking change only. No public API, wire, or file-format changes; the manifest dataset layout is untouched.

Prior submission

The same fix was previously submitted as #6323 and went stale unreviewed.


Tracking: Ported from rerun-io#70.

`DatasetConsistencyWrapper::get_mut` reloaded the dataset and only then
took the write lock, so during the reload it held only a read lock and
no write request was queued. tokio's `RwLock` only holds back new
readers once a writer is waiting, so every reader that arrived during
the reload was admitted ahead of the writer, which then had to wait for
all of them before it could take the write lock.

`get_mut` now takes the write lock first and reloads while holding it,
so write-fairness covers the whole operation. The reload body is
factored into `reload_under_write_lock`, shared with the read path's
`reload`, which still checks under a read lock and upgrades to a write
lock only when a successor version exists.

A unit test queues `get_mut` and then a reader behind an exclusively
held lock and asserts that the writer is served before the reader.
@github-actions github-actions Bot added A-namespace Namespace impls bug Something isn't working labels Sep 16, 2026
@amunra
amunra marked this pull request as ready for review September 16, 2026 14:52

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Gate recommendation: approve.

Acquiring the write lock before the successor probe makes Tokio’s FIFO writer preference cover the full get_mut() operation, while the read path keeps its existing optimistic probe. The shared reload helper preserves freshness and error handling, and the regression test covers the reader/writer ordering.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 16, 2026

@zhangyue19921010 zhangyue19921010 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 nice catch

@zhangyue19921010
zhangyue19921010 merged commit 7b94a26 into lance-format:main Sep 30, 2026
43 checks passed
Xuanwo added a commit that referenced this pull request Oct 1, 2026
…st (#9600)

Directory `__manifest` datasets created before Lance 7.0 carry a default
auto-cleanup policy. If an old writer removes version N+1 while a reader
holds N, the successor-only reload check leaves the reader on an
obsolete catalog, potentially referencing deleted data. Debug builds
also panic when opening these manifests.

This change removes the assertion and resolves the latest version while
the held manifest carries `lance.auto_cleanup.*`. Policy-free manifests
retain the successor-probe fast path. Successful manifest rewrites
remove the inherited policy, so subsequent writers stop automatic
old-version cleanup.

The branch incorporates main, including #9285. Both the read-side probe
and `reload_under_write_lock()` use the compatibility-aware check;
`get_mut()` still acquires the write lock before reloading.

Validation:
- `cargo test --locked -p lance-namespace-impls --lib`: 319 passed,
including the #9285 fairness regression.
- A checked-in fixture generated by released Lance 6.0.1 exercises
`get()`, `get_refreshed()` and `get_mut()` after cleanup removes the
immediate successor, then verifies refresh continues after the policy is
removed. All three pass; temporarily restoring successor-only logic
makes all three fail with held version 5 instead of latest version 7.
The correct code was restored and the full suite rerun.
- `cargo fmt --all -- --check`, `git diff --check`, and Ruff 0.11.2
lint/format checks for the fixture generator pass.
- Required local `cargo clippy --all --tests --benches -- -D warnings`
is blocked by existing `single_range_in_vec_init` and `implicit_clone`
errors in `lance-io`. A separate namespace-only clippy run is blocked by
12 existing `implicit_clone` errors in unchanged code. No lint
suppression or unrelated refactoring is included; fresh PR CI provides
the full platform checks.

Fresh CI for `608146047`: 36 checks passed and the Draft Lance
Gatekeeper was skipped. This includes [Rust platform tests and
clippy](https://github.com/lance-format/lance/actions/runs/36820559607),
[Python platform, compatibility and AWS
tests](https://github.com/lance-format/lance/actions/runs/36820559442),
and [Java 11/17/21/25
tests](https://github.com/lance-format/lance/actions/runs/36820559545).

Manual cleanup of a policy-free `__manifest` remains outside this
module's contiguous-version contract. This PR remains Draft.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-namespace Namespace impls bug Something isn't working K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants