Skip to content

Commit 650cf90

Browse files
committed
feat(sql): support USING zonemap in CREATE INDEX
Adds `ALTER TABLE ... CREATE INDEX ... USING zonemap (col)` end-to-end with a properly distributed multi-segment build. Two production-side changes: 1. IndexUtils now recognises "zonemap" as a method name in both directions (buildIndexType → IndexType.ZONEMAP, buildScalarIndexParamType → "zonemap") with case-insensitive lookup. 2. AddIndexExec routes ZONEMAP through a new runZonemapDistributed path. Unlike the FragmentBasedIndexJob shared-UUID pattern (which BTree uses correctly because per-partition writes use distinct file names — part_<id>_page_data.lance, part_<id>_page_lookup.lance — and a merge step consolidates them), ZoneMap writes a fixed `zonemap.lance` filename and has no merge step. Sharing one UUID across N executor tasks would race on the same object-store path; only one fragment's data would survive. Each Spark task now calls dataset.createIndex with withFragmentIds=[id] and NO withIndexUUID. The lance-core JNI takes the execute_uncommitted path (skip_commit = fragment_ids.is_some()) and generates a fresh per-task UUID, so each task writes to its own indices_dir/<uuid>/zonemap.lance directory. The driver collects all N per-task ZonemapFragmentResult values (uuid, fragmentId, indexDetails, indexVersion, createdAt) and commits them as a single AddIndexOperation transaction with N IndexMetadata entries sharing the index name. lance-core's existing read-side infrastructure — describe_indices chunking by name, getZonemapStats iterating load_indices_by_name and reading each segment's zonemap.lance — handles the multi-segment shape transparently. mergeIndexMetadata is intentionally skipped for ZONEMAP: there is no per-fragment file consolidation needed, and lance-core's merge_index_metadata has no ZoneMap arm anyway (would throw "Unsupported index type"). Supporting refactors: - Extract resolveFieldIdsOrThrow(dataset, columns) — shared by the parallel path's post-build commit and the new distributed ZoneMap path. Single source of truth for the "Cannot find index column in Lance schema" error. - Extract extractNamespaceInfo(lanceDataset, readOptions) — shared between createIndexJob and runZonemapDistributed. - ZonemapFragmentTask wraps execute() in try/catch that re-throws with fragment-id context, preserving the specific exception subclass (IllegalArgumentException, IllegalStateException, RuntimeException) so callers matching on type still match. - Driver-side decoding of per-task results wraps decode failures with task-index context so deploy-skew / serialization issues are diagnosable. - runZonemapBuild validates that createdIndex.indexDetails() is non-empty, mirroring the parallel path's extractIndexBuildResult guard. Tests: IndexUtilsTest (6 cases) — symbol-mapping unit tests for buildIndexType / buildScalarIndexParamType: forward and reverse mappings, case-insensitive lookup, unknown-method rejection with message substring assertions. BaseAddIndexTest additions (5 zonemap cases): - testCreateZonemapIndex — end-to-end USING zonemap + strict per-fragment coverage assertion (every indexed fragment must contribute ≥1 zone). - testCreateZonemapOnNonExistentColumn — fail-fast IllegalArgumentException with "Cannot find index column" message substring. - testCreateZonemapOnStringColumn — strict assertion that every zone's min/max is a non-null String (catches string-codec regressions). - testCreateZonemapWithZoneSize — smoke test for `with (zone_size=N)` parameter forwarding through IndexUtils.toJson. - testZonemapDistributedCommitShape — locks the multi-segment commit invariants: exactly one IndexMetadata per fragment, every segment has a distinct UUID, every segment's fragment-bitmap is a singleton, the union of segment fragment-bitmaps equals the indexed fragment set, all segments share one field-id list. A regression to shared-UUID single-segment commits — the original race — would fail every assertion in this test. Verified end-to-end against lance-core 6.0.0-rc.2 (current upstream/main). Closes #512. Closes #514.
1 parent 4332b60 commit 650cf90

3 files changed

Lines changed: 519 additions & 24 deletions

File tree

lance-spark-base_2.12/src/main/scala/org/apache/spark/sql/execution/datasources/v2/AddIndexExec.scala

Lines changed: 266 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -82,26 +82,22 @@ case class AddIndexExec(
8282
return Seq(new GenericInternalRow(Array[Any](0L, UTF8String.fromString(indexName))))
8383
}
8484

85-
val uuid = UUID.randomUUID()
8685
val indexType = IndexUtils.buildIndexType(method)
87-
8886
val dataset = Utils.openDatasetBuilder(readOptions).build()
8987

88+
if (indexType == IndexType.ZONEMAP) {
89+
return runZonemapDistributed(dataset, lanceDataset, readOptions, fragmentIds)
90+
}
91+
92+
val uuid = UUID.randomUUID()
9093
val indexBuildResult =
9194
createIndexJob(dataset, lanceDataset, readOptions, uuid.toString, fragmentIds).run()
9295

9396
try {
94-
// Merge index metadata after all fragments are indexed
97+
// Merge index metadata after all fragments are indexed.
9598
dataset.mergeIndexMetadata(uuid.toString, indexType, Optional.empty())
9699

97-
val fieldIdByName = dataset.getLanceSchema.fields().asScala
98-
.map(f => f.getName -> f.getId)
99-
.toMap
100-
val fieldIds = columns.map { column =>
101-
fieldIdByName.getOrElse(
102-
column,
103-
throw new IllegalArgumentException(s"Cannot find index column in Lance schema: $column"))
104-
}.toList
100+
val fieldIds = resolveFieldIdsOrThrow(dataset, columns)
105101

106102
val datasetVersion = dataset.version()
107103

@@ -148,26 +144,165 @@ case class AddIndexExec(
148144
UTF8String.fromString(indexName))))
149145
}
150146

147+
/**
148+
* ZONEMAP distributed build.
149+
*
150+
* <p>Each fragment is indexed in a separate Spark task, but unlike the general
151+
* `FragmentBasedIndexJob` path the tasks do NOT share an index UUID. Sharing would force every
152+
* task into the same `indices_dir/&lt;uuid&gt;/` directory, where they all attempt to write the
153+
* fixed filename `zonemap.lance` and race — only one fragment's data would survive. BTree
154+
* fragment-mode is robust under shared UUID because per-partition writes use distinct file
155+
* names (`part_&lt;id&gt;_page_data.lance`, `part_&lt;id&gt;_page_lookup.lance`) and a merge
156+
* step consolidates them. ZoneMap has no per-task file-name namespacing and no merge step.
157+
*
158+
* <p>Approach: each task generates its own UUID (lance-core does this when `withIndexUUID` is
159+
* not set; the JNI `skip_commit = fragment_ids.is_some()` branch takes
160+
* `execute_uncommitted`, returning IndexMetadata without committing). The driver collects all
161+
* N per-task results and commits them as a single transaction with N IndexMetadata entries
162+
* sharing the index name. Read consumers (`describe_indices` chunk-by-name, `getZonemapStats`
163+
* iterating `load_indices_by_name`) handle the multi-segment shape transparently.
164+
*
165+
* @param dataset the open Lance dataset; this method is responsible for closing it
166+
* @param lanceDataset the V2 catalog table view (used to resolve namespace credentials)
167+
* @param readOptions read configuration to forward to executor tasks
168+
* @param fragmentIds fragment IDs to index, one per task
169+
*/
170+
private def runZonemapDistributed(
171+
dataset: Dataset,
172+
lanceDataset: LanceDataset,
173+
readOptions: LanceSparkReadOptions,
174+
fragmentIds: List[Integer]): Seq[InternalRow] = {
175+
try {
176+
// Validate columns up-front on the driver so missing-column errors surface uniformly,
177+
// without having to wait for every executor task to fail. Reused at commit time below to
178+
// populate the new IndexMetadata entries — schema cannot drift within one dataset handle,
179+
// so a single resolution suffices.
180+
val fieldIds = resolveFieldIdsOrThrow(dataset, columns)
181+
182+
val (nsImpl, nsProps, tableId, initialStorageOpts) =
183+
extractNamespaceInfo(lanceDataset, readOptions)
184+
val encodedReadOptions = encode(readOptions)
185+
val argsJson = IndexUtils.toJson(args)
186+
val tasks = fragmentIds.map { fid =>
187+
ZonemapFragmentTask(
188+
encodedReadOptions,
189+
columns.toList,
190+
method,
191+
argsJson,
192+
indexName,
193+
fid,
194+
nsImpl,
195+
nsProps,
196+
tableId,
197+
initialStorageOpts)
198+
}
199+
200+
val encodedResults: Array[String] = session.sparkContext
201+
.parallelize(tasks, tasks.size)
202+
.map(_.execute())
203+
.collect()
204+
val perFragment: Array[ZonemapFragmentResult] = encodedResults.zipWithIndex.map {
205+
case (encoded, idx) =>
206+
try {
207+
decode[ZonemapFragmentResult](encoded)
208+
} catch {
209+
case e: Exception =>
210+
throw new IllegalStateException(
211+
s"Failed to decode ZONEMAP build result at task index $idx: ${e.getMessage}",
212+
e)
213+
}
214+
}
215+
216+
val datasetVersion = dataset.version()
217+
218+
// Build one Index entry per fragment, all sharing the same name. lance-core groups by
219+
// name in describe_indices and per-segment reads serve the multi-segment shape.
220+
val newIndexes = perFragment.toList.map { r =>
221+
val builder = Index.builder()
222+
.uuid(UUID.fromString(r.uuid))
223+
.name(indexName)
224+
.fields(fieldIds.map(java.lang.Integer.valueOf).asJava)
225+
.datasetVersion(datasetVersion)
226+
.indexDetails(r.indexDetails)
227+
.indexVersion(r.indexVersion)
228+
.indexType(IndexType.ZONEMAP)
229+
.fragments(Collections.singletonList(java.lang.Integer.valueOf(r.fragmentId)))
230+
r.createdAt.foreach(builder.createdAt)
231+
builder.build()
232+
}.asJava
233+
234+
val removedIndices = dataset.getIndexes.asScala
235+
.filter(_.name() == indexName)
236+
.toList.asJava
237+
238+
val op = AddIndexOperation.builder()
239+
.withNewIndices(newIndexes)
240+
.withRemovedIndices(removedIndices)
241+
.build()
242+
val txn = new Transaction.Builder()
243+
.readVersion(datasetVersion)
244+
.operation(op)
245+
.build()
246+
try {
247+
val newDataset = new CommitBuilder(dataset)
248+
.writeParams(readOptions.getStorageOptions)
249+
.execute(txn)
250+
newDataset.close()
251+
} finally {
252+
txn.close()
253+
}
254+
} finally {
255+
dataset.close()
256+
}
257+
Seq(new GenericInternalRow(Array[Any](
258+
fragmentIds.size.toLong,
259+
UTF8String.fromString(indexName))))
260+
}
261+
262+
/** Extract namespace credentials info from the catalog, mirroring `createIndexJob`. */
263+
private def extractNamespaceInfo(
264+
lanceDataset: LanceDataset,
265+
readOptions: LanceSparkReadOptions): (
266+
Option[String],
267+
Option[Map[String, String]],
268+
Option[List[String]],
269+
Option[Map[String, String]]) = catalog match {
270+
case nsCatalog: BaseLanceNamespaceSparkCatalog =>
271+
(
272+
Option(nsCatalog.getNamespaceImpl),
273+
Option(nsCatalog.getNamespaceProperties).map(_.asScala.toMap),
274+
Option(readOptions.getTableId).map(_.asScala.toList),
275+
Option(lanceDataset.getInitialStorageOptions).map(_.asScala.toMap))
276+
case _ => (None, None, None, None)
277+
}
278+
279+
/**
280+
* Look up Lance field IDs for the given column names, throwing IllegalArgumentException with a
281+
* single-sourced error message if any column is absent. Shared by the parallel and ZoneMap build
282+
* paths so that failure modes stay uniform.
283+
*/
284+
private def resolveFieldIdsOrThrow(
285+
dataset: Dataset,
286+
columnsToResolve: Seq[String]): List[Int] = {
287+
val fieldIdByName = dataset.getLanceSchema.fields().asScala
288+
.map(f => f.getName -> f.getId)
289+
.toMap
290+
columnsToResolve.map { column =>
291+
fieldIdByName.getOrElse(
292+
column,
293+
throw new IllegalArgumentException(s"Cannot find index column in Lance schema: $column"))
294+
}.toList
295+
}
296+
151297
private def createIndexJob(
152298
dataset: Dataset,
153299
lanceDataset: LanceDataset,
154300
readOptions: LanceSparkReadOptions,
155301
uuid: String,
156302
fragmentIds: List[Integer]): IndexJob = {
157303
// Get namespace info from catalog if available (for credential vending on workers)
158-
val (nsImpl, nsProps, tableId, initialStorageOpts): (
159-
Option[String],
160-
Option[Map[String, String]],
161-
Option[List[String]],
162-
Option[Map[String, String]]) = catalog match {
163-
case nsCatalog: BaseLanceNamespaceSparkCatalog =>
164-
(
165-
Option(nsCatalog.getNamespaceImpl),
166-
Option(nsCatalog.getNamespaceProperties).map(_.asScala.toMap),
167-
Option(readOptions.getTableId).map(_.asScala.toList),
168-
Option(lanceDataset.getInitialStorageOptions).map(_.asScala.toMap))
169-
case _ => (None, None, None, None)
170-
}
304+
val (nsImpl, nsProps, tableId, initialStorageOpts) =
305+
extractNamespaceInfo(lanceDataset, readOptions)
171306

172307
IndexUtils.buildIndexType(method) match {
173308
case IndexType.BTREE =>
@@ -353,6 +488,111 @@ case class FragmentIndexTask(
353488
}
354489
}
355490

491+
/**
492+
* Per-fragment build metadata returned by {@link ZonemapFragmentTask}.
493+
*
494+
* <p>Unlike the shared-UUID `FragmentIndexTask` flow, ZONEMAP requires every task to return its
495+
* own UUID and fragment ID so the driver can build N independent IndexMetadata entries — one per
496+
* fragment — and commit them in a single transaction. lance-core's `describe_indices` groups
497+
* segments by name into one logical description, and `getZonemapStats` reads each segment's
498+
* `zonemap.lance` directly via `load_indices_by_name`.
499+
*/
500+
case class ZonemapFragmentResult(
501+
uuid: String,
502+
fragmentId: Int,
503+
indexDetails: Array[Byte],
504+
indexVersion: Int,
505+
createdAt: Option[Instant])
506+
extends Serializable
507+
508+
/**
509+
* Per-fragment ZONEMAP build task. Calls `dataset.createIndex` with `withFragmentIds=[id]` and no
510+
* `withIndexUUID` so the JNI path takes `execute_uncommitted` (per
511+
* `should_skip_commit`/`fragment_ids.is_some()` in lance-core's blocking_dataset.rs) and
512+
* lance-core generates a fresh per-task UUID. Each task writes to its own `<uuid>/zonemap.lance`
513+
* directory and returns the metadata for the driver to bundle into a multi-segment commit.
514+
*/
515+
case class ZonemapFragmentTask(
516+
encodedReadOptions: String,
517+
columns: List[String],
518+
method: String,
519+
argsJson: String,
520+
indexName: String,
521+
fragmentId: Int,
522+
namespaceImpl: Option[String],
523+
namespaceProperties: Option[Map[String, String]],
524+
tableId: Option[List[String]],
525+
initialStorageOptions: Option[Map[String, String]])
526+
extends Serializable {
527+
528+
def execute(): String = {
529+
try {
530+
buildAndEncode()
531+
} catch {
532+
// Preserve the specific exception subclass so callers matching on type still match —
533+
// only the message gains fragment-id context.
534+
case e: IllegalArgumentException =>
535+
throw new IllegalArgumentException(
536+
s"ZONEMAP build failed for fragment $fragmentId: ${e.getMessage}",
537+
e)
538+
case e: IllegalStateException =>
539+
throw new IllegalStateException(
540+
s"ZONEMAP build failed for fragment $fragmentId: ${e.getMessage}",
541+
e)
542+
case e: RuntimeException =>
543+
throw new RuntimeException(
544+
s"ZONEMAP build failed for fragment $fragmentId: ${e.getMessage}",
545+
e)
546+
}
547+
}
548+
549+
private def buildAndEncode(): String = {
550+
val readOptions = decode[LanceSparkReadOptions](encodedReadOptions)
551+
val params = IndexParams.builder()
552+
.setScalarIndexParams(ScalarIndexParams.create(
553+
IndexUtils.buildScalarIndexParamType(method),
554+
argsJson))
555+
.build()
556+
557+
val indexOptions = IndexOptions
558+
.builder(java.util.Arrays.asList(columns: _*), IndexType.ZONEMAP, params)
559+
.replace(true)
560+
.withIndexName(indexName)
561+
.withFragmentIds(Collections.singletonList(java.lang.Integer.valueOf(fragmentId)))
562+
.build()
563+
564+
val dataset = Utils.openDatasetBuilder(readOptions)
565+
.initialStorageOptions(initialStorageOptions.map(_.asJava).orNull)
566+
.runtimeNamespace(
567+
namespaceImpl.orNull,
568+
namespaceProperties.map(_.asJava).orNull,
569+
tableId.map(_.asJava).orNull)
570+
.build()
571+
572+
try {
573+
val createdIndex = dataset.createIndex(indexOptions)
574+
val details = createdIndex.indexDetails()
575+
if (!details.isPresent || details.get().length == 0) {
576+
throw new IllegalStateException(
577+
s"Index ${createdIndex.name()} was created without index details")
578+
}
579+
val createdAt = if (createdIndex.createdAt().isPresent) {
580+
Some(createdIndex.createdAt().get())
581+
} else {
582+
None
583+
}
584+
encode(ZonemapFragmentResult(
585+
uuid = createdIndex.uuid().toString,
586+
fragmentId = fragmentId,
587+
indexDetails = details.get(),
588+
indexVersion = createdIndex.indexVersion(),
589+
createdAt = createdAt))
590+
} finally {
591+
dataset.close()
592+
}
593+
}
594+
}
595+
356596
/**
357597
* A job implementation for creating range-based BTree indexes using preprocessed, globally sorted data.
358598
* This approach distributes data across multiple partitions based on ranges of values and creates
@@ -562,6 +802,7 @@ object IndexUtils {
562802
method.toLowerCase match {
563803
case "btree" => IndexType.BTREE
564804
case "fts" => IndexType.INVERTED
805+
case "zonemap" => IndexType.ZONEMAP
565806
case other => throw new UnsupportedOperationException(s"Unsupported index method: $other")
566807
}
567808
}
@@ -570,6 +811,7 @@ object IndexUtils {
570811
method.toLowerCase match {
571812
case "btree" => "btree"
572813
case "fts" => "inverted"
814+
case "zonemap" => "zonemap"
573815
case other => throw new UnsupportedOperationException(s"Unsupported index method: $other")
574816
}
575817
}

0 commit comments

Comments
 (0)