Skip to content

fix(namespace): avoid write starvation during high read-count - #70

Merged
amunra merged 1 commit into
release-10.0.0from
adam/write-starvation-r10
Sep 2, 2026
Merged

amunra merged 1 commit into
release-10.0.0from
adam/write-starvation-r10

Conversation

@amunra

@amunra amunra commented Sep 2, 2026 •

Copy link
Copy Markdown

DatasetConsistencyWrapper::get_mut reloaded the dataset and then took the write lock, so the reload's read lock was released before the write lock was requested. Under a steady stream of readers the writer could keep losing that race. It now takes the write lock first and reloads while holding it, so tokio's write-fairness applies to 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 only when a successor version exists.

Lineage: authored by Emil as d543ed8419e on emilk/fix-write-starvation (#12, closed unmerged), then landed by cherry-pick as #13 into release-3.0.0 and #14 into release-2.0.0. It reached the current release lines as 996affee5458af936d78352e80055cf16f4b25b6 on release-8.0.0 and 1b47099f20c559f0022a5b8a42b995f6f256bc81 on release-9.0.0, which are identical to each other and are what this ports, unchanged.

The r8/r9 form is not identical to the original: the shape of the reload it wraps changed on the way, and this port matches r8/r9 rather than d543ed8419e. #15, the follow-up to the release-3.0.0 cherry-pick, also touches this file but is unrelated to the locking — comment cleanup and error-construction style on the read path, against a version of reload that used latest_version_id rather than has_successor_version.

@amunra
amunra requested a review from zehiko September 2, 2026 15:15
@amunra
amunra merged commit 1c84e87 into release-10.0.0 Sep 2, 2026
36 checks passed
@amunra amunra added lance fork 10.0.0 Landed on the release-10.0.0 fork line upstreamable change Belongs upstream, but is not there yet labels Sep 3, 2026
zhangyue19921010 pushed a commit to lance-format/lance that referenced this pull request Sep 30, 2026
## 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.

Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
@amunra amunra added upstreamed Landed upstream; drop at the next rebase and removed upstreamable change Belongs upstream, but is not there yet labels Oct 6, 2026
@amunra

amunra commented Oct 14, 2026

Copy link
Copy Markdown
Author

Rebase note. Upstream as lance-format/lance#9285 (fix(namespace): avoid write starvation under heavy reads, merged 2026-09-30), which is this change.

A release branch cut from an upstream commit at or after lance-format#9285 already has it: drop this commit rather than carrying it forward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-namespace lance fork 10.0.0 Landed on the release-10.0.0 fork line upstreamed Landed upstream; drop at the next rebase

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants