|
17 | 17 |
|
18 | 18 | """Build global index files from Python.""" |
19 | 19 |
|
| 20 | +from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor, wait |
20 | 21 | from functools import cmp_to_key |
21 | 22 | from typing import Dict, List, Optional, Sequence, Union |
22 | 23 |
|
@@ -288,72 +289,164 @@ def _create_sorted_index_writer(self, index_path: str, key_serializer): |
288 | 289 | def _build_generic_index( |
289 | 290 | self, splits, unindexed_ranges, index_field, table_read, index_path: str |
290 | 291 | ) -> List[CommitMessage]: |
291 | | - from pypaimon.read.table_read import _ClosableArrowBatchReader |
292 | | - |
293 | 292 | rows_per_shard = self._core_options.global_index_row_count_per_shard() |
294 | 293 | if rows_per_shard <= 0: |
295 | 294 | raise ValueError( |
296 | 295 | "Option 'global-index.row-count-per-shard' must be greater than 0." |
297 | 296 | ) |
298 | 297 |
|
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 = [] |
304 | 311 | 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: |
334 | 365 | 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 |
335 | 375 |
|
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) |
355 | 448 | ) |
356 | | - return messages |
| 449 | + self._table.file_io.delete_quietly(file_path) |
357 | 450 |
|
358 | 451 | def _create_generic_index_writer(self, index_path: str, index_field): |
359 | 452 | if self._index_type in VINDEX_IDENTIFIERS: |
|
0 commit comments