Skip to content

fix(master): write _stats as one snapshot per scan, push sweep filters down - #207

Merged
beinan merged 1 commit into
lance-format:mainfrom
beinan:fix/stats-snapshot-write
Jul 27, 2026
Merged

fix(master): write _stats as one snapshot per scan, push sweep filters down#207
beinan merged 1 commit into
lance-format:mainfrom
beinan:fix/stats-snapshot-write

Conversation

@beinan

@beinan beinan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

_stats is a ~50-row control-plane cache, but StatsStore::upsert wrote each row delete-then-append: two Lance commits per experiment per round. At the default 300s interval that is ~100 versions/round, ~28,800/day, unbounded. Production reached version 246,000+ with 90+ fragments, and GET /experiments degraded from milliseconds to 30–43s.

1. One commit per round instead of two per experiment

A scan already computes the complete set of live experiments, so it writes that set as a single Overwrite commit. Measured, 5 rounds × 50 experiments:

version fragments
before 501 50
after 6 1
84× fewer 50× fewer

This also subsumes the reconcile-remove pass — an experiment absent from the registry is simply absent from the snapshot.

Two hazards handled explicitly:

  • An empty snapshot is a no-op, not a wipe. A round where every observe failed would otherwise blank the table — which doesn't just empty the UI, it silently stops both auto-sweeps, since they read this table to decide what to enqueue.
  • An experiment that failed to observe this round keeps its previous row rather than vanishing.

⚠️ Dataset::append hardcodes WriteMode::Append and silently ignores params.mode; Dataset::write honours it. Using append here duplicates every row each round instead of replacing the table. My first attempt hit exactly this — row count went to 100 instead of 50. Called out in a comment since it fails silently.

2. Sweep predicates pushed into the scan, and capped

Both auto-sweeps called list(None, usize::MAX, 0) and filtered in a Rust loop — materialising the whole table twice per interval. Now list_above_fragment_count / list_above_pending_wal push the predicate into the scanner.

Each sweep is also capped at 256 enqueues/tick; previously unbounded, so at tens of thousands of experiments one tick could flood the queue. Anything still over threshold is picked up next tick.

3. The first maintenance pass runs without a timeout

MAINTENANCE_TIMEOUT (300s) is right for steady state but guaranteed failure against an already-bloated table: a 246k-version chain cannot be compacted and pruned inside the window, so every pass timed out, rolled back, and left the table exactly as bloated. The bound ensured it could never recover. The first pass per process is now unbounded; later passes take the bound.

On pruning — it wasn't broken, it was outrun

Worth stating since "246k versions" sounds like pruning was absent. cleanup_old_versions already physically deletes old manifests and their data files. Verified:

before cleanup: 12 manifests on disk
after  cleanup:  2 manifests    (old_versions=10, bytes_removed=49,887, rows intact)

It was simply outrun — 100 versions/round against a pass every twelfth round. At 1 version/round it keeps up easily. Over 120 rounds: version number reaches 121 while the table holds 2 manifests and 5.4 KB.

Three things get conflated here and shouldn't be:

grows forever? costs disk?
version number yes (u64 counter) no
manifest files no (steady ~2) yes
data files no (compacted) yes

The production "246,000+" is the number, which is harmless on its own. What degraded /experiments was the un-reclaimed manifest chain.

Testing

5 new tests asserting cost, not just correctness: version growth per round, snapshot-replaces-not-appends, empty-snapshot-is-no-op, threshold queries return only matching rows and respect the cap, deterministic pagination.

Full workspace: 167 core + 20 master + 51 server + 5 concurrency + 1 merge-cleanup, all pass. clippy --workspace --all-targets -D warnings and cargo fmt clean.

Not in this PR

This is the stopgap — it fixes write amplification but keeps the full-scan-every-round model. At the tens-of-thousands scale discussed, the scan itself is the next bottleneck (observe() per experiment per round, ~19–94 min for 30k at concurrency 8 against a 5-minute interval). Follow-ups: retire cold experiments from the hot table after merge+compact, serve the UI as "recently active N + search", and have workers record last_write_at so scans touch only what changed.

🤖 Generated with Claude Code

…s down

`_stats` is a ~50-row control-plane cache, but `StatsStore::upsert` wrote each
row delete-then-append: two Lance commits per experiment per round. At the
default 300s interval that is ~100 new versions per round and ~28,800 per day,
unbounded. Production reached version 246,000+ with the table fragmented into
90+ fragments, and `GET /experiments` degraded from milliseconds to 30-43s
because every open traverses the manifest chain.

Three changes.

1. One commit per round instead of two per experiment.

A scan already computes the complete set of live experiments, so it writes that
set as a single `Overwrite` commit. Measured over 5 rounds x 50 experiments:

    before: version=501  fragments=50
    after:  version=6    fragments=1     (84x fewer versions, 50x fewer fragments)

This also subsumes the reconcile-remove pass: an experiment absent from the
registry is simply absent from the snapshot.

Two hazards handled explicitly. An empty snapshot is a no-op rather than a wipe
-- a round where every `observe` failed would otherwise blank the table, which
does not just empty the UI, it silently stops both auto-sweeps, since they read
this table to decide what to enqueue. And an experiment that failed to observe
this round keeps its previous row instead of vanishing.

Note `Dataset::append` hardcodes `WriteMode::Append` and silently ignores
`params.mode`; `Dataset::write` honours it. Using `append` here would duplicate
every row each round rather than replace the table. Called out in a comment
because it fails silently.

2. Sweep predicates pushed into the scan, and capped.

Both auto-sweeps called `list(None, usize::MAX, 0)` and filtered in a Rust loop,
materialising the whole table twice per interval. They now use
`list_above_fragment_count` / `list_above_pending_wal`, which push the predicate
into the scanner. Each sweep is also capped at 256 enqueues per tick; it was
previously unbounded, so at tens of thousands of experiments one tick could
flood the queue. Anything still over threshold is picked up next tick.

3. The first maintenance pass runs without a timeout.

`MAINTENANCE_TIMEOUT` (300s) is right for steady state but guaranteed failure
against an already-bloated table: a 246k-version chain cannot be compacted and
pruned inside the window, so every pass timed out, rolled back, and left the
table exactly as bloated -- the bound ensured it could never recover. The first
pass per process is now unbounded; later passes take the bound, by which point
the backlog is gone and exceeding it is a genuine fault.

On pruning: `cleanup_old_versions` already physically deletes old manifests and
their data files (verified: 12 manifests on disk -> 2 after one pass, 49,887
bytes reclaimed, rows intact). It was not broken, it was outrun -- 100 versions
per round against a pass every twelfth round. At 1 version per round it keeps
up easily: over 120 rounds the version *number* reaches 121 while the table
holds 2 manifests and 5.4 KB. The monotonic version number is just a u64
counter and costs nothing; only the manifest and data files do.

Co-Authored-By: Claude <noreply@anthropic.com>
@beinan
beinan merged commit dbfd414 into lance-format:main Jul 27, 2026
9 checks passed
beinan added a commit that referenced this pull request Jul 27, 2026
Follow-up to #207. A failing maintenance pass left **no signal at all**.

`master_stats_versions_removed_total` only moves on success, so a pass
failing for days — object-store outage, permissions, a genuinely stuck
compaction — looked **identical to a pass with nothing to reclaim**,
while old manifests piled back up and the table walked back toward
exactly the state that path exists to prevent.

## New metrics

```
master_stats_maintenance_failures_total          counter
master_stats_maintenance_consecutive_failures    gauge, 0 after any success
master_stats_unreclaimed_versions                gauge
```

**`unreclaimed_versions` is the signal to alert on** — the version gap
since the last successful pass.

`master_stats_version` is deliberately *not* the signal: it climbs by
design and says nothing about disk. This distinction caused real
confusion on the original report ("version 246,000+ and climbing") — the
number itself is a harmless `u64` counter. Only manifests still sitting
on storage cost anything, and the gap is what measures those.

The failure path also logs at `warn` with both numbers, so the reason is
visible without a metrics backend.

## Descriptions

Also describes every `_stats` metric, **including the three from #194
that had no HELP or TYPE**. Datadog's OpenMetrics check infers type from
these, and the description text states explicitly which metric is the
alerting signal so it isn't rediscovered the hard way.

## Testing

Two tests on the bookkeeping:
- consecutive failures accumulate and reset on success
- **a failure before any successful pass reports the full backlog**
rather than a zero gap — the case a freshly-upgraded, already-bloated
deployment hits first, and the one most likely to be got wrong

`MasterState` needs etcd to construct, so the counters are tested
directly rather than through an `#[ignore]`d integration test that CI
would skip.

21 master + 1 metrics tests pass; `clippy --workspace --all-targets -D
warnings` and `cargo fmt` clean.

🤖 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 27, 2026
…t moved (#209)

First of three steps toward surviving tens of thousands of experiments.
#207 fixed how `_stats` is *written*; this fixes how much work a scan
*does*.

## The problem

A scan round fully observes **every** experiment, every 300s. `observe`
is not cheap:

| step | cost |
|---|---|
| `wal_shard_snapshots()` | list every MemWAL shard + read each manifest
|
| `pending_wal_rows()` | open every flushed generation |
| `count_rows()` | scan the base table |
| `manifest.version` | **free — already in memory** |

At 30k experiments with concurrency 8: **~19 min** at an optimistic 0.3s
each, **~90 min** at object-storage latencies — against a **5 minute**
interval. The next round starts before the previous ends, forever. No
choice of stats storage fixes this; the scan itself is the wall.

## The fix

`RolloutObservation` **already returned** the base dataset version —
`StatRow` just never persisted it. The cheapest possible change signal
was being thrown away.

Opening the dataset reads its manifest, which is where the version comes
from, so the comparison is **free**. Unchanged version ⇒ no write landed
⇒ every derived metric is necessarily unchanged ⇒ reuse the previous row
and skip everything above.

Cold experiments now cost **one open** per round instead of a full
observation.

Also removes a per-experiment `stats.get()` that took the stats lock
**once per experiment per round**; the previous snapshot is now read
once up front and passed down. `observe_one` no longer needs
`MasterState` at all.

## Two edges, both tested

**1. Existing tables have no `version` column.** Reads tolerate the
absence and record `UNKNOWN_VERSION`, forcing one full observation which
then persists the column. Erroring instead would break **every read on
upgrade** — and reads are what the UI *and both auto-sweeps* depend on,
so the master would come up blind until the table happened to be
rewritten. There's a test that builds a legacy-schema table and asserts
it reads, keeps its data, and migrates on first snapshot.

**2. `refresh_one` must not skip.** Its callers use it precisely when
they believe something just changed (post-compaction, explicit refresh)
— the version check would short-circuit exactly the observation they
asked for. It passes `None`.

## Observability

`master_scan_experiments_skipped` / `_observed`. A ratio near 1 is the
healthy state at scale; trending to 0 means the incremental path stopped
engaging (usually a stats table losing its rows).

## Testing

4 new scanner tests + 1 migration test. **Verified non-vacuous:**
disabling the skip makes 2 of the 4 fail.

Full workspace: 178 core + 24 master + 51 server + 5 concurrency + 1
merge-cleanup, all pass. clippy `-D warnings` and fmt clean.

## Next

- **C2** — retire experiments cold for 7 days: merge WAL → compact →
verify `pending_wal_generations == 0` → drop from the hot table. The
ordering matters: `compact_files` does not touch WAL generations, so
retiring before draining them would strand those generations forever
(the WAL sweep only reads this table) and leak `_mem_wal/` directories.
- **C3** — UI becomes "recently active N + search", with cold
experiments observed on demand via the registry.

🤖 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 27, 2026
#211)

⚠️ **Stacked on #209#210.** Both of those commits are included here.
Merge order: **#209#210 → this**. Once they land, this diff reduces
to the UI commit alone.

## Why this exists

#210 removes cold experiments from the stats table — that's what keeps a
scan round proportional to *active* work. But on its own it also removes
them from `GET /experiments`, which is only acceptable once they stay
findable another way. **This PR is what makes retirement safe to turn
on.**

| | behaviour |
|---|---|
| **default list** | the stats table — experiments written recently
enough not to have been retired |
| **search** | stats table **plus the registry** for names it no longer
holds; cold matches observed on demand |
| **detail** | on a stats miss, resolve via registry and observe on
demand rather than returning 404 |

Retirement therefore removes an experiment from the *default view* and
**never makes it unfindable**.

The default is also the right one at scale on its own merits: a flat
list of every experiment ever created is neither renderable nor
interesting once there are tens of thousands.

## Two deliberate decisions

**1. `observe_cold` does not write a stats row.** Reading about a
retired experiment must not make it hot again — otherwise **browsing the
UI would silently undo retirement**, and the table would creep back
toward holding everything, which is exactly the state retirement exists
to prevent.

It also takes no `MasterState`, so it *structurally* cannot write one. A
refactor that hands it one has to justify itself. There's a test
asserting the no-rehydration property.

**2. Search bounds its on-demand observations by page size.** A query
matching thousands of cold experiments must not open thousands of
datasets to render one page.

## Known gap — stating it rather than hiding it

The two handlers changed here live in `routes.rs`, whose tests **all
require etcd and are `#[ignore]`d**, so CI does not cover them.
`observe_cold` is tested in `scanner.rs`, which CI does run.

So the registry-fallback logic in the handlers is verified locally but
not by CI. That's a pre-existing gap in the test structure, worth fixing
as its own change rather than as a fourth concern here — and it's the
kind of blind spot that only became visible after #202 made CI actually
run integration tests.

## Testing

Full workspace: 178 core + 30 master + 51 server + 5 concurrency + 1
merge-cleanup, all pass. clippy `-D warnings` and fmt clean.

## The complete picture

This finishes the line of work from the original report (`/experiments`
at 30–43s):

| | problem | fix |
|---|---|---|
| **#207** | write amplification — 100 versions/round | one snapshot
commit; **84× fewer versions** |
| **#209** | scan cost — full observe of everything, every round | skip
unchanged; cold = one open |
| **#210** | table size — held every experiment ever created | retire
after 7 days idle, drained first |
| **this** | retirement made cold experiments invisible | active-N
default + registry-backed search |
| **#208** | all of the above failing silently | maintenance failure
metrics |

🤖 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.

2 participants