Skip to content

feat(master): retire experiments idle for a week from the stats table - #210

Merged
beinan merged 1 commit into
lance-format:mainfrom
beinan:feat/stats-retire-cold
Jul 27, 2026
Merged

feat(master): retire experiments idle for a week from the stats table#210
beinan merged 1 commit into
lance-format:mainfrom
beinan:feat/stats-retire-cold

Conversation

@beinan

@beinan beinan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Stacked on #209 — that PR's commit is included here, so review #209 first and merge it before this one. Once #209 lands, this diff reduces to the retirement commit alone.

Why

#209 made cold experiments cheap (one open instead of a full observation). They still cost something, and the table still holds a row for every experiment that has ever existed. At tens of thousands — the overwhelming majority written once and never again — that's the remaining reason a scan round can't stay proportional to actual work.

An experiment with no writes for STATS_COLD_RETIRE_SECS (default 7 days) is dropped from the table. It stays in the registry and can still be observed on demand; it just stops costing a row and an open every round.

Retirement order is a correctness property

This is the part worth reviewing carefully.

Both auto-sweeps read the stats table and nothing else (verified — the registry references in scheduler.rs are all test-only). So an experiment absent from it is invisible to them permanently.

Dropping one that still has un-merged MemWAL generations would strand them:

  • rows stay readable (the read path unions generations) — so nothing looks broken
  • but they're never folded into the base table
  • read amplification never recovers
  • _mem_wal/{shard}/ directories are never reclaimed
  • and no process is left that would notice

So the order is:

merge WAL → compact → verify pending_wal_generations == 0 → drop the row

⚠️ compact_files deliberately leaves MemWAL generations alone, so compaction alone would not have been sufficient. Compaction is still done, because a retired table is one nothing will come back to tidy.

Any step failing leaves the experiment in the table for the next round. Refusing to retire is always safe; retiring early is not.

Testing

Four tests. The important one asserts the shard is genuinely drained after retirement — reopening the store and checking pending_wal_generations == 0 — rather than that the function returned success.

Verified non-vacuous: removing the WAL-merge step makes it fail with exactly the stranding it exists to prevent:

assertion failed: retirement must leave zero pending generations,
                  or they are stranded forever

Full workspace: 178 core + 28 master + 51 server, all pass. clippy -D warnings and fmt clean.

Config

STATS_COLD_RETIRE_SECS=0 disables retirement entirely. Every existing test config sets it to 0, so they exercise the unchanged path.

Metrics

master_stats_hot_experiments (table size — now bounded, rather than tracking the registry), master_stats_experiments_retired_total, master_stats_retire_failures_total.

Next

C3 — UI becomes "recently active N + search", with cold experiments resolved through the registry and observed on demand. That's what makes retirement invisible to users: today a retired experiment would simply vanish from /experiments, which is only acceptable once search covers it.

🤖 Generated with Claude Code

beinan added a commit to beinan/lance-context that referenced this pull request Jul 27, 2026
Stacked on lance-format#210.

Retirement (lance-format#210) removes cold experiments from the stats table, which is what
keeps a scan round proportional to active work. On its own it also removes them
from `GET /experiments`, which is only acceptable once they remain findable
another way. This closes that.

  default list  the stats table, i.e. experiments written recently enough not
                to have been retired. A flat list of every experiment ever
                created is neither renderable nor interesting at tens of
                thousands.

  search        the stats table, plus the registry for names it no longer
                holds. Cold matches are observed on demand.

  detail        on a stats miss, resolve through the registry and observe on
                demand rather than returning 404.

So retirement removes an experiment from the *default* view and never makes it
unfindable.

`observe_cold` deliberately does not write a stats row. Reading about a retired
experiment must not make it hot again, or browsing the UI would silently undo
retirement and the table would creep back toward holding everything -- 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.

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

Known gap: 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. Worth fixing, but as its own change
rather than a fourth concern in this one.

Co-Authored-By: Claude <noreply@anthropic.com>
@beinan
beinan force-pushed the feat/stats-retire-cold branch 2 times, most recently from 2493ed3 to 5df8dc3 Compare July 27, 2026 17:30
beinan added a commit to beinan/lance-context that referenced this pull request Jul 27, 2026
Stacked on lance-format#210.

Retirement (lance-format#210) removes cold experiments from the stats table, which is what
keeps a scan round proportional to active work. On its own it also removes them
from `GET /experiments`, which is only acceptable once they remain findable
another way. This closes that.

  default list  the stats table, i.e. experiments written recently enough not
                to have been retired. A flat list of every experiment ever
                created is neither renderable nor interesting at tens of
                thousands.

  search        the stats table, plus the registry for names it no longer
                holds. Cold matches are observed on demand.

  detail        on a stats miss, resolve through the registry and observe on
                demand rather than returning 404.

So retirement removes an experiment from the *default* view and never makes it
unfindable.

`observe_cold` deliberately does not write a stats row. Reading about a retired
experiment must not make it hot again, or browsing the UI would silently undo
retirement and the table would creep back toward holding everything -- 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.

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

Known gap: 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. Worth fixing, but as its own change
rather than a fourth concern in this one.

Co-Authored-By: Claude <noreply@anthropic.com>
Stacked on lance-format#209.

The stats table currently holds a row for every experiment that has ever
existed, and each one costs an open on every scan round. At the scale this
deployment is heading for — tens of thousands, the overwhelming majority
written once and never again — that is the remaining reason a round cannot stay
proportional to actual work.

An experiment with no writes for `STATS_COLD_RETIRE_SECS` (default 7 days) is
now dropped from the table. It stays in the registry and can still be observed
on demand; it simply stops costing a row and an open every round.

Retirement order is a correctness property, not an optimisation.

Both auto-sweeps read the stats table and nothing else, so an experiment absent
from it is invisible to them permanently. Dropping one that still has un-merged
MemWAL generations would strand them: the rows stay readable because the read
path unions the generations, but they are never folded into the base table,
read amplification never recovers, the `_mem_wal/{shard}/` directories are never
reclaimed, and no process is left that would notice. So retirement is:

  merge the WAL -> compact -> verify pending_wal_generations == 0 -> drop the row

`compact_files` deliberately leaves MemWAL generations alone, so compaction on
its own would not have been sufficient. Compaction is still done, because a
retired table is one nothing will come back to tidy.

Any step failing leaves the experiment in the table for the next round.
Refusing to retire is always safe; retiring early is not.

`STATS_COLD_RETIRE_SECS=0` disables retirement entirely. Every existing test
config sets it to 0, so they exercise the unchanged path.

Four tests, the important one asserting the shard is genuinely drained after
retirement rather than that the function returned success. Verified it is not
vacuous: removing the WAL-merge step makes it fail with exactly the stranding it
exists to prevent.

New: `master_stats_hot_experiments` (table size, now bounded rather than
tracking the registry), `master_stats_experiments_retired_total`,
`master_stats_retire_failures_total`.

Co-Authored-By: Claude <noreply@anthropic.com>
@beinan
beinan force-pushed the feat/stats-retire-cold branch from 5df8dc3 to 1ad7c3f Compare July 27, 2026 17:43
beinan added a commit to beinan/lance-context that referenced this pull request Jul 27, 2026
Stacked on lance-format#210.

Retirement (lance-format#210) removes cold experiments from the stats table, which is what
keeps a scan round proportional to active work. On its own it also removes them
from `GET /experiments`, which is only acceptable once they remain findable
another way. This closes that.

  default list  the stats table, i.e. experiments written recently enough not
                to have been retired. A flat list of every experiment ever
                created is neither renderable nor interesting at tens of
                thousands.

  search        the stats table, plus the registry for names it no longer
                holds. Cold matches are observed on demand.

  detail        on a stats miss, resolve through the registry and observe on
                demand rather than returning 404.

So retirement removes an experiment from the *default* view and never makes it
unfindable.

`observe_cold` deliberately does not write a stats row. Reading about a retired
experiment must not make it hot again, or browsing the UI would silently undo
retirement and the table would creep back toward holding everything -- 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.

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

Known gap: 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. Worth fixing, but as its own change
rather than a fourth concern in this one.

Co-Authored-By: Claude <noreply@anthropic.com>
@beinan
beinan merged commit 0b18cfb into lance-format:main Jul 27, 2026
9 checks passed
beinan added a commit to beinan/lance-context that referenced this pull request Jul 27, 2026
Stacked on lance-format#210.

Retirement (lance-format#210) removes cold experiments from the stats table, which is what
keeps a scan round proportional to active work. On its own it also removes them
from `GET /experiments`, which is only acceptable once they remain findable
another way. This closes that.

  default list  the stats table, i.e. experiments written recently enough not
                to have been retired. A flat list of every experiment ever
                created is neither renderable nor interesting at tens of
                thousands.

  search        the stats table, plus the registry for names it no longer
                holds. Cold matches are observed on demand.

  detail        on a stats miss, resolve through the registry and observe on
                demand rather than returning 404.

So retirement removes an experiment from the *default* view and never makes it
unfindable.

`observe_cold` deliberately does not write a stats row. Reading about a retired
experiment must not make it hot again, or browsing the UI would silently undo
retirement and the table would creep back toward holding everything -- 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.

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

Known gap: 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. Worth fixing, but as its own change
rather than a fourth concern in this one.

Co-Authored-By: Claude <noreply@anthropic.com>
beinan added a commit to beinan/lance-context that referenced this pull request Jul 27, 2026
Stacked on lance-format#210.

Retirement (lance-format#210) removes cold experiments from the stats table, which is what
keeps a scan round proportional to active work. On its own it also removes them
from `GET /experiments`, which is only acceptable once they remain findable
another way. This closes that.

  default list  the stats table, i.e. experiments written recently enough not
                to have been retired. A flat list of every experiment ever
                created is neither renderable nor interesting at tens of
                thousands.

  search        the stats table, plus the registry for names it no longer
                holds. Cold matches are observed on demand.

  detail        on a stats miss, resolve through the registry and observe on
                demand rather than returning 404.

So retirement removes an experiment from the *default* view and never makes it
unfindable.

`observe_cold` deliberately does not write a stats row. Reading about a retired
experiment must not make it hot again, or browsing the UI would silently undo
retirement and the table would creep back toward holding everything -- 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.

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

Known gap: 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. Worth fixing, but as its own change
rather than a fourth concern in this one.

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.

1 participant