Skip to content

feat(master): rebuild the id ZoneMap index after every compaction that rewrote fragments - #273

Merged
beinan merged 1 commit into
lance-format:mainfrom
beinan:feat/index-after-compact
Sep 29, 2026
Merged

beinan merged 1 commit into
lance-format:mainfrom
beinan:feat/index-after-compact

Conversation

@beinan

@beinan beinan commented Sep 28, 2026

Copy link
Copy Markdown
Collaborator

Problem

TaskKind::IndexId (a ZoneMap scalar index on the base table's id column) has existed since #194, but nothing ever enqueues it except a manual POST /api/v1/tasks. In production that means zero IndexId tasks in 15 h across ~10k stores, so point lookups on id scan every fragment.

Fix

A compaction that added fragments enqueues an IndexId for the same target with depends_on = [compaction task id]:

  • the ZoneMap is per-fragment min/max, so compaction is exactly the event that invalidates it;
  • the dependency keeps the index behind the compaction in the per-target lock order and lets it queue like any other task rather than racing the fan-out;
  • nothing is enqueued when compaction rewrote nothing, so an idle store does not accumulate index rebuilds.

compact_inner now returns the CompactionMetrics instead of a formatted string so the caller can make that decision. New flag INDEX_AFTER_COMPACTION (default true) turns it off. Enqueue failure is logged and does not fail the compaction, which is already committed.

Verification

  • New etcd-backed test compaction_that_rewrites_fragments_enqueues_id_index: a 4-fragment store is compacted; an IndexId with the compaction as its dependency appears and runs to Done with the expected detail; a second compaction that rewrites nothing enqueues no second index.
  • Full master suite with etcd (--include-ignored): 72 passed. Clippy -D warnings, fmt clean.

🤖 Generated with Claude Code

…t rewrote fragments

IndexId has been a task kind since lance-format#194 but nothing ever enqueued it
except a manual POST /tasks, so in practice no store had an id index
(prod: 0 IndexId tasks in 15 h across 10k stores). The ZoneMap is
per-fragment min/max, and compaction replaces exactly those fragments,
so the natural trigger is a compaction that added fragments: enqueue an
IndexId that depends on the compaction task. The dependency reuses the
per-target lock ordering and lets the index wait behind the queue like
any other task. Off when nothing was rewritten.

INDEX_AFTER_COMPACTION (default true) turns it off.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@beinan
beinan merged commit 0ed56da into lance-format:main Sep 29, 2026
10 checks passed
beinan added a commit that referenced this pull request Sep 29, 2026
… workers merge (#277)

## Problem

At 2026-09-29 22:12 UTC, 18 of 20 production workers were OOMKilled
within 66 seconds. The merge sweep had queued 184 targets (normal:
20–50) and every worker was running ~24 concurrent `merge-wal` requests.

The memory was not the WAL rows (the #265 budget bounds those). It was
`merge_insert`. Lance's merge_insert joins the source rows to the base
table on the key, and it probes a scalar index only when that index's
plugin `provides_exact_answer()`; otherwise
`create_full_table_joined_stream` reads the **whole base table** into a
DataFusion hash join. Our `id` index was a **ZoneMap**, whose plugin
returns `false`, so every merge of every store did the full join. The
largest affected store has a 1.95 TB, 5.8M-row base table: each
64-generation merge (a few hundred rows) read 2 TB.

That also explains the trend line: pending generations went from 17k to
**426k** in 24 h, 40 stores over 4k pending. Merge cost scaled with
base-table size rather than with the rows merged, so the large stores
could not be drained, so more merges queued, so more full joins ran
concurrently.

#273 (index after compaction) did not help because it built a ZoneMap.

## Fix

- `create_key_zonemap_index` → `create_key_btree_index`
(`IndexType::BTree`). New `has_key_btree_index()` says whether the
exact-answer index is present; a ZoneMap under the same name does not
count.
- A `merge-wal` task on a rollout store without the BTree builds it
before fanning out to the workers (`INDEX_BEFORE_MERGE`, default on; one
manifest read when already present). This is what rescues the stores
already in trouble: the very next merge probes. #273 keeps the index
current after each compaction.
- Master detail string and metrics renamed accordingly;
`master_merge_wal_index_built_total` counts pre-merge builds.

A BTree on a string `id` is larger than a ZoneMap (it stores every key),
tens of MB for 5.8M rows. That is the cost of a merge that reads MBs
instead of TBs.

## Verification

- **`merge_insert_probes_the_id_btree_instead_of_scanning`** (core):
builds a store, then asks Lance's `MergeInsertJob::explain_plan` which
path a merge would take. With no index it renders a plan containing
`HashJoin`; **with a ZoneMap it still renders `HashJoin`**; with the
BTree it refuses with the scalar-index `NotSupported` (explain only
renders the full-scan plan), which is the observable signal for the
indexed path. A real merge through the index then succeeds.
- `create_id_btree_index_builds_and_is_idempotent`: asserts
`has_id_btree_index()` false before / true after, idempotent rebuild.
- `merge_wal_builds_the_id_btree_first` (master, etcd): a merge on an
unindexed store leaves a BTree behind; a second merge does not bump the
version.
- Master suite `--include-ignored` 75/75; `storage_reliability` 10/10;
clippy `-D warnings`; fmt.

## Rollout note

Prod is holding on `MERGE_WAL_CONCURRENCY=1` (env) since the incident.
Once this ships, large stores get a BTree on their first merge, after
which merge memory is independent of base-table size and the concurrency
can go back up.

🤖 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