Skip to content

feat(lance-table): Bε-tree manifest prototype for backfill scaling (#7499)#7848

Draft
jackye1995 wants to merge 7 commits into
lance-format:mainfrom
jackye1995:jack/bepsilon-manifest-proto
Draft

feat(lance-table): Bε-tree manifest prototype for backfill scaling (#7499)#7848
jackye1995 wants to merge 7 commits into
lance-format:mainfrom
jackye1995:jack/bepsilon-manifest-proto

Conversation

@jackye1995

Copy link
Copy Markdown
Contributor

Prototype for the Bε-tree manifest counter-proposal in discussion #7499.

Instead of rewriting the full fragment list on every commit, the manifest root buffers fragment_actions (AddFragment/RemoveFragment/AddDataFile/RemoveDataFile/AddDeletionFile) in an ε-buffer and flushes batches into tabular Lance-file child nodes routed by fragment-id range. This targets the 2D (fragments × columns) growth from add-column / backfill, which is the real manifest-scaling problem.

What's here

  • protos/betree.proto — prototype root/child/action messages (merge into the lance.table pb module).
  • rust/lance-table/src/betree/ — root (protobuf: child refs + inline ε-buffer), children as Lance v2 files, action set, commit → overflow → parallel flush, materialize / cold-open.
  • Honest flat-manifest baseline via the real write_manifest_file_to_path / read_manifest.
  • benches/betree_backfill.rs — criterion bench (IO-tracked), runs local / S3 Standard / S3 Express.

Benchmark (N=1M fragments, realistic Lance data-file names, c7i.16xlarge in us-east-1)

Realistic fine-grained backfill (F fragments/commit → N/F commits, as GPU-computed embeddings trickle in). Flat rewrites the full ~82 MiB manifest on every commit regardless of F:

metric flat Bε-tree (ε=0.5 MiB)
per-commit write 82 MiB ~0.26 MiB
full backfill, F=10 (100K commits) ~7.8 TiB 25.5 GiB (314× less)
mean commit latency (Std / Express) 879 / 607 ms 73 / 16 ms (12× / 37×)

Scope (draft)

Two-level, fixed-partition tree. Child-level ε-buffers (3+ level recursion) and byte-based split/merge self-balancing are in progress. Not for merge — this exists to inform the #7499 design.

Prototype for discussion lance-format#7499: the manifest root buffers fragment actions
(AddFragment/RemoveFragment/AddDataFile/RemoveDataFile/AddDeletionFile) in an
ε-buffer and flushes batches into tabular Lance-file child nodes routed by
fragment-id range, instead of rewriting the full fragment list on every commit.
Adds an honest flat-manifest baseline (real write_manifest_file_to_path /
read_manifest) and a criterion bench (betree_backfill) run against S3 Standard
and S3 Express.

Scope: two-level fixed-partition tree; child-level ε-buffers (3+ level
recursion) and byte-based split/merge self-balancing are follow-ups.
@github-actions

Copy link
Copy Markdown
Contributor

Important

This PR touches the Lance format specification.

Substantive changes to the format specification — the .proto definitions
and the spec docs under docs/src/format/ — require a PMC vote before merge.
Minor edits such as typo fixes, wording, or formatting are excluded; use your
judgment.

If this is a meaningful format change:

  • Start a vote following the Lance community voting process.
    Format specification modifications need 3 binding +1 votes (excluding the
    proposer), held on GitHub Discussions, with a minimum voting period of 1 week.
  • Once the vote passes, link the completed vote in this PR. It should not be
    merged until the vote is linked.

@github-actions github-actions Bot added A-python Python bindings A-java Java bindings + JNI A-deps Dependency updates A-format On-disk format: protos and format spec docs labels Jul 19, 2026
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 8694aef0-8618-4d80-a625-a238adb0dbf5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the enhancement New feature or request label Jul 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

ACTION NEEDED
Lance follows the Conventional Commits specification for release automation.

The PR title and description are used as the merge commit message. Please update your PR title and description to match the specification.

For details on the error please inspect the "PR Title Check" action.

@codecov

codecov Bot commented Jul 19, 2026

Copy link
Copy Markdown

Replace the two-level fixed-partition prototype with a real recursive Bε-tree
per the write-optimization literature (Bender et al.; BetrFS; TokuDB):

- msn-ordered messages buffered per node (newest-wins), flushed to the fullest
  child gated at B/16 (the amortization threshold);
- byte-based leaf split and fanout-based internal split with root grow (+height);
- borrow/coalesce merge on underflow with root shrink (-height); type-aware
  underflow (leaves by bytes, internal by child count);
- immutable copy-on-write nodes: leaves are Lance files, internal/root are
  protobuf; every touched root-to-leaf node is rewritten and the root repoints.

Ideal byte-based defaults: epsilon=1/2, ~10 MiB nodes, fanout 16.
- Decompose the leaf Lance schema from one opaque DataFragment protobuf blob per
  fragment into one row per data file with each field its own column (path,
  versions, size, base_id, field ids). Lets Lance compress each column
  independently (RLE on versions, dictionary/FSST on paths) — ~2x smaller leaves
  than raw protobuf at scale (confirms Xuanwo's columnar observation).
- Add streaming bootstrap (bootstrap_generate) that packs leaves from a fragment
  generator without holding the whole fragment list, so the tree can reach
  billion-data-file scale within a bounded memory budget.
- Add an at-scale bench mode (BASE_FILES_PER_FRAGMENT): bootstrap N fragments
  each with many data files, report leaf compression vs the (infeasible) flat
  manifest, tree height, per-commit write, and cold-open — up to ~1B data files.
A full backfill column at N=1M is up to 100K commits; a 200-commit sample is
enough to characterize per-commit write at scale and keeps the billion-file
run tractable. Count added files exactly for the cold-open verification.
Add flush-depth tracking (CommitStats.max_flush_depth) and an internal-node-size
report (BeTree::internal_node_sizes), plus a DEEP_FLUSH_COMMITS bench mode that
drives a sustained backfill cascading flushes into internal nodes. At B=10 MiB
this reaches max_flush_depth 6 with internal buffers filling to ~9.8 MiB — i.e.
internal nodes are only tiny in the cold/bootstrap tree; under sustained writes
they carry near-full ε-buffers, and per-commit write is bounded by ~height x B.
… nodes

Two fixes so B and fanout are the only tuning knobs and both are safe at any value:

- min_flush was hardcoded to B/16, silently assuming fanout 16. At higher fanout
  the ε-buffer spreads thinner than the gate so flushes never fire and the tree
  degrades to split-only. Derive it as B/fanout (BeTreeConfig::min_flush_bytes),
  the natural "fair share" of a full buffer across fanout children; keep an
  optional override for tests. At fanout 16 this is unchanged (B/16).

- A fully-emptied leaf/internal node was kept with a phantom ChildRef whose
  min_key=0 (unwrap_or(0) on an empty range), which sorts to the front and breaks
  child_index_for's binary search — low-key removes misroute to it and no-op,
  resurrecting deleted fragments. Aggressive flushing (small min_flush) exposed
  this. Fix: drop an emptied node from its parent. Add a regression test plus a
  sparse-delete merge test that exercises real coalescing.
Rename the two tuning knobs to say exactly what they are:
- target_node_bytes -> max_node_bytes (the byte size at which a node splits)
- fanout -> max_children_per_node (the branching factor: split above it,
  merge below a quarter of it)
- ChildRef.fanout_used -> num_children (a node's current direct-child count)

Pure rename + doc cleanup (proto fields, config, tree, bench, tests); no
behavior change. The FANOUT / NODE_SIZE_MB bench env vars are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-deps Dependency updates A-format On-disk format: protos and format spec docs A-java Java bindings + JNI A-python Python bindings enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant