Files
2026-07-13 13:17:40 +08:00

274 lines
9.5 KiB
Python

import logging
import os
import uuid
from abc import abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
from pyarrow import parquet as pq
from pyarrow.fs import FileType
if TYPE_CHECKING:
import pyarrow
from ray.data._internal.util import call_with_retry
from ray.data.block import BlockAccessor
from ray.data.checkpoint import CheckpointBackend, CheckpointConfig
from ray.data.context import DataContext
from ray.data.datasource.path_util import _unwrap_protocol
logger = logging.getLogger(__name__)
# Suffix for pending checkpoint files (2-phase commit)
PENDING_CHECKPOINT_SUFFIX = ".pending"
@dataclass
class PendingCheckpoint:
"""Represents a pending checkpoint file for 2-phase commit.
Attributes:
pending_path: Path to the pending checkpoint file.
committed_path: Path where the checkpoint will be after commit.
"""
pending_path: str
committed_path: str
class CheckpointWriter:
"""Abstract class which defines the interface for writing row-level
checkpoints based on varying backends.
Subclasses must implement `.write_block_checkpoint()`.
For 2-phase commit support, subclasses should also implement:
- `.write_pending_checkpoint()`: Write checkpoint as pending file
- `.commit_checkpoint()`: Rename pending to committed
"""
def __init__(self, config: CheckpointConfig):
self.ckpt_config = config
self.checkpoint_path_unwrapped = _unwrap_protocol(
self.ckpt_config.checkpoint_path
)
self.id_col = self.ckpt_config.id_column
self.filesystem = self.ckpt_config.filesystem
self.write_num_threads = self.ckpt_config.write_num_threads
@abstractmethod
def write_block_checkpoint(self, block: BlockAccessor):
"""Write a checkpoint for all rows in a single block to the checkpoint
output directory given by `self.checkpoint_path`.
This is used for non-file datasinks (SQL, MongoDB, etc.) where there's
no predictable file path to store in checkpoint metadata. For file-based
datasinks that need 2-phase commit with data file path tracking, use
`write_pending_checkpoint()` and `commit_checkpoint()` instead.
Args:
block: The block accessor containing the data to checkpoint.
Subclasses of `CheckpointWriter` must implement this method."""
...
def write_pending_checkpoint(
self,
id_column_data: "pyarrow.Array",
checkpoint_id: str,
) -> Optional[PendingCheckpoint]:
"""Write a pending checkpoint for 2-phase commit.
This is called BEFORE the data file is written. The checkpoint filename
is deterministic (based on checkpoint_id), enabling idempotent writes
on retry. For file-based datasinks, the checkpoint filename matches
the data file prefix, enabling recovery to match pending checkpoints
to data files via prefix trie.
Args:
id_column_data: PyArrow array containing the ID column values.
checkpoint_id: Deterministic identifier for the checkpoint file,
derived from write_uuid and task_idx. Must be the same on retry
to ensure idempotent writes.
Returns:
PendingCheckpoint object for later commit, or None if empty.
"""
raise NotImplementedError(
"2-phase commit not implemented for this checkpoint writer"
)
def commit_checkpoint(self, pending: PendingCheckpoint) -> None:
"""Commit a pending checkpoint by renaming it to committed.
This is called AFTER the data file is successfully written.
Args:
pending: The PendingCheckpoint to commit.
"""
raise NotImplementedError(
"2-phase commit not implemented for this checkpoint writer"
)
@staticmethod
def create(config: CheckpointConfig) -> "CheckpointWriter":
"""Factory method to create a `CheckpointWriter` based on the
provided `CheckpointConfig`."""
backend = config.backend
if backend in [
CheckpointBackend.CLOUD_OBJECT_STORAGE,
CheckpointBackend.FILE_STORAGE,
]:
return BatchBasedCheckpointWriter(config)
raise NotImplementedError(f"Backend {backend} not implemented")
class BatchBasedCheckpointWriter(CheckpointWriter):
"""CheckpointWriter for batch-based backends."""
def __init__(self, config: CheckpointConfig):
super().__init__(config)
self.filesystem.create_dir(self.checkpoint_path_unwrapped, recursive=True)
def _prepare_checkpoint_table_from_block(self, block: BlockAccessor):
"""Prepare the checkpoint table from a block.
Args:
block: The block accessor containing the data to checkpoint.
Returns:
PyArrow table with checkpoint IDs.
"""
checkpoint_ids_block = block.select(columns=[self.id_col])
# `pyarrow.parquet.write_parquet` requires a PyArrow table.
return BlockAccessor.for_block(checkpoint_ids_block).to_arrow()
def _prepare_checkpoint_table_from_id_column(self, id_column_data: "pyarrow.Array"):
"""Prepare the checkpoint table from ID column data.
Args:
id_column_data: PyArrow array containing the ID column values.
Returns:
PyArrow table with checkpoint IDs.
"""
import pyarrow as pa
return pa.table({self.id_col: id_column_data})
def write_block_checkpoint(self, block: BlockAccessor) -> None:
"""Write a checkpoint for all rows in a single block to the checkpoint
output directory given by `self.checkpoint_path`.
This is used for non-file datasinks (SQL, MongoDB, etc.) where there's
no predictable file path to store in checkpoint metadata.
Args:
block: The block accessor containing the data to checkpoint.
"""
if block.num_rows() == 0:
return
file_name = f"{uuid.uuid4()}.parquet"
ckpt_file_path = os.path.join(self.checkpoint_path_unwrapped, file_name)
checkpoint_ids_table = self._prepare_checkpoint_table_from_block(block)
def _write():
pq.write_table(
checkpoint_ids_table,
ckpt_file_path,
filesystem=self.filesystem,
)
try:
call_with_retry(
_write,
description=f"Write checkpoint file: {file_name}",
match=DataContext.get_current().retried_io_errors,
)
except Exception:
logger.exception(f"Checkpoint write failed: {file_name}")
raise
def write_pending_checkpoint(
self,
id_column_data,
checkpoint_id: str,
) -> Optional[PendingCheckpoint]:
if len(id_column_data) == 0:
return None
pending_file_name = f"{checkpoint_id}{PENDING_CHECKPOINT_SUFFIX}.parquet"
committed_file_name = f"{checkpoint_id}.parquet"
pending_path = os.path.join(self.checkpoint_path_unwrapped, pending_file_name)
committed_path = os.path.join(
self.checkpoint_path_unwrapped, committed_file_name
)
checkpoint_ids_table = self._prepare_checkpoint_table_from_id_column(
id_column_data
)
def _write():
pq.write_table(
checkpoint_ids_table,
pending_path,
filesystem=self.filesystem,
)
call_with_retry(
_write,
description=f"Write pending checkpoint file: {pending_file_name}",
match=DataContext.get_current().retried_io_errors,
)
return PendingCheckpoint(
pending_path=pending_path,
committed_path=committed_path,
)
def commit_checkpoint(self, pending: PendingCheckpoint) -> None:
"""Commit a pending checkpoint by renaming it to committed.
This is called AFTER the data file is successfully written.
This operation is idempotent: if the committed file already exists
(and pending doesn't), it's considered already committed. This handles
the case where a retry happens after successful commit (e.g., network
timeout after move succeeded but before acknowledgment).
Args:
pending: The PendingCheckpoint to commit.
"""
def _rename():
# Check if already committed (idempotent)
committed_info = self.filesystem.get_file_info(pending.committed_path)
pending_info = self.filesystem.get_file_info(pending.pending_path)
committed_exists = committed_info.type != FileType.NotFound
pending_exists = pending_info.type != FileType.NotFound
if committed_exists:
# Already committed. Clean up pending file if it exists.
if pending_exists:
self.filesystem.delete_file(pending.pending_path)
return
if not pending_exists:
raise FileNotFoundError(
f"Neither pending ({pending.pending_path}) nor committed "
f"({pending.committed_path}) checkpoint exists"
)
# Normal case: move pending to committed
self.filesystem.move(pending.pending_path, pending.committed_path)
call_with_retry(
_rename,
description=f"Commit checkpoint: {pending.pending_path}",
match=DataContext.get_current().retried_io_errors,
)