Skip to content

Commit 5c18049

Browse files
beinanBeinan Wang
andcommitted
feat: add distributed zonemap index build with configurable segments
Add zonemap as a new index type in CREATE INDEX DDL with distributed build support. Each segment is built in parallel on Spark executors and committed as a logical index on the driver. Co-Authored-By: Beinan Wang <beinanwang@microsoft.com>
1 parent 8f11c3b commit 5c18049

4 files changed

Lines changed: 624 additions & 172 deletions

File tree

docs/src/operations/ddl/create-index.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Creates a scalar index on a Lance table to accelerate queries.
77

88
## Overview
99

10-
The `CREATE INDEX` command builds an index on one or more columns of a Lance table. Indexing can improve the performance of queries that filter on the indexed columns. This operation is performed in a distributed manner, building indexes for each data fragment in parallel.
10+
The `CREATE INDEX` command builds an index on one or more columns of a Lance table. Indexing can improve the performance of queries that filter on the indexed columns. Depending on the index method, Lance Spark either uses a fragment-parallel build path or a driver-coordinated commit flow after parallel executor builds.
1111

1212
## Basic Usage
1313

@@ -24,13 +24,23 @@ The following index methods are supported:
2424

2525
| Method | Description |
2626
|---------|-----------------------------------------------------------------------------|
27+
| `zonemap` | Lightweight min/max index for fragment pruning on a scalar column. |
2728
| `btree` | B-tree index for efficient range queries and point lookups on scalar columns. |
2829
| `fts` | Full-text search (inverted) index for text search on string columns. |
2930

3031
## Options
3132

3233
The `CREATE INDEX` command supports options via the `WITH` clause to control index creation. These options are specific to the chosen index method.
3334

35+
### ZoneMap Options
36+
37+
For the `zonemap` method, the following options are supported:
38+
39+
| Option | Type | Description |
40+
|-----------------|------|----------------------------------------------|
41+
| `rows_per_zone` | Long | The approximate number of rows per zonemap zone. |
42+
| `num_segments` | Integer | Target number of index segments (upper bound; clamped to fragment count when larger). Each segment covers a batch of fragments. Defaults to `min(fragment_count, spark.default.parallelism)`. |
43+
3444
### BTree Options
3545

3646
For the `btree` method, the following options are supported:
@@ -92,6 +102,15 @@ Create a composite index on multiple columns.
92102
ALTER TABLE lance.db.logs CREATE INDEX idx_ts_level USING btree (timestamp, level);
93103
```
94104

105+
### Lightweight Fragment Pruning
106+
107+
Create a zonemap index when you want lightweight min/max-based fragment pruning:
108+
109+
=== "SQL"
110+
```sql
111+
ALTER TABLE lance.db.users CREATE INDEX idx_id_zonemap USING zonemap (id);
112+
```
113+
95114
### Indexing with Options
96115

97116
Create an index and specify the `zone_size` for the B-tree:
@@ -101,6 +120,15 @@ Create an index and specify the `zone_size` for the B-tree:
101120
ALTER TABLE lance.db.users CREATE INDEX idx_id_zoned USING btree (id) WITH (zone_size = 2048);
102121
```
103122

123+
### Zonemap with Options
124+
125+
Create a zonemap index and specify the approximate number of rows per zone:
126+
127+
=== "SQL"
128+
```sql
129+
ALTER TABLE lance.db.users CREATE INDEX idx_id_zonemap USING zonemap (id) WITH (rows_per_zone = 2048);
130+
```
131+
104132
### Full-Text Search Index
105133

106134
Create an FTS index on a text column:
@@ -133,17 +161,19 @@ The `CREATE INDEX` command returns the following information about the operation
133161
Consider creating an index when:
134162

135163
- You frequently filter a large table on a specific column.
164+
- You want lightweight fragment pruning based on per-zone min/max statistics.
136165
- Your queries involve point lookups or small range scans.
137166

138167
## How It Works
139168

140169
The `CREATE INDEX` command operates as follows:
141170

142-
1. **Distributed Index Building**: For each fragment in the Lance dataset, a separate task is launched to build an index on the specified column(s).
143-
2. **Metadata Merging**: Once all per-fragment indexes are built, their metadata is collected and merged.
171+
1. **Index Build Execution**: Lance Spark chooses an execution path based on the index method. Methods such as `btree`, `fts`, and `zonemap` can build physical index segments in parallel across fragments. `zonemap` publishes those segments directly as one logical index. Range-mode `btree` uses Spark repartitioning and sorted preprocessed data.
172+
2. **Metadata Finalization**: Lance Spark merges or commits the resulting index metadata on the driver so the new logical index becomes visible atomically.
144173
3. **Transactional Commit**: A new table version is committed with the new index information. The operation is atomic and ensures that concurrent reads are not affected.
145174

146175
## Notes and Limitations
147176

148-
- **Index Methods**: The `btree` and `fts` methods are supported for scalar index creation.
149-
- **Index Replacement**: If you create an index with the same name as an existing one, the old index will be replaced by the new one. This is because the underlying implementation uses `replace(true)`.
177+
- **Index Methods**: The `zonemap`, `btree`, and `fts` methods are supported for scalar index creation.
178+
- **Zonemap Column Count**: Zonemap indexes currently support a single column only. The generic `CREATE INDEX` grammar accepts a column list, but Lance rejects multi-column zonemap creation.
179+
- **Index Replacement**: If you create an index with the same name as an existing one, the old index will be replaced by the new one.

integration-tests/test_lance_spark.py

Lines changed: 81 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,42 @@ def test_create_btree_index_on_string(self, spark):
778778
assert len(query_result) == 3
779779
_assert_lance_index_metadata(spark, "default.employees", "idx_dept", "BTREE")
780780

781+
def test_create_zonemap_index_on_int(self, spark):
782+
"""Test CREATE INDEX with ZoneMap on integer column."""
783+
spark.sql("""
784+
CREATE TABLE default.test_table (
785+
id INT,
786+
name STRING,
787+
value DOUBLE
788+
)
789+
""")
790+
791+
data = [(i, f"Name{i}", float(i * 10)) for i in range(100)]
792+
df = spark.createDataFrame(data, ["id", "name", "value"])
793+
df.writeTo("default.test_table").append()
794+
795+
result = spark.sql("""
796+
ALTER TABLE default.test_table
797+
CREATE INDEX idx_id_zonemap USING zonemap (id)
798+
WITH (rows_per_zone = 8)
799+
""").collect()
800+
801+
assert len(result) == 1
802+
assert result[0][1] == "idx_id_zonemap"
803+
804+
indexes = spark.sql("""
805+
SHOW INDEXES IN default.test_table
806+
""").collect()
807+
zonemap_rows = [row for row in indexes if row["name"] == "idx_id_zonemap"]
808+
assert len(zonemap_rows) >= 1
809+
assert zonemap_rows[0]["index_type"] == "zonemap"
810+
811+
query_result = spark.sql("""
812+
SELECT * FROM default.test_table WHERE id = 50
813+
""").collect()
814+
assert len(query_result) == 1
815+
assert query_result[0].id == 50
816+
781817
def test_create_fts_index(self, spark):
782818
"""Test CREATE INDEX with full-text search (FTS)."""
783819
spark.sql("""
@@ -2512,26 +2548,26 @@ class TestStableRowIds:
25122548
_row_created_at_version for all rows in that fragment.
25132549
"""
25142550

2515-
def test_tblproperties_enable_stable_row_ids(self, spark, test_table):
2551+
def test_tblproperties_enable_stable_row_ids(self, spark):
25162552
"""Test that TBLPROPERTIES enables CDF version columns."""
2517-
spark.sql(f"""
2518-
CREATE TABLE {test_table} (
2553+
spark.sql("""
2554+
CREATE TABLE default.test_table (
25192555
id INT,
25202556
name STRING,
25212557
value INT
25222558
) TBLPROPERTIES ('enable_stable_row_ids' = 'true')
25232559
""")
25242560

2525-
spark.sql(f"""
2526-
INSERT INTO {test_table} VALUES
2561+
spark.sql("""
2562+
INSERT INTO default.test_table VALUES
25272563
(1, 'Alice', 100),
25282564
(2, 'Bob', 200),
25292565
(3, 'Charlie', 300)
25302566
""")
25312567

2532-
result = spark.sql(f"""
2568+
result = spark.sql("""
25332569
SELECT id, _row_created_at_version, _row_last_updated_at_version
2534-
FROM {test_table}
2570+
FROM default.test_table
25352571
ORDER BY id
25362572
""").collect()
25372573

@@ -2540,30 +2576,30 @@ def test_tblproperties_enable_stable_row_ids(self, spark, test_table):
25402576
assert row._row_created_at_version is not None
25412577
assert row._row_last_updated_at_version is not None
25422578

2543-
def test_default_behavior_no_stable_row_ids(self, spark, test_table):
2579+
def test_default_behavior_no_stable_row_ids(self, spark):
25442580
"""Test version columns without enable_stable_row_ids.
25452581
25462582
Without enable_stable_row_ids the Lance engine still populates
25472583
_row_created_at_version and _row_last_updated_at_version, but
25482584
returns a baseline value of 1 instead of the actual operation version.
25492585
"""
2550-
spark.sql(f"""
2551-
CREATE TABLE {test_table} (
2586+
spark.sql("""
2587+
CREATE TABLE default.test_table (
25522588
id INT,
25532589
name STRING,
25542590
value INT
25552591
)
25562592
""")
25572593

2558-
spark.sql(f"""
2559-
INSERT INTO {test_table} VALUES
2594+
spark.sql("""
2595+
INSERT INTO default.test_table VALUES
25602596
(1, 'Alice', 100),
25612597
(2, 'Bob', 200)
25622598
""")
25632599

2564-
result = spark.sql(f"""
2600+
result = spark.sql("""
25652601
SELECT id, _row_created_at_version, _row_last_updated_at_version
2566-
FROM {test_table}
2602+
FROM default.test_table
25672603
ORDER BY id
25682604
""").collect()
25692605

@@ -2574,23 +2610,23 @@ def test_default_behavior_no_stable_row_ids(self, spark, test_table):
25742610
assert row._row_last_updated_at_version == 1
25752611

25762612
@requires_update_or_merge
2577-
def test_cdc_incremental_ingestion_pattern(self, spark, test_table):
2613+
def test_cdc_incremental_ingestion_pattern(self, spark):
25782614
"""Test CDC incremental ingestion pipeline pattern.
25792615
25802616
Simulates a CDC pipeline that tracks the last processed version and
25812617
incrementally processes changes using version tracking columns.
25822618
"""
2583-
spark.sql(f"""
2584-
CREATE TABLE {test_table} (
2619+
spark.sql("""
2620+
CREATE TABLE default.test_table (
25852621
id INT,
25862622
name STRING,
25872623
value INT
25882624
) TBLPROPERTIES ('enable_stable_row_ids' = 'true')
25892625
""")
25902626

25912627
# v2: Initial data load
2592-
spark.sql(f"""
2593-
INSERT INTO {test_table} VALUES
2628+
spark.sql("""
2629+
INSERT INTO default.test_table VALUES
25942630
(1, 'Alice', 100),
25952631
(2, 'Bob', 200),
25962632
(3, 'Charlie', 300)
@@ -2600,7 +2636,7 @@ def test_cdc_incremental_ingestion_pattern(self, spark, test_table):
26002636
last_processed_version = 1
26012637
batch1 = spark.sql(f"""
26022638
SELECT id, name, value, _row_created_at_version, _row_last_updated_at_version
2603-
FROM {test_table}
2639+
FROM default.test_table
26042640
WHERE (_row_created_at_version > {last_processed_version})
26052641
OR (_row_last_updated_at_version > {last_processed_version})
26062642
ORDER BY id
@@ -2611,13 +2647,13 @@ def test_cdc_incremental_ingestion_pattern(self, spark, test_table):
26112647
last_processed_version = 2
26122648

26132649
# v3: Update one row, v4: Insert new row
2614-
spark.sql(f"UPDATE {test_table} SET value = value + 50 WHERE id = 1")
2615-
spark.sql(f"INSERT INTO {test_table} VALUES (4, 'David', 400)")
2650+
spark.sql("UPDATE default.test_table SET value = value + 50 WHERE id = 1")
2651+
spark.sql("INSERT INTO default.test_table VALUES (4, 'David', 400)")
26162652

26172653
# CDC Pipeline: Process batch 2 (changes since v2)
26182654
batch2 = spark.sql(f"""
26192655
SELECT id, name, value, _row_created_at_version, _row_last_updated_at_version
2620-
FROM {test_table}
2656+
FROM default.test_table
26212657
WHERE (_row_created_at_version > {last_processed_version})
26222658
OR (_row_last_updated_at_version > {last_processed_version})
26232659
ORDER BY id
@@ -2637,12 +2673,12 @@ def test_cdc_incremental_ingestion_pattern(self, spark, test_table):
26372673
last_processed_version = 4
26382674

26392675
# v5: More updates
2640-
spark.sql(f"UPDATE {test_table} SET value = value + 100 WHERE id IN (2, 3)")
2676+
spark.sql("UPDATE default.test_table SET value = value + 100 WHERE id IN (2, 3)")
26412677

26422678
# CDC Pipeline: Process batch 3 (changes since v4)
26432679
batch3 = spark.sql(f"""
26442680
SELECT id, name, value, _row_created_at_version, _row_last_updated_at_version
2645-
FROM {test_table}
2681+
FROM default.test_table
26462682
WHERE (_row_created_at_version > {last_processed_version})
26472683
OR (_row_last_updated_at_version > {last_processed_version})
26482684
ORDER BY id
@@ -2659,12 +2695,12 @@ def test_cdc_incremental_ingestion_pattern(self, spark, test_table):
26592695
last_processed_version = 5
26602696

26612697
# v6: Update entire table
2662-
spark.sql(f"UPDATE {test_table} SET value = value * 2")
2698+
spark.sql("UPDATE default.test_table SET value = value * 2")
26632699

26642700
# CDC Pipeline: Process batch 4 (changes since v5)
26652701
batch4 = spark.sql(f"""
26662702
SELECT id, name, value, _row_created_at_version, _row_last_updated_at_version
2667-
FROM {test_table}
2703+
FROM default.test_table
26682704
WHERE (_row_created_at_version > {last_processed_version})
26692705
OR (_row_last_updated_at_version > {last_processed_version})
26702706
ORDER BY id
@@ -2710,30 +2746,29 @@ def _register_cdf_catalog(self, spark):
27102746

27112747
return catalog_name
27122748

2713-
def test_catalog_level_stable_row_ids(self, spark, test_table):
2749+
def test_catalog_level_stable_row_ids(self, spark):
27142750
"""Test that catalog-level enable_stable_row_ids enables version columns without TBLPROPERTIES."""
27152751
catalog_name = self._register_cdf_catalog(spark)
2716-
cdf_table = f"{catalog_name}.{test_table}"
27172752

27182753
try:
27192754
# CREATE TABLE without TBLPROPERTIES — relies on catalog-level default
27202755
spark.sql(f"""
2721-
CREATE TABLE {cdf_table} (
2756+
CREATE TABLE {catalog_name}.default.test_table (
27222757
id INT,
27232758
name STRING,
27242759
value INT
27252760
)
27262761
""")
27272762

27282763
spark.sql(f"""
2729-
INSERT INTO {cdf_table} VALUES
2764+
INSERT INTO {catalog_name}.default.test_table VALUES
27302765
(1, 'Alice', 100),
27312766
(2, 'Bob', 200)
27322767
""")
27332768

27342769
result = spark.sql(f"""
27352770
SELECT id, _row_created_at_version, _row_last_updated_at_version
2736-
FROM {cdf_table}
2771+
FROM {catalog_name}.default.test_table
27372772
ORDER BY id
27382773
""").collect()
27392774

@@ -2743,52 +2778,52 @@ def test_catalog_level_stable_row_ids(self, spark, test_table):
27432778
assert row._row_last_updated_at_version is not None
27442779
finally:
27452780
try:
2746-
spark.sql(f"DROP TABLE IF EXISTS {cdf_table} PURGE")
2781+
spark.sql(f"DROP TABLE IF EXISTS {catalog_name}.default.test_table PURGE")
27472782
except Exception as e:
2748-
print(f"Failed to clean up {cdf_table}: {e}")
2783+
print(f"Failed to clean up {catalog_name}.default.test_table: {e}")
27492784

27502785

27512786
@requires_update_or_merge
2752-
def test_update_preserves_row_ids(self, spark, test_table):
2787+
def test_update_preserves_row_ids(self, spark):
27532788
"""Test that UPDATE preserves _rowid values when stable row IDs are enabled.
27542789
27552790
Verifies the native DeltaWriter.update() path with RowIdMeta attachment
27562791
keeps row IDs stable across updates, including multi-fragment scenarios.
27572792
"""
2758-
spark.sql(f"""
2759-
CREATE TABLE {test_table} (
2793+
spark.sql("""
2794+
CREATE TABLE default.test_table (
27602795
id INT,
27612796
name STRING,
27622797
value INT
27632798
) TBLPROPERTIES ('enable_stable_row_ids' = 'true')
27642799
""")
27652800

27662801
# Insert across two fragments
2767-
spark.sql(f"""
2768-
INSERT INTO {test_table} VALUES
2802+
spark.sql("""
2803+
INSERT INTO default.test_table VALUES
27692804
(1, 'Alice', 100),
27702805
(2, 'Bob', 200),
27712806
(3, 'Charlie', 300)
27722807
""")
2773-
spark.sql(f"""
2774-
INSERT INTO {test_table} VALUES
2808+
spark.sql("""
2809+
INSERT INTO default.test_table VALUES
27752810
(4, 'Dave', 400),
27762811
(5, 'Eve', 500)
27772812
""")
27782813

27792814
# Capture row IDs before update
2780-
before = spark.sql(f"""
2781-
SELECT id, _rowid FROM {test_table} ORDER BY id
2815+
before = spark.sql("""
2816+
SELECT id, _rowid FROM default.test_table ORDER BY id
27822817
""").collect()
27832818
row_ids_before = {row.id: row._rowid for row in before}
27842819
assert len(row_ids_before) == 5
27852820

27862821
# Update rows spanning both fragments
2787-
spark.sql(f"UPDATE {test_table} SET value = value + 1 WHERE value >= 200")
2822+
spark.sql("UPDATE default.test_table SET value = value + 1 WHERE value >= 200")
27882823

27892824
# Capture row IDs after update
2790-
after = spark.sql(f"""
2791-
SELECT id, _rowid, value FROM {test_table} ORDER BY id
2825+
after = spark.sql("""
2826+
SELECT id, _rowid, value FROM default.test_table ORDER BY id
27922827
""").collect()
27932828
row_ids_after = {row.id: row._rowid for row in after}
27942829

0 commit comments

Comments
 (0)