Skip to content

Commit da87cce

Browse files
committed
[python] Build global index shards concurrently
1 parent c081ccd commit da87cce

5 files changed

Lines changed: 299 additions & 56 deletions

File tree

‎docs/docs/multimodal-table/global-index/manage-indexes.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ These table options affect global index build and read behavior:
454454
| `sorted-index.records-per-range` | `10000000` | Expected number of records per sorted global index file for BTree, Bitmap, and Multivalue builds. |
455455
| `sorted-index.build.max-parallelism` | `4096` | Maximum Flink or Spark parallelism for building sorted global indexes. |
456456
| `global-index.row-count-per-shard` | `100000` | Target row count per shard for non-sorted global index builds such as vector and full-text indexes. |
457+
| `global-index.build.parallelism` | `1` | Number of shards built concurrently by the local PyPaimon builder. Each shard may use native worker threads, so increase this value conservatively. |
457458
| `global-index.build.max-shard` | `32` | Preferred maximum shard count for global index builds. |
458459
| `global-index.build.max-parallelism` | `4096` | Maximum Flink or Spark parallelism for building non-sorted global indexes. |
459460
| `global-index.thread-num` | `32` | Maximum number of concurrent threads for global index I/O. |

‎paimon-python/pypaimon/common/options/core_options.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,17 @@ class CoreOptions:
903903
.with_description("Row count per shard for global index.")
904904
)
905905

906+
GLOBAL_INDEX_BUILD_PARALLELISM: ConfigOption[int] = (
907+
ConfigOptions.key("global-index.build.parallelism")
908+
.int_type()
909+
.default_value(1)
910+
.with_description(
911+
"Number of global index shards built concurrently by the local "
912+
"Python builder. Each shard may also use native worker threads, "
913+
"so increase this value conservatively."
914+
)
915+
)
916+
906917
PK_VECTOR_INDEX_COLUMNS: ConfigOption[str] = (
907918
ConfigOptions.key("pk-vector.index.columns")
908919
.string_type()
@@ -1620,6 +1631,9 @@ def global_index_thread_num(self) -> Optional[int]:
16201631
def global_index_row_count_per_shard(self) -> int:
16211632
return self.options.get(CoreOptions.GLOBAL_INDEX_ROW_COUNT_PER_SHARD)
16221633

1634+
def global_index_build_parallelism(self) -> int:
1635+
return self.options.get(CoreOptions.GLOBAL_INDEX_BUILD_PARALLELISM)
1636+
16231637
def primary_key_btree_index_columns(self) -> List[str]:
16241638
return self._primary_key_index_columns(CoreOptions.PK_BTREE_INDEX_COLUMNS)
16251639

‎paimon-python/pypaimon/globalindex/create_global_index.py‎

Lines changed: 149 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
"""Build global index files from Python."""
1919

20+
from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor, wait
2021
from functools import cmp_to_key
2122
from typing import Dict, List, Optional, Sequence, Union
2223

@@ -288,72 +289,164 @@ def _create_sorted_index_writer(self, index_path: str, key_serializer):
288289
def _build_generic_index(
289290
self, splits, unindexed_ranges, index_field, table_read, index_path: str
290291
) -> List[CommitMessage]:
291-
from pypaimon.read.table_read import _ClosableArrowBatchReader
292-
293292
rows_per_shard = self._core_options.global_index_row_count_per_shard()
294293
if rows_per_shard <= 0:
295294
raise ValueError(
296295
"Option 'global-index.row-count-per-shard' must be greater than 0."
297296
)
298297

299-
messages = []
300-
for index_split, index_range in _split_by_global_index_shard(
301-
splits, rows_per_shard, unindexed_ranges
302-
):
303-
writer = None
298+
parallelism = self._core_options.global_index_build_parallelism()
299+
if parallelism <= 0:
300+
raise ValueError(
301+
"Option 'global-index.build.parallelism' must be greater than 0."
302+
)
303+
304+
shards = _split_by_global_index_shard(
305+
splits, rows_per_shard, unindexed_ranges)
306+
if not shards:
307+
return []
308+
309+
if parallelism == 1 or len(shards) == 1:
310+
messages = []
304311
try:
305-
reader, batches = table_read._new_arrow_batch_reader([index_split])
306-
# Close the Python iterator explicitly on failure as well as
307-
# the Arrow reader, which may retain a suspended generator.
308-
with _ClosableArrowBatchReader(reader, batches) as batch_reader:
309-
for batch in batch_reader:
310-
if batch.num_rows == 0:
311-
continue
312-
if writer is None:
313-
writer = self._create_generic_index_writer(
314-
index_path, index_field)
315-
if self._index_type in VINDEX_IDENTIFIERS:
316-
if batch.column(SpecialFields.ROW_ID.name).null_count:
317-
raise ValueError(
318-
"Cannot build global index because _ROW_ID is null.")
319-
for offset in range(0, batch.num_rows, ADD_BATCH_SIZE):
320-
_write_vector_batch(
321-
writer, batch.slice(offset, ADD_BATCH_SIZE),
322-
self._index_columns[0], index_range)
323-
else:
324-
for value, row_id in _extract_index_rows(
325-
batch,
326-
self._index_columns[0],
327-
SpecialFields.ROW_ID.name,
328-
index_range,
329-
):
330-
writer.write(value, row_id - index_range.from_)
331-
del batch
332-
333-
if writer is None:
312+
for index_split, index_range in shards:
313+
message = self._build_generic_shard(
314+
index_split, index_range, index_field, table_read, index_path)
315+
if message is not None:
316+
messages.append(message)
317+
return messages
318+
except BaseException:
319+
self._delete_uncommitted_indexes(messages)
320+
raise
321+
322+
futures = []
323+
try:
324+
with ThreadPoolExecutor(
325+
max_workers=min(parallelism, len(shards)),
326+
thread_name_prefix="paimon-global-index-build",
327+
) as executor:
328+
futures = [
329+
executor.submit(
330+
self._build_generic_shard,
331+
index_split,
332+
index_range,
333+
index_field,
334+
table_read,
335+
index_path,
336+
)
337+
for index_split, index_range in shards
338+
]
339+
done, _ = wait(futures, return_when=FIRST_EXCEPTION)
340+
failed = next(
341+
(future for future in futures
342+
if future in done and future.exception() is not None),
343+
None,
344+
)
345+
if failed is not None:
346+
for future in futures:
347+
future.cancel()
348+
failed.result()
349+
350+
# Futures are consumed in shard-plan order so index manifest
351+
# messages are deterministic even when shards finish out of order.
352+
results = [future.result() for future in futures]
353+
return [message for message in results if message is not None]
354+
except BaseException:
355+
# Exiting the executor waits for in-flight shards to close their
356+
# readers and writers. Delete every completed index because build()
357+
# will not return commit messages after a shard failure.
358+
messages = []
359+
for future in futures:
360+
if future.cancelled() or not future.done():
361+
continue
362+
try:
363+
message = future.result()
364+
except BaseException:
334365
continue
366+
if message is not None:
367+
messages.append(message)
368+
self._delete_uncommitted_indexes(messages)
369+
raise
370+
371+
def _build_generic_shard(
372+
self, index_split, index_range, index_field, table_read, index_path: str
373+
) -> Optional[CommitMessage]:
374+
from pypaimon.read.table_read import _ClosableArrowBatchReader
335375

336-
index_adds = _to_index_manifest_entries(
337-
self._table,
338-
index_split.partition,
339-
index_range,
340-
index_field.id,
341-
self._index_type,
342-
writer.finish(),
343-
)
344-
finally:
345-
if writer is not None:
346-
writer.close()
347-
if index_adds:
348-
messages.append(
349-
CommitMessage(
350-
partition=tuple(index_split.partition.values),
351-
bucket=0,
352-
new_files=[],
353-
index_adds=index_adds,
354-
)
376+
writer = None
377+
try:
378+
reader, batches = table_read._new_arrow_batch_reader([index_split])
379+
# Close the Python iterator explicitly on failure as well as
380+
# the Arrow reader, which may retain a suspended generator.
381+
with _ClosableArrowBatchReader(reader, batches) as batch_reader:
382+
for batch in batch_reader:
383+
if batch.num_rows == 0:
384+
continue
385+
if writer is None:
386+
writer = self._create_generic_index_writer(
387+
index_path, index_field)
388+
if self._index_type in VINDEX_IDENTIFIERS:
389+
if batch.column(SpecialFields.ROW_ID.name).null_count:
390+
raise ValueError(
391+
"Cannot build global index because _ROW_ID is null.")
392+
for offset in range(0, batch.num_rows, ADD_BATCH_SIZE):
393+
_write_vector_batch(
394+
writer, batch.slice(offset, ADD_BATCH_SIZE),
395+
self._index_columns[0], index_range)
396+
else:
397+
for value, row_id in _extract_index_rows(
398+
batch,
399+
self._index_columns[0],
400+
SpecialFields.ROW_ID.name,
401+
index_range,
402+
):
403+
writer.write(value, row_id - index_range.from_)
404+
del batch
405+
406+
if writer is None:
407+
return None
408+
409+
index_adds = _to_index_manifest_entries(
410+
self._table,
411+
index_split.partition,
412+
index_range,
413+
index_field.id,
414+
self._index_type,
415+
writer.finish(),
416+
)
417+
if not index_adds:
418+
return None
419+
return CommitMessage(
420+
partition=tuple(index_split.partition.values),
421+
bucket=0,
422+
new_files=[],
423+
index_adds=index_adds,
424+
)
425+
except BaseException:
426+
if writer is not None:
427+
self._delete_writer_output(writer, index_path)
428+
raise
429+
finally:
430+
if writer is not None:
431+
writer.close()
432+
433+
def _delete_writer_output(self, writer, index_path: str) -> None:
434+
file_name = getattr(writer, "file_name", None)
435+
if file_name:
436+
self._table.file_io.delete_quietly(
437+
"%s/%s" % (index_path.rstrip("/"), file_name))
438+
439+
def _delete_uncommitted_indexes(self, messages) -> None:
440+
path_factory = self._table.path_factory().global_index_path_factory()
441+
for message in messages:
442+
for index_add in message.index_adds:
443+
index_file = index_add.index_file
444+
file_path = (
445+
index_file.external_path
446+
if index_file.external_path is not None
447+
else path_factory.to_path(index_file.file_name)
355448
)
356-
return messages
449+
self._table.file_io.delete_quietly(file_path)
357450

358451
def _create_generic_index_writer(self, index_path: str, index_field):
359452
if self._index_type in VINDEX_IDENTIFIERS:

0 commit comments

Comments
 (0)