Skip to content

perf(rollout): reuse a resident MemWAL ShardWriter across appends#159

Merged
beinan merged 1 commit into
mainfrom
perf/rollout-resident-writer
Jul 21, 2026
Merged

perf(rollout): reuse a resident MemWAL ShardWriter across appends#159
beinan merged 1 commit into
mainfrom
perf/rollout-resident-writer

Conversation

@beinan

@beinan beinan commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

RolloutStore::add opened a brand-new ShardWriter for every append and immediately close()d it. Each open cold-rebuilds the object store — ObjectStore::from_uri constructs a fresh ObjectStoreRegistry::default(), i.e. a new connection pool — so every append paid a cold DNS resolution + TCP/TLS handshake plus a redundant claim_epoch, then threw the connection away.

On Azure blob (where DNS resolution is the slow part) this dominated append latency (p50 ~1.5s, tail 12–100s) and left pods with a pile of TIME_WAIT sockets and zero long-lived connections.

This holds one resident ShardWriter per shard and reuses it across appends, so the epoch is claimed once and the object-store connection pool is reused (DNS resolved once). Per append we now do putforce_seal_activewait_for_flush_drain, which still commits a flushed generation to the shard manifest before returning — cross-instance read-after-write visibility is unchanged. Only the per-append open/claim/reconnect churn is removed.

Note: because we keep strong cross-instance visibility, the generation-per-visible-append (and the existing count/time-triggered self-merge that collapses them) is intentionally unchanged. This PR reduces latency, not generation count.

Details

  • Resident writer: new write_writer: Option<ShardWriter> field, opened lazily via ensure_write_writer, reused across add.
  • Epoch fencing: merge_own_shard calls claim_epoch, which fences a live writer, so it now close()s and clears the resident writer first; the next add transparently reopens against the fresh epoch.
  • Fence self-heal: add retries once (reopening the writer) if an op returns a fence error.
  • Lifecycle: ShardWriter has no Drop, so added pub async fn close() for graceful draining plus a best-effort Drop impl that spawns a detached close() when a Tokio runtime exists — avoids background-task leaks on LRU eviction.

Test plan

  • cargo test -p lance-context-core (25 passed; includes 3 new tests)
    • add_reuses_resident_writer_epoch_stable — epoch does not advance per append; every append immediately visible via get_by_id.
    • merge_reopens_writer_after_epoch_claim — merge-per-append never surfaces a fence error; rows stay visible.
    • close_drains_resident_writer_and_add_reopensclose() drops the writer (idempotent); add reopens.
  • cargo build -p lance-context-server (unchanged callers compile)
  • cargo clippy -p lance-context-core --tests (clean)
  • cargo fmt --all
  • Manual e2e on Azure: watch p50 append latency drop and ESTABLISHED connections persist instead of TIME_WAIT churn.

🤖 Generated with Claude Code

RolloutStore::add opened a fresh ShardWriter per append and immediately
close()d it. Each open cold-rebuilt the object store (ObjectStore::from_uri
constructs a fresh registry -> new connection pool), so every append paid a
cold DNS resolution + TCP/TLS handshake and a redundant claim_epoch. On Azure
blob this dominated append latency (p50 ~1.5s, tail 12-100s) and left the pod
with a pile of TIME_WAIT sockets and zero long-lived connections.

Hold one resident ShardWriter per shard instead. add() now does
put -> force_seal_active -> wait_for_flush_drain, which still commits a flushed
generation to the shard manifest before returning, so cross-instance
read-after-write visibility is unchanged; only the per-append open/claim/
reconnect churn is removed. The epoch is claimed once at open and the
connection pool is reused.

Correctness:
- merge_own_shard claims the shard epoch (which fences a live writer), so it
  now closes and clears the resident writer first; the next add reopens against
  the fresh epoch.
- add retries once, reopening the writer, if an operation is fenced.
- ShardWriter has no Drop, so add close() for graceful draining plus a
  best-effort Drop impl that spawns a detached close() when a runtime exists,
  avoiding background-task leaks on LRU eviction.

Tests: resident-writer epoch stays stable across appends with each append
immediately visible; merge reopens the writer without fencing; close drains and
add reopens.
@beinan
beinan merged commit bc8cc57 into main Jul 21, 2026
9 checks passed
beinan added a commit that referenced this pull request Jul 21, 2026
## Background
The Rust Tests job has been consistently slow (~23min). Two unrelated
PRs (#151, #159) showed the same pattern, which pointed to a CI config
issue rather than anything a specific change introduced.

## Root cause
1. **Wrong rust-cache path** (the real culprit): `workspaces` pointed at
`crates/lance-context-core` and `python`, but this is a **single root
workspace** whose build output lives in `./target`. Those subdirs have
no `target/`, so the cache was always empty and every run rebuilt all
dependencies from scratch — including the rocksdb C++ library compiled
from source.
2. **Doctest step has near-zero value**: `lance-context-core` has a
single doctest (~7s locally), but the step re-links the entire
rocksdb-bearing crate, costing ~6.85min per run.

## Changes
- `workspaces: "."` — cache the actual `./target`.
- Remove the `Run doc tests` step.

## Expected impact
- Drops the doctest step outright: **~-7min**.
- Once the cache is warm, dependencies (especially rocksdb) are no
longer rebuilt every run. The first run still has to populate the cache;
from the **second run onward** compile time drops substantially.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
beinan added a commit that referenced this pull request Jul 21, 2026
## Summary
- add a first-class DatagenStore backed by one append-only Lance log,
avoiding cross-dataset consistency hazards
- model item lifecycle, field deltas, failures, terminals, no-op step
completion, deterministic retry ids, fold, and trajectory reads
- use multi-shard MemWAL ingest with a resident writer, retry
deduplication, per-shard cleanup, and row-id blob point reads
- document the single-log schema and re-export the public API from
lance-context

## Testing
- cargo fmt --all -- --check
- cargo clippy --workspace --all-targets -- -D warnings
- cargo test -p lance-context-core datagen --no-fail-fast (8 passed)
- cargo test -p lance-context-core --no-fail-fast: 148 unit tests
passed; the existing rollout wal_merge_generation_cleanup integration
test fails on current main after #159 with 32 rows observed for 30
appends
- uv run ruff format --check python/
- uv run ruff check python/
- uv run pyright
- uv run pytest: 181 passed, 5 skipped, 1 xfailed; 10 unrelated
current-main failures in stale DummyInner signatures and moto S3 bucket
visibility
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