2037 lines
70 KiB
Python
2037 lines
70 KiB
Python
import csv
|
|
import os
|
|
import random
|
|
from typing import List, Literal, Union
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pyarrow
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
import pytest
|
|
from pyarrow.fs import FileSelector, FileType, LocalFileSystem
|
|
from pytest_lazy_fixtures import lf as lazy_fixture
|
|
|
|
import ray
|
|
from ray.data._internal.arrow_ops import transform_pyarrow
|
|
from ray.data._internal.datasource.csv_datasource import CSVDatasource
|
|
from ray.data._internal.datasource.parquet_datasink import ParquetDatasink
|
|
from ray.data._internal.execution.interfaces import TaskContext
|
|
from ray.data._internal.execution.operators.map_operator import MapOperator
|
|
from ray.data._internal.logical.interfaces.logical_plan import LogicalPlan
|
|
from ray.data._internal.logical.operators import Read, Write
|
|
from ray.data._internal.logical.optimizers import get_execution_plan
|
|
from ray.data._internal.planner.checkpoint.plan_read_op import (
|
|
_get_checkpoint_map_transformer,
|
|
plan_read_op_with_checkpoint_filter,
|
|
)
|
|
from ray.data._internal.planner.checkpoint.plan_write_op import (
|
|
WRITE_UUID_KWARG_NAME,
|
|
_generate_base_filename,
|
|
_generate_prepare_checkpoint_transform,
|
|
)
|
|
from ray.data.block import BlockAccessor
|
|
from ray.data.checkpoint import CheckpointConfig
|
|
from ray.data.checkpoint.checkpoint_filter import (
|
|
IdColumnCheckpointManager,
|
|
NumpyArrayBasedCheckpointFilter,
|
|
)
|
|
from ray.data.checkpoint.checkpoint_writer import (
|
|
PENDING_CHECKPOINT_SUFFIX,
|
|
BatchBasedCheckpointWriter,
|
|
)
|
|
from ray.data.checkpoint.interfaces import (
|
|
CheckpointBackend,
|
|
InvalidCheckpointingConfig,
|
|
)
|
|
from ray.data.checkpoint.util import PrefixTrie
|
|
from ray.data.context import DataContext
|
|
from ray.data.datasource import BlockBasedFileDatasink, RowBasedFileDatasink
|
|
from ray.data.datasource.path_util import _unwrap_protocol
|
|
from ray.data.tests.conftest import * # noqa
|
|
from ray.tests.conftest import * # noqa
|
|
|
|
# User-provided ID column name
|
|
ID_COL = "id"
|
|
# Number of rows in the sample data
|
|
SAMPLE_DATA_NUM_ROWS = 10
|
|
|
|
# Auto-use `restore_data_context` for each test and apply 300-second timeout to all tests.
|
|
pytestmark = [
|
|
pytest.mark.usefixtures("restore_data_context"),
|
|
pytest.mark.timeout(300),
|
|
]
|
|
|
|
|
|
@pytest.fixture
|
|
def generate_sample_data_csv(tmp_path):
|
|
def _generate(id_type: Literal["int", "str"] = "int"):
|
|
# Generate a dummy dataset with SAMPLE_DATA_NUM_ROWS rows and columns [ID_COL, "col1"]
|
|
ids = (
|
|
[f"id_{i}" for i in range(SAMPLE_DATA_NUM_ROWS)]
|
|
if id_type == "str"
|
|
else list(range(SAMPLE_DATA_NUM_ROWS))
|
|
)
|
|
|
|
data = [{ID_COL: id_val, "col1": random.random()} for id_val in ids]
|
|
|
|
f_path = os.path.join(tmp_path, "sample_data.csv")
|
|
with open(f_path, mode="w", newline="") as file:
|
|
writer = csv.DictWriter(file, fieldnames=data[0].keys())
|
|
writer.writeheader()
|
|
writer.writerows(data)
|
|
return f_path
|
|
|
|
return _generate
|
|
|
|
|
|
@pytest.fixture
|
|
def checkpoint_path(tmp_path):
|
|
"""Fixture to provide a temporary checkpoint path."""
|
|
return str(tmp_path / "checkpoint")
|
|
|
|
|
|
@pytest.fixture
|
|
def data_output_path(data_path):
|
|
"""Fixture to provide a standardized data output path."""
|
|
return os.path.join(data_path, "output")
|
|
|
|
|
|
@pytest.fixture
|
|
def generate_sample_data_parquet(tmp_path):
|
|
def _generate():
|
|
f_dir = os.path.join(tmp_path, "sample_data_parquet")
|
|
os.makedirs(f_dir, exist_ok=True)
|
|
# Generate a dummy dataset with SAMPLE_DATA_NUM_ROWS rows and columns [ID_COL, "col1"]
|
|
df = pd.DataFrame(
|
|
[{ID_COL: i, "col1": random.random()} for i in range(SAMPLE_DATA_NUM_ROWS)]
|
|
)
|
|
|
|
f_path = os.path.join(f_dir, "sample_data.parquet")
|
|
# Write 3 row groups per file with uneven distribution of rows per row group
|
|
table = pa.table(df)
|
|
row_group_size = max(1, SAMPLE_DATA_NUM_ROWS // 3)
|
|
pq.write_table(table, f_path, row_group_size=row_group_size)
|
|
return f_dir
|
|
|
|
return _generate
|
|
|
|
|
|
@pytest.fixture
|
|
def generate_sample_physical_plan(generate_sample_data_csv, tmp_path):
|
|
ctx = ray.data.DataContext.get_current()
|
|
|
|
datasource = CSVDatasource(generate_sample_data_csv())
|
|
|
|
read_op = Read(datasource, datasource, -1, None)
|
|
write_path = os.path.join(tmp_path, "output")
|
|
write_op = Write(ParquetDatasink(write_path), input_dependencies=[read_op])
|
|
logical_plan = LogicalPlan(write_op, ctx)
|
|
physical_plan, _ = get_execution_plan(logical_plan)
|
|
yield physical_plan
|
|
|
|
|
|
def _get_batch_based_files(ckpt_path: str, fs) -> List[str]:
|
|
"""Get checkpoint file paths for batch-based backends."""
|
|
if fs is None:
|
|
if not os.path.exists(ckpt_path):
|
|
return []
|
|
return [os.path.join(ckpt_path, f) for f in os.listdir(ckpt_path)]
|
|
else:
|
|
files = fs.get_file_info(
|
|
FileSelector(_unwrap_protocol(ckpt_path), allow_not_found=True)
|
|
)
|
|
return [file_info.path for file_info in files if file_info.is_file]
|
|
|
|
|
|
def _read_batch_file_ids(file_paths: List[str], id_column: str, fs) -> List[int]:
|
|
"""Read IDs from batch-based checkpoint files."""
|
|
ids = []
|
|
for file_path in file_paths:
|
|
if fs is None:
|
|
table = pa.parquet.read_table(file_path)
|
|
else:
|
|
with fs.open_input_file(file_path) as f:
|
|
table = pa.parquet.read_table(f)
|
|
df = table.to_pandas()
|
|
ids.extend(df[id_column].tolist())
|
|
return ids
|
|
|
|
|
|
def read_ids_from_checkpoint_files(config: CheckpointConfig) -> List[Union[int, str]]:
|
|
"""Reads the checkpoint files and returns a sorted list of IDs which have been checkpointed."""
|
|
# Batch-based backends
|
|
if config.backend in (
|
|
CheckpointBackend.FILE_STORAGE,
|
|
CheckpointBackend.CLOUD_OBJECT_STORAGE,
|
|
):
|
|
file_paths = _get_batch_based_files(config.checkpoint_path, config.filesystem)
|
|
return sorted(
|
|
_read_batch_file_ids(file_paths, config.id_column, config.filesystem)
|
|
)
|
|
|
|
else:
|
|
raise ValueError(f"Invalid backend: {config.backend}")
|
|
|
|
|
|
class TestCheckpointConfig:
|
|
@pytest.mark.parametrize("id_column", ["", 1])
|
|
def test_invalid_id_column(self, id_column, local_path):
|
|
with pytest.raises(
|
|
InvalidCheckpointingConfig,
|
|
match="Checkpoint ID column",
|
|
):
|
|
CheckpointConfig(id_column, local_path)
|
|
|
|
def test_override_backend_emits_deprecation_warning(self):
|
|
with pytest.warns(FutureWarning, match="deprecated"):
|
|
CheckpointConfig(
|
|
ID_COL,
|
|
"s3://bucket/path",
|
|
override_backend=CheckpointBackend.FILE_STORAGE,
|
|
)
|
|
|
|
def test_default_checkpoint_path(self, s3_path, monkeypatch):
|
|
with pytest.raises(
|
|
InvalidCheckpointingConfig,
|
|
match="CheckpointConfig.checkpoint_path",
|
|
):
|
|
CheckpointConfig(ID_COL, None)
|
|
|
|
default_bucket = s3_path
|
|
monkeypatch.setenv(
|
|
CheckpointConfig.DEFAULT_CHECKPOINT_PATH_BUCKET_ENV_VAR, default_bucket
|
|
)
|
|
|
|
config = CheckpointConfig(ID_COL, None)
|
|
assert (
|
|
config.checkpoint_path
|
|
== f"{default_bucket}/{CheckpointConfig.DEFAULT_CHECKPOINT_PATH_DIR}"
|
|
)
|
|
|
|
@pytest.mark.parametrize("checkpoint_path", ["tmp/", "s3:/tmp", "s4://tmp"])
|
|
def test_invalid_checkpoint_path(self, checkpoint_path):
|
|
with pytest.raises(
|
|
InvalidCheckpointingConfig,
|
|
match="Invalid checkpoint path",
|
|
):
|
|
CheckpointConfig(ID_COL, checkpoint_path)
|
|
|
|
@pytest.mark.parametrize(
|
|
"checkpoint_path",
|
|
[
|
|
lazy_fixture("local_path"),
|
|
lazy_fixture("s3_path"),
|
|
],
|
|
)
|
|
def test_infer_filesystem_and_backend(self, checkpoint_path):
|
|
config = CheckpointConfig(ID_COL, checkpoint_path)
|
|
if checkpoint_path.startswith("/"):
|
|
assert isinstance(config.filesystem, pyarrow.fs.LocalFileSystem)
|
|
assert config.backend == CheckpointBackend.FILE_STORAGE
|
|
else:
|
|
assert isinstance(config.filesystem, pyarrow.fs.S3FileSystem)
|
|
assert config.backend == CheckpointBackend.CLOUD_OBJECT_STORAGE
|
|
|
|
@pytest.mark.parametrize(
|
|
"checkpoint_path,fs,backend",
|
|
[
|
|
(
|
|
lazy_fixture("local_path"),
|
|
lazy_fixture("local_fs"),
|
|
CheckpointBackend.FILE_STORAGE,
|
|
),
|
|
(
|
|
lazy_fixture("s3_path"),
|
|
lazy_fixture("s3_fs"),
|
|
CheckpointBackend.FILE_STORAGE,
|
|
),
|
|
(
|
|
lazy_fixture("local_path"),
|
|
lazy_fixture("local_fs"),
|
|
CheckpointBackend.CLOUD_OBJECT_STORAGE,
|
|
),
|
|
(
|
|
lazy_fixture("s3_path"),
|
|
lazy_fixture("s3_fs"),
|
|
CheckpointBackend.CLOUD_OBJECT_STORAGE,
|
|
),
|
|
],
|
|
)
|
|
def test_override_filesystem_and_backend(self, checkpoint_path, fs, backend):
|
|
config = CheckpointConfig(
|
|
ID_COL, checkpoint_path, override_filesystem=fs, override_backend=backend
|
|
)
|
|
assert config.filesystem is fs
|
|
assert config.backend is backend
|
|
|
|
def test_skip_inference_with_overrides(self):
|
|
"""Test that filesystem inference is skipped when override is provided."""
|
|
# Inferring filesystem will fail if the path doesn't exist.
|
|
path = "s3://non-existing-bucket/"
|
|
fs = pyarrow.fs.S3FileSystem()
|
|
config = CheckpointConfig(
|
|
ID_COL,
|
|
path,
|
|
override_filesystem=fs,
|
|
)
|
|
assert config.filesystem is fs
|
|
assert config.backend is CheckpointBackend.CLOUD_OBJECT_STORAGE
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"backend,fs,data_path",
|
|
[
|
|
(CheckpointBackend.FILE_STORAGE, None, lazy_fixture("local_path")),
|
|
(
|
|
CheckpointBackend.FILE_STORAGE,
|
|
lazy_fixture("local_fs"),
|
|
lazy_fixture("local_path"),
|
|
),
|
|
(
|
|
CheckpointBackend.CLOUD_OBJECT_STORAGE,
|
|
lazy_fixture("s3_fs"),
|
|
lazy_fixture("s3_path"),
|
|
),
|
|
],
|
|
)
|
|
def test_checkpoint_end_to_end(
|
|
ray_start_10_cpus_shared,
|
|
generate_sample_data_csv,
|
|
backend,
|
|
fs,
|
|
data_path,
|
|
data_output_path,
|
|
):
|
|
"""The end-to-end test for checkpoint."""
|
|
|
|
class TestActor:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def __call__(self, batch):
|
|
return batch
|
|
|
|
ctx = ray.data.DataContext.get_current()
|
|
ckpt_path = os.path.join(data_path, "test_checkpoint_output_files")
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=ckpt_path,
|
|
override_filesystem=fs,
|
|
override_backend=backend,
|
|
)
|
|
|
|
csv_file = generate_sample_data_csv()
|
|
|
|
# Generate checkpoint file
|
|
checkpointed_ids = list(range(SAMPLE_DATA_NUM_ROWS // 2))
|
|
expected_remaining_ids = sorted(
|
|
set(range(SAMPLE_DATA_NUM_ROWS)) - set(checkpointed_ids)
|
|
)
|
|
|
|
ckpt_unwrapped = _unwrap_protocol(ckpt_path)
|
|
if fs is None:
|
|
os.makedirs(ckpt_unwrapped, exist_ok=True)
|
|
ckpt_table = pa.table({ID_COL: checkpointed_ids})
|
|
pq.write_table(
|
|
ckpt_table, os.path.join(ckpt_unwrapped, "pre_checkpoint.parquet")
|
|
)
|
|
else:
|
|
fs.create_dir(ckpt_unwrapped)
|
|
ckpt_table = pa.table({ID_COL: checkpointed_ids})
|
|
ckpt_file_path = os.path.join(ckpt_unwrapped, "pre_checkpoint.parquet")
|
|
with fs.open_output_stream(ckpt_file_path) as f:
|
|
pq.write_table(ckpt_table, f)
|
|
|
|
ds = ray.data.read_csv(csv_file)
|
|
|
|
# Execute the dataset with checkpointing enabled.
|
|
ds = ds.map_batches(TestActor, concurrency=1)
|
|
ds.write_parquet(data_output_path, filesystem=fs)
|
|
|
|
# When execution succeeds, checkpoint data should be automatically deleted.
|
|
# Check that the checkpoint directory is empty or doesn't exist
|
|
if ctx.checkpoint_config.delete_checkpoint_on_success:
|
|
try:
|
|
unwrapped_path = _unwrap_protocol(ckpt_path)
|
|
# Try to get file info for the checkpoint directory
|
|
files = ctx.checkpoint_config.filesystem.get_file_info(
|
|
pyarrow.fs.FileSelector(unwrapped_path, recursive=True)
|
|
)
|
|
# If we can get file info, the directory exists and should be empty
|
|
assert (
|
|
len(files) == 0
|
|
), f"Checkpoint directory should be empty but contains {len(files)} files"
|
|
except (FileNotFoundError, OSError):
|
|
# If directory doesn't exist, that's also fine (cleanup worked)
|
|
pass
|
|
|
|
# Ensure that the written data only contains the non-checkpointed rows.
|
|
# Disable checkpointing before reading back to avoid filtering.
|
|
ctx.checkpoint_config = None
|
|
ds_readback = ray.data.read_parquet(data_output_path, filesystem=fs)
|
|
actual_output = sorted([row[ID_COL] for row in ds_readback.iter_rows()])
|
|
assert actual_output == expected_remaining_ids, (
|
|
f"Expected only non-checkpointed IDs {expected_remaining_ids}, "
|
|
f"but got {actual_output}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"backend,fs,data_path",
|
|
[
|
|
(CheckpointBackend.FILE_STORAGE, None, lazy_fixture("local_path")),
|
|
(
|
|
CheckpointBackend.FILE_STORAGE,
|
|
lazy_fixture("local_fs"),
|
|
lazy_fixture("local_path"),
|
|
),
|
|
(
|
|
CheckpointBackend.CLOUD_OBJECT_STORAGE,
|
|
lazy_fixture("s3_fs"),
|
|
lazy_fixture("s3_path"),
|
|
),
|
|
],
|
|
)
|
|
def test_full_dataset_executed_for_non_write(
|
|
ray_start_10_cpus_shared,
|
|
generate_sample_data_parquet,
|
|
backend,
|
|
fs,
|
|
data_path,
|
|
data_output_path,
|
|
):
|
|
"""Tests that for an already fully checkpointed Dataset,
|
|
calling `schema()` and `count()` should not skip checkpointing
|
|
and should execute the full Dataset to get the correct information.
|
|
"""
|
|
|
|
ctx = ray.data.DataContext.get_current()
|
|
ctx.default_hash_shuffle_parallelism = 1
|
|
ckpt_path = os.path.join(data_path, "test_checkpoint_output_files")
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=ckpt_path,
|
|
override_filesystem=fs,
|
|
override_backend=backend,
|
|
)
|
|
|
|
parquet_dir = generate_sample_data_parquet()
|
|
|
|
ds = ray.data.read_parquet(parquet_dir)
|
|
|
|
ds = ds.map(lambda row: row)
|
|
|
|
# Get the schema and count prior to writing the dataset.
|
|
schema_before_write = ds.schema()
|
|
count_before_write = ds.count()
|
|
|
|
ds.write_parquet(data_output_path, filesystem=fs)
|
|
|
|
# Recreate the same dataset, so that it will skip checkpointed rows.
|
|
ds2 = ray.data.read_parquet(parquet_dir)
|
|
ds2 = ds2.map(lambda row: row)
|
|
|
|
# Check that when re-running a dataset which has already been completely
|
|
# checkpointed, it does not skip any rows during `schema()` and `count()` calls.
|
|
assert ds2.schema() == schema_before_write
|
|
assert ds2.count() == count_before_write
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"ds_factory",
|
|
[
|
|
(lazy_fixture("generate_sample_data_parquet")),
|
|
(lazy_fixture("generate_sample_data_parquet")),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"backend,fs,data_path",
|
|
[
|
|
(CheckpointBackend.FILE_STORAGE, None, lazy_fixture("local_path")),
|
|
(
|
|
CheckpointBackend.FILE_STORAGE,
|
|
lazy_fixture("local_fs"),
|
|
lazy_fixture("local_path"),
|
|
),
|
|
(
|
|
CheckpointBackend.CLOUD_OBJECT_STORAGE,
|
|
lazy_fixture("s3_fs"),
|
|
lazy_fixture("s3_path"),
|
|
),
|
|
],
|
|
)
|
|
def test_recovery_no_missing_rows(
|
|
ray_start_10_cpus_shared,
|
|
ds_factory,
|
|
backend,
|
|
fs,
|
|
data_path,
|
|
data_output_path,
|
|
):
|
|
"""Tests that recovery is at_least_once: no missing rows after retry."""
|
|
|
|
ctx = ray.data.DataContext.get_current()
|
|
ctx.execution_options.preserve_order = True
|
|
ctx.default_hash_shuffle_parallelism = 1
|
|
ckpt_path = os.path.join(data_path, "test_checkpoint_output_files")
|
|
|
|
# Ensure checkpoint directory exists
|
|
os.makedirs(ckpt_path, exist_ok=True)
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=ckpt_path,
|
|
override_filesystem=fs,
|
|
override_backend=backend,
|
|
)
|
|
|
|
# Catch the custom TestException raised by FailActor.
|
|
ctx.raise_original_map_exception = True
|
|
|
|
@ray.remote(num_cpus=0)
|
|
class Coordinator:
|
|
def __init__(self):
|
|
self._should_fail = True
|
|
|
|
def disable_failure(self):
|
|
self._should_fail = False
|
|
|
|
def should_fail(self):
|
|
return self._should_fail
|
|
|
|
coordinator_actor = Coordinator.remote()
|
|
|
|
class TestException(Exception):
|
|
pass
|
|
|
|
class FailActor:
|
|
"""Simple passthrough actor, which fails after a certain number of rows."""
|
|
|
|
def __init__(self, coordinator_actor, max_num_items, checkpoint_config):
|
|
self._should_fail = ray.get(coordinator_actor.should_fail.remote())
|
|
self._max_num_items = max_num_items
|
|
self._checkpoint_config = checkpoint_config
|
|
|
|
def __call__(self, batch):
|
|
# Get the ID column name from the checkpoint config
|
|
id_col = self._checkpoint_config.id_column
|
|
|
|
# Process each row in the batch
|
|
ids = batch[id_col]
|
|
|
|
for _, id in enumerate(ids):
|
|
if self._should_fail and id == 2:
|
|
raise TestException(f"FailActor: Failing on row {id}")
|
|
|
|
return batch
|
|
|
|
# Use the ds_factory to create the dataset
|
|
local_data_path = ds_factory()
|
|
ds = ray.data.read_parquet(local_data_path)
|
|
|
|
# Get the actual number of items from the dataset
|
|
max_num_items = ds.count()
|
|
|
|
ds = ds.map_batches(
|
|
FailActor,
|
|
fn_constructor_args=[coordinator_actor, max_num_items, ctx.checkpoint_config],
|
|
concurrency=1,
|
|
batch_size=None,
|
|
num_cpus=1.1, # Use a different num_cpus to avoid operator fusion.
|
|
)
|
|
|
|
# Should fail in the middle.
|
|
with pytest.raises(TestException):
|
|
ds.write_parquet(data_output_path, filesystem=fs, concurrency=1)
|
|
|
|
ray.get(coordinator_actor.disable_failure.remote())
|
|
# When executing the same dataset again, this should skip the already
|
|
# checkpointed rows.
|
|
ds.write_parquet(data_output_path, filesystem=fs, concurrency=1)
|
|
|
|
# When execution succeeds, checkpoint data should be automatically deleted.
|
|
assert read_ids_from_checkpoint_files(ctx.checkpoint_config) == []
|
|
|
|
# Get the ID column name from the checkpoint config
|
|
id_col = ctx.checkpoint_config.id_column
|
|
|
|
# Disable checkpointing prior to reading back the data, so we don't skip any rows.
|
|
ctx.checkpoint_config = None
|
|
|
|
# Ensure that the written data is correct.
|
|
ds_readback = ray.data.read_parquet(data_output_path, filesystem=fs)
|
|
|
|
# Checkpointing is at_least_once, so duplicates are allowed; ensure no missing IDs.
|
|
actual_output = [row[id_col] for row in ds_readback.iter_rows()]
|
|
expected_output = list(range(max_num_items))
|
|
assert set(actual_output) == set(expected_output)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"backend,fs,data_path",
|
|
[
|
|
(CheckpointBackend.FILE_STORAGE, None, lazy_fixture("local_path")),
|
|
(
|
|
CheckpointBackend.FILE_STORAGE,
|
|
lazy_fixture("local_fs"),
|
|
lazy_fixture("local_path"),
|
|
),
|
|
(
|
|
CheckpointBackend.CLOUD_OBJECT_STORAGE,
|
|
lazy_fixture("s3_fs"),
|
|
lazy_fixture("s3_path"),
|
|
),
|
|
],
|
|
)
|
|
def test_manual_checkpoint_filters_ids(
|
|
ray_start_10_cpus_shared,
|
|
generate_sample_data_parquet,
|
|
backend,
|
|
fs,
|
|
data_path,
|
|
data_output_path,
|
|
):
|
|
"""Manually write checkpoint IDs and verify they are filtered from output."""
|
|
ctx = ray.data.DataContext.get_current()
|
|
ctx.default_hash_shuffle_parallelism = 1
|
|
ckpt_path = os.path.join(data_path, "test_manual_checkpoint_files")
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=ckpt_path,
|
|
override_filesystem=fs,
|
|
override_backend=backend,
|
|
)
|
|
|
|
checkpoint_ids = [1, 3, 7]
|
|
df = pd.DataFrame({ID_COL: checkpoint_ids})
|
|
checkpoint_writer = BatchBasedCheckpointWriter(ctx.checkpoint_config)
|
|
checkpoint_writer.write_block_checkpoint(BlockAccessor.for_block(df))
|
|
assert set(read_ids_from_checkpoint_files(ctx.checkpoint_config)) == set(
|
|
checkpoint_ids
|
|
)
|
|
|
|
parquet_dir = generate_sample_data_parquet()
|
|
ds = ray.data.read_parquet(parquet_dir)
|
|
ds.write_parquet(data_output_path, filesystem=fs)
|
|
|
|
# Disable checkpointing prior to reading back the data, so we don't skip any rows.
|
|
ctx.checkpoint_config = None
|
|
ds_readback = ray.data.read_parquet(data_output_path, filesystem=fs)
|
|
actual_output = sorted([row[ID_COL] for row in ds_readback.iter_rows()])
|
|
expected_output = sorted(
|
|
[i for i in range(SAMPLE_DATA_NUM_ROWS) if i not in set(checkpoint_ids)]
|
|
)
|
|
assert actual_output == expected_output
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_path",
|
|
[
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
],
|
|
ids=["local", "s3"],
|
|
)
|
|
def test_pending_checkpoint_write_and_commit(fs, base_path):
|
|
"""Test the two-phase commit (2PC) checkpoint write and commit workflow.
|
|
|
|
This verifies that:
|
|
1. write_pending_checkpoint() creates a checkpoint file with PENDING suffix
|
|
2. The pending checkpoint exists but the committed version does not
|
|
3. commit_checkpoint() renames the pending file to committed (removes suffix)
|
|
4. After commit, the pending file no longer exists and committed file exists
|
|
"""
|
|
ctx = ray.data.DataContext.get_current()
|
|
checkpoint_path = os.path.join(base_path, "checkpoint")
|
|
fs.create_dir(_unwrap_protocol(checkpoint_path))
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
override_filesystem=fs,
|
|
)
|
|
|
|
df = pd.DataFrame({ID_COL: [1, 2], "col1": [0.1, 0.2]})
|
|
|
|
writer = BatchBasedCheckpointWriter(ctx.checkpoint_config)
|
|
id_column_data = BlockAccessor.for_block(df).to_arrow()[ID_COL]
|
|
pending = writer.write_pending_checkpoint(
|
|
id_column_data,
|
|
checkpoint_id="test_000000_000000",
|
|
)
|
|
assert pending is not None
|
|
assert fs.get_file_info(pending.pending_path).type != FileType.NotFound
|
|
assert fs.get_file_info(pending.committed_path).type == FileType.NotFound
|
|
|
|
writer.commit_checkpoint(pending)
|
|
assert fs.get_file_info(pending.pending_path).type == FileType.NotFound
|
|
assert fs.get_file_info(pending.committed_path).type != FileType.NotFound
|
|
|
|
# Read back and verify contents
|
|
with fs.open_input_file(pending.committed_path) as f:
|
|
table = pq.read_table(f)
|
|
assert table.column(ID_COL).to_pylist() == [1, 2]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_path",
|
|
[
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
],
|
|
ids=["local", "s3"],
|
|
)
|
|
def test_commit_checkpoint_idempotent_already_committed(fs, base_path):
|
|
"""Test that commit_checkpoint is idempotent when already committed.
|
|
|
|
If the committed file already exists and pending doesn't, calling
|
|
commit_checkpoint should succeed without error (no-op).
|
|
"""
|
|
ctx = ray.data.DataContext.get_current()
|
|
checkpoint_path = os.path.join(base_path, "checkpoint")
|
|
fs.create_dir(_unwrap_protocol(checkpoint_path))
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
override_filesystem=fs,
|
|
)
|
|
|
|
df = pd.DataFrame({ID_COL: [1, 2]})
|
|
|
|
writer = BatchBasedCheckpointWriter(ctx.checkpoint_config)
|
|
id_column_data = BlockAccessor.for_block(df).to_arrow()[ID_COL]
|
|
pending = writer.write_pending_checkpoint(
|
|
id_column_data,
|
|
checkpoint_id="test_000000_000000",
|
|
)
|
|
assert pending is not None
|
|
|
|
# First commit succeeds
|
|
writer.commit_checkpoint(pending)
|
|
assert fs.get_file_info(pending.pending_path).type == FileType.NotFound
|
|
assert fs.get_file_info(pending.committed_path).type != FileType.NotFound
|
|
|
|
# Second commit should be idempotent (no error)
|
|
writer.commit_checkpoint(pending)
|
|
assert fs.get_file_info(pending.pending_path).type == FileType.NotFound
|
|
assert fs.get_file_info(pending.committed_path).type != FileType.NotFound
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_path",
|
|
[
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
],
|
|
ids=["local", "s3"],
|
|
)
|
|
def test_commit_checkpoint_idempotent_both_exist(fs, base_path):
|
|
"""Test that commit_checkpoint cleans up when both files exist.
|
|
|
|
If both committed and pending files exist (edge case), the pending
|
|
file should be deleted and committed file preserved.
|
|
"""
|
|
ctx = ray.data.DataContext.get_current()
|
|
checkpoint_path = os.path.join(base_path, "checkpoint")
|
|
fs.create_dir(_unwrap_protocol(checkpoint_path))
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
override_filesystem=fs,
|
|
)
|
|
|
|
df = pd.DataFrame({ID_COL: [1, 2]})
|
|
|
|
writer = BatchBasedCheckpointWriter(ctx.checkpoint_config)
|
|
id_column_data = BlockAccessor.for_block(df).to_arrow()[ID_COL]
|
|
pending = writer.write_pending_checkpoint(
|
|
id_column_data,
|
|
checkpoint_id="test_000000_000000",
|
|
)
|
|
assert pending is not None
|
|
|
|
# Commit normally
|
|
writer.commit_checkpoint(pending)
|
|
|
|
# Manually recreate the pending file to simulate edge case
|
|
with fs.open_output_stream(pending.pending_path) as f:
|
|
f.write(b"dummy")
|
|
|
|
assert fs.get_file_info(pending.pending_path).type != FileType.NotFound
|
|
assert fs.get_file_info(pending.committed_path).type != FileType.NotFound
|
|
|
|
# Commit should clean up the pending file
|
|
writer.commit_checkpoint(pending)
|
|
assert fs.get_file_info(pending.pending_path).type == FileType.NotFound
|
|
assert fs.get_file_info(pending.committed_path).type != FileType.NotFound
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_path",
|
|
[
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
],
|
|
ids=["local", "s3"],
|
|
)
|
|
def test_commit_checkpoint_neither_exists(fs, base_path):
|
|
"""Test that commit_checkpoint raises error when neither file exists."""
|
|
ctx = ray.data.DataContext.get_current()
|
|
checkpoint_path = os.path.join(base_path, "checkpoint")
|
|
fs.create_dir(_unwrap_protocol(checkpoint_path))
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
override_filesystem=fs,
|
|
)
|
|
|
|
df = pd.DataFrame({ID_COL: [1, 2]})
|
|
|
|
writer = BatchBasedCheckpointWriter(ctx.checkpoint_config)
|
|
id_column_data = BlockAccessor.for_block(df).to_arrow()[ID_COL]
|
|
pending = writer.write_pending_checkpoint(
|
|
id_column_data,
|
|
checkpoint_id="test_000000_000000",
|
|
)
|
|
assert pending is not None
|
|
|
|
# Delete the pending file to simulate missing state
|
|
fs.delete_file(pending.pending_path)
|
|
assert fs.get_file_info(pending.pending_path).type == FileType.NotFound
|
|
assert fs.get_file_info(pending.committed_path).type == FileType.NotFound
|
|
|
|
# Commit should raise FileNotFoundError
|
|
with pytest.raises(FileNotFoundError):
|
|
writer.commit_checkpoint(pending)
|
|
|
|
|
|
@pytest.mark.parametrize("data_file_exists", [True, False])
|
|
@pytest.mark.parametrize(
|
|
"fs,base_path",
|
|
[
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
],
|
|
ids=["local", "s3"],
|
|
)
|
|
def test_clean_pending_checkpoint(
|
|
ray_start_10_cpus_shared, fs, base_path, data_file_exists
|
|
):
|
|
"""Test pending checkpoint cleanup removes incomplete writes.
|
|
|
|
When a write fails after creating a pending checkpoint but before commit,
|
|
the cleanup process must:
|
|
1. Find pending checkpoint files, build prefix trie from their basenames
|
|
2. Delete associated data files matching the prefix (if they exist)
|
|
3. Delete the pending checkpoint files
|
|
|
|
This test verifies cleanup works correctly whether the data file was
|
|
actually written (data_file_exists=True) or not (data_file_exists=False).
|
|
"""
|
|
ctx = ray.data.DataContext.get_current()
|
|
checkpoint_path = os.path.join(base_path, "checkpoint")
|
|
data_dir = os.path.join(base_path, "data")
|
|
for p in [checkpoint_path, data_dir]:
|
|
fs.create_dir(_unwrap_protocol(p))
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
override_filesystem=fs,
|
|
)
|
|
|
|
writer = BatchBasedCheckpointWriter(ctx.checkpoint_config)
|
|
|
|
# Write a pending checkpoint (simulating pre-write phase)
|
|
df = pd.DataFrame({ID_COL: [0]})
|
|
id_column_data = BlockAccessor.for_block(df).to_arrow()[ID_COL]
|
|
pending = writer.write_pending_checkpoint(
|
|
id_column_data,
|
|
checkpoint_id="test_000000_000000",
|
|
)
|
|
assert pending is not None
|
|
assert fs.get_file_info(pending.pending_path).type != FileType.NotFound
|
|
|
|
# Optionally create a data file matching the pending checkpoint prefix
|
|
data_file = os.path.join(_unwrap_protocol(data_dir), "test_000000_000000.csv")
|
|
if data_file_exists:
|
|
with fs.open_output_stream(data_file) as f:
|
|
f.write(b"id\n0\n")
|
|
|
|
checkpoint_manager = IdColumnCheckpointManager(ctx.checkpoint_config, ctx)
|
|
checkpoint_manager._clean_pending_checkpoints(data_dir, fs)
|
|
|
|
# Data file should be deleted (if it existed)
|
|
if data_file_exists:
|
|
assert fs.get_file_info(data_file).type == FileType.NotFound
|
|
# Pending checkpoint should be deleted
|
|
assert fs.get_file_info(pending.pending_path).type == FileType.NotFound
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_path",
|
|
[
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
],
|
|
ids=["local", "s3"],
|
|
)
|
|
def test_clean_pending_checkpoint_with_partitioned_data(
|
|
ray_start_10_cpus_shared, fs, base_path
|
|
):
|
|
"""Test pending checkpoint cleanup removes files in partition subdirectories.
|
|
|
|
When using ParquetDatasink with partition_cols, data files are written to
|
|
subdirectories like output/col=val/file.parquet. The cleanup process must
|
|
recursively search subdirectories to find and delete data files matching
|
|
pending checkpoint prefixes.
|
|
"""
|
|
ctx = ray.data.DataContext.get_current()
|
|
checkpoint_path = os.path.join(base_path, "checkpoint")
|
|
data_dir = os.path.join(base_path, "data")
|
|
for p in [checkpoint_path, data_dir]:
|
|
fs.create_dir(_unwrap_protocol(p))
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
override_filesystem=fs,
|
|
)
|
|
|
|
writer = BatchBasedCheckpointWriter(ctx.checkpoint_config)
|
|
|
|
# Write a pending checkpoint for "pending_task" (simulating failed write)
|
|
base_filename = "pending_task"
|
|
df = pd.DataFrame({ID_COL: [0, 1, 2]})
|
|
id_column_data = BlockAccessor.for_block(df).to_arrow()[ID_COL]
|
|
pending = writer.write_pending_checkpoint(
|
|
id_column_data,
|
|
checkpoint_id=base_filename,
|
|
)
|
|
assert pending is not None
|
|
|
|
# Create data files matching the pending checkpoint in partition subdirectories
|
|
partition_dirs = ["partition_col=a", "partition_col=b"]
|
|
pending_data_files = []
|
|
for partition_dir in partition_dirs:
|
|
partition_path = os.path.join(_unwrap_protocol(data_dir), partition_dir)
|
|
fs.create_dir(partition_path)
|
|
data_file = os.path.join(partition_path, f"{base_filename}-0.parquet")
|
|
with fs.open_output_stream(data_file) as f:
|
|
f.write(b"dummy data")
|
|
pending_data_files.append(data_file)
|
|
|
|
# Also create a matching file directly in base dir
|
|
base_data_file = os.path.join(
|
|
_unwrap_protocol(data_dir), f"{base_filename}-0.parquet"
|
|
)
|
|
with fs.open_output_stream(base_data_file) as f:
|
|
f.write(b"dummy data")
|
|
pending_data_files.append(base_data_file)
|
|
|
|
# Create unrelated data files that should NOT be deleted
|
|
unrelated_files = []
|
|
for partition_dir in partition_dirs:
|
|
partition_path = os.path.join(_unwrap_protocol(data_dir), partition_dir)
|
|
data_file = os.path.join(partition_path, "other_task-0.parquet")
|
|
with fs.open_output_stream(data_file) as f:
|
|
f.write(b"dummy data")
|
|
unrelated_files.append(data_file)
|
|
|
|
# Verify all files were created
|
|
for f in pending_data_files + unrelated_files:
|
|
assert (
|
|
fs.get_file_info(f).type != FileType.NotFound
|
|
), f"Expected file to exist: {f}"
|
|
|
|
# Run cleanup
|
|
checkpoint_manager = IdColumnCheckpointManager(ctx.checkpoint_config, ctx)
|
|
checkpoint_manager._clean_pending_checkpoints(data_dir, fs)
|
|
|
|
# Verify data files matching pending checkpoint were deleted
|
|
for f in pending_data_files:
|
|
assert (
|
|
fs.get_file_info(f).type == FileType.NotFound
|
|
), f"Expected pending data file to be deleted: {f}"
|
|
|
|
# Verify unrelated data files still exist
|
|
for f in unrelated_files:
|
|
assert (
|
|
fs.get_file_info(f).type != FileType.NotFound
|
|
), f"Expected unrelated file to still exist: {f}"
|
|
|
|
# Verify pending checkpoint was also deleted
|
|
assert fs.get_file_info(pending.pending_path).type == FileType.NotFound
|
|
|
|
|
|
def test_clean_pending_checkpoints_nonexistent_path(ray_start_10_cpus_shared, tmp_path):
|
|
"""Test that _clean_pending_checkpoints handles a non-existent checkpoint dir.
|
|
|
|
On cloud storage like Azure Blob Storage, listing a non-existent directory
|
|
raises FileNotFoundError (unlike S3 which returns an empty result). The
|
|
FileSelector uses allow_not_found=True so that first-run scenarios where
|
|
the checkpoint directory does not yet exist succeed without error.
|
|
"""
|
|
ctx = ray.data.DataContext.get_current()
|
|
checkpoint_path = os.path.join(tmp_path, "nonexistent", "deeply", "nested", "path")
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
checkpoint_manager = IdColumnCheckpointManager(ctx.checkpoint_config, ctx)
|
|
|
|
data_dir = os.path.join(tmp_path, "data")
|
|
os.makedirs(data_dir, exist_ok=True)
|
|
|
|
# Should succeed gracefully (no pending checkpoints to clean)
|
|
checkpoint_manager._clean_pending_checkpoints(data_dir)
|
|
|
|
|
|
def test_prepare_checkpoint_transform_writes_pending(tmp_path):
|
|
"""Test that the pre-write checkpoint transform writes a pending checkpoint.
|
|
|
|
This verifies that:
|
|
1. _generate_prepare_checkpoint_transform() creates a transform that writes
|
|
a pending checkpoint file
|
|
2. The pending checkpoint filename matches the base filename with .pending suffix
|
|
"""
|
|
|
|
class MockFileDatasink(BlockBasedFileDatasink):
|
|
def write_block_to_file(self, block: BlockAccessor, file: "pyarrow.NativeFile"):
|
|
file.write(b"")
|
|
|
|
ctx = ray.data.DataContext.get_current()
|
|
checkpoint_path = os.path.join(tmp_path, "checkpoint")
|
|
os.makedirs(checkpoint_path, exist_ok=True)
|
|
data_output_path = os.path.join(tmp_path, "output")
|
|
os.makedirs(data_output_path, exist_ok=True)
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
datasink = MockFileDatasink(data_output_path, file_format="csv")
|
|
checkpoint_writer = BatchBasedCheckpointWriter(ctx.checkpoint_config)
|
|
transform = _generate_prepare_checkpoint_transform(ctx, datasink, checkpoint_writer)
|
|
|
|
ctx_task = TaskContext(task_idx=0, op_name="test")
|
|
ctx_task.kwargs[WRITE_UUID_KWARG_NAME] = "test-write-uuid"
|
|
|
|
df = pd.DataFrame({ID_COL: [0], "col1": [0.1]})
|
|
list(transform._apply_transform(ctx_task, [df]))
|
|
|
|
pending_files = [
|
|
f
|
|
for f in os.listdir(checkpoint_path)
|
|
if f.endswith(f"{PENDING_CHECKPOINT_SUFFIX}.parquet")
|
|
]
|
|
assert len(pending_files) == 1
|
|
|
|
# Verify pending checkpoint filename matches the base filename
|
|
base_filename = _generate_base_filename(datasink, ctx_task)
|
|
assert pending_files[0] == f"{base_filename}{PENDING_CHECKPOINT_SUFFIX}.parquet"
|
|
|
|
|
|
def test_2pc_fail_retry_cleans_pending_checkpoints(
|
|
ray_start_10_cpus_shared,
|
|
tmp_path,
|
|
):
|
|
"""Test end-to-end 2PC cleanup: fail during write, retry succeeds after cleanup.
|
|
|
|
This is an integration test for the full two-phase commit cleanup flow:
|
|
1. First write attempt: pre-write creates pending checkpoint, datasink
|
|
writes partial data then fails, leaving pending checkpoint files
|
|
2. Retry: the checkpoint filter's _clean_pending_checkpoints() detects
|
|
pending files, deletes them along with partial data files
|
|
3. Second write attempt: succeeds, writing complete data with no duplicates
|
|
|
|
Verifies that after cleanup, all pending checkpoints are removed and
|
|
the final output contains the correct data.
|
|
"""
|
|
ctx = ray.data.DataContext.get_current()
|
|
ctx.raise_original_map_exception = True
|
|
ctx.default_hash_shuffle_parallelism = 1
|
|
|
|
checkpoint_path = os.path.join(tmp_path, "checkpoint")
|
|
data_output_path = os.path.join(tmp_path, "output")
|
|
os.makedirs(checkpoint_path, exist_ok=True)
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
@ray.remote(num_cpus=0)
|
|
class FailController:
|
|
def __init__(self):
|
|
self._should_fail = True
|
|
|
|
def should_fail(self):
|
|
return self._should_fail
|
|
|
|
def disable_failure(self):
|
|
self._should_fail = False
|
|
|
|
controller = FailController.remote()
|
|
|
|
class FailOnceCSVDatasink(BlockBasedFileDatasink):
|
|
def __init__(self, path: str, controller):
|
|
super().__init__(path, file_format="csv")
|
|
self._controller = controller
|
|
|
|
def write_block_to_file(self, block: BlockAccessor, file: "pyarrow.NativeFile"):
|
|
if ray.get(self._controller.should_fail.remote()):
|
|
# Write a partial file and then fail to simulate incomplete write.
|
|
file.write(b"id\n0\n")
|
|
raise RuntimeError("Simulated write failure")
|
|
block.to_pandas().to_csv(file, index=False)
|
|
|
|
datasink = FailOnceCSVDatasink(data_output_path, controller)
|
|
ds = ray.data.range(SAMPLE_DATA_NUM_ROWS, override_num_blocks=1)
|
|
|
|
with pytest.raises(RuntimeError, match="Simulated write failure"):
|
|
ds.write_datasink(datasink, ray_remote_args={"max_retries": 0})
|
|
|
|
# After failure, there should be pending checkpoint files (written in
|
|
# pre-write phase, before the data write failed)
|
|
pending_files = [
|
|
f
|
|
for f in os.listdir(checkpoint_path)
|
|
if f.endswith(f"{PENDING_CHECKPOINT_SUFFIX}.parquet")
|
|
]
|
|
assert pending_files, "Expected pending checkpoint files after failed write."
|
|
|
|
ray.get(controller.disable_failure.remote())
|
|
ds.write_datasink(datasink, ray_remote_args={"max_retries": 0})
|
|
|
|
# After successful retry, pending checkpoints should be cleaned up
|
|
pending_files_after = [
|
|
f
|
|
for f in os.listdir(checkpoint_path)
|
|
if f.endswith(f"{PENDING_CHECKPOINT_SUFFIX}.parquet")
|
|
]
|
|
assert pending_files_after == []
|
|
|
|
ctx.checkpoint_config = None
|
|
ds_readback = ray.data.read_csv(data_output_path)
|
|
actual_output = sorted([row[ID_COL] for row in ds_readback.iter_rows()])
|
|
expected_output = sorted(range(SAMPLE_DATA_NUM_ROWS))
|
|
assert actual_output == expected_output
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"backend,fs,data_path",
|
|
[
|
|
(CheckpointBackend.FILE_STORAGE, None, lazy_fixture("local_path")),
|
|
(
|
|
CheckpointBackend.FILE_STORAGE,
|
|
lazy_fixture("local_fs"),
|
|
lazy_fixture("local_path"),
|
|
),
|
|
(
|
|
CheckpointBackend.CLOUD_OBJECT_STORAGE,
|
|
lazy_fixture("s3_fs"),
|
|
lazy_fixture("s3_path"),
|
|
),
|
|
],
|
|
)
|
|
def test_skip_checkpoint_flag(
|
|
ray_start_10_cpus_shared,
|
|
generate_sample_data_csv,
|
|
backend,
|
|
fs,
|
|
data_path,
|
|
):
|
|
"""Test that for a valid Dataset with checkpointing enabled, calling methods like
|
|
`schema()` and `count()` should skip checkpointing and not create any checkpoint
|
|
files. Subsequently calling `write_xxx()` on the same dataset should have
|
|
checkpointing enabled."""
|
|
|
|
ctx = ray.data.DataContext.get_current()
|
|
ckpt_path = os.path.join(data_path, "test_checkpoint_output_files")
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
ID_COL,
|
|
ckpt_path,
|
|
delete_checkpoint_on_success=False,
|
|
override_filesystem=fs,
|
|
override_backend=backend,
|
|
)
|
|
|
|
def generate_ds():
|
|
ds = ray.data.read_csv(generate_sample_data_csv())
|
|
|
|
ds = ds.map(lambda row: row)
|
|
return ds
|
|
|
|
ds = generate_ds()
|
|
|
|
# Calling `ds.schema()` should skip checkpointing.
|
|
assert ds.schema() is not None
|
|
assert len(read_ids_from_checkpoint_files(ctx.checkpoint_config)) == 0
|
|
|
|
# Calling `ds.count()` should skip checkpointing.
|
|
ds = generate_ds()
|
|
assert ds.count() is not None
|
|
assert len(read_ids_from_checkpoint_files(ctx.checkpoint_config)) == 0
|
|
|
|
# Calling `ds.write_xxx()` afterwards should enable checkpointing.
|
|
ds.write_parquet(os.path.join(data_path, "output"), filesystem=fs)
|
|
|
|
# Check what checkpoint files exist
|
|
checkpoint_files = read_ids_from_checkpoint_files(ctx.checkpoint_config)
|
|
|
|
assert len(checkpoint_files) == SAMPLE_DATA_NUM_ROWS
|
|
|
|
|
|
def test_checkpoint_with_missing_id_column(
|
|
ray_start_10_cpus_shared,
|
|
generate_sample_data_csv,
|
|
tmp_path,
|
|
):
|
|
"""Test that checkpointing fails gracefully when the configured id_column doesn't exist in the data."""
|
|
|
|
ctx = ray.data.DataContext.get_current()
|
|
ckpt_path = os.path.join(tmp_path, "test_checkpoint_output_files")
|
|
# Configure checkpointing with an id_column that doesn't exist in the CSV data
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column="nonexistent_column",
|
|
checkpoint_path=ckpt_path,
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
def generate_ds():
|
|
ds = ray.data.read_csv(generate_sample_data_csv())
|
|
ds = ds.map(lambda row: row)
|
|
return ds
|
|
|
|
ds = generate_ds()
|
|
|
|
# The write operation should fail because the id_column doesn't exist
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="ID column nonexistent_column is absent in the block to be written",
|
|
):
|
|
ds.write_parquet(os.path.join(tmp_path, "output"))
|
|
|
|
|
|
def test_dict_checkpoint_config(checkpoint_path):
|
|
"""Test that a dict checkpoint config can be used to create a CheckpointConfig."""
|
|
context = ray.data.DataContext.get_current()
|
|
fs = LocalFileSystem()
|
|
context.checkpoint_config = {
|
|
"id_column": ID_COL,
|
|
"checkpoint_path": checkpoint_path,
|
|
"override_filesystem": fs,
|
|
"override_backend": "CLOUD_OBJECT_STORAGE",
|
|
}
|
|
assert context.checkpoint_config.id_column == ID_COL
|
|
assert context.checkpoint_config.checkpoint_path == checkpoint_path
|
|
assert context.checkpoint_config.filesystem is fs
|
|
assert context.checkpoint_config.backend == CheckpointBackend.CLOUD_OBJECT_STORAGE
|
|
|
|
|
|
def test_write_block_checkpoint_with_pandas_df(restore_data_context, tmp_path):
|
|
ctx = ray.data.DataContext.get_current()
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
ID_COL,
|
|
str(tmp_path),
|
|
)
|
|
df = pd.DataFrame({ID_COL: [0, 1]})
|
|
expected_ids = [0, 1]
|
|
|
|
checkpoint_writer = BatchBasedCheckpointWriter(ctx.checkpoint_config)
|
|
checkpoint_writer.write_block_checkpoint(BlockAccessor.for_block(df))
|
|
|
|
assert len(os.listdir(tmp_path)) == 1
|
|
checkpoint_filename = os.listdir(tmp_path)[0]
|
|
checkpoint_path = tmp_path / checkpoint_filename
|
|
|
|
table = pa.parquet.read_table(checkpoint_path)
|
|
df = table.to_pandas()
|
|
written_ids = df[ID_COL].tolist()
|
|
assert written_ids == expected_ids
|
|
|
|
|
|
def test_filter_rows_for_block():
|
|
"""Test NumpyArrayBasedCheckpointFilter.filter_rows_for_block."""
|
|
|
|
# Common test setup
|
|
checkpoint_path = "/mock/path"
|
|
|
|
# Test with simple ID column
|
|
config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
)
|
|
|
|
# Create a mock block.
|
|
block = pyarrow.table(
|
|
{
|
|
ID_COL: list(range(10)),
|
|
"data": [str(i) for i in range(10)],
|
|
}
|
|
)
|
|
# Create a mock checkpointed_ids with multiple chunks.
|
|
chunk1 = pyarrow.table({ID_COL: [1, 2, 4]})
|
|
chunk2 = pyarrow.table({ID_COL: [6, 8, 9, 11]})
|
|
chunk3 = pyarrow.table({ID_COL: [12, 13]})
|
|
checkpointed_ids = pyarrow.concat_tables([chunk1, chunk2, chunk3])
|
|
assert len(checkpointed_ids[ID_COL].chunks) == 3
|
|
|
|
checkpoint_ids_array = []
|
|
for ckpt_chunk in checkpointed_ids[ID_COL].chunks:
|
|
checkpoint_ids_array.append(
|
|
transform_pyarrow.to_numpy(ckpt_chunk, zero_copy_only=False)
|
|
)
|
|
checkpointed_ids_ndarray = np.concatenate(checkpoint_ids_array)
|
|
checkpointed_ids_ref = ray.put(checkpointed_ids_ndarray)
|
|
|
|
expected_block = pyarrow.table(
|
|
{
|
|
ID_COL: [0, 3, 5, 7],
|
|
"data": ["0", "3", "5", "7"],
|
|
}
|
|
)
|
|
|
|
filter_instance = NumpyArrayBasedCheckpointFilter(config, checkpointed_ids_ref)
|
|
filtered_block = filter_instance.filter_rows_for_block(
|
|
block=block,
|
|
)
|
|
|
|
assert filtered_block.equals(expected_block)
|
|
|
|
|
|
def test_checkpoint_restore_after_full_execution(
|
|
ray_start_10_cpus_shared,
|
|
tmp_path,
|
|
generate_sample_data_parquet,
|
|
checkpoint_path,
|
|
):
|
|
"""Test checkpoint restore after full execution of data pipeline. This is
|
|
done by retaining the checkpoint metadata files with
|
|
delete_checkpoint_on_success=False.
|
|
"""
|
|
|
|
def run_simple_pipeline(
|
|
checkpoint_config: CheckpointConfig, input_path: str, output_path: str
|
|
) -> int:
|
|
"""Run a simple pipeline with checkpointing."""
|
|
from ray.data.datasource import WriteResult
|
|
|
|
ctx = DataContext.get_current()
|
|
ctx.checkpoint_config = checkpoint_config
|
|
ctx.default_hash_shuffle_parallelism = 1
|
|
ds = ray.data.read_parquet(input_path)
|
|
|
|
# Patch `on_write_complete` to get the WriteResult.
|
|
num_rows_written = None
|
|
original_on_write_complete = ParquetDatasink.on_write_complete
|
|
|
|
def patched_on_write_complete(self, write_result: WriteResult[None]):
|
|
nonlocal num_rows_written
|
|
num_rows_written = write_result.num_rows
|
|
return original_on_write_complete(self, write_result)
|
|
|
|
ParquetDatasink.on_write_complete = patched_on_write_complete
|
|
|
|
ds.write_parquet(output_path)
|
|
return int(num_rows_written)
|
|
|
|
# Create test paths
|
|
input_data_path = generate_sample_data_parquet()
|
|
data_output_path = str(tmp_path / "output")
|
|
|
|
# Create checkpoint config
|
|
checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
override_backend=CheckpointBackend.FILE_STORAGE,
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
# First run: create checkpoint
|
|
num_rows_first = run_simple_pipeline(
|
|
checkpoint_config, input_data_path, data_output_path
|
|
)
|
|
assert (
|
|
num_rows_first == SAMPLE_DATA_NUM_ROWS
|
|
), f"Expected {SAMPLE_DATA_NUM_ROWS} rows, got {num_rows_first}"
|
|
|
|
# Check if checkpoint files were created
|
|
assert os.path.exists(checkpoint_path), "No checkpoint directory created!"
|
|
|
|
# Second run: should use checkpoint
|
|
num_rows_second = run_simple_pipeline(
|
|
checkpoint_config, input_data_path, data_output_path
|
|
)
|
|
assert (
|
|
num_rows_second == 0 # No rows should be written
|
|
), f"Expected 0 rows, got {num_rows_second}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data_path",
|
|
[
|
|
(lazy_fixture("local_path")),
|
|
],
|
|
)
|
|
def test_checkpoint_map_transformer(
|
|
ray_start_10_cpus_shared,
|
|
data_path,
|
|
):
|
|
"""Test checkpoint map transformer."""
|
|
ctx = ray.data.DataContext.get_current()
|
|
ckpt_path = os.path.join(data_path, "test_checkpoint_output_files")
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL, checkpoint_path=ckpt_path
|
|
)
|
|
|
|
checkpointed_ids_ndarray = np.array([1, 3, 5, 7, 9], dtype=np.int64)
|
|
checkpointed_ids_ref = ray.put(checkpointed_ids_ndarray)
|
|
|
|
map_transformer = _get_checkpoint_map_transformer(ctx, checkpointed_ids_ref)
|
|
map_transformer.init()
|
|
|
|
block = pyarrow.table(
|
|
{
|
|
ID_COL: list(range(10)),
|
|
"data": [str(i) for i in range(10)],
|
|
}
|
|
)
|
|
filtered_blocks = map_transformer.apply_transform(
|
|
input_blocks=[block],
|
|
ctx=TaskContext(task_idx=0, op_name="test_checkpoint"),
|
|
)
|
|
|
|
filtered_block = next(iter(filtered_blocks))
|
|
expected_block = pyarrow.table(
|
|
{
|
|
ID_COL: [0, 2, 4, 6, 8],
|
|
"data": ["0", "2", "4", "6", "8"],
|
|
}
|
|
)
|
|
assert filtered_block.equals(expected_block)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data_path",
|
|
[
|
|
(lazy_fixture("local_path")),
|
|
],
|
|
)
|
|
def test_plan_read_op_with_checkpoint_filter_no_checkpoint_dir(
|
|
ray_start_10_cpus_shared, generate_sample_data_csv, data_path
|
|
):
|
|
"""Test that when checkpoint directory does not exist,
|
|
plan_read_op_with_checkpoint_filter returns the original read physical operator."""
|
|
ctx = ray.data.DataContext.get_current()
|
|
|
|
csv_file = generate_sample_data_csv()
|
|
datasource = CSVDatasource(csv_file)
|
|
|
|
# checkpoint_path points to a non-existent directory
|
|
ckpt_path = os.path.join(str(data_path), "non_existent_ckpt_dir")
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=ckpt_path,
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
read_op = Read(datasource, datasource, -1, None)
|
|
physical_op = plan_read_op_with_checkpoint_filter(
|
|
None,
|
|
None,
|
|
op=read_op,
|
|
physical_children=[],
|
|
data_context=ctx,
|
|
)
|
|
|
|
# Should return the original ReadCSV op
|
|
assert physical_op.name == "ReadCSV"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data_path",
|
|
[
|
|
(lazy_fixture("local_path")),
|
|
],
|
|
)
|
|
def test_plan_read_op_with_checkpoint_filter_empty_checkpoint_dir(
|
|
ray_start_10_cpus_shared, generate_sample_data_csv, data_path
|
|
):
|
|
"""Test that when checkpoint directory exists but is empty,
|
|
plan_read_op_with_checkpoint_filter returns the original read physical operator."""
|
|
ctx = ray.data.DataContext.get_current()
|
|
|
|
csv_file = generate_sample_data_csv()
|
|
datasource = CSVDatasource(csv_file)
|
|
|
|
# Create an empty checkpoint directory
|
|
ckpt_path = os.path.join(str(data_path), "empty_ckpt_dir")
|
|
os.makedirs(ckpt_path, exist_ok=True)
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=ckpt_path,
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
read_op = Read(datasource, datasource, -1, None)
|
|
physical_op = plan_read_op_with_checkpoint_filter(
|
|
None,
|
|
None,
|
|
op=read_op,
|
|
physical_children=[],
|
|
data_context=ctx,
|
|
)
|
|
|
|
# Should return the original ReadCSV op
|
|
assert physical_op.name == "ReadCSV"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"data_path",
|
|
[
|
|
(lazy_fixture("local_path")),
|
|
],
|
|
)
|
|
def test_plan_read_op_with_checkpoint_filter_with_valid_checkpoint(
|
|
ray_start_10_cpus_shared,
|
|
generate_sample_data_csv,
|
|
data_path,
|
|
):
|
|
"""Test that when a valid checkpoint exists,
|
|
plan_read_op_with_checkpoint_filter returns a CheckpointFilter MapOperator."""
|
|
ctx = ray.data.DataContext.get_current()
|
|
|
|
csv_file = generate_sample_data_csv()
|
|
datasource = CSVDatasource(csv_file)
|
|
|
|
# Create a checkpoint directory with valid checkpoint data
|
|
ckpt_path = os.path.join(str(data_path), "valid_ckpt_dir")
|
|
os.makedirs(ckpt_path, exist_ok=True)
|
|
|
|
# Write some checkpoint IDs (e.g., IDs 0-4 are already processed)
|
|
checkpointed_ids = pa.table({ID_COL: list(range(5))})
|
|
pq.write_table(checkpointed_ids, os.path.join(ckpt_path, "ckpt_0.parquet"))
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=ckpt_path,
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
read_op = Read(datasource, datasource, -1, None)
|
|
physical_op = plan_read_op_with_checkpoint_filter(
|
|
None,
|
|
None,
|
|
op=read_op,
|
|
physical_children=[],
|
|
data_context=ctx,
|
|
)
|
|
|
|
# Should return a CheckpointFilter MapOperator
|
|
assert isinstance(physical_op, MapOperator)
|
|
assert physical_op.name == "CheckpointFilter"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"backend,fs,data_path",
|
|
[
|
|
(CheckpointBackend.FILE_STORAGE, None, lazy_fixture("local_path")),
|
|
],
|
|
)
|
|
def test_checkpoint_with_string_typed_id(
|
|
ray_start_10_cpus_shared,
|
|
generate_sample_data_csv,
|
|
backend,
|
|
fs,
|
|
data_path,
|
|
data_output_path,
|
|
):
|
|
"""Test the checkpoint when the ID column is of type string."""
|
|
|
|
class TestActor:
|
|
def __init__(self):
|
|
pass
|
|
|
|
def __call__(self, batch):
|
|
return batch
|
|
|
|
ctx = ray.data.DataContext.get_current()
|
|
ckpt_path = os.path.join(data_path, "test_checkpoint_output_files")
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=ckpt_path,
|
|
override_filesystem=fs,
|
|
override_backend=backend,
|
|
)
|
|
|
|
csv_file = generate_sample_data_csv(id_type="str")
|
|
|
|
# Generate checkpoint file
|
|
checkpointed_ids = [f"id_{id}" for id in range(SAMPLE_DATA_NUM_ROWS // 2)]
|
|
expected_remaining_ids = sorted(
|
|
{f"id_{id}" for id in range(SAMPLE_DATA_NUM_ROWS)} - set(checkpointed_ids)
|
|
)
|
|
|
|
ckpt_unwrapped = _unwrap_protocol(ckpt_path)
|
|
|
|
os.makedirs(ckpt_unwrapped, exist_ok=True)
|
|
ckpt_table = pa.table({ID_COL: checkpointed_ids})
|
|
pq.write_table(ckpt_table, os.path.join(ckpt_unwrapped, "pre_checkpoint.parquet"))
|
|
|
|
ds = ray.data.read_csv(csv_file)
|
|
|
|
# Execute the dataset with checkpointing enabled.
|
|
ds = ds.map_batches(TestActor, concurrency=1)
|
|
ds.write_parquet(data_output_path, filesystem=fs)
|
|
|
|
# Ensure that the written data only contains the non-checkpointed rows.
|
|
# Disable checkpointing before reading back to avoid filtering.
|
|
ctx.checkpoint_config = None
|
|
ds_readback = ray.data.read_parquet(data_output_path, filesystem=fs)
|
|
actual_output = sorted([row[ID_COL] for row in ds_readback.iter_rows()])
|
|
assert actual_output == expected_remaining_ids, (
|
|
f"Expected only non-checkpointed IDs {expected_remaining_ids}, "
|
|
f"but got {actual_output}"
|
|
)
|
|
|
|
|
|
class FailAfterWriteParquetDatasink(ParquetDatasink):
|
|
"""Test helper: ParquetDatasink that fails AFTER writing data (simulates post-write crash).
|
|
|
|
This simulates the failure scenario where:
|
|
- Data is successfully written to the output file
|
|
- But the process crashes/fails before the checkpoint can be committed
|
|
This is the critical case that 2PC is designed to handle - the data file
|
|
exists but is "uncommitted" and should be cleaned up on recovery.
|
|
"""
|
|
|
|
def __init__(self, path: str, fail_threshold: int = 100, **kwargs):
|
|
super().__init__(path, **kwargs)
|
|
self._fail_threshold = fail_threshold
|
|
|
|
def write(self, blocks, ctx):
|
|
blocks_list = list(blocks)
|
|
|
|
# Check if any block has id > threshold
|
|
should_fail = False
|
|
for block in blocks_list:
|
|
accessor = BlockAccessor.for_block(block)
|
|
df = accessor.to_pandas()
|
|
if ID_COL in df.columns and df[ID_COL].max() > self._fail_threshold:
|
|
should_fail = True
|
|
break
|
|
|
|
# First, write the blocks normally
|
|
result = super().write(iter(blocks_list), ctx)
|
|
|
|
# Then fail if threshold exceeded (simulates post-write failure)
|
|
if should_fail:
|
|
raise RuntimeError(
|
|
f"Simulated failure: block contains {ID_COL} > {self._fail_threshold}"
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def test_partial_failure_no_duplicates(
|
|
ray_start_10_cpus_shared,
|
|
tmp_path,
|
|
):
|
|
"""Test checkpoint deduplication: partial failure + retry produces no duplicate rows.
|
|
|
|
This is the key correctness test for the checkpoint deduplication feature.
|
|
It verifies that when a write pipeline fails partway through:
|
|
1. Already-committed rows (from successful blocks before failure) are tracked
|
|
2. Uncommitted rows (from blocks that failed after writing data) are cleaned up
|
|
3. On retry, only uncommitted rows are re-written
|
|
4. Final output has exactly the expected rows with NO duplicates
|
|
|
|
The test uses run_tag to verify which rows came from which run, confirming
|
|
that committed rows from run 1 are preserved and not re-written in run 2.
|
|
|
|
Note: This test requires ray.shutdown() + ray.init() mid-test to flush
|
|
in-flight checkpoint writes, which is incompatible with mock S3 (pyarrow's
|
|
S3 subsystem gets finalized during shutdown). The checkpoint storage layer
|
|
is already tested with S3 in other parameterized tests.
|
|
"""
|
|
num_rows = 1000
|
|
fail_threshold = 100
|
|
|
|
# Create paths
|
|
input_path = tmp_path / "input"
|
|
output_path = tmp_path / "output"
|
|
checkpoint_path_dir = tmp_path / "checkpoint"
|
|
for path in [input_path, output_path, checkpoint_path_dir]:
|
|
path.mkdir(exist_ok=True)
|
|
|
|
# Create sample data (1000 rows with unique IDs)
|
|
df = pd.DataFrame(
|
|
{ID_COL: range(num_rows), "value": [f"row_{i}" for i in range(num_rows)]}
|
|
)
|
|
df.to_parquet(input_path / "data.parquet", index=False)
|
|
|
|
# Configure checkpointing
|
|
ctx = DataContext.get_current()
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=str(checkpoint_path_dir),
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
def add_run_tag(batch, run_tag):
|
|
"""Add a run_tag column to identify which run wrote the data."""
|
|
batch["run_tag"] = [run_tag] * len(batch[ID_COL])
|
|
return batch
|
|
|
|
# Run 1: Use the failing datasink - should write some blocks then fail
|
|
with pytest.raises(RuntimeError, match="Simulated failure"):
|
|
ds = ray.data.read_parquet(str(input_path))
|
|
ds = ds.repartition(200) # 200 blocks
|
|
ds = ds.map_batches(lambda b: add_run_tag(b, "first"), batch_size=None)
|
|
failing_datasink = FailAfterWriteParquetDatasink(
|
|
str(output_path), fail_threshold=fail_threshold
|
|
)
|
|
ds.write_datasink(failing_datasink, ray_remote_args={"max_retries": 0})
|
|
|
|
# Shutdown Ray to ensure all in-flight tasks complete before checking state.
|
|
# This addresses the race condition where background tasks may still be writing
|
|
# checkpoint files after the exception is raised.
|
|
ray.shutdown()
|
|
ray.init()
|
|
|
|
# Run 2: Use regular write_parquet - should resume from checkpoint
|
|
ds2 = ray.data.read_parquet(str(input_path))
|
|
ds2 = ds2.repartition(200)
|
|
ds2 = ds2.map_batches(lambda b: add_run_tag(b, "second"), batch_size=None)
|
|
ds2.write_parquet(str(output_path))
|
|
|
|
result = ray.data.read_parquet(str(output_path)).to_pandas()
|
|
|
|
assert len(result) == num_rows, f"Expected {num_rows} rows, got {len(result)}"
|
|
|
|
# Check for duplicates
|
|
assert result[ID_COL].is_unique, (
|
|
f"Duplicate IDs found: "
|
|
f"{sorted(result[result.duplicated(ID_COL, keep=False)][ID_COL].unique().tolist())}"
|
|
)
|
|
|
|
# Verify that some rows came from first run (before failure) and rest from second
|
|
run_tag_counts = result["run_tag"].value_counts()
|
|
assert "first" in run_tag_counts.index, "Expected some rows from first run"
|
|
assert "second" in run_tag_counts.index, "Expected some rows from second run"
|
|
|
|
|
|
def test_partial_failure_no_duplicates_partitioned(
|
|
ray_start_10_cpus_shared,
|
|
tmp_path,
|
|
):
|
|
"""Test checkpoint deduplication with multi-level partitioned parquet output.
|
|
|
|
Same as test_partial_failure_no_duplicates, but writes partitioned output
|
|
using 3 partition columns, creating deeply nested subdirectories
|
|
(e.g., output/region=us/category=x/tier=1/file.parquet). This exercises
|
|
the recovery path where data files must be found via recursive search
|
|
using the data_file_dir passed through the call chain.
|
|
|
|
Note: This test requires ray.shutdown() + ray.init() mid-test to flush
|
|
in-flight checkpoint writes, which is incompatible with mock S3 (pyarrow's
|
|
S3 subsystem gets finalized during shutdown). The checkpoint storage layer
|
|
is already tested with S3 in other parameterized tests.
|
|
"""
|
|
num_rows = 1000
|
|
fail_threshold = 100
|
|
|
|
# Create paths
|
|
input_path = tmp_path / "input"
|
|
output_path = tmp_path / "output"
|
|
checkpoint_path_dir = tmp_path / "checkpoint"
|
|
for path in [input_path, output_path, checkpoint_path_dir]:
|
|
path.mkdir(exist_ok=True)
|
|
|
|
# Create sample data with 3 partition columns for deeply nested output
|
|
# (e.g., output/region=us/category=x/tier=1/file.parquet)
|
|
regions = ["us", "eu"]
|
|
categories = ["x", "y", "z"]
|
|
tiers = [1, 2]
|
|
df = pd.DataFrame(
|
|
{
|
|
ID_COL: range(num_rows),
|
|
"value": [f"row_{i}" for i in range(num_rows)],
|
|
"region": [regions[i % len(regions)] for i in range(num_rows)],
|
|
"category": [categories[i % len(categories)] for i in range(num_rows)],
|
|
"tier": [tiers[i % len(tiers)] for i in range(num_rows)],
|
|
}
|
|
)
|
|
df.to_parquet(input_path / "data.parquet", index=False)
|
|
|
|
# Configure checkpointing
|
|
ctx = DataContext.get_current()
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=str(checkpoint_path_dir),
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
def add_run_tag(batch, run_tag):
|
|
"""Add a run_tag column to identify which run wrote the data."""
|
|
batch["run_tag"] = [run_tag] * len(batch[ID_COL])
|
|
return batch
|
|
|
|
partition_cols = ["region", "category", "tier"]
|
|
|
|
# Run 1: Use the failing datasink with partition_cols
|
|
with pytest.raises(RuntimeError, match="Simulated failure"):
|
|
ds = ray.data.read_parquet(str(input_path))
|
|
ds = ds.repartition(200)
|
|
ds = ds.map_batches(lambda b: add_run_tag(b, "first"), batch_size=None)
|
|
failing_datasink = FailAfterWriteParquetDatasink(
|
|
str(output_path),
|
|
fail_threshold=fail_threshold,
|
|
partition_cols=partition_cols,
|
|
)
|
|
ds.write_datasink(failing_datasink, ray_remote_args={"max_retries": 0})
|
|
|
|
# Shutdown Ray to ensure all in-flight tasks complete before checking state.
|
|
ray.shutdown()
|
|
ray.init()
|
|
|
|
# Run 2: Use regular write_parquet with partition_cols - should resume
|
|
ds2 = ray.data.read_parquet(str(input_path))
|
|
ds2 = ds2.repartition(200)
|
|
ds2 = ds2.map_batches(lambda b: add_run_tag(b, "second"), batch_size=None)
|
|
ds2.write_parquet(str(output_path), partition_cols=partition_cols)
|
|
|
|
result = ray.data.read_parquet(str(output_path)).to_pandas()
|
|
|
|
assert len(result) == num_rows, f"Expected {num_rows} rows, got {len(result)}"
|
|
|
|
# Check for duplicates
|
|
assert result[ID_COL].is_unique, (
|
|
f"Duplicate IDs found: "
|
|
f"{sorted(result[result.duplicated(ID_COL, keep=False)][ID_COL].unique().tolist())}"
|
|
)
|
|
|
|
# Verify that some rows came from first run (before failure) and rest from second
|
|
run_tag_counts = result["run_tag"].value_counts()
|
|
assert "first" in run_tag_counts.index, "Expected some rows from first run"
|
|
assert "second" in run_tag_counts.index, "Expected some rows from second run"
|
|
|
|
# Verify partitioned output structure: partition subdirectories should exist
|
|
output_subdirs = [
|
|
d for d in os.listdir(str(output_path)) if os.path.isdir(output_path / d)
|
|
]
|
|
assert len(output_subdirs) > 0, "Expected partition subdirectories in output"
|
|
|
|
|
|
class TextRowDatasink(RowBasedFileDatasink):
|
|
"""Test helper: RowBasedFileDatasink that writes each row as a text file."""
|
|
|
|
def __init__(self, path: str, **kwargs):
|
|
super().__init__(path, file_format="txt", **kwargs)
|
|
|
|
def write_row_to_file(self, row, file):
|
|
file.write(f"{row[ID_COL]},{row['value']}".encode())
|
|
|
|
|
|
class FailAfterWriteTextRowDatasink(TextRowDatasink):
|
|
"""Test helper: TextRowDatasink that fails AFTER writing data."""
|
|
|
|
def __init__(self, path: str, fail_threshold: int = 100, **kwargs):
|
|
super().__init__(path, **kwargs)
|
|
self._fail_threshold = fail_threshold
|
|
|
|
def write(self, blocks, ctx):
|
|
blocks_list = list(blocks)
|
|
|
|
should_fail = False
|
|
for block in blocks_list:
|
|
accessor = BlockAccessor.for_block(block)
|
|
df = accessor.to_pandas()
|
|
if ID_COL in df.columns and df[ID_COL].max() > self._fail_threshold:
|
|
should_fail = True
|
|
break
|
|
|
|
result = super().write(iter(blocks_list), ctx)
|
|
|
|
if should_fail:
|
|
raise RuntimeError(
|
|
f"Simulated failure: block contains {ID_COL} > {self._fail_threshold}"
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
def test_partial_failure_no_duplicates_row_based(
|
|
ray_start_10_cpus_shared,
|
|
tmp_path,
|
|
):
|
|
"""Test checkpoint deduplication with row-based datasink (one file per row).
|
|
|
|
Row-based datasinks write multiple files per task, e.g.:
|
|
write_uuid_000000_000000_000000.txt
|
|
write_uuid_000000_000000_000001.txt
|
|
Recovery must match all of them via the checkpoint prefix (write_uuid_000000).
|
|
This validates the PrefixTrie-based matching works for row-based writes.
|
|
"""
|
|
num_rows = 1000
|
|
fail_threshold = 100
|
|
|
|
# Create paths
|
|
input_path = tmp_path / "input"
|
|
output_path = tmp_path / "output"
|
|
checkpoint_path_dir = tmp_path / "checkpoint"
|
|
for path in [input_path, output_path, checkpoint_path_dir]:
|
|
path.mkdir(exist_ok=True)
|
|
|
|
# Create sample data
|
|
df = pd.DataFrame(
|
|
{ID_COL: range(num_rows), "value": [f"row_{i}" for i in range(num_rows)]}
|
|
)
|
|
df.to_parquet(input_path / "data.parquet", index=False)
|
|
|
|
# Configure checkpointing
|
|
ctx = DataContext.get_current()
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=str(checkpoint_path_dir),
|
|
delete_checkpoint_on_success=False,
|
|
)
|
|
|
|
def add_run_tag(batch, run_tag):
|
|
batch["run_tag"] = [run_tag] * len(batch[ID_COL])
|
|
return batch
|
|
|
|
# Run 1: Use the failing row-based datasink
|
|
with pytest.raises(RuntimeError, match="Simulated failure"):
|
|
ds = ray.data.read_parquet(str(input_path))
|
|
ds = ds.repartition(200)
|
|
ds = ds.map_batches(lambda b: add_run_tag(b, "first"), batch_size=None)
|
|
failing_datasink = FailAfterWriteTextRowDatasink(
|
|
str(output_path), fail_threshold=fail_threshold
|
|
)
|
|
ds.write_datasink(failing_datasink, ray_remote_args={"max_retries": 0})
|
|
|
|
ray.shutdown()
|
|
ray.init()
|
|
|
|
# Run 2: Use the same row-based datasink (non-failing) to resume
|
|
ds2 = ray.data.read_parquet(str(input_path))
|
|
ds2 = ds2.repartition(200)
|
|
ds2 = ds2.map_batches(lambda b: add_run_tag(b, "second"), batch_size=None)
|
|
ds2.write_datasink(TextRowDatasink(str(output_path)))
|
|
|
|
# Read all output text files and parse them
|
|
output_files = [f for f in os.listdir(str(output_path)) if f.endswith(".txt")]
|
|
rows = []
|
|
for fname in output_files:
|
|
with open(os.path.join(str(output_path), fname)) as f:
|
|
content = f.read()
|
|
id_val, value = content.split(",", 1)
|
|
rows.append({ID_COL: int(id_val), "value": value})
|
|
|
|
result = pd.DataFrame(rows)
|
|
|
|
assert len(result) == num_rows, f"Expected {num_rows} rows, got {len(result)}"
|
|
|
|
# Check for duplicates
|
|
assert result[ID_COL].is_unique, (
|
|
f"Duplicate IDs found: "
|
|
f"{sorted(result[result.duplicated(ID_COL, keep=False)][ID_COL].unique().tolist())}"
|
|
)
|
|
|
|
|
|
def test_prefix_trie():
|
|
"""Test PrefixTrie insert and has_prefix_of operations."""
|
|
trie = PrefixTrie()
|
|
trie.insert("abc")
|
|
trie.insert("def")
|
|
trie.insert("ab")
|
|
|
|
# "ab" is a prefix of "abc", "abcd", "ab"
|
|
assert trie.has_prefix_of("abc")
|
|
assert trie.has_prefix_of("abcd")
|
|
assert trie.has_prefix_of("ab")
|
|
assert trie.has_prefix_of("abxyz")
|
|
assert trie.has_prefix_of("def")
|
|
assert trie.has_prefix_of("defgh")
|
|
|
|
# No prefix matches for these
|
|
assert not trie.has_prefix_of("a")
|
|
assert not trie.has_prefix_of("xyz")
|
|
assert not trie.has_prefix_of("d")
|
|
assert not trie.has_prefix_of("de")
|
|
assert not trie.has_prefix_of("")
|
|
|
|
|
|
def test_prefix_trie_empty():
|
|
"""Test that an empty PrefixTrie returns False for all queries."""
|
|
trie = PrefixTrie()
|
|
assert not trie.has_prefix_of("")
|
|
assert not trie.has_prefix_of("abc")
|
|
assert not trie.has_prefix_of("anything")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fs,base_path",
|
|
[
|
|
(lazy_fixture("local_fs"), lazy_fixture("local_path")),
|
|
(lazy_fixture("s3_fs"), lazy_fixture("s3_path")),
|
|
],
|
|
ids=["local", "s3"],
|
|
)
|
|
def test_clean_pending_checkpoints_no_pending(ray_start_10_cpus_shared, fs, base_path):
|
|
"""Test that cleanup is a no-op when there are no pending checkpoint files.
|
|
|
|
With no pending checkpoints, no data files should be deleted. This is the
|
|
normal case after a successful write where all checkpoints were committed.
|
|
"""
|
|
ctx = ray.data.DataContext.get_current()
|
|
checkpoint_path = os.path.join(base_path, "checkpoint")
|
|
fs.create_dir(_unwrap_protocol(checkpoint_path))
|
|
|
|
ctx.checkpoint_config = CheckpointConfig(
|
|
id_column=ID_COL,
|
|
checkpoint_path=checkpoint_path,
|
|
delete_checkpoint_on_success=False,
|
|
override_filesystem=fs,
|
|
)
|
|
|
|
# Create data files but no pending checkpoints
|
|
data_dir = os.path.join(base_path, "data")
|
|
fs.create_dir(_unwrap_protocol(data_dir))
|
|
data_file = os.path.join(_unwrap_protocol(data_dir), "some_data.parquet")
|
|
with fs.open_output_stream(data_file) as f:
|
|
f.write(b"dummy")
|
|
|
|
checkpoint_manager = IdColumnCheckpointManager(ctx.checkpoint_config, ctx)
|
|
checkpoint_manager._clean_pending_checkpoints(data_dir, fs)
|
|
|
|
# Data file should still exist (no pending checkpoints means nothing to clean)
|
|
assert fs.get_file_info(data_file).type != FileType.NotFound
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
|
|
sys.exit(pytest.main(["-v", __file__]))
|