feat(rollout): concurrent writes with periodic flush - #181
Merged
Conversation
Rollout ingest was throughput-bound under high concurrency with a slow object store: every `add` did put -> force_seal_active -> wait_for_flush_drain behind `add(&mut self)` and a handler `store.write()` lock, so a single hot store was capped at ~1/latency and concurrent requests queued on the lock. The serialization was ours, not lance's: ShardWriter::put/force_seal_active/ wait_for_flush_drain are all `&self` and put waits for WAL durability outside its lock. Durability = the WAL entry PUT to object storage; flush (materializing the memtable into a queryable generation) is a separate, deferrable concern. - `add` is now `&self`: concurrent appends each do only a durable WAL `put` and return. The resident writer is held in `Mutex<Option<Arc<ShardWriter>>>`, locked only to fetch/open/invalidate — never across `put` — so steady-state appends run concurrently. Fenced writers are reopened exactly once via an identity-checked (`Arc::ptr_eq`) invalidation. - New `flush(&self)` seals the active memtable into a queryable generation. A new `spawn_flush_sweeper` flushes every resident store on a fixed interval (`ROLLOUT_FLUSH_INTERVAL_SECS`, default 30, 0 disables) and runs the count-triggered merge that used to ride the append path. - The add handler switches `store.write()` -> `read()`. - MemWAL index init moves to open time so the hot `add` path stays `&self`. Semantic change: read-after-write is now asynchronous, bounded by the flush interval (<=30s). Durability is unchanged (every `add` waits for its WAL PUT). Tests: new concurrent-throughput, durability, and async-visibility tests; the 27 add-then-read unit tests and 5 server route tests now flush before reading. The wal_merge integration test asserts correctness via the deduplicating read path instead of observe().row_count (see the base-table dedup issue lance-format#180). Co-Authored-By: Claude <noreply@anthropic.com>
`RolloutStore::add` is now `&self`, so master route tests that only append no longer need a `mut` binding. Fixes `-D warnings` clippy failures under the workspace-wide lint (unused_mut at routes.rs:774/844/919). Co-Authored-By: Claude <noreply@anthropic.com>
This was referenced Jul 25, 2026
Closed
beinan
added a commit
that referenced
this pull request
Jul 25, 2026
Fixes #183. ## Problem #181 moved the seal off the append path: `add` now performs only a durable `put`, and `force_seal_active` + `wait_for_flush_drain` live in `flush`, driven by the server's periodic flush sweeper (default 30s). The doc comment on `add` was not updated. It still claimed: > Each append is sealed and its flushed generation committed to the shard manifest **before returning**, so the rows are **immediately visible** to reads on **any** instance and still described the per-append work as `put → force_seal_active → wait_for_flush_drain`. This directly contradicts the inline comment ~10 lines below it, which correctly says read-after-write is asynchronous. As a public rustdoc contract it tells every downstream caller they have read-your-write when in the default configuration they may wait up to 30 seconds. ## Change Docs only. The rewritten comment states: - `add` is **durable** on return — `put` waits for the WAL entry to reach object storage - `add` is **not** visible on return; a row becomes readable only once its memtable is sealed by `flush` (or `close`, or the merge path's internal close) - the gap is bounded by `ROLLOUT_FLUSH_INTERVAL_SECS`; callers needing immediate visibility should `add().await` then `flush().await` - why the decoupling exists (per-append seal serialized concurrent appends) - a forward pointer that `RolloutObservation::row_count` does not count durable-but-unflushed rows Also fixes a stale intra-doc link to `ensure_write_writer`, which no longer exists (now `resident_writer`). ## Verification No behavior change. The documented contract is already asserted by the existing `add_is_durable_but_not_visible_until_flush` test added in #181 — I started to add an equivalent test before finding it, and dropped mine as redundant. `cargo test -p lance-context-core --lib` → 161 passed, 0 failed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
beinan
added a commit
that referenced
this pull request
Jul 25, 2026
## The problem `python-test.yml` ran `pytest python/tests/` with `working-directory: python`, which resolves to **`python/python/tests/`** — a single stale file with 3 tests. The real suite, **`python/tests/` (24 files, 179 tests)**, was never collected. pytest exits 0 on a valid-but-nearly-empty directory, so this failed silently. Git history dates the split: `python/python/tests/` last changed in #59; `python/tests/` is current through #158. **~100 PRs merged ungated** — rollout store, async wrappers, export, ingestion. Two further layers of silencing, each sufficient on its own: - Only bare `pytest` was installed, never the `[tests]` extra — so `pytest-asyncio` was absent. - No `asyncio_mode` anywhere, so the 12 `async def` tests were collected, never awaited, and **passed without executing a line**. The same path bug appeared three more times: the doctest step was a permanent no-op (an `if` on a path that never exists, plus `|| echo`), and `ruff format --check python/` / `ruff check python/` **never linted the 24 test files**. `.codex/.../run_ci_checks.sh` pointed at a `rust/` directory that does not exist, so `set -e` killed it before any Python check ran. ## CI changes - Pin collection with `testpaths = ["tests"]` so the ambiguous relative path cannot resurface. - `asyncio_mode = "strict"` + `--strict-markers --strict-config` — a mistyped marker now fails instead of silently disarming a test. - Install `[lance-python,tests]`; lint `.` instead of `python/`; make the doctest step real. - Point the helper script at the real `crates/` layout. ## Turning the suite on surfaced 19 real failures **1. Embedded `RolloutStore` silently discarded every write.** This is the significant one. #181 made `add` durable-but-not-visible, with visibility driven by a server-side sweeper. The embedded path has no sweeper, and `flush()` was never exposed to Python — so: ```python s = RolloutStore.open(d) s.add({'id':'a','rollout_id':'t1'}) # -> {'version': 2, 'ids': ['a'], 'count': 1} s.list() # -> [] ... and still [] after reopen ``` Data acknowledged, then unreadable forever. This PR exposes `flush()` through the facade, PyO3, and both sync and async Python wrappers. **2. `test_search.py`'s `DummyInner` fake had drifted** from the real binding signature (missing `include_binary`/`include_embedding`), so all 55 tests raised `TypeError` on contact with current code. **3. Remote rollout tests** asserted read-your-write against a server whose default flush interval is 30s. They now set `ROLLOUT_FLUSH_INTERVAL_SECS=1` and poll — asserting the row *arrives* rather than pinning to the interval. The 3 stale tests are **moved** into `python/tests/` rather than dropped (they cover `snapshot`/`fork`/`memory://` and still pass), and the orphaned directory is removed so nothing can silently collect it again. ## Verification - **182 passed, 2 skipped** (179 real + 3 rescued). - `ruff format --check .`, `ruff check .`, `pyright` — all clean. - `cargo fmt --all -- --check` and clippy clean on the changed crates. - Confirmed the async tests now genuinely execute by injecting a failing assertion and watching it fail — it would not have before this change. ## Note on scope This is deliberately scoped to *making CI honest* plus the failures that gating surfaced. It does **not** address: publish workflows having no `needs:` on any test job (a release cut from a red `main` ships to PyPI/crates.io), `cargo test` covering only 2 of 8 crates and `--lib` only, or the `DummyInner` fake asserting hardcoded literals so RRF ranking is still never exercised. Happy to file those separately. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rollout ingest was throughput-bound under high concurrency with a slow object store. Every
adddidput → force_seal_active → wait_for_flush_drainbehindadd(&mut self)and a handlerstore.write()lock, so a single hot store was capped at ~1/latency and concurrent requests queued on the exclusive lock (the source of the observed p99 = 20–30s — queueing, not slow single writes).The serialization was ours, not lance's:
ShardWriter::{put, force_seal_active, wait_for_flush_drain}are all&self, andputwaits for WAL durability outside its internal lock. Verified against lance 7.0mem_walsource:wal.rsatomic_put). That alone means "won't be lost".freeze_memtableswaps in a fresh memtable and hands the old one to a background flusher without holding the state lock.replay_memtable_from_wal), so a durable-but-unflushedputis recovered on a fenced-writer reopen — no loss.Changes
addis now&self— concurrent appends each do only a durable WALputand return. The resident writer lives inMutex<Option<Arc<ShardWriter>>>, locked only to fetch/open/invalidate, never acrossput, so steady-state appends run concurrently. Fenced writers reopen exactly once via an identity-checked (Arc::ptr_eq) invalidation.flush(&self)seals the active memtable into a queryable generation. Newspawn_flush_sweeperflushes every resident store on a fixed interval (ROLLOUT_FLUSH_INTERVAL_SECS, default 30,0disables) and runs the count-triggered merge that used to ride the append path.store.write()→read().addpath stays&self.Semantic change (accepted)
Read-after-write is now asynchronous, bounded by the flush interval (≤30s). Durability is unchanged — every
addwaits for its WAL PUT before returning.Test plan
adds on a sharedArc<RolloutStore>, all durable, none lost; durable-but-not-visible-until-flush; flush-is-noop-without-writes.flush()before reading (the accepted async-visibility change).wal_mergeintegration test asserts correctness via the deduplicating read path instead ofobserve().row_count— see WAL merge leaves physical duplicate rows in the base table; count-based reads over-count #180 (pre-existing base-table physical-duplication bug, reproduces onmain, unrelated to this change).cargo test -p lance-context-core(161 passed) and-p lance-context-server(49 passed).cargo fmt --check+cargo clippy --all-targetsclean on both crates.Notes
ROLLOUT_MERGE_AFTER_GENERATIONS) moved off the append path onto the flush sweeper; the option is unchanged and still used by datagen.count_rows()over-counts).🤖 Generated with Claude Code