Skip to content

fix(rollout): bound periodic WAL cleanup pass with a timeout - #133

Merged
beinan merged 1 commit into
lance-format:mainfrom
beinan:rollout-cleanup-timeout
Jul 14, 2026
Merged

beinan merged 1 commit into
lance-format:mainfrom
beinan:rollout-cleanup-timeout

Conversation

@beinan

@beinan beinan commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • The periodic per-shard WAL cleanup loop (spawn_periodic_cleanup) called cleanup_own_shard() under the store write lock with no time bound.
  • A hung object-store call could never return: tokio::time::interval fires serially and the pass holds the write lock the whole time — so one stuck I/O would permanently wedge both the cleanup timer and every read/write on that store, with no self-recovery.
  • Fix: wrap each pass in tokio::time::timeout (5× interval, floored at 30s). On timeout the pass is abandoned, the write lock is released, and the next tick retries. A merely-slow merge is not aborted; a truly hung one no longer blocks the store forever.

Test plan

  • cargo test -p lance-context-core rollout (16 pass, 1 ignored bench)
  • cargo fmt --all --check, cargo clippy -p lance-context-core --all-targets
  • Existing spawn_periodic_cleanup_reclaims_on_a_timer still covers the normal loop. A fault-injected hang test was skipped (needs a test-only seam into the merge path + 30s+ wall-clock).

🤖 Generated with Claude Code

The periodic per-shard WAL cleanup loop called `cleanup_own_shard()` under
the store's write lock with no time bound. A hung object-store call could
never return: `tokio::time::interval` fires serially (the next tick will
not start until the current pass completes), and the pass holds the write
lock the whole time — so one stuck I/O would permanently wedge both the
cleanup timer and every read/write on that store, with no self-recovery.

Wrap each pass in `tokio::time::timeout` (5x interval, floored at 30s). On
timeout the pass is abandoned, the write lock is released, and the next
tick retries — a merely-slow merge is not aborted, but a truly hung one no
longer blocks the store forever.

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

## Why

We want to partition rollouts by `experiment_name` — one physical Lance
dataset per experiment. At the target scale (tens to hundreds of
thousands of experiments) the current server runtime cannot cope:

1. `rollout_stores` is an **unbounded `HashMap`** of open handles →
never evicted → OOM.
2. Each store spawns its **own tokio cleanup timer**
(`spawn_periodic_cleanup`) → 100k+ background timers wedge the process.
3. `list_rollout_stores` only enumerates the **in-memory cache**, so
with eviction it would be incomplete, and there is no durable directory
to enumerate.

This PR decouples resident memory / background tasks from the number of
stores.

## What

- **Durable registry** (`RolloutRegistry`, new
`crates/lance-context-core/src/registry.rs`): a single small Lance table
(`name`, `uri`, `created_at`) that is the source of truth for which
stores exist. `create` / `list` / `delete` / existence checks now go
through it. Existence is no longer inferred from cache membership, so
eviction cannot cause a false 404, and `list` returns every store.
- **Bounded LRU** replaces the unbounded `HashMap`; evicted stores are
transparently reopened from storage on the next request (reusing
`open_existing_with_options` from #135).
- **Single global sweeper** (`AppState::spawn_global_sweeper`) replaces
one-timer-per-store. It sweeps only the resident set each tick, with the
same per-pass `tokio::time::timeout` hardening as #133. Cold (evicted)
stores accrue no new generations and are swept again once re-opened.
- `RolloutStoreInfo.version` → `Option<u64>` (omitted in `list` to avoid
opening every dataset; populated for single get/create). Client updated
(`unwrap_or(0)`).
- New `--rollout-cache-capacity` / `ROLLOUT_CACHE_CAPACITY` (default
2000).

## Tests

- `registry.rs`: upsert idempotency, contains/remove, list,
survive-reopen.
- Server: evicted-store-transparently-reopened (capacity-1 LRU),
list-enumerates-all-registered, delete-unregisters, sweeper gating.
Existing multi-replica regression tests (#135) still pass.
- `cargo test --workspace`, `cargo clippy --workspace --all-targets`,
`cargo fmt --check` all clean.

## Follow-ups (out of scope)

- Startup reconciliation (scan `data_dir` to backfill registry rows if a
crash left a dataset without its row).
- Optional btree index on `registry.name` if the narrow scan becomes
hot.

🤖 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