Skip to content

fix(master): recount pending WAL generations even when the base version is unchanged - #281

Merged
beinan merged 1 commit into
lance-format:mainfrom
beinan:fix/scan-pending-despite-version
Sep 30, 2026
Merged

beinan merged 1 commit into
lance-format:mainfrom
beinan:fix/scan-pending-despite-version

Conversation

@beinan

@beinan beinan commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator

Problem

The scanner's unchanged-version shortcut reuses the previous stats row wholesale. WAL flushes never bump the base-table version, so a store that no worker or master has ever merged keeps its version forever while its shards fill up. In production mai3_bigclimb_run6p5_77b_t0r1 sat at "3 pending" in the stats table while its 20 shards accumulated 17,254 generations; the merge sweep reads that row, so it never scheduled a merge, and after #278/#279 every read on the store was refused (503) by the pending-generation cap.

Fix

Both observe_generic and observe_one still skip the base-table row count on an unchanged version, but recount pending_wal_generations (one small shard-manifest read per shard, under the existing observe timeout) and write it into the reused row.

Verification

  • skipped_round_recounts_pending_generations: 1 → 4 flushed generations with the base version unchanged; the skipped row reports 4.
  • generic_store_is_observed_with_pending_wal extended the same way (3 → 5).
  • Master suite incl. etcd --include-ignored passes; clippy clean.

🤖 Generated with Claude Code

…on is unchanged

The scanner reuses the previous stats row when a store's base-table
version has not moved. WAL flushes never move it: a store nobody has
merged keeps its version forever while its shards fill up. Its stats row
kept saying "3 pending" while 20 shards accumulated 17,254 generations,
so the merge sweep -- which reads that row -- never scheduled it, and
reads on it were refused by the pending-generation cap.

The shortcut still skips the base-table row count, but now recounts
pending generations (one small manifest read per shard) and writes the
new number into the reused row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@beinan
beinan merged commit be8d5ad into lance-format:main Sep 30, 2026
10 checks passed
beinan added a commit that referenced this pull request Sep 30, 2026
…tats now (#283)

## Problem

The merge sweep picks targets from the stats table, which the scanner
refreshes on its interval (300 s in production). When a row is known to
be wrong — `mai3_bigclimb_run6p5_77b_t0r1` showed 3 pending generations
while its shards held 17,254 (#281) — an operator had no direct way to
correct it: wait for the next scan, or call `GET
/experiments/{name}?fresh=true`, which reads as a query rather than a
scheduler input change.

## Fix

`POST /api/v1/experiments/{name}/rescan`: resolves the store through the
registry (404 if absent), runs the existing `scanner::refresh_one` (full
observation under the stats-writer lock, upserts the row) and returns
the refreshed `ExperimentDetail`.

## Verification

- `rescan_experiment_refreshes_pending_generations` (etcd-backed): 0 → 2
flushed generations; the response and the persisted row both report 2;
unknown name → `NotFound`.
- Master suite incl. `--include-ignored` passes; clippy clean.

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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
beinan added a commit that referenced this pull request Sep 30, 2026
…table (#282)

## Problem

The delete-only `merge_insert` from #280 probes the key BTree. When the
base table has none, Lance falls back to a hash join over the entire
base table. `mai3_bigclimb_run6p5_77b_t0r1` (174 GB, 67k rows, 2.6
MB/row) had never been indexed because the master's stats row for it was
stale (#281), so every worker that ran a merge on it — the master's
fan-out or a manual `/internal/merge-wal` call — was OOMKilled within
~15 s. Building the BTree first took 19 s and the same merge then peaked
at 9 GiB.

## Fix

`StorageBase::merge_prepared_batches` builds the key BTree
(`create_key_btree_index`, idempotent) when the base table has fragments
and `has_key_btree_index()` is false, before the delete-only merge. An
empty base table has nothing to join and is left unindexed. The master's
pre-fan-out build (#277) stays; this is the worker-side guarantee that
does not depend on the master having visited the store.

## Verification

- `merge_builds_the_id_btree_when_the_base_has_rows_but_no_index`: first
merge into an empty base builds nothing; the second merge, against a
populated base, creates the BTree and rows still read back exactly once.
- Full core suite (249 tests) passes; clippy clean across the workspace.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
beinan added a commit that referenced this pull request Sep 30, 2026
## Problem

Today `mai3_bigclimb_run6p5_77b_t0r1` sat at 17,254 pending WAL
generations, every read answered 503 for ~5 hours, and no metric or log
said so — a customer noticed first. The stats scanner was reusing a
stale row (#281), no master had ever indexed the store (#282), and the
read cap fired silently. 40+ more stores were in the same state.

## Fix

One metric per blind spot:

| metric | kind | meaning / alert |
|---|---|---|
| `master_stores_pending_over_read_cap` | gauge | stores whose reads the
cap is refusing right now; **alert when > 0** |
| `master_stores_pending_over_1k`,
`master_wal_pending_generations_total`,
`master_wal_pending_generations_max` | gauge | fleet backlog shape,
refreshed every stats scan |
| `master_stats_pending_recounted_total` | counter | version-unchanged
shortcut found a pending count different from the row it would have
reused (the #281 condition); `warn!` when growth > 256 |
| `rollout_reads_refused_pending_total{store}` | counter | now labeled
by store so the unreadable store is named |
| `rollout_merge_index_built_on_demand_total` | counter | a merge built
the key BTree because no master had (the #282 condition) |

Gauges come from a small `Backlog::summarize` over the scan snapshot.

## Verification

`backlog_summary_counts_stores_over_the_read_cap` (17,254 / 1,200 / 3 →
total 18,457, max 17,254, 1 over cap, 2 over 1k). Existing scanner
recount tests and core read-cap/merge tests pass; clippy clean.

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

Co-authored-by: Claude Fable 5 <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