Skip to content

feat(rollout): concurrent writes with periodic flush - #181

Merged
beinan merged 2 commits into
lance-format:mainfrom
beinan:feat/rollout-concurrent-writes
Jul 25, 2026
Merged

feat(rollout): concurrent writes with periodic flush#181
beinan merged 2 commits into
lance-format:mainfrom
beinan:feat/rollout-concurrent-writes

Conversation

@beinan

@beinan beinan commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

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 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, and put waits for WAL durability outside its internal lock. Verified against lance 7.0 mem_wal source:

  • Durability = the WAL entry PUT to object storage (wal.rs atomic_put). That alone means "won't be lost".
  • Flush (materializing the memtable into a queryable generation) is a separate, deferrable concern; freeze_memtable swaps in a fresh memtable and hands the old one to a background flusher without holding the state lock.
  • Reopen replays un-flushed WAL (replay_memtable_from_wal), so a durable-but-unflushed put is recovered on a fenced-writer reopen — no loss.

Changes

  • add is now &self — concurrent appends each do only a durable WAL put and return. The resident writer lives in Mutex<Option<Arc<ShardWriter>>>, locked only to fetch/open/invalidate, never across put, so steady-state appends run concurrently. Fenced writers reopen exactly once via an identity-checked (Arc::ptr_eq) invalidation.
  • New flush(&self) seals the active memtable into a queryable generation. 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.
  • Add handler switches store.write()read().
  • MemWAL index init moves to open time so the hot add path stays &self.

Semantic change (accepted)

Read-after-write is now asynchronous, bounded by the flush interval (≤30s). Durability is unchanged — every add waits for its WAL PUT before returning.

Test plan

  • New tests: 50 concurrent adds on a shared Arc<RolloutStore>, all durable, none lost; durable-but-not-visible-until-flush; flush-is-noop-without-writes.
  • The 27 add-then-read core unit tests and 5 server route tests now flush() before reading (the accepted async-visibility change).
  • wal_merge integration test asserts correctness via the deduplicating read path instead of observe().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 on main, 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-targets clean on both crates.
  • Manual: high-concurrency ingest against a slow object store; confirm throughput scales past 1/latency and rows appear within the flush interval.

Notes

🤖 Generated with Claude Code

beinan and others added 2 commits July 25, 2026 00:41
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>
@beinan
beinan merged commit 8b2c49c into lance-format:main Jul 25, 2026
9 checks passed
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>
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