Skip to content

Add create_index (fragment-parallel) / optimize_indices / compact table procedures - #188

Open
fl0-m wants to merge 3 commits into
lance-format:mainfrom
fl0-m:feat/create-index-table-procedure
Open

Add create_index (fragment-parallel) / optimize_indices / compact table procedures#188
fl0-m wants to merge 3 commits into
lance-format:mainfrom
fl0-m:feat/create-index-table-procedure

Conversation

@fl0-m

@fl0-m fl0-m commented Jul 3, 2026

Copy link
Copy Markdown

Summary

Implements #187: ALTER TABLE ... EXECUTE table procedures that build and maintain Lance indexes directly from Trino, without needing the Python/Rust SDK out-of-band. This now includes both phases sketched in the issue, though phase 2 landed in a different (and, after a correction below, differently-implemented) shape than originally planned — see below.

-- Build an FTS (inverted) index
ALTER TABLE lance.db.docs EXECUTE create_index(
    column => 'body',
    index_type => 'fts',
    base_tokenizer => 'simple',
    language => 'English',
    stem => true,
    remove_stop_words => true);

-- Incrementally catch up newly-inserted rows without a full rebuild
ALTER TABLE lance.db.docs EXECUTE optimize_indices();

-- Compact without forcing an index rebuild
ALTER TABLE lance.db.docs EXECUTE compact(defer_index_remap => true);

All three procedures are TableProcedureExecutionMode.coordinatorOnly() — they call directly into org.lance.Dataset#createIndex / #optimizeIndices / #compact (already on the classpath via the existing lance-core:7.0.0 dependency), the same APIs lance-spark's own CREATE INDEX uses. No new Lance dependency.

create_index currently only supports index_type => 'fts'; other Lance scalar index types (btree/zonemap/bitmap/ngram) fit the same shape and are a natural, small follow-up rather than part of this PR.

Changes

  • LanceTableProcedures — registers the three procedures + their ALTER TABLE ... EXECUTE argument schemas via Connector#getTableProcedures().
  • LanceMetadata#getTableHandleForExecute / #executeTableExecute — parses arguments into per-procedure handles and dispatches to Dataset.
  • LanceTableExecuteHandle / LanceProcedureHandle / Lance{CreateIndex,OptimizeIndices,Compact}Handle — plain Java records implementing the SPI handle interfaces, with @JsonTypeInfo/@JsonSubTypes on the polymorphic procedureHandle field (mirrors Iceberg's IcebergTableExecuteHandle/IcebergProcedureHandle).
  • create_index fragment-parallel build (see "Phase 2" below): when a fresh (train => true) build spans more than one fragment, LanceMetadata#executeCreateIndex splits the fragments into batches sized to Runtime.getRuntime().availableProcessors() and builds one index segment per batch concurrently, each on its own independently-opened Dataset handle (buildIndexSegmentsInParallel takes tablePath/storageOptions, not a shared Dataset — see the correction below for why), then consolidates via mergeExistingIndexSegments and commits once via commitExistingIndexSegments.
  • TestLanceTableExecuteProcedures — end-to-end coverage: builds an FTS index, verifies duplicate-without-replace fails and replace => true succeeds (both single-fragment and fragment-parallel paths), validates required/invalid arguments, exercises insert → optimize_indices → insert → compact(defer_index_remap => true), exercises the fragment-parallel build+merge across 4 separately-inserted fragments with row-level correctness checks, and verifies base_tokenizer => 'ngram' works over mixed Chinese/Japanese/English text (script-agnostic tokenization, doesn't rely on whitespace word boundaries).

Two non-obvious gotchas worth flagging for reviewers

1. Coordinator-only procedures still need JSON-serializable handles. Even a fully coordinatorOnly() procedure needs its ConnectorTableExecuteHandle to be Jackson-serializable: Trino still builds a SimpleTableExecuteNode into the query plan and serializes it as part of the (coordinator-local) TaskUpdateRequest. My first pass used plain classes with fluent no-get-prefix accessors and it failed at runtime with No serializer found for class LanceTableExecuteHandle ... no properties discovered — Jackson's default bean introspection doesn't pick those up. Switching to Java records (same pattern Iceberg uses for IcebergTableExecuteHandle) fixed it. This only surfaces at runtime against a live DistributedQueryRunner, not in a TestLanceMetadata-style unit test that calls getTableHandleForExecute/executeTableExecute directly without ever serializing anything — so it would have shipped broken without the integration-style test.

2. IndexOptions.withIndexUUID's Javadoc is misleading for this use case. It says: "A UUID to use for fragment-level distributed indexing — multiple fragment-level indices need to share UUID for later merging." Passing one shared UUID across parallel createIndex(fragmentIds=...) calls actually fails at mergeExistingIndexSegments with duplicate segment uuid ... for index. Leaving withIndexUUID unset (so each call gets its own auto-generated UUID) is what works. Separately, each per-fragment createIndex call validates the target index name against the dataset's currently committed indices immediately — even though it doesn't commit anything itself — so replace => true has to drop the existing index before building segments, not just before the final commitExistingIndexSegments call, or every batch fails with "Index name already exists." Both are called out in code comments at the call sites.

Correction: the original "Phase 2" reasoning was partly wrong — fixed in a follow-up commit

The first version of this PR parallelized fragment builds by sharing one Dataset object across threads, on the (unverified) assumption that concurrent createIndex(fragmentIds=...) calls against a shared handle would scale fine since each only takes a read lock. A reviewer asked me to actually verify this instead of taking it on faith, and benchmarking (real data, 1M rows, forced parallelism 1/4/8) showed the shared-Dataset version was slower than not parallelizing at all, and got worse with more threads:

parallelism shared Dataset (original) isolated Dataset per thread (now)
1 1.15s 0.83s
4 1.11s 0.64s
8 1.04s 0.66s

This was not because Lance's createIndex is internally parallelized — I checked rust/lance-index/src/scalar/inverted/builder.rs (the actual FTS build code, ~4,300 lines) and there's no rayon/tokio::spawn/thread::spawn anywhere in it. The real cause was contention from sharing one Dataset's allocator/native handle across threads. The fix (now in this PR): each parallel batch opens its own independent Dataset.open(tablePath, ...) handle instead of sharing one — matching what genuinely separate processes would naturally do, with no shared in-process state. Full regression suite still passes with this change.

What about genuine worker-node distribution (not just coordinator threads)?

Also investigated, since the isolated-Dataset result raised the natural follow-up: if independent handles scale better, why not actual separate Trino worker processes? Two things came out of that:

  • The Trino-SPI side is solvable, via either (a) reusing ALTER TABLE ... EXECUTE's existing distributed mode with a placeholder-scan trick (the connector's own page source/sink control how expensive "reading" the required columns actually is, so it can do the real createIndex work as a worker-side side effect and only nominally satisfy the engine's full-column-scan plan-shape requirement), or (b) a ConnectorTableFunction-based fallback, precedented by Trino's own SequenceFunction.
  • The actual blocker is upstream in lance-core, not in Trino or this connector: org.lance.index.Index (the object createIndex(fragmentIds=...) returns, needed for the coordinator-side mergeExistingIndexSegments/commitExistingIndexSegments step) doesn't implement Serializable and has a private constructor, so it can't cross a JVM process boundary at all. This turns out to be a known, currently-unresolved limitation — see Unified API or Separate APIs for Distributed Index Building? lance#5359, an open RFC from the Lance maintainers describing exactly this ("merge_metadata ... is invoked at the Python/Java layer by directly calling into Index, and there isn't a unified entry point for this at the Rust level").

Full write-up (both Trino-side designs, the blocker, and a concrete large-scale motivating scenario) is in #189. Not blocking this PR — coordinator-local parallelism (now fixed above) is what's shipping here.

optimize_indices and compact are unchanged from the coordinator-only design — I don't think they're good parallelization candidates: optimizeIndices is documented as single-node even in Lance's own Python/Ray ecosystem (Ray's distributed indexing uses a separate lower-level create_fragment_index API, not optimizeIndices), so there's no distributed primitive to delegate to without reimplementing unindexed-fragment detection ourselves.

I've updated #187 with the same corrections so the issue reflects what actually shipped rather than the original (partly incorrect) plan.

🤖 Generated with Claude Code

Adds ALTER TABLE ... EXECUTE support for building and maintaining Lance
scalar/FTS indexes directly from Trino, via org.lance.Dataset APIs the
connector already depends on (lance-core:7.0.0):

- create_index(column, index_type, ...) - builds an inverted (FTS) index
- optimize_indices(...) - incrementally indexes newly appended fragments
- compact(defer_index_remap => true) - compaction that doesn't force an
  index rebuild, via Lance's Fragment Reuse Index

All three are coordinator-only (TableProcedureExecutionMode.coordinatorOnly()):
none of them read or write table data through Trino's split/page pipeline,
they call directly into the Lance dataset the same way lance-spark's own
CREATE INDEX does. See lance-format#187 for the full design rationale, including why
this is scoped to coordinator-only for now and what would be needed to
delegate index builds to workers for very large tables.

Closes lance-format#187
Phase 2 follow-up to the previous commit. When a fresh (train=true) index
build has more than one fragment, split the fragments into batches and
build one index segment per batch in parallel on the coordinator, then
merge and commit as a single logical index:

  dataset.createIndex(fragmentIds=batch)   // one per batch, read-locked,
                                            // does not commit
  dataset.mergeExistingIndexSegments(segments)
  dataset.commitExistingIndexSegments(name, column, [merged])

This does not distribute across Trino worker nodes. Trino's distributed
ALTER TABLE EXECUTE machinery (TableProcedureExecutionMode.distributedWith
FilteringAndRepartitioning) fixes the scanned columns to the table's real
columns at analysis time (see BeginTableWrite#findTableScanHandleForTable
Execute in trino-main) - it's built for procedures like Iceberg's OPTIMIZE
that genuinely read and rewrite full rows. Lance's createIndex already
reads fragment data natively and doesn't need Trino to also move that data
through the page pipeline, so using that machinery here would mean paying
for a full-table read just to throw the pages away. Parallelizing across
the coordinator's cores gets the CPU-bound tokenize/build speedup without
that redundant I/O.

Two non-obvious things learned from the native errors while wiring this up
(both now called out in code comments):
- Despite IndexOptions.withIndexUUID's Javadoc ("multiple fragment-level
  indices need to share UUID for later merging"), passing one shared UUID
  across parallel createIndex calls fails at mergeExistingIndexSegments
  with "duplicate segment uuid" - each batch needs its own auto-generated
  UUID (leave withIndexUUID unset).
- Each per-fragment createIndex call validates the target index name
  against the dataset's currently committed indices immediately, even
  though it doesn't commit anything - so replace => true has to drop the
  existing index before building segments, not just before the final
  commitExistingIndexSegments call.

TestLanceTableExecuteProcedures#testCreateIndexParallelizesAcrossMultiple
Fragments exercises this against 4 separately-inserted fragments and
verifies row-level correctness through the merge. Full regression suite
(TestLanceMetadata, TestLancePlugin, TestLanceTableHandle,
TestLanceDirectorySingleLevelConnectorTest incl. its ~370 inherited
BaseConnectorTest cases) still passes.
@fl0-m fl0-m changed the title Add create_index / optimize_indices / compact table procedures (coordinator-only) Add create_index (fragment-parallel) / optimize_indices / compact table procedures Jul 3, 2026
The previous commit shared one Dataset object across all parallel
createIndex(fragmentIds=...) calls. Benchmarking (real data, 1M rows,
forced parallelism 1/4/8) showed this was consistently slower than the
plain non-split path, and got worse with more threads:

  parallelism=1  shared=1.15s  isolated=0.83s
  parallelism=4  shared=1.11s  isolated=0.64s
  parallelism=8  shared=1.04s  isolated=0.66s

This was NOT because Lance's createIndex is internally parallelized -
lance-index's inverted-index builder (rust/lance-index/src/scalar/
inverted/builder.rs, ~4300 lines) has no rayon/tokio/thread concurrency
at all. The actual cause was contention from sharing one Dataset's
allocator/native handle across threads.

buildIndexSegmentsInParallel now takes (tablePath, storageOptions, ...)
instead of a shared Dataset, and each batch opens its own independent
Dataset.open(tablePath, ...) handle, matching what N separate worker
processes would naturally do, with no shared in-process state. Full
regression suite (267 inherited BaseConnectorTest cases plus all
procedure tests) still passes.
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