Skip to content

feat(rollout): periodic per-shard WAL cleanup + concurrency hardening - #131

Merged
beinan merged 2 commits into
lance-format:mainfrom
beinan:rollout-wal-cleanup
Jul 13, 2026
Merged

beinan merged 2 commits into
lance-format:mainfrom
beinan:rollout-wal-cleanup

Conversation

@beinan

@beinan beinan commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a time-triggered per-shard WAL cleanup for the rollout store, complementing the existing count-triggered self-merge, and hardens the merge path against several concurrency corner cases discovered by reviewing the actual Lance 7.0.0 commit/OCC model.

Each instance cleans only the shard it owns and writes, reusing its own writer epoch, so it never fences a concurrent writer — the same safety invariant as the existing count-triggered merge.

Feature: periodic WAL cleanup

  • RolloutStoreOptions gains cleanup_interval_secs (the time trigger) and cleanup_min_generations (the count gate a tick must meet before merging).
  • cleanup_own_shard() runs a single pass; spawn_periodic_cleanup() runs a background timer that holds only a Weak reference, so it self-terminates when the store is dropped (e.g. on delete) — no leaked task, no store kept alive.
  • Server exposes --rollout-cleanup-interval-secs / --rollout-cleanup-min-generations (env ROLLOUT_CLEANUP_INTERVAL_SECS / ROLLOUT_CLEANUP_MIN_GENERATIONS) and spawns the timer per rollout store. Embedded use leaves it disabled (no long-lived runtime to own the timer).

Concurrency hardening

  • ensure_mem_wal: two concurrent first-writers both observe no index and both commit CreateIndex, which Lance treats as a hard, non-retried conflict — the loser previously failed the whole write. Now we reload and accept "index now present" as success. (This was caught by a new test.)
  • Merge drain: the manifest drain now removes exactly the generations it merged instead of clearing the vec. A blanket clear would silently discard any generation flushed during the merge window without merging it (data loss). Removing only the merged ids preserves anything else for the next pass.

Corner cases analyzed (against Lance source) and confirmed safe

  • Merge (append) racing base compaction — Append vs Rewrite are non-conflicting; OCC auto-rebases.
  • Two shards merging into one base table concurrently — two Appends auto-retry; each drains only its own shard manifest.
  • Merge racing an append on the same shard — serialized by &mut self + per-store RwLock, backstopped by MemWAL epoch fencing.
  • Periodic timer racing the count-triggered merge — both take the write lock; the loser is a no-op.
  • Crash between base-append and drain — rows dedup by id on read; next merge drains.
  • Delete-store racing the timer — timer holds Weak, exits on next tick.

Test plan

  • cleanup_own_shard_merges_only_at_min_generations — min-generation gating
  • spawn_periodic_cleanup_reclaims_on_a_timer — timer drains the shard
  • spawn_periodic_cleanup_disabled_returns_none — interval 0 spawns nothing
  • concurrent_merges_from_two_shards_into_one_base_table — two shards merge into one base table, all rows conserved exactly once
  • merge_drains_only_merged_generations — surgical drain conserves rows across two merge rounds
  • Full lance-context-core suite, cargo clippy --workspace, and workspace build all green

🤖 Generated with Claude Code

beinan and others added 2 commits July 13, 2026 17:22
Add a time-triggered per-shard WAL cleanup for the rollout store,
complementing the existing count-triggered self-merge. Each instance
cleans only the shard it owns and writes, reusing its own writer epoch,
so it never fences a concurrent writer.

- RolloutStoreOptions gains `cleanup_interval_secs` (time trigger) and
  `cleanup_min_generations` (count gate for a tick).
- `cleanup_own_shard` runs one pass; `spawn_periodic_cleanup` runs a
  background timer that holds only a Weak ref, so it self-terminates
  when the store is dropped (e.g. on delete).
- Server exposes `--rollout-cleanup-interval-secs` /
  `--rollout-cleanup-min-generations` and spawns the timer per store.

Concurrency hardening (verified against Lance 7.0.0 commit/OCC model):
- ensure_mem_wal: two concurrent first-writers both commit CreateIndex,
  which Lance treats as a hard (non-retried) conflict. Reload and accept
  "index now present" as success instead of surfacing the conflict.
- merge drain: remove exactly the generations merged instead of clearing
  the vec, so a generation flushed during a merge is retained for the
  next pass rather than silently dropped.

Tests: concurrent two-shard merge into one base table, surgical-drain
row conservation, timer-driven cleanup, min-generation gating.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
- rustfmt the new rollout cleanup code
- avoid clippy unnecessary_to_owned / needless_update
- add rollout_cleanup_* fields to server test AppState literals

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
@beinan
beinan merged commit bb994a0 into lance-format:main Jul 13, 2026
9 checks passed
beinan added a commit that referenced this pull request Jul 14, 2026
## Summary

Adds **base-table compaction** to the rollout store. Every WAL merge
(`merge_own_shard`) appends a fragment to the base table, so a
long-lived rollout store accumulates many small fragments that slow
scans. This folds them back together via Lance's `compact_files`,
mirroring `ContextStore::compact`.

Follow-up to #131 (WAL cleanup + concurrency hardening).

### Core (`RolloutStore`)
- `compact(config)` — runs `compact_files` on the base table, reloads
the handle, and tracks `last_compaction` / `total_compactions` /
`last_error`.
- `should_compact(config)` — gates on `min_fragments` + `quiet_hours` so
an external scheduler can pass the same config.
- `compaction_stats()` — fragment count + compaction history.
- Uses `defer_index_remap: true`: the rollout base table carries a
**fieldless MemWAL index**, and Lance's inline index remap panics on it
(`"An index existed with no fields"`). Deferring records a
fragment-reuse index and remaps lazily instead of touching the MemWAL
index during the rewrite.

### Server
- `POST /api/v1/rollouts/{name}/compact` and `GET
/api/v1/rollouts/{name}/compact/stats`, reusing the existing `Compact*`
DTOs.

### Distributed model (important)
Compaction rewrites the **shared** base table, and two concurrent
`Rewrite` commits conflict in Lance's commit matrix (one wins, the other
must redo its work). So this is meant to be driven by a **single
external scheduler** (cron / k8s CronJob), **not** a per-worker
background timer — unlike WAL merge, where each worker only ever touches
its own shard. It is safe to run alongside worker appends / WAL merges:
`Append` vs `Rewrite` is non-conflicting in Lance's matrix, so a
concurrent append simply rebases on the compaction (or vice-versa).

## Test plan
- [x] `compact_reduces_fragments_and_preserves_reads` — fragment count
drops; all rows readable exactly once; inline artifact blobs still
fetchable; stats updated
- [x] `should_compact_respects_min_fragments`
- [x] `compact_composes_with_concurrent_wal_merge` — a compaction
(Rewrite) and a WAL merge (Append) from another shard run concurrently
without failing or losing rows
- [x] Full `lance-context-core` (114) and `lance-context-server` (28)
suites, `cargo fmt --all --check`, `cargo clippy --workspace
--all-targets` all green

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

Co-authored-by: Claude Opus 4 <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