Skip to content

Commit c9dcf64

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 c9dcf64

3 files changed

Lines changed: 496 additions & 24 deletions

File tree

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

Lines changed: 243 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,152 @@ case class AddIndexExec(
148144
UTF8String.fromString(indexName))))
149145
}
150146

147+
/**
148+
* Distributed ZONEMAP build. Each task indexes one fragment with a fresh per-task UUID (no
149+
* `withIndexUUID`), so each writes to its own `&lt;uuid&gt;/zonemap.lance` directory. The driver
150+
* commits N IndexMetadata entries under a shared name in one transaction; lance-core's read
151+
* path serves multi-segment indexes natively.
152+
*
153+
* <p>A shared UUID would race on the fixed `zonemap.lance` filename — ZoneMap, unlike BTree, has
154+
* no per-task file-name namespacing or merge step.
155+
*
156+
* @param dataset open Lance dataset; this method closes it
157+
* @param lanceDataset V2 catalog view, for namespace credentials
158+
* @param readOptions read config forwarded to executor tasks
159+
* @param fragmentIds fragments to index, one task each
160+
*/
161+
private def runZonemapDistributed(
162+
dataset: Dataset,
163+
lanceDataset: LanceDataset,
164+
readOptions: LanceSparkReadOptions,
165+
fragmentIds: List[Integer]): Seq[InternalRow] = {
166+
try {
167+
// Validate columns up-front (fail-fast before parallelize) and reuse at commit time —
168+
// schema cannot drift within one dataset handle.
169+
val fieldIds = resolveFieldIdsOrThrow(dataset, columns)
170+
171+
val (nsImpl, nsProps, tableId, initialStorageOpts) =
172+
extractNamespaceInfo(lanceDataset, readOptions)
173+
val encodedReadOptions = encode(readOptions)
174+
val argsJson = IndexUtils.toJson(args)
175+
val tasks = fragmentIds.map { fid =>
176+
ZonemapFragmentTask(
177+
encodedReadOptions,
178+
columns.toList,
179+
method,
180+
argsJson,
181+
indexName,
182+
fid,
183+
nsImpl,
184+
nsProps,
185+
tableId,
186+
initialStorageOpts)
187+
}
188+
189+
val encodedResults: Array[String] = session.sparkContext
190+
.parallelize(tasks, tasks.size)
191+
.map(_.execute())
192+
.collect()
193+
val perFragment: Array[ZonemapFragmentResult] = encodedResults.zipWithIndex.map {
194+
case (encoded, idx) =>
195+
try {
196+
decode[ZonemapFragmentResult](encoded)
197+
} catch {
198+
case e: Exception =>
199+
throw new IllegalStateException(
200+
s"Failed to decode ZONEMAP build result at task index $idx: ${e.getMessage}",
201+
e)
202+
}
203+
}
204+
205+
val datasetVersion = dataset.version()
206+
207+
// One Index entry per fragment, all sharing the index name; the read path groups by name.
208+
val newIndexes = perFragment.toList.map { r =>
209+
val builder = Index.builder()
210+
.uuid(UUID.fromString(r.uuid))
211+
.name(indexName)
212+
.fields(fieldIds.map(java.lang.Integer.valueOf).asJava)
213+
.datasetVersion(datasetVersion)
214+
.indexDetails(r.indexDetails)
215+
.indexVersion(r.indexVersion)
216+
.indexType(IndexType.ZONEMAP)
217+
.fragments(Collections.singletonList(java.lang.Integer.valueOf(r.fragmentId)))
218+
r.createdAt.foreach(builder.createdAt)
219+
builder.build()
220+
}.asJava
221+
222+
val removedIndices = dataset.getIndexes.asScala
223+
.filter(_.name() == indexName)
224+
.toList.asJava
225+
226+
val op = AddIndexOperation.builder()
227+
.withNewIndices(newIndexes)
228+
.withRemovedIndices(removedIndices)
229+
.build()
230+
val txn = new Transaction.Builder()
231+
.readVersion(datasetVersion)
232+
.operation(op)
233+
.build()
234+
try {
235+
val newDataset = new CommitBuilder(dataset)
236+
.writeParams(readOptions.getStorageOptions)
237+
.execute(txn)
238+
newDataset.close()
239+
} finally {
240+
txn.close()
241+
}
242+
} finally {
243+
dataset.close()
244+
}
245+
Seq(new GenericInternalRow(Array[Any](
246+
fragmentIds.size.toLong,
247+
UTF8String.fromString(indexName))))
248+
}
249+
250+
/** Extract namespace credentials info from the catalog, mirroring `createIndexJob`. */
251+
private def extractNamespaceInfo(
252+
lanceDataset: LanceDataset,
253+
readOptions: LanceSparkReadOptions): (
254+
Option[String],
255+
Option[Map[String, String]],
256+
Option[List[String]],
257+
Option[Map[String, String]]) = catalog match {
258+
case nsCatalog: BaseLanceNamespaceSparkCatalog =>
259+
(
260+
Option(nsCatalog.getNamespaceImpl),
261+
Option(nsCatalog.getNamespaceProperties).map(_.asScala.toMap),
262+
Option(readOptions.getTableId).map(_.asScala.toList),
263+
Option(lanceDataset.getInitialStorageOptions).map(_.asScala.toMap))
264+
case _ => (None, None, None, None)
265+
}
266+
267+
/**
268+
* Resolve column names to Lance field IDs. Throws IllegalArgumentException if any column is
269+
* absent; shared by both build paths so failure modes stay uniform.
270+
*/
271+
private def resolveFieldIdsOrThrow(
272+
dataset: Dataset,
273+
columnsToResolve: Seq[String]): List[Int] = {
274+
val fieldIdByName = dataset.getLanceSchema.fields().asScala
275+
.map(f => f.getName -> f.getId)
276+
.toMap
277+
columnsToResolve.map { column =>
278+
fieldIdByName.getOrElse(
279+
column,
280+
throw new IllegalArgumentException(s"Cannot find index column in Lance schema: $column"))
281+
}.toList
282+
}
283+
151284
private def createIndexJob(
152285
dataset: Dataset,
153286
lanceDataset: LanceDataset,
154287
readOptions: LanceSparkReadOptions,
155288
uuid: String,
156289
fragmentIds: List[Integer]): IndexJob = {
157290
// 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-
}
291+
val (nsImpl, nsProps, tableId, initialStorageOpts) =
292+
extractNamespaceInfo(lanceDataset, readOptions)
171293

172294
IndexUtils.buildIndexType(method) match {
173295
case IndexType.BTREE =>
@@ -353,6 +475,101 @@ case class FragmentIndexTask(
353475
}
354476
}
355477

478+
/** Per-fragment build metadata returned by {@link ZonemapFragmentTask}. */
479+
case class ZonemapFragmentResult(
480+
uuid: String,
481+
fragmentId: Int,
482+
indexDetails: Array[Byte],
483+
indexVersion: Int,
484+
createdAt: Option[Instant])
485+
extends Serializable
486+
487+
/**
488+
* Per-fragment ZONEMAP build task. Calls `dataset.createIndex` with `withFragmentIds=[id]` and no
489+
* `withIndexUUID`, so lance-core takes the uncommitted path with a fresh per-task UUID. The
490+
* returned metadata is collected by the driver into a multi-segment commit.
491+
*/
492+
case class ZonemapFragmentTask(
493+
encodedReadOptions: String,
494+
columns: List[String],
495+
method: String,
496+
argsJson: String,
497+
indexName: String,
498+
fragmentId: Int,
499+
namespaceImpl: Option[String],
500+
namespaceProperties: Option[Map[String, String]],
501+
tableId: Option[List[String]],
502+
initialStorageOptions: Option[Map[String, String]])
503+
extends Serializable {
504+
505+
def execute(): String = {
506+
try {
507+
buildAndEncode()
508+
} catch {
509+
// Preserve the specific exception subclass so callers matching on type still match —
510+
// only the message gains fragment-id context.
511+
case e: IllegalArgumentException =>
512+
throw new IllegalArgumentException(
513+
s"ZONEMAP build failed for fragment $fragmentId: ${e.getMessage}",
514+
e)
515+
case e: IllegalStateException =>
516+
throw new IllegalStateException(
517+
s"ZONEMAP build failed for fragment $fragmentId: ${e.getMessage}",
518+
e)
519+
case e: RuntimeException =>
520+
throw new RuntimeException(
521+
s"ZONEMAP build failed for fragment $fragmentId: ${e.getMessage}",
522+
e)
523+
}
524+
}
525+
526+
private def buildAndEncode(): String = {
527+
val readOptions = decode[LanceSparkReadOptions](encodedReadOptions)
528+
val params = IndexParams.builder()
529+
.setScalarIndexParams(ScalarIndexParams.create(
530+
IndexUtils.buildScalarIndexParamType(method),
531+
argsJson))
532+
.build()
533+
534+
val indexOptions = IndexOptions
535+
.builder(java.util.Arrays.asList(columns: _*), IndexType.ZONEMAP, params)
536+
.replace(true)
537+
.withIndexName(indexName)
538+
.withFragmentIds(Collections.singletonList(java.lang.Integer.valueOf(fragmentId)))
539+
.build()
540+
541+
val dataset = Utils.openDatasetBuilder(readOptions)
542+
.initialStorageOptions(initialStorageOptions.map(_.asJava).orNull)
543+
.runtimeNamespace(
544+
namespaceImpl.orNull,
545+
namespaceProperties.map(_.asJava).orNull,
546+
tableId.map(_.asJava).orNull)
547+
.build()
548+
549+
try {
550+
val createdIndex = dataset.createIndex(indexOptions)
551+
val details = createdIndex.indexDetails()
552+
if (!details.isPresent || details.get().length == 0) {
553+
throw new IllegalStateException(
554+
s"Index ${createdIndex.name()} was created without index details")
555+
}
556+
val createdAt = if (createdIndex.createdAt().isPresent) {
557+
Some(createdIndex.createdAt().get())
558+
} else {
559+
None
560+
}
561+
encode(ZonemapFragmentResult(
562+
uuid = createdIndex.uuid().toString,
563+
fragmentId = fragmentId,
564+
indexDetails = details.get(),
565+
indexVersion = createdIndex.indexVersion(),
566+
createdAt = createdAt))
567+
} finally {
568+
dataset.close()
569+
}
570+
}
571+
}
572+
356573
/**
357574
* A job implementation for creating range-based BTree indexes using preprocessed, globally sorted data.
358575
* This approach distributes data across multiple partitions based on ranges of values and creates
@@ -562,6 +779,7 @@ object IndexUtils {
562779
method.toLowerCase match {
563780
case "btree" => IndexType.BTREE
564781
case "fts" => IndexType.INVERTED
782+
case "zonemap" => IndexType.ZONEMAP
565783
case other => throw new UnsupportedOperationException(s"Unsupported index method: $other")
566784
}
567785
}
@@ -570,6 +788,7 @@ object IndexUtils {
570788
method.toLowerCase match {
571789
case "btree" => "btree"
572790
case "fts" => "inverted"
791+
case "zonemap" => "zonemap"
573792
case other => throw new UnsupportedOperationException(s"Unsupported index method: $other")
574793
}
575794
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Licensed under the Apache License, Version 2.0 (the "License");
3+
* you may not use this file except in compliance with the License.
4+
* You may obtain a copy of the License at
5+
*
6+
* http://www.apache.org/licenses/LICENSE-2.0
7+
*
8+
* Unless required by applicable law or agreed to in writing, software
9+
* distributed under the License is distributed on an "AS IS" BASIS,
10+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
* See the License for the specific language governing permissions and
12+
* limitations under the License.
13+
*/
14+
package org.lance.spark.index;
15+
16+
import org.lance.index.IndexType;
17+
18+
import org.apache.spark.sql.execution.datasources.v2.IndexUtils;
19+
import org.junit.jupiter.api.Test;
20+
21+
import static org.junit.jupiter.api.Assertions.assertEquals;
22+
import static org.junit.jupiter.api.Assertions.assertThrows;
23+
import static org.junit.jupiter.api.Assertions.assertTrue;
24+
25+
class IndexUtilsTest {
26+
27+
@Test
28+
public void testBuildIndexTypeKnown() {
29+
assertEquals(IndexType.BTREE, IndexUtils.buildIndexType("btree"));
30+
assertEquals(IndexType.INVERTED, IndexUtils.buildIndexType("fts"));
31+
assertEquals(IndexType.ZONEMAP, IndexUtils.buildIndexType("zonemap"));
32+
}
33+
34+
@Test
35+
public void testBuildIndexTypeCaseInsensitive() {
36+
assertEquals(IndexType.ZONEMAP, IndexUtils.buildIndexType("ZoneMap"));
37+
assertEquals(IndexType.BTREE, IndexUtils.buildIndexType("BTREE"));
38+
assertEquals(IndexType.INVERTED, IndexUtils.buildIndexType("FTS"));
39+
}
40+
41+
@Test
42+
public void testBuildIndexTypeUnknown() {
43+
UnsupportedOperationException ex =
44+
assertThrows(UnsupportedOperationException.class, () -> IndexUtils.buildIndexType("hash"));
45+
assertTrue(ex.getMessage().contains("Unsupported index method"));
46+
assertTrue(ex.getMessage().contains("hash"));
47+
}
48+
49+
@Test
50+
public void testBuildScalarIndexParamTypeKnown() {
51+
assertEquals("btree", IndexUtils.buildScalarIndexParamType("btree"));
52+
assertEquals("inverted", IndexUtils.buildScalarIndexParamType("fts"));
53+
assertEquals("zonemap", IndexUtils.buildScalarIndexParamType("zonemap"));
54+
}
55+
56+
@Test
57+
public void testBuildScalarIndexParamTypeCaseInsensitive() {
58+
assertEquals("zonemap", IndexUtils.buildScalarIndexParamType("ZoneMap"));
59+
assertEquals("btree", IndexUtils.buildScalarIndexParamType("BTREE"));
60+
assertEquals("inverted", IndexUtils.buildScalarIndexParamType("FTS"));
61+
}
62+
63+
@Test
64+
public void testBuildScalarIndexParamTypeUnknown() {
65+
UnsupportedOperationException ex =
66+
assertThrows(
67+
UnsupportedOperationException.class,
68+
() -> IndexUtils.buildScalarIndexParamType("hash"));
69+
assertTrue(ex.getMessage().contains("Unsupported index method"));
70+
assertTrue(ex.getMessage().contains("hash"));
71+
}
72+
}

0 commit comments

Comments
 (0)