Distributed REFRESH INDEX for incremental scalar index maintenance #788
ivscheianu
started this conversation in
Design Proposals
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
CREATE INDEXrebuilds every fragment, so keeping a scalar index current on an append heavy tablecosts the whole table each time. The only incremental path today is
Dataset.optimizeIndices, whichruns on one node; SQL
OPTIMIZEcompacts fragments and does not train indexes. This adds thedistributed counterpart.
The five sets everything is built from
A Lance index is a set of physical segments. Each declares a
fragment_bitmap, absent only onsegments predating coverage tracking, and a bitmap can name fragments that no longer exist, so
coverage only means anything against the live set.
For a segment that declares a bitmap, intersecting with
livereproduces core'seffective_fragment_bitmapexactly, so the two sides do not disagree about what an index covers. Asegment with no bitmap is the exception: it reads as covering nothing here, while core calls its
coverage unknown and accepts only a full replacement of it.
Flow
Pinned build, unpinned commit. Tasks open the dataset themselves, so without a pin each resolves
the latest version independently. Which fragments a segment covers is fixed by the driver's batch
either way; what diverges is what sits behind them. Each sibling would read its fragments at a
different version and stamp its segment with that version, and a fragment compacted away since planning
fails its task outright. Step 4 opens at latest on purpose, because the commit has to fit the table as
it is now, which is also why the index is re-resolved there rather than carried over from step 1.
Range mode
btreestays unpinned throughout. Its coverage comes from the fragment ids in thescanned rows, and the scan pins its own version through the catalog, so pinning the executor open to
V_planwould stamp a segment with a version older than the rows inside it.Batching
unindexedis split into exactlynum_segmentscontiguous runs of fragment ids, chosen so theheaviest run's row count is minimal.
Runs are contiguous because compaction groups two fragments only when the identical set of index
segments covers both.
Measured on four single fragment appends, with coverage committed directly so the layout is the only
variable, then
OPTIMIZE ... target_rows_per_fragment = 1000:{0,1,2,3}{0,1} {2,3}{0,2} {1,3}Interleaving is what freezes compaction, not the number of segments: contiguous coverage still
coalesces within each run. The last row is the limit accumulated refreshes approach, one segment per
refresh, which is why the docs ask for
OPTIMIZEbeforeREFRESH INDEXrather than after.Runs are balanced exactly, by binary searching the smallest row budget a contiguous packing can
respect. Greedy packing at a fixed budget uses the fewest runs, so the smallest feasible budget is the
optimal maximum, floored by the widest single fragment. If packing lands on fewer runs than asked for,
the rest split at their balance points, which only lowers the maximum.
What the commit does with an existing segment
A refresh only builds
unindexed, so on a quiet table its segments are disjoint from every existingsegment's live coverage. One that still covers a live fragment is therefore KEPT; one that covers none,
including the empty segment a deferred
train = falseindex leaves behind, is REMOVED, and that is whatlets a refresh populate a deferred index.
The tree covers segments that declare a bitmap. Core has two further rejections, for a segment with no
bitmap and for a change of index type, both refusing a partial replacement. Either command can reach a
rejection when the table moves underneath it, and core stays the authority.
Guards
DROP INDEXduring the build, otherwise silently undoneftsrefresh configured differently from the segments it joinsReporting after step 5 is a correction rather than a guard. A count taken before the commit is a
prediction: it reads the manifest its handle opened at, and Lance prunes coverage both for a fragment
that is gone and for one whose indexed field was rewritten under the same id, which no comparison of
ids can see. So the count is read back from what the commit returned.
Observability
SHOW INDEXESgains three columns, all from metadata it already fetches.indexed_percentnum_segmentssize_bytesQueries search every segment, so a growing
num_segmentsis a read cost in its own right as well as abrake on compaction.
Scope
In
train = falseleaves covering nothingSHOW INDEXESOut
CREATE INDEXa rebuild
from the
WITHclause and fall back to type defaultsOn that last one, for most methods a mismatch costs performance only, because each segment is queried
on its own.
ftsis the exception, it is read with one configuration for all of its segments, so therefresh compares what it built against the segments it would join and refuses a mismatch. Exposing
derive_index_params()through the Java binding would remove the caveat.Known gaps
Concurrent
DROP INDEX. Re-resolving the index and committing are separate transactions, and Lancedoes not treat a concurrent same name drop as a conflict, so a drop landing between them is undone. No
connector side fix exists: the commit takes no expected predecessor,
CommitBuilderhas no conflictpolicy, and a compensating drop carries the same race. Needs
lance#6806. Pinned by a disabled regression.
Partially covered
zonemapdrops rows. A predicate on the indexed column can return fewer rowsthan the table holds, while an unfiltered
COUNT(*)stays correct. This one is ours, not core's: zonestatistics come from the committed segments only, and the driver's fragment pruner builds its surviving
set purely from the fragments those zones name, with no gate on coverage, so a fragment no segment
covers contributes no zones and is pruned away. It reproduces from
CREATE INDEXplus an append, so itpredates this proposal, but
indexed_percentis what makes it visible, and a coverage gate on thepruner would close it.
btree,bitmapandbloomfilterstay complete while partially covered.Plan
Six PRs against
main, tracked in #789. The first three are pre-existing bugs and land independentlyof this proposal.
WITHclause option names at the parser boundarySHOW INDEXESALTER TABLE ... REFRESH INDEX#784 keeps the whole change set until 1 to 5 land, since it does not compile without them, then rebases
to the
REFRESH INDEXslice.All reactions