Skip to content

fix(master): compact and version-prune _stats so it stops growing unbounded - #194

Merged
beinan merged 1 commit into
lance-format:mainfrom
beinan:fix/stats-compaction
Jul 25, 2026
Merged

fix(master): compact and version-prune _stats so it stops growing unbounded#194
beinan merged 1 commit into
lance-format:mainfrom
beinan:fix/stats-compaction

Conversation

@beinan

@beinan beinan commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Problem

_stats.rollout.lance accumulates a new Lance version on every stats upsert and is never compacted or version-pruned. On a long-running master it reaches hundreds of thousands of versions (observed 172,789, climbing ~1/min), which makes cold start and GET /api/v1/experiments progressively slower.

StatsStore::upsert does delete-then-append (WriteMode::Append), so each upsert creates ≥1 version. The periodic scan upserts one row per experiment every STATS_SCAN_INTERVAL_SECS, and update_stats_after_compaction upserts again after each compaction — hundreds of versions per hour, unbounded. This is the same class of problem #178 fixed for the task table, which didn't cover _stats.

Fix

Proposals 1 and 2 from the issue:

  • StatsStore::maintain(older_than)compact_files with materialize_deletions: true (every upsert leaves a deletion) into one large fragment, then cleanup_old_versions with a grace window. The handle is reloaded around both so the cleanup sees the rewritten manifest and subsequent reads see the compacted version.
  • Scanner integration — runs under the existing stats-writer coordination lock, so only one replica ever rewrites the dataset and it can't race a concurrent scan. Fires on the first round (an existing deployment shouldn't wait N intervals to reclaim a 170k chain) and every Nth round after.
  • Bounded by a 5-minute timeout and logged-not-propagated, so a slow object store can neither wedge the scanner loop nor fail the scan round.

Cleanup never touches versions newer than the grace window, so readers on another replica holding a recent version are unaffected.

Config

Env Default Meaning
STATS_MAINTENANCE_EVERY_N_SCANS 12 Maintain every Nth scan round; 0 disables. At the default 300s scan interval that's hourly.
STATS_HISTORY_TTL_SECS 3600 Grace window; versions newer than this are never removed.

Metrics

master_stats_maintenance_duration_seconds, master_stats_versions_removed_total, master_stats_version (gauge — the thing that was silently climbing).

Tests

  • maintain_bounds_versions_and_preserves_rows — 40 upserts, assert versions actually reclaimed and fragments compacted, rows intact and correct afterwards, store still writable.
  • maintain_respects_grace_window — a wide TTL removes nothing.

cargo test -p lance-context-master and cargo clippy --all-targets clean.

🤖 Generated with Claude Code

…ounded

_stats.rollout.lance is written delete-then-append on every stats upsert,
so each scan round adds versions and fragments per experiment, and Lance
retains every historical manifest until explicitly cleaned. On a
long-running master the chain reached 170k+ versions, making cold start
and GET /api/v1/experiments progressively slower.

Add StatsStore::maintain(): compact_files with materialize_deletions,
then cleanup_old_versions with a grace window, reloading the handle
around both. The scanner runs it under the existing stats-writer
coordination lock on the first round and every Nth round after
(STATS_MAINTENANCE_EVERY_N_SCANS, default 12; STATS_HISTORY_TTL_SECS,
default 3600), bounded by a timeout and never failing the scan round.

Co-Authored-By: Claude <noreply@anthropic.com>
@beinan
beinan merged commit ed0caaf into lance-format:main Jul 25, 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>
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