Skip to content

feat: balance scalar index fragment batches - #758

Merged
majin1102 merged 2 commits into
lance-format:mainfrom
ddupg:feat/balance-index-fragment-batches
Aug 24, 2026
Merged

feat: balance scalar index fragment batches#758
majin1102 merged 2 commits into
lance-format:mainfrom
ddupg:feat/balance-index-fragment-batches

Conversation

@ddupg

@ddupg ddupg commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Balance scalar index fragment batches using fragment row counts.
  • Keep assignments deterministic and document the non-contiguous batching behavior.

Testing

  • Spark 3.5 / Scala 2.12: AddIndexTest (48 tests)
  • Scala 2.12 and 2.13: IndexUtilsTest (26 tests each)

Closes #757

@github-actions github-actions Bot added the enhancement New feature or request label Aug 11, 2026
@ddupg
ddupg marked this pull request as ready for review August 11, 2026 07:38
@ddupg
ddupg force-pushed the feat/balance-index-fragment-batches branch from 80be25d to 7c79a7a Compare August 11, 2026 07:42

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: approve.

The change addresses count-only task skew at the planner boundary with deterministic largest-first assignment by live row count, while preserving disjoint exact-once fragment coverage and the existing segment commit path. This is preferable to contiguous count slicing because its worst-case behavior no longer depends on fragment ordering.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 11, 2026
@ddupg
ddupg force-pushed the feat/balance-index-fragment-batches branch from 7c79a7a to 13f9216 Compare August 20, 2026 08:13
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 20, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: approve.

The change addresses count-only task skew with deterministic largest-first assignment by live row count while preserving disjoint exact-once fragment coverage and the existing segment commit path. The current boxing simplification preserves those semantics across the supported Scala variants.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 20, 2026

@majin1102 majin1102 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@majin1102
majin1102 merged commit 382fb05 into lance-format:main Aug 24, 2026
13 of 20 checks passed
ivscheianu added a commit to ivscheianu/lance-spark that referenced this pull request Aug 26, 2026
REFRESH INDEX could commit an FTS index that no full-text query could read.
Segments carry their build configuration, and an inverted index is read with
one configuration for all of them, so a refresh that did not repeat the
original options built segments Lance accepted at commit and then refused at
read: "inconsistent inverted index details across segments". SHOW INDEXES
reported full coverage throughout. The refresh now compares the details of
the segments it built against the ones they would join and fails before
committing, for index types whose read path requires them to agree. The
comparison is on what was built rather than what was requested, and the
segments are still uncommitted, so a rejection leaves the index untouched.
Lance exposes no Java contract for reading a built index's parameters back --
only optimizeIndices, which derives them internally and runs on a single node
-- so options still come from the WITH clause, and the docs now say which
methods that matters for.

Refreshing an index named <column>_idx failed on every executor task. The
segment build let Lance derive its own name, which is exactly that, so the
name-collision pre-check fired against the index being refreshed. That is the
name Lance assigns an unnamed index, and the name used throughout the docs.
Naming the segment after the index it will join, with replace set, skips a
check that means nothing for an uncommitted build; the driver's single
commitExistingIndexSegments transaction still decides what to keep. Re-running
CREATE INDEX with such a name failed the same way, and the explicit name also
moves the genuine "already exists with different fields" check ahead of the
distributed build instead of after it.

Uppercase WITH-clause options were silently ignored. ANTLR reports identifier
text as written and every consumer matches lower-case literals, so
WITH (NUM_SEGMENTS = 2) built with default parallelism and WITH (TRAIN = false)
refreshed instead of being rejected. Option names are now normalized at the
parser boundary, which fixes CREATE INDEX, OPTIMIZE and VACUUM the same way.

Fragment batches are contiguous runs again. Lance can only compact fragments
covered by an identical set of index segments, so the row-balanced assignment
added in lance-format#758 left every adjacent pair under a different segment and made
OPTIMIZE a no-op for the whole table. Balancing by row count and covering
contiguous runs are compatible: boundaries are placed where an even split
would fall, moved to the nearest fragment, which stays deterministic and still
yields exactly num_segments batches.

The commit-time liveness check is replaced by coverage accounting. A fragment
leaves the manifest either because its rows moved or because they were all
deleted; only the first leaves data unindexed, and in both cases the segments
for what remains are correct. Discarding a finished distributed build was the
wrong response, and it made a routine concurrent DELETE fatal. The commit now
restricts declared coverage to the live fragments, warns about the rest,
reports the count it actually achieved, and fails only when nothing would be
covered. One rule covers both build paths, so range-mode BTree is no longer
judged against a fragment list its coverage does not come from.

Range-mode BTree also goes back to unpinned read options. It reads the table
back through the catalog, which resolves its own version, so pinning made the
segment record a dataset version older than its own contents.

A DROP INDEX during a refresh no longer resurrects the index. The commit
re-resolves the index on a fresh handle, because committing segments under a
name Lance no longer knows creates that index rather than extending it.

Fragment enumeration goes through getFragmentStatistics(), which returns
primitive arrays, instead of getFragments(), which materializes a Java object
per fragment and per data file on every driver-side pass.

Docs: scope the compaction guidance to the methods that actually lose
coverage, record that a partially covered zonemap index can return incomplete
results rather than merely scanning uncovered fragments, describe how
accumulated segments limit OPTIMIZE, and drop the claim that Lance does not
expose a built index's parameters.
ivscheianu added a commit to ivscheianu/lance-spark that referenced this pull request Aug 26, 2026
IndexUtils.batchFragments assigned fragments to segments with a least-loaded
heap. For equal-sized fragments that deals them out round-robin, so segment
coverage interleaved fragment ids: four equal fragments with num_segments=2
produced segments covering [0,2] and [1,3].

Lance's compaction planner only groups fragments covered by an identical set of
index metadata entries, and every segment is a separate entry. Interleaved
coverage therefore puts every adjacent pair of fragments in a different group,
so OPTIMIZE coalesces nothing at all on an indexed table. Four single-fragment
appends survive `optimize ... with (target_rows_per_fragment = 1000)` unchanged
when a two-segment index covers them, while the same fragments compact with one
contiguous segment, and compact with no index.

Batching was contiguous before lance-format#758, which traded that away for row balance.
The two are compatible, so this restores contiguity without giving the balance
back: the partition minimising the heaviest batch is found exactly, by binary
searching the smallest row budget a contiguous packing can respect. For a fixed
budget, extending each run as far as it will go uses the fewest runs, so the
smallest feasible budget is the optimal maximum. Packing there can use fewer
runs than were asked for, which would cost parallelism, so the remainder are
split at their balance points; a split only lowers the heaviest run, so
optimality survives it.

A heuristic that placed each boundary where an even split would fall was tried
first and rejected: it cuts after indivisible leading fragments have already
overshot their shares, which on [95, 93, 89, 8, 1, 4, 74, 88, 38] across six
segments leaves a batch of 162 where fragment 0 alone forces 95. Both that case
and a comparison against every contiguous partition are now regressions.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Balance fragment batches for distributed scalar index builds

2 participants