mirror of
https://github.com/deepseek-ai/smallpond
synced 2025-06-26 18:27:45 +00:00
init
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
30
tests/conftest.py
Normal file
30
tests/conftest.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import ray
|
||||
|
||||
import smallpond
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ray_address():
|
||||
"""A global Ray instance for all tests"""
|
||||
ray_address = ray.init(
|
||||
address="local",
|
||||
# disable dashboard in unit tests
|
||||
include_dashboard=False,
|
||||
).address_info["gcs_address"]
|
||||
yield ray_address
|
||||
ray.shutdown()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sp(ray_address: str, request):
|
||||
"""A smallpond session for each test"""
|
||||
runtime_root = os.getenv("TEST_RUNTIME_ROOT") or f"tests/runtime"
|
||||
sp = smallpond.init(
|
||||
data_root=os.path.join(runtime_root, request.node.name),
|
||||
ray_address=ray_address,
|
||||
)
|
||||
yield sp
|
||||
sp.shutdown()
|
||||
186
tests/datagen.py
Normal file
186
tests/datagen.py
Normal file
@@ -0,0 +1,186 @@
|
||||
import base64
|
||||
import glob
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Tuple
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pyarrow.parquet as pq
|
||||
from filelock import FileLock
|
||||
|
||||
|
||||
def generate_url_and_domain() -> Tuple[str, str]:
|
||||
domain_part = "".join(
|
||||
random.choices(string.ascii_lowercase, k=random.randint(5, 15))
|
||||
)
|
||||
tld = random.choice(["com", "net", "org", "cn", "edu", "gov", "co", "io"])
|
||||
domain = f"www.{domain_part}.{tld}"
|
||||
|
||||
path_segments = []
|
||||
for _ in range(random.randint(1, 3)):
|
||||
segment = "".join(
|
||||
random.choices(
|
||||
string.ascii_lowercase + string.digits, k=random.randint(3, 10)
|
||||
)
|
||||
)
|
||||
path_segments.append(segment)
|
||||
path = "/" + "/".join(path_segments)
|
||||
|
||||
protocol = random.choice(["http", "https"])
|
||||
|
||||
if random.random() < 0.3:
|
||||
path += random.choice([".html", ".php", ".htm", ".aspx"])
|
||||
|
||||
return f"{protocol}://{domain}{path}", domain
|
||||
|
||||
|
||||
def generate_random_date() -> str:
|
||||
start = datetime(2023, 1, 1, tzinfo=timezone.utc)
|
||||
end = datetime(2023, 12, 31, tzinfo=timezone.utc)
|
||||
delta = end - start
|
||||
random_date = start + timedelta(
|
||||
seconds=random.randint(0, int(delta.total_seconds()))
|
||||
)
|
||||
return random_date.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def generate_content() -> bytes:
|
||||
target_length = (
|
||||
random.randint(1000, 100000)
|
||||
if random.random() < 0.8
|
||||
else random.randint(100000, 1000000)
|
||||
)
|
||||
before = b"<!DOCTYPE html><html><head><title>Random Page</title></head><body>"
|
||||
after = b"</body></html>"
|
||||
total_before_after = len(before) + len(after)
|
||||
|
||||
fill_length = max(target_length - total_before_after, 0)
|
||||
filler = "".join(random.choices(string.printable, k=fill_length)).encode("ascii")[
|
||||
:fill_length
|
||||
]
|
||||
|
||||
return before + filler + after
|
||||
|
||||
|
||||
def generate_arrow_parquet(path: str, num_rows=100):
|
||||
data = []
|
||||
for _ in range(num_rows):
|
||||
url, domain = generate_url_and_domain()
|
||||
date = generate_random_date()
|
||||
content = generate_content()
|
||||
|
||||
data.append({"url": url, "domain": domain, "date": date, "content": content})
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df.to_parquet(path, engine="pyarrow")
|
||||
|
||||
|
||||
def generate_arrow_files(output_dir: str, num_files=10):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with ProcessPoolExecutor(max_workers=10) as executor:
|
||||
executor.map(
|
||||
generate_arrow_parquet,
|
||||
[f"{output_dir}/data{i}.parquet" for i in range(num_files)],
|
||||
)
|
||||
|
||||
|
||||
def concat_arrow_files(input_dir: str, output_dir: str, repeat: int = 10):
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
files = glob.glob(os.path.join(input_dir, "*.parquet"))
|
||||
table = pa.concat_tables([pa.parquet.read_table(file) for file in files] * repeat)
|
||||
pq.write_table(table, os.path.join(output_dir, "large_array.parquet"))
|
||||
|
||||
|
||||
def generate_random_string(length: int) -> str:
|
||||
"""Generate a random string of a specified length"""
|
||||
return "".join(random.choices(string.ascii_letters + string.digits, k=length))
|
||||
|
||||
|
||||
def generate_random_url() -> str:
|
||||
"""Generate a random URL"""
|
||||
path = generate_random_string(random.randint(10, 20))
|
||||
return (
|
||||
f"com.{random.randint(10000, 999999)}.{random.randint(100, 9999)}/{path}.html"
|
||||
)
|
||||
|
||||
|
||||
def generate_random_data() -> str:
|
||||
"""Generate random data"""
|
||||
url = generate_random_url()
|
||||
content = generate_random_string(random.randint(50, 100))
|
||||
encoded_content = base64.b64encode(content.encode()).decode()
|
||||
return f"{url}\t{encoded_content}"
|
||||
|
||||
|
||||
def generate_url_parquet(path: str, num_rows=100):
|
||||
"""Generate a parquet file with a specified number of random data lines"""
|
||||
data = []
|
||||
for _ in range(num_rows):
|
||||
url = generate_random_url()
|
||||
host = url.split("/")[0]
|
||||
data.append({"host": host, "url": url})
|
||||
|
||||
df = pd.DataFrame(data)
|
||||
df.to_parquet(path, engine="pyarrow")
|
||||
|
||||
|
||||
def generate_url_parquet_files(output_dir: str, num_files: int = 10):
|
||||
"""Generate multiple parquet files with a specified number of random data lines"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with ProcessPoolExecutor(max_workers=10) as executor:
|
||||
executor.map(
|
||||
generate_url_parquet,
|
||||
[f"{output_dir}/urls{i}.parquet" for i in range(num_files)],
|
||||
)
|
||||
|
||||
|
||||
def generate_url_tsv_files(
|
||||
output_dir: str, num_files: int = 10, lines_per_file: int = 100
|
||||
):
|
||||
"""Generate multiple files, each containing a specified number of random data lines"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
for i in range(num_files):
|
||||
with open(f"{output_dir}/urls{i}.tsv", "w") as f:
|
||||
for _ in range(lines_per_file):
|
||||
f.write(generate_random_data() + "\n")
|
||||
|
||||
|
||||
def generate_long_path_list(path: str, num_lines: int = 1048576):
|
||||
"""Generate a list of long paths"""
|
||||
with open(path, "w", buffering=16 * 1024 * 1024) as f:
|
||||
for i in range(num_lines):
|
||||
path = os.path.abspath(f"tests/data/arrow/data{i % 10}.parquet")
|
||||
f.write(f"{path}\n")
|
||||
|
||||
|
||||
def generate_data(path: str = "tests/data"):
|
||||
"""
|
||||
Generate all data for testing.
|
||||
"""
|
||||
os.makedirs(path, exist_ok=True)
|
||||
try:
|
||||
with FileLock(path + "/data.lock"):
|
||||
print("Generating data...")
|
||||
if not os.path.exists(path + "/mock_urls"):
|
||||
generate_url_tsv_files(
|
||||
output_dir=path + "/mock_urls", num_files=10, lines_per_file=100
|
||||
)
|
||||
generate_url_parquet_files(output_dir=path + "/mock_urls", num_files=10)
|
||||
if not os.path.exists(path + "/arrow"):
|
||||
generate_arrow_files(output_dir=path + "/arrow", num_files=10)
|
||||
if not os.path.exists(path + "/large_array"):
|
||||
concat_arrow_files(
|
||||
input_dir=path + "/arrow", output_dir=path + "/large_array"
|
||||
)
|
||||
if not os.path.exists(path + "/long_path_list.txt"):
|
||||
generate_long_path_list(path=path + "/long_path_list.txt")
|
||||
except Exception as e:
|
||||
print(f"Error generating data: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_data()
|
||||
187
tests/test_arrow.py
Normal file
187
tests/test_arrow.py
Normal file
@@ -0,0 +1,187 @@
|
||||
import glob
|
||||
import os.path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import pyarrow.parquet as parquet
|
||||
from loguru import logger
|
||||
|
||||
from smallpond.io.arrow import (
|
||||
RowRange,
|
||||
build_batch_reader_from_files,
|
||||
cast_columns_to_large_string,
|
||||
dump_to_parquet_files,
|
||||
load_from_parquet_files,
|
||||
)
|
||||
from smallpond.utility import ConcurrentIter
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class TestArrow(TestFabric, unittest.TestCase):
|
||||
def test_load_from_parquet_files(self):
|
||||
for dataset_path in (
|
||||
"tests/data/arrow/*.parquet",
|
||||
"tests/data/large_array/*.parquet",
|
||||
):
|
||||
with self.subTest(dataset_path=dataset_path):
|
||||
parquet_files = glob.glob(dataset_path)
|
||||
expected = self._load_parquet_files(parquet_files)
|
||||
actual = load_from_parquet_files(parquet_files)
|
||||
self._compare_arrow_tables(expected, actual)
|
||||
|
||||
def test_load_parquet_row_ranges(self):
|
||||
for dataset_path in (
|
||||
"tests/data/arrow/data0.parquet",
|
||||
"tests/data/large_array/large_array.parquet",
|
||||
):
|
||||
with self.subTest(dataset_path=dataset_path):
|
||||
metadata = parquet.read_metadata(dataset_path)
|
||||
file_num_rows = metadata.num_rows
|
||||
data_size = sum(
|
||||
metadata.row_group(i).total_byte_size
|
||||
for i in range(metadata.num_row_groups)
|
||||
)
|
||||
row_range = RowRange(
|
||||
path=dataset_path,
|
||||
begin=100,
|
||||
end=200,
|
||||
data_size=data_size,
|
||||
file_num_rows=file_num_rows,
|
||||
)
|
||||
expected = self._load_parquet_files([dataset_path]).slice(
|
||||
offset=100, length=100
|
||||
)
|
||||
actual = load_from_parquet_files([row_range])
|
||||
self._compare_arrow_tables(expected, actual)
|
||||
|
||||
def test_dump_to_parquet_files(self):
|
||||
for dataset_path in (
|
||||
"tests/data/arrow/*.parquet",
|
||||
"tests/data/large_array/*.parquet",
|
||||
):
|
||||
with self.subTest(dataset_path=dataset_path):
|
||||
parquet_files = glob.glob(dataset_path)
|
||||
expected = self._load_parquet_files(parquet_files)
|
||||
with tempfile.TemporaryDirectory(
|
||||
dir=self.output_root_abspath
|
||||
) as output_dir:
|
||||
ok = dump_to_parquet_files(expected, output_dir)
|
||||
self.assertTrue(ok)
|
||||
actual = self._load_parquet_files(
|
||||
glob.glob(f"{output_dir}/*.parquet")
|
||||
)
|
||||
self._compare_arrow_tables(expected, actual)
|
||||
|
||||
def test_dump_load_empty_table(self):
|
||||
# create empty table
|
||||
empty_table = self._load_parquet_files(
|
||||
["tests/data/arrow/data0.parquet"]
|
||||
).slice(length=0)
|
||||
self.assertEqual(empty_table.num_rows, 0)
|
||||
# dump empty table
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
ok = dump_to_parquet_files(empty_table, output_dir)
|
||||
self.assertTrue(ok)
|
||||
parquet_files = glob.glob(f"{output_dir}/*.parquet")
|
||||
# load empty table from file
|
||||
actual_table = load_from_parquet_files(parquet_files)
|
||||
self._compare_arrow_tables(empty_table, actual_table)
|
||||
|
||||
def test_parquet_batch_reader(self):
|
||||
for dataset_path in (
|
||||
"tests/data/arrow/*.parquet",
|
||||
"tests/data/large_array/*.parquet",
|
||||
):
|
||||
with self.subTest(dataset_path=dataset_path):
|
||||
parquet_files = glob.glob(dataset_path)
|
||||
expected_num_rows = sum(
|
||||
parquet.read_metadata(file).num_rows for file in parquet_files
|
||||
)
|
||||
with build_batch_reader_from_files(
|
||||
parquet_files,
|
||||
batch_size=expected_num_rows,
|
||||
max_batch_byte_size=None,
|
||||
) as batch_reader, ConcurrentIter(batch_reader) as concurrent_iter:
|
||||
total_num_rows = 0
|
||||
for batch in concurrent_iter:
|
||||
print(
|
||||
f"batch.num_rows {batch.num_rows}, max_batch_row_size {expected_num_rows}"
|
||||
)
|
||||
self.assertLessEqual(batch.num_rows, expected_num_rows)
|
||||
total_num_rows += batch.num_rows
|
||||
self.assertEqual(total_num_rows, expected_num_rows)
|
||||
|
||||
def test_table_to_batches(self):
|
||||
for dataset_path in (
|
||||
"tests/data/arrow/*.parquet",
|
||||
"tests/data/large_array/*.parquet",
|
||||
):
|
||||
with self.subTest(dataset_path=dataset_path):
|
||||
parquet_files = glob.glob(dataset_path)
|
||||
table = self._load_parquet_files(parquet_files)
|
||||
total_num_rows = 0
|
||||
for batch in table.to_batches(max_chunksize=table.num_rows):
|
||||
print(
|
||||
f"batch.num_rows {batch.num_rows}, max_batch_row_size {table.num_rows}"
|
||||
)
|
||||
self.assertLessEqual(batch.num_rows, table.num_rows)
|
||||
total_num_rows += batch.num_rows
|
||||
self.assertEqual(total_num_rows, table.num_rows)
|
||||
|
||||
def test_arrow_schema_metadata(self):
|
||||
table = self._load_parquet_files(glob.glob("tests/data/arrow/*.parquet"))
|
||||
metadata = {b"a": b"1", b"b": b"2"}
|
||||
table_with_meta = table.replace_schema_metadata(metadata)
|
||||
print(f"table_with_meta.schema.metadata {table_with_meta.schema.metadata}")
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
self.assertTrue(
|
||||
dump_to_parquet_files(
|
||||
table_with_meta, output_dir, "arrow_schema_metadata", max_workers=2
|
||||
)
|
||||
)
|
||||
parquet_files = glob.glob(
|
||||
os.path.join(output_dir, "arrow_schema_metadata*.parquet")
|
||||
)
|
||||
loaded_table = load_from_parquet_files(
|
||||
parquet_files, table.column_names[:1]
|
||||
)
|
||||
print(f"loaded_table.schema.metadata {loaded_table.schema.metadata}")
|
||||
self.assertEqual(
|
||||
table_with_meta.schema.metadata, loaded_table.schema.metadata
|
||||
)
|
||||
with parquet.ParquetFile(parquet_files[0]) as file:
|
||||
print(f"file.schema_arrow.metadata {file.schema_arrow.metadata}")
|
||||
self.assertEqual(
|
||||
table_with_meta.schema.metadata, file.schema_arrow.metadata
|
||||
)
|
||||
|
||||
def test_load_mixed_string_types(self):
|
||||
parquet_paths = glob.glob("tests/data/arrow/*.parquet")
|
||||
table = self._load_parquet_files(parquet_paths)
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
dump_to_parquet_files(cast_columns_to_large_string(table), output_dir)
|
||||
parquet_paths += glob.glob(os.path.join(output_dir, "*.parquet"))
|
||||
loaded_table = load_from_parquet_files(parquet_paths)
|
||||
self.assertEqual(table.num_rows * 2, loaded_table.num_rows)
|
||||
batch_reader = build_batch_reader_from_files(parquet_paths)
|
||||
self.assertEqual(
|
||||
table.num_rows * 2, sum(batch.num_rows for batch in batch_reader)
|
||||
)
|
||||
|
||||
@logger.catch(reraise=True, message="failed to load parquet files")
|
||||
def _load_from_parquet_files_with_log(self, paths, columns):
|
||||
load_from_parquet_files(paths, columns)
|
||||
|
||||
def test_load_not_exist_column(self):
|
||||
parquet_files = glob.glob("tests/data/arrow/*.parquet")
|
||||
with self.assertRaises(AssertionError) as context:
|
||||
self._load_from_parquet_files_with_log(parquet_files, ["not_exist_column"])
|
||||
|
||||
def test_change_ordering_of_columns(self):
|
||||
parquet_files = glob.glob("tests/data/arrow/*.parquet")
|
||||
loaded_table = load_from_parquet_files(parquet_files)
|
||||
reversed_cols = list(reversed(loaded_table.column_names))
|
||||
loaded_table = load_from_parquet_files(parquet_files, reversed_cols)
|
||||
self.assertEqual(loaded_table.column_names, reversed_cols)
|
||||
90
tests/test_bench.py
Normal file
90
tests/test_bench.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import shutil
|
||||
import unittest
|
||||
|
||||
from benchmarks.file_io_benchmark import file_io_benchmark
|
||||
from benchmarks.gray_sort_benchmark import generate_random_records, gray_sort_benchmark
|
||||
from benchmarks.hash_partition_benchmark import hash_partition_benchmark
|
||||
from benchmarks.urls_sort_benchmark import urls_sort_benchmark
|
||||
from smallpond.common import MB
|
||||
from smallpond.logical.node import Context, LogicalPlan
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class TestBench(TestFabric, unittest.TestCase):
|
||||
|
||||
fault_inject_prob = 0.05
|
||||
|
||||
def test_file_io_benchmark(self):
|
||||
for io_engine in ("duckdb", "arrow", "stream"):
|
||||
with self.subTest(io_engine=io_engine):
|
||||
plan = file_io_benchmark(
|
||||
["tests/data/mock_urls/*.parquet"],
|
||||
npartitions=3,
|
||||
io_engine=io_engine,
|
||||
)
|
||||
self.execute_plan(plan, enable_profiling=True)
|
||||
|
||||
def test_urls_sort_benchmark(self):
|
||||
for engine_type in ("duckdb", "arrow"):
|
||||
with self.subTest(engine_type=engine_type):
|
||||
plan = urls_sort_benchmark(
|
||||
["tests/data/mock_urls/*.tsv"],
|
||||
num_data_partitions=3,
|
||||
num_hash_partitions=3,
|
||||
engine_type=engine_type,
|
||||
)
|
||||
self.execute_plan(plan, enable_profiling=True)
|
||||
|
||||
@unittest.skipIf(shutil.which("gensort") is None, "gensort not found")
|
||||
def test_gray_sort_benchmark(self):
|
||||
record_nbytes = 100
|
||||
key_nbytes = 10
|
||||
total_data_nbytes = 100 * MB
|
||||
gensort_batch_nbytes = 10 * MB
|
||||
num_data_partitions = 5
|
||||
num_sort_partitions = 1 << 3
|
||||
for shuffle_engine in ("duckdb", "arrow"):
|
||||
for sort_engine in ("duckdb", "arrow", "polars"):
|
||||
with self.subTest(
|
||||
shuffle_engine=shuffle_engine, sort_engine=sort_engine
|
||||
):
|
||||
ctx = Context()
|
||||
random_records = generate_random_records(
|
||||
ctx,
|
||||
record_nbytes,
|
||||
key_nbytes,
|
||||
total_data_nbytes,
|
||||
gensort_batch_nbytes,
|
||||
num_data_partitions,
|
||||
num_sort_partitions,
|
||||
)
|
||||
plan = LogicalPlan(ctx, random_records)
|
||||
exec_plan = self.execute_plan(plan, enable_profiling=True)
|
||||
|
||||
plan = gray_sort_benchmark(
|
||||
record_nbytes,
|
||||
key_nbytes,
|
||||
total_data_nbytes,
|
||||
gensort_batch_nbytes,
|
||||
num_data_partitions,
|
||||
num_sort_partitions,
|
||||
input_paths=exec_plan.final_output.resolved_paths,
|
||||
shuffle_engine=shuffle_engine,
|
||||
sort_engine=sort_engine,
|
||||
hive_partitioning=True,
|
||||
validate_results=True,
|
||||
)
|
||||
self.execute_plan(plan, enable_profiling=True)
|
||||
|
||||
def test_hash_partition_benchmark(self):
|
||||
for engine_type in ("duckdb", "arrow"):
|
||||
with self.subTest(engine_type=engine_type):
|
||||
plan = hash_partition_benchmark(
|
||||
["tests/data/mock_urls/*.parquet"],
|
||||
npartitions=5,
|
||||
hash_columns=["url"],
|
||||
engine_type=engine_type,
|
||||
hive_partitioning=True,
|
||||
partition_stats=True,
|
||||
)
|
||||
self.execute_plan(plan, enable_profiling=True)
|
||||
73
tests/test_common.py
Normal file
73
tests/test_common.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import itertools
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from smallpond.common import get_nth_partition, split_into_cols, split_into_rows
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class TestCommon(TestFabric, unittest.TestCase):
|
||||
def test_get_nth_partition(self):
|
||||
items = [1, 2, 3]
|
||||
# split into 1 partitions
|
||||
self.assertListEqual([1, 2, 3], get_nth_partition(items, 0, 1))
|
||||
# split into 2 partitions
|
||||
self.assertListEqual([1, 2], get_nth_partition(items, 0, 2))
|
||||
self.assertListEqual([3], get_nth_partition(items, 1, 2))
|
||||
# split into 3 partitions
|
||||
self.assertListEqual([1], get_nth_partition(items, 0, 3))
|
||||
self.assertListEqual([2], get_nth_partition(items, 1, 3))
|
||||
self.assertListEqual([3], get_nth_partition(items, 2, 3))
|
||||
# split into 5 partitions
|
||||
self.assertListEqual([1], get_nth_partition(items, 0, 5))
|
||||
self.assertListEqual([2], get_nth_partition(items, 1, 5))
|
||||
self.assertListEqual([3], get_nth_partition(items, 2, 5))
|
||||
self.assertListEqual([], get_nth_partition(items, 3, 5))
|
||||
self.assertListEqual([], get_nth_partition(items, 4, 5))
|
||||
|
||||
@given(st.data())
|
||||
def test_split_into_rows(self, data: st.data):
|
||||
nelements = data.draw(st.integers(1, 100))
|
||||
npartitions = data.draw(st.integers(1, 2 * nelements))
|
||||
items = list(range(nelements))
|
||||
computed = split_into_rows(items, npartitions)
|
||||
expected = [
|
||||
get_nth_partition(items, n, npartitions) for n in range(npartitions)
|
||||
]
|
||||
self.assertEqual(expected, computed)
|
||||
|
||||
@given(st.data())
|
||||
def test_split_into_cols(self, data: st.data):
|
||||
nelements = data.draw(st.integers(1, 100))
|
||||
npartitions = data.draw(st.integers(1, 2 * nelements))
|
||||
items = list(range(nelements))
|
||||
chunks = split_into_cols(items, npartitions)
|
||||
self.assertEqual(npartitions, len(chunks))
|
||||
self.assertListEqual(
|
||||
items,
|
||||
[x for row in itertools.zip_longest(*chunks) for x in row if x is not None],
|
||||
)
|
||||
chunk_sizes = set(len(chk) for chk in chunks)
|
||||
if len(chunk_sizes) > 1:
|
||||
small_size, large_size = sorted(chunk_sizes)
|
||||
self.assertEqual(small_size + 1, large_size)
|
||||
else:
|
||||
(chunk_size,) = chunk_sizes
|
||||
self.assertEqual(len(items), chunk_size * npartitions)
|
||||
|
||||
def test_split_into_rows_bench(self):
|
||||
for nelements in [100000, 1000000]:
|
||||
items = np.arange(nelements)
|
||||
for npartitions in [1024, 4096, 10240, nelements, 2 * nelements]:
|
||||
chunks = split_into_rows(items, npartitions)
|
||||
self.assertEqual(npartitions, len(chunks))
|
||||
|
||||
def test_split_into_cols_bench(self):
|
||||
for nelements in [100000, 1000000]:
|
||||
items = np.arange(nelements)
|
||||
for npartitions in [1024, 4096, 10240, nelements, 2 * nelements]:
|
||||
chunks = split_into_cols(items, npartitions)
|
||||
self.assertEqual(npartitions, len(chunks))
|
||||
223
tests/test_dataframe.py
Normal file
223
tests/test_dataframe.py
Normal file
@@ -0,0 +1,223 @@
|
||||
from typing import List
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow as pa
|
||||
import pytest
|
||||
|
||||
from smallpond.dataframe import Session
|
||||
|
||||
|
||||
def test_pandas(sp: Session):
|
||||
pandas_df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
|
||||
df = sp.from_pandas(pandas_df)
|
||||
assert df.to_pandas().equals(pandas_df)
|
||||
|
||||
|
||||
def test_arrow(sp: Session):
|
||||
arrow_table = pa.table({"a": [1, 2, 3], "b": [4, 5, 6]})
|
||||
df = sp.from_arrow(arrow_table)
|
||||
assert df.to_arrow() == arrow_table
|
||||
|
||||
|
||||
def test_items(sp: Session):
|
||||
df = sp.from_items([1, 2, 3])
|
||||
assert df.take_all() == [{"item": 1}, {"item": 2}, {"item": 3}]
|
||||
df = sp.from_items([{"a": 1, "b": 4}, {"a": 2, "b": 5}, {"a": 3, "b": 6}])
|
||||
assert df.take_all() == [{"a": 1, "b": 4}, {"a": 2, "b": 5}, {"a": 3, "b": 6}]
|
||||
|
||||
|
||||
def test_csv(sp: Session):
|
||||
df = sp.read_csv(
|
||||
"tests/data/mock_urls/*.tsv",
|
||||
schema={"urlstr": "varchar", "valstr": "varchar"},
|
||||
delim=r"\t",
|
||||
)
|
||||
assert df.count() == 1000
|
||||
|
||||
|
||||
def test_parquet(sp: Session):
|
||||
df = sp.read_parquet("tests/data/mock_urls/*.parquet")
|
||||
assert df.count() == 1000
|
||||
|
||||
|
||||
def test_take(sp: Session):
|
||||
df = sp.from_pandas(pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
|
||||
assert df.take(2) == [{"a": 1, "b": 4}, {"a": 2, "b": 5}]
|
||||
assert df.take_all() == [{"a": 1, "b": 4}, {"a": 2, "b": 5}, {"a": 3, "b": 6}]
|
||||
|
||||
|
||||
def test_map(sp: Session):
|
||||
df = sp.from_arrow(pa.table({"a": [1, 2, 3], "b": [4, 5, 6]}))
|
||||
df1 = df.map("a + b as c")
|
||||
assert df1.to_arrow() == pa.table({"c": [5, 7, 9]})
|
||||
df2 = df.map(lambda r: {"c": r["a"] + r["b"]})
|
||||
assert df2.to_arrow() == pa.table({"c": [5, 7, 9]})
|
||||
|
||||
# user need to specify the schema if can not be inferred from the mapping values
|
||||
df3 = df.map(
|
||||
lambda r: {"c": None if r["a"] == 1 else r["a"] + r["b"]},
|
||||
schema=pa.schema([("c", pa.int64())]),
|
||||
)
|
||||
assert df3.to_arrow() == pa.table({"c": pa.array([None, 7, 9], type=pa.int64())})
|
||||
|
||||
|
||||
def test_flat_map(sp: Session):
|
||||
df = sp.from_arrow(pa.table({"a": [1, 2, 3], "b": [4, 5, 6]}))
|
||||
df1 = df.flat_map(lambda r: [{"c": r["a"]}, {"c": r["b"]}])
|
||||
assert df1.to_arrow() == pa.table({"c": [1, 4, 2, 5, 3, 6]})
|
||||
df2 = df.flat_map("unnest(array[a, b]) as c")
|
||||
assert df2.to_arrow() == pa.table({"c": [1, 4, 2, 5, 3, 6]})
|
||||
|
||||
# user need to specify the schema if can not be inferred from the mapping values
|
||||
df3 = df.flat_map(lambda r: [{"c": None}], schema=pa.schema([("c", pa.int64())]))
|
||||
assert df3.to_arrow() == pa.table(
|
||||
{"c": pa.array([None, None, None], type=pa.int64())}
|
||||
)
|
||||
|
||||
|
||||
def test_map_batches(sp: Session):
|
||||
df = sp.read_parquet("tests/data/mock_urls/*.parquet")
|
||||
df = df.map_batches(
|
||||
lambda batch: pa.table({"num_rows": [batch.num_rows]}),
|
||||
batch_size=350,
|
||||
)
|
||||
assert df.take_all() == [{"num_rows": 350}, {"num_rows": 350}, {"num_rows": 300}]
|
||||
|
||||
|
||||
def test_filter(sp: Session):
|
||||
df = sp.from_arrow(pa.table({"a": [1, 2, 3], "b": [4, 5, 6]}))
|
||||
df1 = df.filter("a > 1")
|
||||
assert df1.to_arrow() == pa.table({"a": [2, 3], "b": [5, 6]})
|
||||
df2 = df.filter(lambda r: r["a"] > 1)
|
||||
assert df2.to_arrow() == pa.table({"a": [2, 3], "b": [5, 6]})
|
||||
|
||||
|
||||
def test_random_shuffle(sp: Session):
|
||||
df = sp.from_items(list(range(1000))).repartition(10, by_rows=True)
|
||||
df = df.random_shuffle()
|
||||
shuffled = [d["item"] for d in df.take_all()]
|
||||
assert sorted(shuffled) == list(range(1000))
|
||||
|
||||
def count_inversions(arr: List[int]) -> int:
|
||||
return sum(
|
||||
sum(1 for j in range(i + 1, len(arr)) if arr[i] > arr[j])
|
||||
for i in range(len(arr))
|
||||
)
|
||||
|
||||
# check the shuffle is random enough
|
||||
# the expected number of inversions is n*(n-1)/4 = 249750
|
||||
assert 220000 <= count_inversions(shuffled) <= 280000
|
||||
|
||||
|
||||
def test_partition_by(sp: Session):
|
||||
df = sp.from_items(list(range(1000))).repartition(10, by="item % 10")
|
||||
df = df.map("min(item % 10) as min, max(item % 10) as max")
|
||||
assert df.take_all() == [{"min": i, "max": i} for i in range(10)]
|
||||
|
||||
|
||||
def test_partition_by_key_out_of_range(sp: Session):
|
||||
df = sp.from_items(list(range(1000))).repartition(10, by="item % 11")
|
||||
try:
|
||||
df.to_arrow()
|
||||
except Exception as ex:
|
||||
assert "partition key 10 is out of range 0-9" in str(ex)
|
||||
else:
|
||||
assert False, "expected exception"
|
||||
|
||||
|
||||
def test_partition_by_hash(sp: Session):
|
||||
df = sp.from_items(list(range(1000))).repartition(10, hash_by="item")
|
||||
items = [d["item"] for d in df.take_all()]
|
||||
assert sorted(items) == list(range(1000))
|
||||
|
||||
|
||||
def test_count(sp: Session):
|
||||
df = sp.from_items([1, 2, 3])
|
||||
assert df.count() == 3
|
||||
|
||||
|
||||
def test_limit(sp: Session):
|
||||
df = sp.from_items(list(range(1000))).repartition(10, by_rows=True)
|
||||
assert df.limit(2).count() == 2
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="limit can not be pushed down to sql node for now")
|
||||
@pytest.mark.timeout(10)
|
||||
def test_limit_large(sp: Session):
|
||||
# limit will be fused with the previous select
|
||||
# otherwise, it will be timeout
|
||||
df = sp.partial_sql("select * from range(1000000000)")
|
||||
assert df.limit(2).count() == 2
|
||||
|
||||
|
||||
def test_partial_sql(sp: Session):
|
||||
# no input deps
|
||||
df = sp.partial_sql("select * from range(3)")
|
||||
assert df.to_arrow() == pa.table({"range": [0, 1, 2]})
|
||||
|
||||
# join
|
||||
df1 = sp.from_arrow(pa.table({"id1": [1, 2, 3], "val1": ["a", "b", "c"]}))
|
||||
df2 = sp.from_arrow(pa.table({"id2": [1, 2, 3], "val2": ["d", "e", "f"]}))
|
||||
joined = sp.partial_sql(
|
||||
"select id1, val1, val2 from {0} join {1} on id1 = id2", df1, df2
|
||||
)
|
||||
assert joined.to_arrow() == pa.table(
|
||||
{"id1": [1, 2, 3], "val1": ["a", "b", "c"], "val2": ["d", "e", "f"]},
|
||||
schema=pa.schema(
|
||||
[
|
||||
("id1", pa.int64()),
|
||||
("val1", pa.large_string()),
|
||||
("val2", pa.large_string()),
|
||||
]
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_error_message(sp: Session):
|
||||
df = sp.from_items([1, 2, 3])
|
||||
df = sp.partial_sql("select a,, from {0}", df)
|
||||
try:
|
||||
df.to_arrow()
|
||||
except Exception as ex:
|
||||
# sql query should be in the exception message
|
||||
assert "select a,, from" in str(ex)
|
||||
else:
|
||||
assert False, "expected exception"
|
||||
|
||||
|
||||
def test_unpicklable_task_exception(sp: Session):
|
||||
from loguru import logger
|
||||
|
||||
df = sp.from_items([1, 2, 3])
|
||||
try:
|
||||
df.map(lambda x: logger.info("use outside logger")).to_arrow()
|
||||
except Exception as ex:
|
||||
assert "Can't pickle task" in str(ex)
|
||||
assert (
|
||||
"HINT: DO NOT use externally imported loguru logger in your task. Please import it within the task."
|
||||
in str(ex)
|
||||
)
|
||||
else:
|
||||
assert False, "expected exception"
|
||||
|
||||
|
||||
def test_log(sp: Session):
|
||||
df = sp.from_items([1, 2, 3])
|
||||
|
||||
def log_record(x):
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from loguru import logger
|
||||
|
||||
print("stdout")
|
||||
print("stderr", file=sys.stderr)
|
||||
logger.info("loguru")
|
||||
logging.info("logging")
|
||||
return x
|
||||
|
||||
df.map(log_record).to_arrow()
|
||||
|
||||
# TODO: check logs should be see in the log file
|
||||
# FIXME: logs in unit test are not written to the log file
|
||||
# because we share the same ray instance for all tests
|
||||
174
tests/test_dataset.py
Normal file
174
tests/test_dataset.py
Normal file
@@ -0,0 +1,174 @@
|
||||
import glob
|
||||
import os.path
|
||||
import unittest
|
||||
from pathlib import PurePath
|
||||
|
||||
import duckdb
|
||||
import pandas
|
||||
import pyarrow as arrow
|
||||
import pytest
|
||||
from loguru import logger
|
||||
|
||||
from smallpond.common import DEFAULT_ROW_GROUP_SIZE, MB
|
||||
from smallpond.logical.dataset import ParquetDataSet
|
||||
from smallpond.utility import ConcurrentIter
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class TestDataSet(TestFabric, unittest.TestCase):
|
||||
def test_parquet_file_created_by_pandas(self):
|
||||
num_urls = 0
|
||||
for txt_file in glob.glob("tests/data/mock_urls/*.tsv"):
|
||||
urls = pandas.read_csv(txt_file, delimiter="\t", names=["url"])
|
||||
urls.to_parquet(
|
||||
os.path.join(
|
||||
self.output_root_abspath,
|
||||
PurePath(os.path.basename(txt_file)).with_suffix(".parquet"),
|
||||
)
|
||||
)
|
||||
num_urls += urls.size
|
||||
dataset = ParquetDataSet([os.path.join(self.output_root_abspath, "*.parquet")])
|
||||
self.assertEqual(num_urls, dataset.num_rows)
|
||||
|
||||
def _generate_parquet_dataset(
|
||||
self, output_path, npartitions, num_rows, row_group_size
|
||||
):
|
||||
duckdb.sql(
|
||||
f"""copy (
|
||||
select range as i, range % {npartitions} as partition from range(0, {num_rows}) )
|
||||
to '{output_path}'
|
||||
(FORMAT PARQUET, ROW_GROUP_SIZE {row_group_size}, PARTITION_BY partition, OVERWRITE_OR_IGNORE true)"""
|
||||
)
|
||||
return ParquetDataSet([f"{output_path}/**/*.parquet"])
|
||||
|
||||
def _check_partition_datasets(
|
||||
self, orig_dataset: ParquetDataSet, partition_func, npartition
|
||||
):
|
||||
# build partitioned datasets
|
||||
partitioned_datasets = partition_func(npartition)
|
||||
self.assertEqual(npartition, len(partitioned_datasets))
|
||||
self.assertEqual(
|
||||
orig_dataset.num_rows,
|
||||
sum(dataset.num_rows for dataset in partitioned_datasets),
|
||||
)
|
||||
# load as arrow table
|
||||
loaded_table = arrow.concat_tables(
|
||||
[dataset.to_arrow_table(max_workers=1) for dataset in partitioned_datasets]
|
||||
)
|
||||
self.assertEqual(orig_dataset.num_rows, loaded_table.num_rows)
|
||||
# compare arrow tables
|
||||
orig_table = orig_dataset.to_arrow_table(max_workers=1)
|
||||
self.assertEqual(orig_table.shape, loaded_table.shape)
|
||||
self.assertTrue(orig_table.sort_by("i").equals(loaded_table.sort_by("i")))
|
||||
# compare sql query results
|
||||
join_query = f"""
|
||||
select count(a.i) as num_rows
|
||||
from {orig_dataset.sql_query_fragment()} as a
|
||||
join ( {' union all '.join([dataset.sql_query_fragment() for dataset in partitioned_datasets])} ) as b on a.i = b.i"""
|
||||
results = duckdb.sql(join_query).fetchall()
|
||||
self.assertEqual(orig_dataset.num_rows, results[0][0])
|
||||
|
||||
def test_num_rows(self):
|
||||
dataset = ParquetDataSet(["tests/data/arrow/*.parquet"])
|
||||
self.assertEqual(dataset.num_rows, 1000)
|
||||
|
||||
def test_partition_by_files(self):
|
||||
output_path = os.path.join(self.output_root_abspath, "test_partition_by_files")
|
||||
orig_dataset = self._generate_parquet_dataset(
|
||||
output_path, npartitions=11, num_rows=170 * 1000, row_group_size=10 * 1000
|
||||
)
|
||||
num_files = len(orig_dataset.resolved_paths)
|
||||
for npartition in range(1, num_files + 1):
|
||||
for random_shuffle in (False, True):
|
||||
with self.subTest(npartition=npartition, random_shuffle=random_shuffle):
|
||||
orig_dataset.reset(orig_dataset.paths, orig_dataset.root_dir)
|
||||
self._check_partition_datasets(
|
||||
orig_dataset,
|
||||
lambda n: orig_dataset.partition_by_files(
|
||||
n, random_shuffle=random_shuffle
|
||||
),
|
||||
npartition,
|
||||
)
|
||||
|
||||
def test_partition_by_rows(self):
|
||||
output_path = os.path.join(self.output_root_abspath, "test_partition_by_rows")
|
||||
orig_dataset = self._generate_parquet_dataset(
|
||||
output_path, npartitions=11, num_rows=170 * 1000, row_group_size=10 * 1000
|
||||
)
|
||||
num_files = len(orig_dataset.resolved_paths)
|
||||
for npartition in range(1, 2 * num_files + 1):
|
||||
for random_shuffle in (False, True):
|
||||
with self.subTest(npartition=npartition, random_shuffle=random_shuffle):
|
||||
orig_dataset.reset(orig_dataset.paths, orig_dataset.root_dir)
|
||||
self._check_partition_datasets(
|
||||
orig_dataset,
|
||||
lambda n: orig_dataset.partition_by_rows(
|
||||
n, random_shuffle=random_shuffle
|
||||
),
|
||||
npartition,
|
||||
)
|
||||
|
||||
def test_resolved_many_paths(self):
|
||||
with open("tests/data/long_path_list.txt", buffering=16 * MB) as fin:
|
||||
filenames = list(map(os.path.basename, map(str.strip, fin.readlines())))
|
||||
logger.info(f"loaded {len(filenames)} filenames")
|
||||
dataset = ParquetDataSet(filenames)
|
||||
self.assertEqual(len(dataset.resolved_paths), len(filenames))
|
||||
|
||||
def test_paths_with_char_ranges(self):
|
||||
dataset_with_char_ranges = ParquetDataSet(
|
||||
["tests/data/arrow/data[0-9].parquet"]
|
||||
)
|
||||
dataset_with_wildcards = ParquetDataSet(["tests/data/arrow/*.parquet"])
|
||||
self.assertEqual(
|
||||
len(dataset_with_char_ranges.resolved_paths),
|
||||
len(dataset_with_wildcards.resolved_paths),
|
||||
)
|
||||
|
||||
def test_to_arrow_table_batch_reader(self):
|
||||
memdb = duckdb.connect(
|
||||
database=":memory:", config={"arrow_large_buffer_size": "true"}
|
||||
)
|
||||
for dataset_path in (
|
||||
"tests/data/arrow/*.parquet",
|
||||
"tests/data/large_array/*.parquet",
|
||||
):
|
||||
for conn in (None, memdb):
|
||||
print(f"dataset_path: {dataset_path}, conn: {conn}")
|
||||
with self.subTest(dataset_path=dataset_path, conn=conn):
|
||||
dataset = ParquetDataSet([dataset_path])
|
||||
to_batches = dataset.to_arrow_table(
|
||||
max_workers=1, conn=conn
|
||||
).to_batches(max_chunksize=DEFAULT_ROW_GROUP_SIZE * 2)
|
||||
batch_reader = dataset.to_batch_reader(
|
||||
batch_size=DEFAULT_ROW_GROUP_SIZE * 2, conn=conn
|
||||
)
|
||||
with ConcurrentIter(
|
||||
batch_reader, max_buffer_size=2
|
||||
) as batch_reader:
|
||||
for batch_iter in (to_batches, batch_reader):
|
||||
total_num_rows = 0
|
||||
for batch in batch_iter:
|
||||
print(
|
||||
f"batch.num_rows {batch.num_rows}, max_batch_row_size {DEFAULT_ROW_GROUP_SIZE*2}"
|
||||
)
|
||||
self.assertLessEqual(
|
||||
batch.num_rows, DEFAULT_ROW_GROUP_SIZE * 2
|
||||
)
|
||||
total_num_rows += batch.num_rows
|
||||
print(f"{dataset_path}: total_num_rows {total_num_rows}")
|
||||
self.assertEqual(total_num_rows, dataset.num_rows)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reader", ["arrow", "duckdb"])
|
||||
@pytest.mark.parametrize("dataset_path", ["tests/data/arrow/*.parquet"])
|
||||
# @pytest.mark.parametrize("dataset_path", ["tests/data/arrow/*.parquet", "tests/data/large_array/*.parquet"])
|
||||
def test_arrow_reader(benchmark, reader: str, dataset_path: str):
|
||||
dataset = ParquetDataSet([dataset_path])
|
||||
conn = None
|
||||
if reader == "duckdb":
|
||||
conn = duckdb.connect(
|
||||
database=":memory:", config={"arrow_large_buffer_size": "true"}
|
||||
)
|
||||
benchmark(dataset.to_arrow_table, conn=conn)
|
||||
# result: arrow reader is 4x faster than duckdb reader in small dataset, 1.4x faster in large dataset
|
||||
60
tests/test_deltalake.py
Normal file
60
tests/test_deltalake.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import glob
|
||||
import importlib
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from smallpond.io.arrow import cast_columns_to_large_string
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
@unittest.skipUnless(
|
||||
importlib.util.find_spec("deltalake") is not None, "cannot find deltalake"
|
||||
)
|
||||
class TestDeltaLake(TestFabric, unittest.TestCase):
|
||||
def test_read_write_deltalake(self):
|
||||
from deltalake import DeltaTable, write_deltalake
|
||||
|
||||
for dataset_path in (
|
||||
"tests/data/arrow/*.parquet",
|
||||
"tests/data/large_array/*.parquet",
|
||||
):
|
||||
parquet_files = glob.glob(dataset_path)
|
||||
expected = self._load_parquet_files(parquet_files)
|
||||
with self.subTest(dataset_path=dataset_path), tempfile.TemporaryDirectory(
|
||||
dir=self.output_root_abspath
|
||||
) as output_dir:
|
||||
write_deltalake(output_dir, expected, large_dtypes=True)
|
||||
dt = DeltaTable(output_dir)
|
||||
self._compare_arrow_tables(expected, dt.to_pyarrow_table())
|
||||
|
||||
def test_load_mixed_large_dtypes(self):
|
||||
from deltalake import DeltaTable, write_deltalake
|
||||
|
||||
for dataset_path in (
|
||||
"tests/data/arrow/*.parquet",
|
||||
"tests/data/large_array/*.parquet",
|
||||
):
|
||||
parquet_files = glob.glob(dataset_path)
|
||||
with self.subTest(dataset_path=dataset_path), tempfile.TemporaryDirectory(
|
||||
dir=self.output_root_abspath
|
||||
) as output_dir:
|
||||
table = cast_columns_to_large_string(
|
||||
self._load_parquet_files(parquet_files)
|
||||
)
|
||||
write_deltalake(output_dir, table, large_dtypes=True, mode="overwrite")
|
||||
write_deltalake(output_dir, table, large_dtypes=False, mode="append")
|
||||
loaded_table = DeltaTable(output_dir).to_pyarrow_table()
|
||||
print("table:\n", table.schema)
|
||||
print("loaded_table:\n", loaded_table.schema)
|
||||
self.assertEqual(table.num_rows * 2, loaded_table.num_rows)
|
||||
|
||||
def test_delete_update(self):
|
||||
import pandas as pd
|
||||
from deltalake import DeltaTable, write_deltalake
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
df = pd.DataFrame({"num": [1, 2, 3], "animal": ["cat", "dog", "snake"]})
|
||||
write_deltalake(output_dir, df, mode="overwrite")
|
||||
dt = DeltaTable(output_dir)
|
||||
dt.delete("animal = 'cat'")
|
||||
dt.update(predicate="num = 3", new_values={"animal": "fish"})
|
||||
46
tests/test_driver.py
Normal file
46
tests/test_driver.py
Normal file
@@ -0,0 +1,46 @@
|
||||
import os.path
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from benchmarks.gray_sort_benchmark import gray_sort_benchmark
|
||||
from examples.sort_mock_urls import sort_mock_urls
|
||||
from smallpond.common import GB, MB
|
||||
from smallpond.execution.driver import Driver
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
@unittest.skipUnless(os.getenv("ENABLE_DRIVER_TEST"), "unit test disabled")
|
||||
class TestDriver(TestFabric, unittest.TestCase):
|
||||
|
||||
fault_inject_prob = 0.05
|
||||
|
||||
def create_driver(self, num_executors: int):
|
||||
cmdline = f"scheduler --job_id {str(uuid.uuid4())} --job_name {self._testMethodName} --data_root {self.output_root_abspath} --num_executors {num_executors} --fault_inject_prob {self.fault_inject_prob}"
|
||||
driver = Driver()
|
||||
driver.parse_arguments(args=cmdline.split())
|
||||
logger.info(f"{cmdline=} {driver.mode=} {driver.job_id=} {driver.data_root=}")
|
||||
return driver
|
||||
|
||||
def test_standalone_mode(self):
|
||||
plan = sort_mock_urls(["tests/data/mock_urls/*.tsv"], npartitions=3)
|
||||
driver = self.create_driver(num_executors=0)
|
||||
exec_plan = driver.run(plan, stop_process_on_done=False)
|
||||
self.assertTrue(exec_plan.successful)
|
||||
self.assertGreater(exec_plan.final_output.num_files, 0)
|
||||
|
||||
def test_run_on_remote_executors(self):
|
||||
driver = self.create_driver(num_executors=2)
|
||||
plan = gray_sort_benchmark(
|
||||
record_nbytes=100,
|
||||
key_nbytes=10,
|
||||
total_data_nbytes=1 * GB,
|
||||
gensort_batch_nbytes=100 * MB,
|
||||
num_data_partitions=10,
|
||||
num_sort_partitions=10,
|
||||
validate_results=True,
|
||||
)
|
||||
exec_plan = driver.run(plan, stop_process_on_done=False)
|
||||
self.assertTrue(exec_plan.successful)
|
||||
self.assertGreater(exec_plan.final_output.num_files, 0)
|
||||
886
tests/test_execution.py
Normal file
886
tests/test_execution.py
Normal file
@@ -0,0 +1,886 @@
|
||||
import functools
|
||||
import os.path
|
||||
import socket
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from typing import Iterable, List, Tuple
|
||||
|
||||
import pandas
|
||||
import pyarrow as arrow
|
||||
from loguru import logger
|
||||
from pandas.core.api import DataFrame as DataFrame
|
||||
|
||||
from smallpond.common import GB, MB, split_into_rows
|
||||
from smallpond.execution.task import (
|
||||
DataSinkTask,
|
||||
DataSourceTask,
|
||||
JobId,
|
||||
PartitionInfo,
|
||||
PythonScriptTask,
|
||||
RuntimeContext,
|
||||
StreamOutput,
|
||||
)
|
||||
from smallpond.execution.workqueue import WorkStatus
|
||||
from smallpond.logical.dataset import (
|
||||
ArrowTableDataSet,
|
||||
DataSet,
|
||||
ParquetDataSet,
|
||||
SqlQueryDataSet,
|
||||
)
|
||||
from smallpond.logical.node import (
|
||||
ArrowBatchNode,
|
||||
ArrowComputeNode,
|
||||
ArrowStreamNode,
|
||||
Context,
|
||||
DataSetPartitionNode,
|
||||
DataSinkNode,
|
||||
DataSourceNode,
|
||||
EvenlyDistributedPartitionNode,
|
||||
HashPartitionNode,
|
||||
LogicalPlan,
|
||||
Node,
|
||||
PandasBatchNode,
|
||||
PandasComputeNode,
|
||||
ProjectionNode,
|
||||
PythonScriptNode,
|
||||
RootNode,
|
||||
SqlEngineNode,
|
||||
)
|
||||
from smallpond.logical.udf import UDFListType, UDFType
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class OutputMsgPythonTask(PythonScriptTask):
|
||||
def __init__(self, msg: str, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.msg = msg
|
||||
|
||||
def initialize(self):
|
||||
pass
|
||||
|
||||
def finalize(self):
|
||||
pass
|
||||
|
||||
def process(
|
||||
self,
|
||||
runtime_ctx: RuntimeContext,
|
||||
input_datasets: List[DataSet],
|
||||
output_path: str,
|
||||
) -> bool:
|
||||
logger.info(
|
||||
f"msg: {self.msg}, num files: {input_datasets[0].num_files}, local gpu ranks: {self.local_gpu_ranks}"
|
||||
)
|
||||
self.inject_fault()
|
||||
return True
|
||||
|
||||
|
||||
# method1: inherit Task class and override spawn method
|
||||
class OutputMsgPythonNode(PythonScriptNode):
|
||||
def spawn(self, *args, **kwargs) -> OutputMsgPythonTask:
|
||||
return OutputMsgPythonTask("python script", *args, **kwargs)
|
||||
|
||||
|
||||
# method2: override process method
|
||||
# this usage is not recommended and only for testing. use `process_func` instead.
|
||||
class OutputMsgPythonNode2(PythonScriptNode):
|
||||
def __init__(self, ctx: Context, input_deps: Tuple[Node, ...], msg: str) -> None:
|
||||
super().__init__(ctx, input_deps)
|
||||
self.msg = msg
|
||||
|
||||
def process(
|
||||
self,
|
||||
runtime_ctx: RuntimeContext,
|
||||
input_datasets: List[DataSet],
|
||||
output_path: str,
|
||||
) -> bool:
|
||||
logger.info(f"msg: {self.msg}, num files: {input_datasets[0].num_files}")
|
||||
return True
|
||||
|
||||
|
||||
# this usage is not recommended and only for testing. use `process_func` instead.
|
||||
class CopyInputArrowNode(ArrowComputeNode):
|
||||
def __init__(self, ctx: Context, input_deps: Tuple[Node, ...], msg: str) -> None:
|
||||
super().__init__(ctx, input_deps)
|
||||
self.msg = msg
|
||||
|
||||
def process(
|
||||
self, runtime_ctx: RuntimeContext, input_tables: List[arrow.Table]
|
||||
) -> arrow.Table:
|
||||
return copy_input_arrow(runtime_ctx, input_tables, self.msg)
|
||||
|
||||
|
||||
# this usage is not recommended and only for testing. use `process_func` instead.
|
||||
class CopyInputStreamNode(ArrowStreamNode):
|
||||
def __init__(self, ctx: Context, input_deps: Tuple[Node, ...], msg: str) -> None:
|
||||
super().__init__(ctx, input_deps)
|
||||
self.msg = msg
|
||||
|
||||
def process(
|
||||
self, runtime_ctx: RuntimeContext, input_readers: List[arrow.RecordBatchReader]
|
||||
) -> Iterable[arrow.Table]:
|
||||
return copy_input_stream(runtime_ctx, input_readers, self.msg)
|
||||
|
||||
|
||||
def copy_input_arrow(
|
||||
runtime_ctx: RuntimeContext, input_tables: List[arrow.Table], msg: str
|
||||
) -> arrow.Table:
|
||||
logger.info(f"msg: {msg}, num rows: {input_tables[0].num_rows}")
|
||||
time.sleep(runtime_ctx.secs_executor_probe_interval)
|
||||
runtime_ctx.task.inject_fault()
|
||||
return input_tables[0]
|
||||
|
||||
|
||||
def copy_input_stream(
|
||||
runtime_ctx: RuntimeContext, input_readers: List[arrow.RecordBatchReader], msg: str
|
||||
) -> Iterable[arrow.Table]:
|
||||
for index, batch in enumerate(input_readers[0]):
|
||||
logger.info(f"msg: {msg}, batch index: {index}, num rows: {batch.num_rows}")
|
||||
time.sleep(runtime_ctx.secs_executor_probe_interval)
|
||||
yield StreamOutput(
|
||||
arrow.Table.from_batches([batch]),
|
||||
batch_indices=[index],
|
||||
force_checkpoint=True,
|
||||
)
|
||||
runtime_ctx.task.inject_fault()
|
||||
|
||||
|
||||
def copy_input_batch(
|
||||
runtime_ctx: RuntimeContext, input_batches: List[arrow.Table], msg: str
|
||||
) -> arrow.Table:
|
||||
logger.info(f"msg: {msg}, num rows: {input_batches[0].num_rows}")
|
||||
time.sleep(runtime_ctx.secs_executor_probe_interval)
|
||||
runtime_ctx.task.inject_fault()
|
||||
return input_batches[0]
|
||||
|
||||
|
||||
def copy_input_data_frame(
|
||||
runtime_ctx: RuntimeContext, input_dfs: List[DataFrame]
|
||||
) -> DataFrame:
|
||||
runtime_ctx.task.inject_fault()
|
||||
return input_dfs[0]
|
||||
|
||||
|
||||
def copy_input_data_frame_batch(
|
||||
runtime_ctx: RuntimeContext, input_dfs: List[DataFrame]
|
||||
) -> DataFrame:
|
||||
runtime_ctx.task.inject_fault()
|
||||
return input_dfs[0]
|
||||
|
||||
|
||||
def merge_input_tables(
|
||||
runtime_ctx: RuntimeContext, input_batches: List[arrow.Table]
|
||||
) -> arrow.Table:
|
||||
runtime_ctx.task.inject_fault()
|
||||
output = arrow.concat_tables(input_batches)
|
||||
logger.info(
|
||||
f"input rows: {[len(batch) for batch in input_batches]}, output rows: {len(output)}"
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def merge_input_data_frames(
|
||||
runtime_ctx: RuntimeContext, input_dfs: List[DataFrame]
|
||||
) -> DataFrame:
|
||||
runtime_ctx.task.inject_fault()
|
||||
output = pandas.concat(input_dfs)
|
||||
logger.info(
|
||||
f"input rows: {[len(df) for df in input_dfs]}, output rows: {len(output)}"
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def parse_url(
|
||||
runtime_ctx: RuntimeContext, input_tables: List[arrow.Table]
|
||||
) -> arrow.Table:
|
||||
urls = input_tables[0].columns[0]
|
||||
hosts = [url.as_py().split("/", maxsplit=2)[0] for url in urls]
|
||||
return input_tables[0].append_column("host", arrow.array(hosts))
|
||||
|
||||
|
||||
def nonzero_exit_code(
|
||||
runtime_ctx: RuntimeContext, input_datasets: List[DataSet], output_path: str
|
||||
) -> bool:
|
||||
import sys
|
||||
|
||||
if runtime_ctx.task._memory_boost == 1:
|
||||
sys.exit(1)
|
||||
return True
|
||||
|
||||
|
||||
# create an empty file with a fixed name
|
||||
def empty_file(
|
||||
runtime_ctx: RuntimeContext, input_datasets: List[DataSet], output_path: str
|
||||
) -> bool:
|
||||
import os
|
||||
|
||||
with open(os.path.join(output_path, "file"), "w") as fout:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def return_fake_gpus(count: int = 8):
|
||||
import GPUtil
|
||||
|
||||
return [GPUtil.GPU(i, *list(range(11))) for i in range(count)]
|
||||
|
||||
|
||||
def split_url(urls: arrow.array) -> arrow.array:
|
||||
url_parts = [url.as_py().split("/") for url in urls]
|
||||
return arrow.array(url_parts, type=arrow.list_(arrow.string()))
|
||||
|
||||
|
||||
def choose_random_urls(
|
||||
runtime_ctx: RuntimeContext, input_tables: List[arrow.Table], k: int = 5
|
||||
) -> arrow.Table:
|
||||
# get the current running task
|
||||
runtime_task = runtime_ctx.task
|
||||
# access task-specific attributes
|
||||
cpu_limit = runtime_task.cpu_limit
|
||||
random_gen = runtime_task.python_random_gen
|
||||
# input data
|
||||
(url_table,) = input_tables
|
||||
hosts, urls = url_table.columns
|
||||
logger.info(f"{cpu_limit=} {len(urls)=}")
|
||||
# generate ramdom samples
|
||||
random_urls = random_gen.choices(urls.to_pylist(), k=k)
|
||||
return arrow.Table.from_arrays([arrow.array(random_urls)], names=["random_urls"])
|
||||
|
||||
|
||||
class TestExecution(TestFabric, unittest.TestCase):
|
||||
|
||||
fault_inject_prob = 0.05
|
||||
|
||||
def test_arrow_task(self):
|
||||
for use_duckdb_reader in (False, True):
|
||||
with self.subTest(use_duckdb_reader=use_duckdb_reader):
|
||||
with tempfile.TemporaryDirectory(
|
||||
dir=self.output_root_abspath
|
||||
) as output_dir:
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_table = dataset.to_arrow_table()
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(
|
||||
ctx, (data_files,), npartitions=7
|
||||
)
|
||||
if use_duckdb_reader:
|
||||
data_partitions = ProjectionNode(
|
||||
ctx,
|
||||
data_partitions,
|
||||
columns=["*", "string_split(url, '/')[0] as parsed_host"],
|
||||
)
|
||||
arrow_compute = ArrowComputeNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
process_func=functools.partial(
|
||||
copy_input_arrow, msg="arrow compute"
|
||||
),
|
||||
use_duckdb_reader=use_duckdb_reader,
|
||||
output_name="arrow_compute",
|
||||
output_path=output_dir,
|
||||
cpu_limit=2,
|
||||
)
|
||||
arrow_stream = ArrowStreamNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
process_func=functools.partial(
|
||||
copy_input_stream, msg="arrow stream"
|
||||
),
|
||||
streaming_batch_size=10,
|
||||
secs_checkpoint_interval=0.5,
|
||||
use_duckdb_reader=use_duckdb_reader,
|
||||
output_name="arrow_stream",
|
||||
output_path=output_dir,
|
||||
cpu_limit=2,
|
||||
)
|
||||
arrow_batch = ArrowBatchNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
process_func=functools.partial(
|
||||
copy_input_batch, msg="arrow batch"
|
||||
),
|
||||
streaming_batch_size=10,
|
||||
secs_checkpoint_interval=0.5,
|
||||
use_duckdb_reader=use_duckdb_reader,
|
||||
output_name="arrow_batch",
|
||||
output_path=output_dir,
|
||||
cpu_limit=2,
|
||||
)
|
||||
data_sink = DataSinkNode(
|
||||
ctx,
|
||||
(arrow_compute, arrow_stream, arrow_batch),
|
||||
output_path=output_dir,
|
||||
)
|
||||
plan = LogicalPlan(ctx, data_sink)
|
||||
exec_plan = self.execute_plan(
|
||||
plan, fault_inject_prob=0.1, secs_executor_probe_interval=0.5
|
||||
)
|
||||
self.assertTrue(
|
||||
all(map(os.path.exists, exec_plan.final_output.resolved_paths))
|
||||
)
|
||||
arrow_compute_output = ParquetDataSet(
|
||||
[os.path.join(output_dir, "arrow_compute", "**/*.parquet")],
|
||||
recursive=True,
|
||||
)
|
||||
arrow_stream_output = ParquetDataSet(
|
||||
[os.path.join(output_dir, "arrow_stream", "**/*.parquet")],
|
||||
recursive=True,
|
||||
)
|
||||
arrow_batch_output = ParquetDataSet(
|
||||
[os.path.join(output_dir, "arrow_batch", "**/*.parquet")],
|
||||
recursive=True,
|
||||
)
|
||||
self._compare_arrow_tables(
|
||||
data_table,
|
||||
arrow_compute_output.to_arrow_table().select(
|
||||
data_table.column_names
|
||||
),
|
||||
)
|
||||
self._compare_arrow_tables(
|
||||
data_table,
|
||||
arrow_stream_output.to_arrow_table().select(
|
||||
data_table.column_names
|
||||
),
|
||||
)
|
||||
self._compare_arrow_tables(
|
||||
data_table,
|
||||
arrow_batch_output.to_arrow_table().select(
|
||||
data_table.column_names
|
||||
),
|
||||
)
|
||||
|
||||
def test_pandas_task(self):
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_table = dataset.to_arrow_table()
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(ctx, (data_files,), npartitions=7)
|
||||
pandas_compute = PandasComputeNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
process_func=copy_input_data_frame,
|
||||
output_name="pandas_compute",
|
||||
output_path=output_dir,
|
||||
cpu_limit=2,
|
||||
)
|
||||
pandas_batch = PandasBatchNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
process_func=copy_input_data_frame_batch,
|
||||
streaming_batch_size=10,
|
||||
secs_checkpoint_interval=0.5,
|
||||
output_name="pandas_batch",
|
||||
output_path=output_dir,
|
||||
cpu_limit=2,
|
||||
)
|
||||
data_sink = DataSinkNode(
|
||||
ctx, (pandas_compute, pandas_batch), output_path=output_dir
|
||||
)
|
||||
plan = LogicalPlan(ctx, data_sink)
|
||||
exec_plan = self.execute_plan(
|
||||
plan, fault_inject_prob=0.1, secs_executor_probe_interval=0.5
|
||||
)
|
||||
self.assertTrue(
|
||||
all(map(os.path.exists, exec_plan.final_output.resolved_paths))
|
||||
)
|
||||
pandas_compute_output = ParquetDataSet(
|
||||
[os.path.join(output_dir, "pandas_compute", "**/*.parquet")],
|
||||
recursive=True,
|
||||
)
|
||||
pandas_batch_output = ParquetDataSet(
|
||||
[os.path.join(output_dir, "pandas_batch", "**/*.parquet")],
|
||||
recursive=True,
|
||||
)
|
||||
self._compare_arrow_tables(
|
||||
data_table, pandas_compute_output.to_arrow_table()
|
||||
)
|
||||
self._compare_arrow_tables(data_table, pandas_batch_output.to_arrow_table())
|
||||
|
||||
def test_variable_length_input_datasets(self):
|
||||
ctx = Context()
|
||||
small_dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
large_dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"] * 10)
|
||||
small_partitions = DataSetPartitionNode(
|
||||
ctx, (DataSourceNode(ctx, small_dataset),), npartitions=7
|
||||
)
|
||||
large_partitions = DataSetPartitionNode(
|
||||
ctx, (DataSourceNode(ctx, large_dataset),), npartitions=7
|
||||
)
|
||||
arrow_batch = ArrowBatchNode(
|
||||
ctx,
|
||||
(small_partitions, large_partitions),
|
||||
process_func=merge_input_tables,
|
||||
streaming_batch_size=100,
|
||||
secs_checkpoint_interval=0.5,
|
||||
output_name="arrow_batch",
|
||||
cpu_limit=2,
|
||||
)
|
||||
pandas_batch = PandasBatchNode(
|
||||
ctx,
|
||||
(small_partitions, large_partitions),
|
||||
process_func=merge_input_data_frames,
|
||||
streaming_batch_size=100,
|
||||
secs_checkpoint_interval=0.5,
|
||||
output_name="pandas_batch",
|
||||
cpu_limit=2,
|
||||
)
|
||||
plan = LogicalPlan(ctx, RootNode(ctx, (arrow_batch, pandas_batch)))
|
||||
exec_plan = self.execute_plan(
|
||||
plan, fault_inject_prob=0.1, secs_executor_probe_interval=0.5
|
||||
)
|
||||
self.assertTrue(all(map(os.path.exists, exec_plan.final_output.resolved_paths)))
|
||||
arrow_batch_output = ParquetDataSet(
|
||||
[os.path.join(exec_plan.ctx.output_root, "arrow_batch", "**/*.parquet")],
|
||||
recursive=True,
|
||||
)
|
||||
pandas_batch_output = ParquetDataSet(
|
||||
[os.path.join(exec_plan.ctx.output_root, "pandas_batch", "**/*.parquet")],
|
||||
recursive=True,
|
||||
)
|
||||
self.assertEqual(
|
||||
small_dataset.num_rows + large_dataset.num_rows, arrow_batch_output.num_rows
|
||||
)
|
||||
self.assertEqual(
|
||||
small_dataset.num_rows + large_dataset.num_rows,
|
||||
pandas_batch_output.num_rows,
|
||||
)
|
||||
|
||||
def test_projection_task(self):
|
||||
ctx = Context()
|
||||
# select columns when defining dataset
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"], columns=["url"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(
|
||||
ctx, (data_files,), npartitions=3, partition_by_rows=True
|
||||
)
|
||||
# projection as input of arrow node
|
||||
generated_columns = ["filename", "file_row_number"]
|
||||
urls_with_host = ArrowComputeNode(
|
||||
ctx,
|
||||
(ProjectionNode(ctx, data_partitions, ["url"], generated_columns),),
|
||||
process_func=parse_url,
|
||||
use_duckdb_reader=True,
|
||||
)
|
||||
# projection as input of sql node
|
||||
distinct_urls_with_host = SqlEngineNode(
|
||||
ctx,
|
||||
(
|
||||
ProjectionNode(
|
||||
ctx,
|
||||
data_partitions,
|
||||
["url", "string_split(url, '/')[0] as host"],
|
||||
generated_columns,
|
||||
),
|
||||
),
|
||||
r"select distinct host, url, filename from {0}",
|
||||
)
|
||||
# unify different schemas
|
||||
merged_diff_schemas = ProjectionNode(
|
||||
ctx,
|
||||
DataSetPartitionNode(
|
||||
ctx, (distinct_urls_with_host, urls_with_host), npartitions=1
|
||||
),
|
||||
union_by_name=True,
|
||||
)
|
||||
host_partitions = HashPartitionNode(
|
||||
ctx,
|
||||
(merged_diff_schemas,),
|
||||
npartitions=3,
|
||||
hash_columns=["host"],
|
||||
engine_type="duckdb",
|
||||
output_name="host_partitions",
|
||||
)
|
||||
host_partitions.max_num_producer_tasks = 1
|
||||
plan = LogicalPlan(ctx, host_partitions)
|
||||
final_output = self.execute_plan(plan, fault_inject_prob=0.1).final_output
|
||||
final_table = final_output.to_arrow_table()
|
||||
self.assertEqual(
|
||||
sorted(
|
||||
[
|
||||
"url",
|
||||
"host",
|
||||
*generated_columns,
|
||||
HashPartitionNode.default_data_partition_column,
|
||||
]
|
||||
),
|
||||
sorted(final_table.column_names),
|
||||
)
|
||||
|
||||
def test_arrow_type_in_udfs(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(
|
||||
ctx, (data_files,), npartitions=dataset.num_files
|
||||
)
|
||||
ctx.create_function(
|
||||
"split_url",
|
||||
split_url,
|
||||
[UDFType.VARCHAR],
|
||||
UDFListType(UDFType.VARCHAR),
|
||||
use_arrow_type=True,
|
||||
)
|
||||
uniq_hosts = SqlEngineNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
r"select split_url(url) as url_parts from {0}",
|
||||
udfs=["split_url"],
|
||||
)
|
||||
plan = LogicalPlan(ctx, uniq_hosts)
|
||||
self.execute_plan(plan)
|
||||
|
||||
def test_many_simple_tasks(self):
|
||||
ctx = Context()
|
||||
npartitions = 1000
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"] * npartitions)
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = EvenlyDistributedPartitionNode(
|
||||
ctx, (data_files,), npartitions=npartitions
|
||||
)
|
||||
output_msg = OutputMsgPythonNode(ctx, (data_partitions,))
|
||||
plan = LogicalPlan(ctx, output_msg)
|
||||
self.execute_plan(
|
||||
plan,
|
||||
num_executors=10,
|
||||
secs_executor_probe_interval=5,
|
||||
enable_profiling=True,
|
||||
)
|
||||
|
||||
def test_many_producers_and_partitions(self):
|
||||
ctx = Context()
|
||||
npartitions = 10000
|
||||
dataset = ParquetDataSet(
|
||||
["tests/data/mock_urls/*.parquet"] * (npartitions * 10)
|
||||
)
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = EvenlyDistributedPartitionNode(
|
||||
ctx, (data_files,), npartitions=npartitions, cpu_limit=1
|
||||
)
|
||||
data_partitions.max_num_producer_tasks = 20
|
||||
output_msg = OutputMsgPythonNode(ctx, (data_partitions,))
|
||||
plan = LogicalPlan(ctx, output_msg)
|
||||
self.execute_plan(
|
||||
plan,
|
||||
num_executors=10,
|
||||
secs_executor_probe_interval=5,
|
||||
enable_profiling=True,
|
||||
)
|
||||
|
||||
def test_local_gpu_rank(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(
|
||||
ctx, (data_files,), npartitions=dataset.num_files
|
||||
)
|
||||
output_msg = OutputMsgPythonNode(
|
||||
ctx, (data_partitions,), cpu_limit=1, gpu_limit=0.5
|
||||
)
|
||||
plan = LogicalPlan(ctx, output_msg)
|
||||
runtime_ctx = RuntimeContext(
|
||||
JobId.new(),
|
||||
datetime.now(),
|
||||
self.output_root_abspath,
|
||||
console_log_level="WARNING",
|
||||
)
|
||||
runtime_ctx.get_local_gpus = return_fake_gpus
|
||||
runtime_ctx.initialize(socket.gethostname(), cleanup_root=True)
|
||||
self.execute_plan(plan, runtime_ctx=runtime_ctx)
|
||||
|
||||
def test_python_node_with_process_method(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
copy_input_arrow_node = CopyInputArrowNode(ctx, (data_files,), "hello")
|
||||
copy_input_stream_node = CopyInputStreamNode(ctx, (data_files,), "hello")
|
||||
output_msg = OutputMsgPythonNode2(
|
||||
ctx, (copy_input_arrow_node, copy_input_stream_node), "hello"
|
||||
)
|
||||
plan = LogicalPlan(ctx, output_msg)
|
||||
self.execute_plan(plan)
|
||||
|
||||
def test_sql_engine_oom(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
uniq_urls = SqlEngineNode(
|
||||
ctx, (data_files,), r"select distinct * from {0}", memory_limit=2 * MB
|
||||
)
|
||||
uniq_url_partitions = DataSetPartitionNode(ctx, (uniq_urls,), 2)
|
||||
uniq_url_count = SqlEngineNode(
|
||||
ctx,
|
||||
(uniq_url_partitions,),
|
||||
sql_query=r"select count(distinct columns(*)) from {0}",
|
||||
memory_limit=2 * MB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, uniq_url_count)
|
||||
self.execute_plan(plan, max_fail_count=10)
|
||||
|
||||
@unittest.skip("flaky on CI")
|
||||
def test_enforce_memory_limit(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/arrow/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
arrow_compute = ArrowComputeNode(
|
||||
ctx,
|
||||
(data_files,),
|
||||
process_func=functools.partial(copy_input_arrow, msg="arrow compute"),
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
arrow_stream = ArrowStreamNode(
|
||||
ctx,
|
||||
(data_files,),
|
||||
process_func=functools.partial(copy_input_stream, msg="arrow stream"),
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
data_sink = DataSinkNode(
|
||||
ctx, (arrow_compute, arrow_stream), output_path=output_dir
|
||||
)
|
||||
plan = LogicalPlan(ctx, data_sink)
|
||||
self.execute_plan(
|
||||
plan,
|
||||
max_fail_count=10,
|
||||
enforce_memory_limit=True,
|
||||
nonzero_exitcode_as_oom=True,
|
||||
)
|
||||
|
||||
def test_task_crash_as_oom(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
nonzero_exitcode = PythonScriptNode(
|
||||
ctx, (data_files,), process_func=nonzero_exit_code
|
||||
)
|
||||
plan = LogicalPlan(ctx, nonzero_exitcode)
|
||||
exec_plan = self.execute_plan(
|
||||
plan, num_executors=1, check_result=False, nonzero_exitcode_as_oom=False
|
||||
)
|
||||
self.assertFalse(exec_plan.successful)
|
||||
exec_plan = self.execute_plan(
|
||||
plan, num_executors=1, check_result=False, nonzero_exitcode_as_oom=True
|
||||
)
|
||||
self.assertTrue(exec_plan.successful)
|
||||
|
||||
def test_manifest_only_data_sink(self):
|
||||
with open("tests/data/long_path_list.txt", buffering=16 * MB) as fin:
|
||||
filenames = list(map(str.strip, fin.readlines()))
|
||||
logger.info(f"loaded {len(filenames)} filenames")
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(filenames)
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(ctx, (data_files,), npartitions=512)
|
||||
data_sink = DataSinkNode(
|
||||
ctx, (data_partitions,), output_path=output_dir, manifest_only=True
|
||||
)
|
||||
plan = LogicalPlan(ctx, data_sink)
|
||||
self.execute_plan(plan)
|
||||
|
||||
with open(
|
||||
os.path.join(output_dir, DataSinkTask.manifest_filename),
|
||||
buffering=16 * MB,
|
||||
) as fin:
|
||||
num_lines = len(fin.readlines())
|
||||
self.assertEqual(len(filenames), num_lines)
|
||||
|
||||
def test_sql_batched_processing(self):
|
||||
for materialize_in_memory in (False, True):
|
||||
with self.subTest(materialize_in_memory=materialize_in_memory):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/large_array/*.parquet"] * 2)
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
content_length = SqlEngineNode(
|
||||
ctx,
|
||||
(data_files,),
|
||||
r"select url, octet_length(content) as content_len from {0}",
|
||||
materialize_in_memory=materialize_in_memory,
|
||||
batched_processing=True,
|
||||
cpu_limit=2,
|
||||
memory_limit=2 * GB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, content_length)
|
||||
final_output: ParquetDataSet = self.execute_plan(plan).final_output
|
||||
self.assertEqual(dataset.num_rows, final_output.num_rows)
|
||||
|
||||
def test_multiple_sql_queries(self):
|
||||
for materialize_in_memory in (False, True):
|
||||
with self.subTest(materialize_in_memory=materialize_in_memory):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/large_array/*.parquet"] * 2)
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
content_length = SqlEngineNode(
|
||||
ctx,
|
||||
(data_files,),
|
||||
[
|
||||
r"create or replace temp table content_len_data as select url, octet_length(content) as content_len from {0}",
|
||||
r"select * from content_len_data",
|
||||
],
|
||||
materialize_in_memory=materialize_in_memory,
|
||||
batched_processing=True,
|
||||
cpu_limit=2,
|
||||
memory_limit=2 * GB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, content_length)
|
||||
final_output: ParquetDataSet = self.execute_plan(plan).final_output
|
||||
self.assertEqual(dataset.num_rows, final_output.num_rows)
|
||||
|
||||
def test_temp_outputs_in_final_results(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(ctx, (data_files,), npartitions=10)
|
||||
url_counts = SqlEngineNode(
|
||||
ctx, (data_partitions,), r"select count(url) as cnt from {0}"
|
||||
)
|
||||
distinct_url_counts = SqlEngineNode(
|
||||
ctx, (data_partitions,), r"select count(distinct url) as cnt from {0}"
|
||||
)
|
||||
merged_counts = DataSetPartitionNode(
|
||||
ctx,
|
||||
(
|
||||
ProjectionNode(ctx, url_counts, ["cnt"]),
|
||||
ProjectionNode(ctx, distinct_url_counts, ["cnt"]),
|
||||
),
|
||||
npartitions=1,
|
||||
)
|
||||
split_counts = DataSetPartitionNode(ctx, (merged_counts,), npartitions=10)
|
||||
plan = LogicalPlan(ctx, split_counts)
|
||||
final_output: ParquetDataSet = self.execute_plan(plan).final_output
|
||||
self.assertEqual(data_partitions.npartitions * 2, final_output.num_rows)
|
||||
|
||||
def test_override_output_path(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(ctx, (data_files,), npartitions=10)
|
||||
url_counts = SqlEngineNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
r"select count(url) as cnt from {0}",
|
||||
output_name="url_counts",
|
||||
)
|
||||
distinct_url_counts = SqlEngineNode(
|
||||
ctx, (data_partitions,), r"select count(distinct url) as cnt from {0}"
|
||||
)
|
||||
merged_counts = DataSetPartitionNode(
|
||||
ctx,
|
||||
(
|
||||
ProjectionNode(ctx, url_counts, ["cnt"]),
|
||||
ProjectionNode(ctx, distinct_url_counts, ["cnt"]),
|
||||
),
|
||||
npartitions=1,
|
||||
)
|
||||
plan = LogicalPlan(ctx, merged_counts)
|
||||
|
||||
output_path = os.path.join(self.runtime_ctx.output_root, "final_output")
|
||||
final_output = self.execute_plan(plan, output_path=output_path).final_output
|
||||
self.assertTrue(os.path.exists(os.path.join(output_path, "url_counts")))
|
||||
self.assertTrue(os.path.exists(os.path.join(output_path, "FinalResults")))
|
||||
|
||||
def test_data_sink_avoid_filename_conflicts(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(ctx, (data_files,), npartitions=10)
|
||||
empty_files1 = PythonScriptNode(
|
||||
ctx, (data_partitions,), process_func=empty_file
|
||||
)
|
||||
empty_files2 = PythonScriptNode(
|
||||
ctx, (data_partitions,), process_func=empty_file
|
||||
)
|
||||
link_path = os.path.join(self.runtime_ctx.output_root, "link")
|
||||
copy_path = os.path.join(self.runtime_ctx.output_root, "copy")
|
||||
copy_input_path = os.path.join(self.runtime_ctx.output_root, "copy_input")
|
||||
data_link = DataSinkNode(
|
||||
ctx, (empty_files1, empty_files2), type="link", output_path=link_path
|
||||
)
|
||||
data_copy = DataSinkNode(
|
||||
ctx, (empty_files1, empty_files2), type="copy", output_path=copy_path
|
||||
)
|
||||
data_copy_input = DataSinkNode(
|
||||
ctx, (data_partitions,), type="copy", output_path=copy_input_path
|
||||
)
|
||||
plan = LogicalPlan(ctx, RootNode(ctx, (data_link, data_copy, data_copy_input)))
|
||||
|
||||
self.execute_plan(plan)
|
||||
# there should be 21 files (20 input files + 1 manifest file) in the sink dir
|
||||
self.assertEqual(21, len(os.listdir(link_path)))
|
||||
self.assertEqual(21, len(os.listdir(copy_path)))
|
||||
# file name should not be modified if no conflict
|
||||
self.assertEqual(
|
||||
set(
|
||||
filename
|
||||
for filename in os.listdir("tests/data/mock_urls")
|
||||
if filename.endswith(".parquet")
|
||||
),
|
||||
set(
|
||||
filename
|
||||
for filename in os.listdir(copy_input_path)
|
||||
if filename.endswith(".parquet")
|
||||
),
|
||||
)
|
||||
|
||||
def test_literal_datasets_as_data_sources(self):
|
||||
ctx = Context()
|
||||
num_rows = 10
|
||||
query_dataset = SqlQueryDataSet(f"select i from range({num_rows}) as x(i)")
|
||||
table_dataset = ArrowTableDataSet(
|
||||
arrow.Table.from_arrays([list(range(num_rows))], names=["i"])
|
||||
)
|
||||
query_source = DataSourceNode(ctx, query_dataset)
|
||||
table_source = DataSourceNode(ctx, table_dataset)
|
||||
query_partitions = DataSetPartitionNode(
|
||||
ctx, (query_source,), npartitions=num_rows, partition_by_rows=True
|
||||
)
|
||||
table_partitions = DataSetPartitionNode(
|
||||
ctx, (table_source,), npartitions=num_rows, partition_by_rows=True
|
||||
)
|
||||
joined_rows = SqlEngineNode(
|
||||
ctx,
|
||||
(query_partitions, table_partitions),
|
||||
r"select a.i as i, b.i as j from {0} as a join {1} as b on a.i = b.i",
|
||||
)
|
||||
plan = LogicalPlan(ctx, joined_rows)
|
||||
final_output: ParquetDataSet = self.execute_plan(plan).final_output
|
||||
self.assertEqual(num_rows, final_output.num_rows)
|
||||
|
||||
def test_partial_process_func(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(ctx, (data_files,), npartitions=3)
|
||||
# use default value of k
|
||||
random_urls_k5 = ArrowComputeNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
process_func=choose_random_urls,
|
||||
output_name="random_urls_k5",
|
||||
)
|
||||
# set value of k using functools.partial
|
||||
random_urls_k10 = ArrowComputeNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
process_func=functools.partial(choose_random_urls, k=10),
|
||||
output_name="random_urls_k10",
|
||||
)
|
||||
random_urls_all = SqlEngineNode(
|
||||
ctx,
|
||||
(random_urls_k5, random_urls_k10),
|
||||
r"select * from {0} union select * from {1}",
|
||||
output_name="random_urls_all",
|
||||
)
|
||||
plan = LogicalPlan(ctx, random_urls_all)
|
||||
exec_plan = self.execute_plan(plan)
|
||||
self.assertEqual(
|
||||
data_partitions.npartitions * 5,
|
||||
exec_plan.get_output("random_urls_k5").to_arrow_table().num_rows,
|
||||
)
|
||||
self.assertEqual(
|
||||
data_partitions.npartitions * 10,
|
||||
exec_plan.get_output("random_urls_k10").to_arrow_table().num_rows,
|
||||
)
|
||||
294
tests/test_fabric.py
Normal file
294
tests/test_fabric.py
Normal file
@@ -0,0 +1,294 @@
|
||||
import os.path
|
||||
import queue
|
||||
import sys
|
||||
import unittest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from multiprocessing import Manager, Process
|
||||
from typing import List, Optional
|
||||
|
||||
import fsspec
|
||||
import numpy as np
|
||||
import psutil
|
||||
import pyarrow as arrow
|
||||
import pyarrow.compute as pc
|
||||
import pyarrow.parquet as parquet
|
||||
from loguru import logger
|
||||
|
||||
from smallpond.common import DEFAULT_MAX_FAIL_COUNT, DEFAULT_MAX_RETRY_COUNT, GB, MB
|
||||
from smallpond.execution.executor import Executor
|
||||
from smallpond.execution.scheduler import Scheduler
|
||||
from smallpond.execution.task import ExecutionPlan, JobId, RuntimeContext
|
||||
from smallpond.io.arrow import cast_columns_to_large_string
|
||||
from smallpond.logical.node import LogicalPlan
|
||||
from smallpond.logical.planner import Planner
|
||||
from tests.datagen import generate_data
|
||||
|
||||
generate_data()
|
||||
|
||||
|
||||
def run_scheduler(
|
||||
runtime_ctx: RuntimeContext, scheduler: Scheduler, queue: queue.Queue
|
||||
):
|
||||
runtime_ctx.initialize("scheduler")
|
||||
scheduler.add_state_observer(Scheduler.StateObserver(SaveSchedState(queue)))
|
||||
retval = scheduler.run()
|
||||
print(f"scheduler exited with value {retval}", file=sys.stderr)
|
||||
|
||||
|
||||
def run_executor(runtime_ctx: RuntimeContext, executor: Executor):
|
||||
runtime_ctx.initialize(executor.id)
|
||||
retval = executor.run()
|
||||
print(f"{executor.id} exited with value {retval}", file=sys.stderr)
|
||||
|
||||
|
||||
class SaveSchedState:
|
||||
"""
|
||||
A state observer that push the scheduler state into a queue when finished.
|
||||
"""
|
||||
|
||||
def __init__(self, queue: queue.Queue):
|
||||
self.queue = queue
|
||||
|
||||
def __call__(self, sched_state: Scheduler) -> bool:
|
||||
if sched_state.num_local_running_works == 0:
|
||||
self.queue.put(sched_state)
|
||||
return True
|
||||
|
||||
|
||||
class TestFabric(unittest.TestCase):
|
||||
"""
|
||||
A helper class that includes boilerplate code to test a logical plan.
|
||||
"""
|
||||
|
||||
runtime_root = os.getenv("TEST_RUNTIME_ROOT") or f"tests/runtime"
|
||||
runtime_ctx = None
|
||||
fault_inject_prob = 0.00
|
||||
|
||||
queue_manager = None
|
||||
sched_states: queue.Queue = None
|
||||
latest_state: Scheduler = None
|
||||
executors: List[Executor] = None
|
||||
processes: List[Process] = None
|
||||
|
||||
@property
|
||||
def output_dir(self):
|
||||
return os.path.join(self.__class__.__name__, self._testMethodName)
|
||||
|
||||
@property
|
||||
def output_root_abspath(self):
|
||||
output_root = os.path.abspath(os.path.join(self.runtime_root, self.output_dir))
|
||||
os.makedirs(output_root, exist_ok=True)
|
||||
return output_root
|
||||
|
||||
def setUp(self) -> None:
|
||||
try:
|
||||
from pytest_cov.embed import cleanup_on_sigterm
|
||||
except ImportError:
|
||||
pass
|
||||
else:
|
||||
cleanup_on_sigterm()
|
||||
self.runtime_ctx = RuntimeContext(
|
||||
JobId.new(),
|
||||
datetime.now(),
|
||||
self.output_root_abspath,
|
||||
console_log_level="WARNING",
|
||||
)
|
||||
self.runtime_ctx.initialize("setup")
|
||||
return super().setUp()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
if self.sched_states is not None:
|
||||
self.get_latest_sched_state()
|
||||
assert self.sched_states.qsize() == 0
|
||||
self.sched_states = None
|
||||
if self.queue_manager is not None:
|
||||
self.queue_manager.shutdown()
|
||||
self.queue_manager = None
|
||||
return super().tearDown()
|
||||
|
||||
def get_latest_sched_state(self) -> Scheduler:
|
||||
while True:
|
||||
try:
|
||||
self.latest_state = self.sched_states.get(block=False)
|
||||
except queue.Empty:
|
||||
return self.latest_state
|
||||
|
||||
def join_running_procs(self, timeout=30):
|
||||
for i, process in enumerate(self.processes):
|
||||
if process.is_alive():
|
||||
logger.info(f"join #{i} process: {process.name}")
|
||||
process.join(timeout=None if i == 0 else timeout)
|
||||
|
||||
if process.exitcode is None:
|
||||
logger.info(f"terminate #{i} process: {process.name}")
|
||||
process.terminate()
|
||||
process.join(timeout=timeout)
|
||||
|
||||
if process.exitcode is None:
|
||||
logger.info(f"kill #{i} process: {process.name}")
|
||||
process.kill()
|
||||
process.join()
|
||||
|
||||
logger.info(
|
||||
f"#{i} process {process.name} exited with code {process.exitcode}"
|
||||
)
|
||||
|
||||
def start_execution(
|
||||
self,
|
||||
plan: LogicalPlan,
|
||||
num_executors: int = 2,
|
||||
secs_wq_poll_interval: float = 0.1,
|
||||
secs_executor_probe_interval: float = 1,
|
||||
max_num_missed_probes: int = 10,
|
||||
max_retry_count: int = DEFAULT_MAX_RETRY_COUNT,
|
||||
max_fail_count: int = DEFAULT_MAX_FAIL_COUNT,
|
||||
prioritize_retry=False,
|
||||
speculative_exec="enable",
|
||||
stop_executor_on_failure=False,
|
||||
enforce_memory_limit=False,
|
||||
nonzero_exitcode_as_oom=False,
|
||||
fault_inject_prob=None,
|
||||
enable_profiling=False,
|
||||
enable_diagnostic_metrics=False,
|
||||
remove_empty_parquet=False,
|
||||
skip_task_with_empty_input=False,
|
||||
console_log_level="WARNING",
|
||||
file_log_level="DEBUG",
|
||||
output_path: Optional[str] = None,
|
||||
runtime_ctx: Optional[RuntimeContext] = None,
|
||||
):
|
||||
"""
|
||||
Start a scheduler and `num_executors` executors to execute `plan`.
|
||||
When this function returns, the execution is mostly still running.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
plan
|
||||
A logical plan.
|
||||
num_executors, optional
|
||||
The number of executors
|
||||
console_log_level, optional
|
||||
Set to logger.INFO if more verbose loguru is needed for debug, by default "WARNING".
|
||||
|
||||
Returns
|
||||
-------
|
||||
A 3-tuple of type (Scheduler, List[Executor], List[Process]).
|
||||
"""
|
||||
if runtime_ctx is None:
|
||||
runtime_ctx = RuntimeContext(
|
||||
JobId.new(),
|
||||
datetime.now(),
|
||||
self.output_root_abspath,
|
||||
num_executors=num_executors,
|
||||
random_seed=123456,
|
||||
enforce_memory_limit=enforce_memory_limit,
|
||||
max_usable_cpu_count=min(64, psutil.cpu_count(logical=False)),
|
||||
max_usable_gpu_count=0,
|
||||
max_usable_memory_size=min(64 * GB, psutil.virtual_memory().total),
|
||||
secs_wq_poll_interval=secs_wq_poll_interval,
|
||||
secs_executor_probe_interval=secs_executor_probe_interval,
|
||||
max_num_missed_probes=max_num_missed_probes,
|
||||
fault_inject_prob=(
|
||||
fault_inject_prob
|
||||
if fault_inject_prob is not None
|
||||
else self.fault_inject_prob
|
||||
),
|
||||
enable_profiling=enable_profiling,
|
||||
enable_diagnostic_metrics=enable_diagnostic_metrics,
|
||||
remove_empty_parquet=remove_empty_parquet,
|
||||
skip_task_with_empty_input=skip_task_with_empty_input,
|
||||
console_log_level=console_log_level,
|
||||
file_log_level=file_log_level,
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
self.queue_manager = Manager()
|
||||
self.sched_states = self.queue_manager.Queue()
|
||||
|
||||
exec_plan = Planner(runtime_ctx).create_exec_plan(plan)
|
||||
scheduler = Scheduler(
|
||||
exec_plan,
|
||||
max_retry_count=max_retry_count,
|
||||
max_fail_count=max_fail_count,
|
||||
prioritize_retry=prioritize_retry,
|
||||
speculative_exec=speculative_exec,
|
||||
stop_executor_on_failure=stop_executor_on_failure,
|
||||
nonzero_exitcode_as_oom=nonzero_exitcode_as_oom,
|
||||
)
|
||||
self.latest_state = scheduler
|
||||
self.executors = [
|
||||
Executor.create(runtime_ctx, f"executor-{i}") for i in range(num_executors)
|
||||
]
|
||||
self.processes = [
|
||||
Process(
|
||||
target=run_scheduler,
|
||||
# XXX: on macOS, scheduler state observer will be cleared when cross-process
|
||||
# so we pass the queue and add the observer in the new process
|
||||
args=(runtime_ctx, scheduler, self.sched_states),
|
||||
name="scheduler",
|
||||
)
|
||||
]
|
||||
self.processes += [
|
||||
Process(target=run_executor, args=(runtime_ctx, executor), name=executor.id)
|
||||
for executor in self.executors
|
||||
]
|
||||
|
||||
for process in reversed(self.processes):
|
||||
process.start()
|
||||
|
||||
return self.sched_states, self.executors, self.processes
|
||||
|
||||
def execute_plan(self, *args, check_result=True, **kvargs) -> ExecutionPlan:
|
||||
"""
|
||||
Start a scheduler and `num_executors` executors to execute `plan`,
|
||||
and wait the execution completed, then assert if it succeeds.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
plan
|
||||
A logical plan.
|
||||
num_executors, optional
|
||||
The number of executors
|
||||
console_log_level, optional
|
||||
Set to logger.INFO if more verbose loguru is needed for debug, by default "WARNING".
|
||||
|
||||
Returns
|
||||
-------
|
||||
The completed ExecutionPlan instance.
|
||||
"""
|
||||
self.start_execution(*args, **kvargs)
|
||||
self.join_running_procs()
|
||||
latest_state = self.get_latest_sched_state()
|
||||
if check_result:
|
||||
self.assertTrue(latest_state.success)
|
||||
return latest_state.exec_plan
|
||||
|
||||
def _load_parquet_files(
|
||||
self, paths, filesystem: fsspec.AbstractFileSystem = None
|
||||
) -> arrow.Table:
|
||||
def read_parquet_file(path):
|
||||
return arrow.Table.from_batches(
|
||||
parquet.ParquetFile(
|
||||
path, buffer_size=16 * MB, filesystem=filesystem
|
||||
).iter_batches()
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(16) as pool:
|
||||
return arrow.concat_tables(pool.map(read_parquet_file, paths))
|
||||
|
||||
def _compare_arrow_tables(self, expected: arrow.Table, actual: arrow.Table):
|
||||
def sorted_table(t: arrow.Table):
|
||||
return t.sort_by([(col, "ascending") for col in t.column_names])
|
||||
|
||||
self.assertEqual(expected.shape, actual.shape)
|
||||
self.assertEqual(expected.column_names, actual.column_names)
|
||||
expected = sorted_table(cast_columns_to_large_string(expected))
|
||||
actual = sorted_table(cast_columns_to_large_string(actual))
|
||||
for col, x, y in zip(expected.column_names, expected.columns, actual.columns):
|
||||
if not pc.equal(x, y):
|
||||
x = x.to_numpy(zero_copy_only=False)
|
||||
y = y.to_numpy(zero_copy_only=False)
|
||||
logger.error(f" expect {col}: {x}")
|
||||
logger.error(f" actual {col}: {y}")
|
||||
np.testing.assert_array_equal(x, y, verbose=True)
|
||||
25
tests/test_filesystem.py
Normal file
25
tests/test_filesystem.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import os.path
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from smallpond.io.filesystem import dump, load
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class TestFilesystem(TestFabric, unittest.TestCase):
|
||||
def test_pickle_runtime_ctx(self):
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
pickle_path = os.path.join(output_dir, "runtime_ctx.pickle")
|
||||
dump(self.runtime_ctx, pickle_path)
|
||||
runtime_ctx = load(pickle_path)
|
||||
self.assertEqual(self.runtime_ctx.job_id, runtime_ctx.job_id)
|
||||
|
||||
def test_pickle_trace(self):
|
||||
with self.assertRaises(TypeError) as context:
|
||||
with tempfile.TemporaryDirectory(
|
||||
dir=self.output_root_abspath
|
||||
) as output_dir:
|
||||
thread = threading.Thread()
|
||||
pickle_path = os.path.join(output_dir, "thread.pickle")
|
||||
dump(thread, pickle_path)
|
||||
103
tests/test_logical.py
Normal file
103
tests/test_logical.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import unittest
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from smallpond.logical.dataset import ParquetDataSet
|
||||
from smallpond.logical.node import (
|
||||
Context,
|
||||
DataSetPartitionNode,
|
||||
DataSourceNode,
|
||||
EvenlyDistributedPartitionNode,
|
||||
HashPartitionNode,
|
||||
LogicalPlan,
|
||||
SqlEngineNode,
|
||||
)
|
||||
from smallpond.logical.planner import Planner
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class TestLogicalPlan(TestFabric, unittest.TestCase):
|
||||
|
||||
def test_join_chunkmeta_inodes(self):
|
||||
ctx = Context()
|
||||
|
||||
chunkmeta_dump = DataSourceNode(
|
||||
ctx, dataset=ParquetDataSet(["tests/data/chunkmeta*.parquet"])
|
||||
)
|
||||
chunkmeta_partitions = HashPartitionNode(
|
||||
ctx, (chunkmeta_dump,), npartitions=2, hash_columns=["inodeId"]
|
||||
)
|
||||
|
||||
inodes_dump = DataSourceNode(
|
||||
ctx, dataset=ParquetDataSet(["tests/data/inodes*.parquet"])
|
||||
)
|
||||
inodes_partitions = HashPartitionNode(
|
||||
ctx, (inodes_dump,), npartitions=2, hash_columns=["inode_id"]
|
||||
)
|
||||
|
||||
num_gc_chunks = SqlEngineNode(
|
||||
ctx,
|
||||
(chunkmeta_partitions, inodes_partitions),
|
||||
r"""
|
||||
select count(chunkmeta_chunkId) from {0}
|
||||
where chunkmeta.chunkmeta_chunkId NOT LIKE "F%" AND
|
||||
chunkmeta.inodeId not in ( select distinct inode_id from {1} )""",
|
||||
)
|
||||
|
||||
plan = LogicalPlan(ctx, num_gc_chunks)
|
||||
logger.info(str(plan))
|
||||
exec_plan = Planner(self.runtime_ctx).create_exec_plan(plan)
|
||||
logger.info(str(exec_plan))
|
||||
|
||||
def test_partition_dims_not_compatible(self):
|
||||
ctx = Context()
|
||||
parquet_dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_source = DataSourceNode(ctx, parquet_dataset)
|
||||
partition_dim_a = EvenlyDistributedPartitionNode(
|
||||
ctx, (data_source,), npartitions=parquet_dataset.num_files, dimension="A"
|
||||
)
|
||||
partition_dim_b = EvenlyDistributedPartitionNode(
|
||||
ctx, (data_source,), npartitions=parquet_dataset.num_files, dimension="B"
|
||||
)
|
||||
join_two_inputs = SqlEngineNode(
|
||||
ctx,
|
||||
(partition_dim_a, partition_dim_b),
|
||||
r"select a.* from {0} as a join {1} as b on a.host = b.host",
|
||||
)
|
||||
plan = LogicalPlan(ctx, join_two_inputs)
|
||||
logger.info(str(plan))
|
||||
with self.assertRaises(AssertionError) as context:
|
||||
Planner(self.runtime_ctx).create_exec_plan(plan)
|
||||
|
||||
def test_npartitions_not_compatible(self):
|
||||
ctx = Context()
|
||||
parquet_dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_source = DataSourceNode(ctx, parquet_dataset)
|
||||
partition_dim_a = EvenlyDistributedPartitionNode(
|
||||
ctx, (data_source,), npartitions=parquet_dataset.num_files, dimension="A"
|
||||
)
|
||||
partition_dim_a2 = EvenlyDistributedPartitionNode(
|
||||
ctx,
|
||||
(data_source,),
|
||||
npartitions=parquet_dataset.num_files * 2,
|
||||
dimension="A",
|
||||
)
|
||||
join_two_inputs1 = SqlEngineNode(
|
||||
ctx,
|
||||
(partition_dim_a, partition_dim_a2),
|
||||
r"select a.* from {0} as a join {1} as b on a.host = b.host",
|
||||
)
|
||||
join_two_inputs2 = SqlEngineNode(
|
||||
ctx,
|
||||
(partition_dim_a2, partition_dim_a),
|
||||
r"select a.* from {0} as a join {1} as b on a.host = b.host",
|
||||
)
|
||||
plan = LogicalPlan(
|
||||
ctx,
|
||||
DataSetPartitionNode(
|
||||
ctx, (join_two_inputs1, join_two_inputs2), npartitions=1
|
||||
),
|
||||
)
|
||||
logger.info(str(plan))
|
||||
with self.assertRaises(AssertionError) as context:
|
||||
Planner(self.runtime_ctx).create_exec_plan(plan)
|
||||
659
tests/test_partition.py
Normal file
659
tests/test_partition.py
Normal file
@@ -0,0 +1,659 @@
|
||||
import os.path
|
||||
import tempfile
|
||||
import unittest
|
||||
from typing import List
|
||||
|
||||
import pyarrow.compute as pc
|
||||
|
||||
from smallpond.common import DATA_PARTITION_COLUMN_NAME, GB
|
||||
from smallpond.execution.task import RuntimeContext
|
||||
from smallpond.logical.dataset import DataSet, ParquetDataSet
|
||||
from smallpond.logical.node import (
|
||||
ArrowComputeNode,
|
||||
ConsolidateNode,
|
||||
Context,
|
||||
DataSetPartitionNode,
|
||||
DataSinkNode,
|
||||
DataSourceNode,
|
||||
EvenlyDistributedPartitionNode,
|
||||
HashPartitionNode,
|
||||
LoadPartitionedDataSetNode,
|
||||
LogicalPlan,
|
||||
ProjectionNode,
|
||||
SqlEngineNode,
|
||||
UnionNode,
|
||||
UserDefinedPartitionNode,
|
||||
UserPartitionedDataSourceNode,
|
||||
)
|
||||
from tests.test_execution import parse_url
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class CalculatePartitionFromFilename(UserDefinedPartitionNode):
|
||||
def partition(self, runtime_ctx: RuntimeContext, dataset: DataSet) -> List[DataSet]:
|
||||
partitioned_datasets: List[ParquetDataSet] = [
|
||||
ParquetDataSet([]) for _ in range(self.npartitions)
|
||||
]
|
||||
for path in dataset.resolved_paths:
|
||||
partition_idx = hash(path) % self.npartitions
|
||||
partitioned_datasets[partition_idx].paths.append(path)
|
||||
return partitioned_datasets
|
||||
|
||||
|
||||
class TestPartition(TestFabric, unittest.TestCase):
|
||||
def test_many_file_partitions(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"] * 10)
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(
|
||||
ctx, (data_files,), npartitions=dataset.num_files
|
||||
)
|
||||
count_rows = SqlEngineNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
"select count(*) from {0}",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, count_rows)
|
||||
self.execute_plan(plan)
|
||||
|
||||
def test_many_row_partitions(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(
|
||||
ctx, (data_files,), npartitions=dataset.num_rows, partition_by_rows=True
|
||||
)
|
||||
count_rows = SqlEngineNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
"select count(*) from {0}",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, count_rows)
|
||||
exec_plan = self.execute_plan(plan, num_executors=5)
|
||||
self.assertEqual(
|
||||
exec_plan.final_output.to_arrow_table().num_rows, dataset.num_rows
|
||||
)
|
||||
|
||||
def test_empty_dataset_partition(self):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
# create more partitions than files
|
||||
data_partitions = EvenlyDistributedPartitionNode(
|
||||
ctx, (data_files,), npartitions=dataset.num_files * 2
|
||||
)
|
||||
data_partitions.max_num_producer_tasks = 3
|
||||
unique_urls = SqlEngineNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
r"select distinct url from {0}",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
# nested partition
|
||||
nested_partitioned_urls = EvenlyDistributedPartitionNode(
|
||||
ctx, (unique_urls,), npartitions=3, dimension="nested", nested=True
|
||||
)
|
||||
parsed_urls = ArrowComputeNode(
|
||||
ctx,
|
||||
(nested_partitioned_urls,),
|
||||
process_func=parse_url,
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, parsed_urls)
|
||||
final_output = self.execute_plan(
|
||||
plan, remove_empty_parquet=True, skip_task_with_empty_input=True
|
||||
).final_output
|
||||
self.assertTrue(isinstance(final_output, ParquetDataSet))
|
||||
self.assertEqual(dataset.num_rows, final_output.num_rows)
|
||||
|
||||
def test_hash_partition(self):
|
||||
for engine_type in ("duckdb", "arrow"):
|
||||
for partition_by_rows in (False, True):
|
||||
for hive_partitioning in (
|
||||
(False, True) if engine_type == "duckdb" else (False,)
|
||||
):
|
||||
with self.subTest(
|
||||
engine_type=engine_type,
|
||||
partition_by_rows=partition_by_rows,
|
||||
hive_partitioning=hive_partitioning,
|
||||
):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/arrow/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
npartitions = 3
|
||||
data_partitions = DataSetPartitionNode(
|
||||
ctx,
|
||||
(data_files,),
|
||||
npartitions=npartitions,
|
||||
partition_by_rows=partition_by_rows,
|
||||
)
|
||||
hash_partitions = HashPartitionNode(
|
||||
ctx,
|
||||
(ProjectionNode(ctx, data_partitions, ["url"]),),
|
||||
npartitions=npartitions,
|
||||
hash_columns=["url"],
|
||||
engine_type=engine_type,
|
||||
hive_partitioning=hive_partitioning,
|
||||
cpu_limit=2,
|
||||
memory_limit=2 * GB,
|
||||
output_name="hash_partitions",
|
||||
)
|
||||
row_count = SqlEngineNode(
|
||||
ctx,
|
||||
(hash_partitions,),
|
||||
r"select count(*) as row_count from {0}",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, row_count)
|
||||
exec_plan = self.execute_plan(plan)
|
||||
self.assertEqual(
|
||||
dataset.num_rows,
|
||||
pc.sum(
|
||||
exec_plan.final_output.to_arrow_table().column(
|
||||
"row_count"
|
||||
)
|
||||
).as_py(),
|
||||
)
|
||||
self.assertEqual(
|
||||
npartitions,
|
||||
len(
|
||||
exec_plan.final_output.load_partitioned_datasets(
|
||||
npartitions, DATA_PARTITION_COLUMN_NAME
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
npartitions,
|
||||
len(
|
||||
exec_plan.get_output(
|
||||
"hash_partitions"
|
||||
).load_partitioned_datasets(
|
||||
npartitions,
|
||||
DATA_PARTITION_COLUMN_NAME,
|
||||
hive_partitioning,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_empty_hash_partition(self):
|
||||
for engine_type in ("duckdb", "arrow"):
|
||||
for partition_by_rows in (False, True):
|
||||
for hive_partitioning in (
|
||||
(False, True) if engine_type == "duckdb" else (False,)
|
||||
):
|
||||
with self.subTest(
|
||||
engine_type=engine_type,
|
||||
partition_by_rows=partition_by_rows,
|
||||
hive_partitioning=hive_partitioning,
|
||||
):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
npartitions = 3
|
||||
npartitions_nested = 4
|
||||
num_rows = 1
|
||||
head_rows = SqlEngineNode(
|
||||
ctx, (data_files,), f"select * from {{0}} limit {num_rows}"
|
||||
)
|
||||
data_partitions = DataSetPartitionNode(
|
||||
ctx,
|
||||
(head_rows,),
|
||||
npartitions=npartitions,
|
||||
partition_by_rows=partition_by_rows,
|
||||
)
|
||||
hash_partitions = HashPartitionNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
npartitions=npartitions,
|
||||
hash_columns=["url"],
|
||||
data_partition_column="hash_partitions",
|
||||
engine_type=engine_type,
|
||||
hive_partitioning=hive_partitioning,
|
||||
output_name="hash_partitions",
|
||||
cpu_limit=2,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
nested_hash_partitions = HashPartitionNode(
|
||||
ctx,
|
||||
(hash_partitions,),
|
||||
npartitions=npartitions_nested,
|
||||
hash_columns=["url"],
|
||||
data_partition_column="nested_hash_partitions",
|
||||
nested=True,
|
||||
engine_type=engine_type,
|
||||
hive_partitioning=hive_partitioning,
|
||||
output_name="nested_hash_partitions",
|
||||
cpu_limit=2,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
select_every_row = SqlEngineNode(
|
||||
ctx,
|
||||
(nested_hash_partitions,),
|
||||
r"select * from {0}",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, select_every_row)
|
||||
exec_plan = self.execute_plan(
|
||||
plan, skip_task_with_empty_input=True
|
||||
)
|
||||
self.assertEqual(num_rows, exec_plan.final_output.num_rows)
|
||||
self.assertEqual(
|
||||
npartitions,
|
||||
len(
|
||||
exec_plan.final_output.load_partitioned_datasets(
|
||||
npartitions, "hash_partitions"
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
npartitions_nested,
|
||||
len(
|
||||
exec_plan.final_output.load_partitioned_datasets(
|
||||
npartitions_nested, "nested_hash_partitions"
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
npartitions,
|
||||
len(
|
||||
exec_plan.get_output(
|
||||
"hash_partitions"
|
||||
).load_partitioned_datasets(
|
||||
npartitions, "hash_partitions"
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
npartitions_nested,
|
||||
len(
|
||||
exec_plan.get_output(
|
||||
"nested_hash_partitions"
|
||||
).load_partitioned_datasets(
|
||||
npartitions_nested, "nested_hash_partitions"
|
||||
)
|
||||
),
|
||||
)
|
||||
if hive_partitioning:
|
||||
self.assertEqual(
|
||||
npartitions,
|
||||
len(
|
||||
exec_plan.get_output(
|
||||
"hash_partitions"
|
||||
).load_partitioned_datasets(
|
||||
npartitions,
|
||||
"hash_partitions",
|
||||
hive_partitioning=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
npartitions_nested,
|
||||
len(
|
||||
exec_plan.get_output(
|
||||
"nested_hash_partitions"
|
||||
).load_partitioned_datasets(
|
||||
npartitions_nested,
|
||||
"nested_hash_partitions",
|
||||
hive_partitioning=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_load_partitioned_datasets(self):
|
||||
def run_test_plan(
|
||||
npartitions: int,
|
||||
data_partition_column: str,
|
||||
engine_type: str,
|
||||
hive_partitioning: bool,
|
||||
):
|
||||
ctx = Context()
|
||||
input_dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
input_data_files = DataSourceNode(ctx, input_dataset)
|
||||
# create hash partitions
|
||||
input_partitions = HashPartitionNode(
|
||||
ctx,
|
||||
(input_data_files,),
|
||||
npartitions=npartitions,
|
||||
hash_columns=["url"],
|
||||
data_partition_column=data_partition_column,
|
||||
engine_type=engine_type,
|
||||
hive_partitioning=hive_partitioning,
|
||||
output_name="input_partitions",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
split_urls = SqlEngineNode(
|
||||
ctx,
|
||||
(input_partitions,),
|
||||
f"select url, string_split(url, '/')[0] as host from {{0}}",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, split_urls)
|
||||
exec_plan = self.execute_plan(plan)
|
||||
self.assertEqual(
|
||||
npartitions,
|
||||
len(
|
||||
exec_plan.final_output.load_partitioned_datasets(
|
||||
npartitions, data_partition_column
|
||||
)
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
npartitions,
|
||||
len(
|
||||
exec_plan.get_output("input_partitions").load_partitioned_datasets(
|
||||
npartitions, data_partition_column, hive_partitioning
|
||||
)
|
||||
),
|
||||
)
|
||||
return exec_plan
|
||||
|
||||
npartitions = 5
|
||||
data_partition_column = "_human_readable_column_name_"
|
||||
|
||||
for engine_type in ("duckdb", "arrow"):
|
||||
with self.subTest(engine_type=engine_type):
|
||||
exec_plan1 = run_test_plan(
|
||||
npartitions,
|
||||
data_partition_column,
|
||||
engine_type,
|
||||
hive_partitioning=engine_type == "duckdb",
|
||||
)
|
||||
exec_plan2 = run_test_plan(
|
||||
npartitions,
|
||||
data_partition_column,
|
||||
engine_type,
|
||||
hive_partitioning=False,
|
||||
)
|
||||
|
||||
ctx = Context()
|
||||
output1 = DataSourceNode(
|
||||
ctx, dataset=exec_plan1.get_output("input_partitions")
|
||||
)
|
||||
output2 = DataSourceNode(
|
||||
ctx, dataset=exec_plan2.get_output("input_partitions")
|
||||
)
|
||||
split_urls1 = LoadPartitionedDataSetNode(
|
||||
ctx,
|
||||
(output1,),
|
||||
npartitions=npartitions,
|
||||
data_partition_column=data_partition_column,
|
||||
hive_partitioning=engine_type == "duckdb",
|
||||
)
|
||||
split_urls2 = LoadPartitionedDataSetNode(
|
||||
ctx,
|
||||
(output2,),
|
||||
npartitions=npartitions,
|
||||
data_partition_column=data_partition_column,
|
||||
hive_partitioning=False,
|
||||
)
|
||||
split_urls3 = SqlEngineNode(
|
||||
ctx,
|
||||
(split_urls1, split_urls2),
|
||||
f"""
|
||||
select split_urls1.url, string_split(split_urls2.url, '/')[0] as host
|
||||
from {{0}} as split_urls1
|
||||
join {{1}} as split_urls2
|
||||
on split_urls1.url = split_urls2.url
|
||||
""",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
plan = LogicalPlan(ctx, split_urls3)
|
||||
exec_plan3 = self.execute_plan(plan)
|
||||
# load each partition as arrow table and compare
|
||||
final_output_partitions1 = (
|
||||
exec_plan1.final_output.load_partitioned_datasets(
|
||||
npartitions, data_partition_column
|
||||
)
|
||||
)
|
||||
final_output_partitions3 = (
|
||||
exec_plan3.final_output.load_partitioned_datasets(
|
||||
npartitions, data_partition_column
|
||||
)
|
||||
)
|
||||
self.assertEqual(npartitions, len(final_output_partitions3))
|
||||
for x, y in zip(final_output_partitions1, final_output_partitions3):
|
||||
self._compare_arrow_tables(x.to_arrow_table(), y.to_arrow_table())
|
||||
|
||||
def test_nested_partition(self):
|
||||
ctx = Context()
|
||||
parquet_files = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_source = DataSourceNode(ctx, parquet_files)
|
||||
|
||||
SqlEngineNode.default_cpu_limit = 1
|
||||
SqlEngineNode.default_memory_limit = 1 * GB
|
||||
initial_reduce = r"select host, count(*) as cnt from {0} group by host"
|
||||
combine_reduce_results = (
|
||||
r"select host, cast(sum(cnt) as bigint) as cnt from {0} group by host"
|
||||
)
|
||||
join_query = r"select host, cnt from {0} where (exists (select * from {1} where {1}.host = {0}.host)) and (exists (select * from {2} where {2}.host = {0}.host))"
|
||||
|
||||
partition_by_hosts = HashPartitionNode(
|
||||
ctx,
|
||||
(data_source,),
|
||||
npartitions=3,
|
||||
hash_columns=["host"],
|
||||
data_partition_column="host_partition",
|
||||
)
|
||||
partition_by_hosts_x_urls = HashPartitionNode(
|
||||
ctx,
|
||||
(partition_by_hosts,),
|
||||
npartitions=5,
|
||||
hash_columns=["url"],
|
||||
data_partition_column="url_partition",
|
||||
nested=True,
|
||||
)
|
||||
url_count_by_hosts_x_urls1 = SqlEngineNode(
|
||||
ctx,
|
||||
(partition_by_hosts_x_urls,),
|
||||
initial_reduce,
|
||||
output_name="url_count_by_hosts_x_urls1",
|
||||
)
|
||||
url_count_by_hosts1 = SqlEngineNode(
|
||||
ctx,
|
||||
(ConsolidateNode(ctx, url_count_by_hosts_x_urls1, ["host_partition"]),),
|
||||
combine_reduce_results,
|
||||
output_name="url_count_by_hosts1",
|
||||
)
|
||||
join_count_by_hosts_x_urls1 = SqlEngineNode(
|
||||
ctx,
|
||||
(url_count_by_hosts_x_urls1, url_count_by_hosts1, data_source),
|
||||
join_query,
|
||||
output_name="join_count_by_hosts_x_urls1",
|
||||
)
|
||||
|
||||
partitioned_urls = LoadPartitionedDataSetNode(
|
||||
ctx,
|
||||
(partition_by_hosts_x_urls,),
|
||||
data_partition_column="url_partition",
|
||||
npartitions=5,
|
||||
)
|
||||
partitioned_hosts_x_urls = LoadPartitionedDataSetNode(
|
||||
ctx,
|
||||
(partitioned_urls,),
|
||||
data_partition_column="host_partition",
|
||||
npartitions=3,
|
||||
nested=True,
|
||||
)
|
||||
partitioned_3dims = EvenlyDistributedPartitionNode(
|
||||
ctx,
|
||||
(partitioned_hosts_x_urls,),
|
||||
npartitions=2,
|
||||
dimension="inner_partition",
|
||||
partition_by_rows=True,
|
||||
nested=True,
|
||||
)
|
||||
url_count_by_3dims = SqlEngineNode(ctx, (partitioned_3dims,), initial_reduce)
|
||||
url_count_by_hosts_x_urls2 = SqlEngineNode(
|
||||
ctx,
|
||||
(
|
||||
ConsolidateNode(
|
||||
ctx, url_count_by_3dims, ["host_partition", "url_partition"]
|
||||
),
|
||||
),
|
||||
combine_reduce_results,
|
||||
output_name="url_count_by_hosts_x_urls2",
|
||||
)
|
||||
url_count_by_hosts2 = SqlEngineNode(
|
||||
ctx,
|
||||
(ConsolidateNode(ctx, url_count_by_hosts_x_urls2, ["host_partition"]),),
|
||||
combine_reduce_results,
|
||||
output_name="url_count_by_hosts2",
|
||||
)
|
||||
url_count_by_hosts_expected = SqlEngineNode(
|
||||
ctx,
|
||||
(data_source,),
|
||||
initial_reduce,
|
||||
per_thread_output=False,
|
||||
output_name="url_count_by_hosts_expected",
|
||||
)
|
||||
join_count_by_hosts_x_urls2 = SqlEngineNode(
|
||||
ctx,
|
||||
(url_count_by_hosts_x_urls2, url_count_by_hosts2, data_source),
|
||||
join_query,
|
||||
output_name="join_count_by_hosts_x_urls2",
|
||||
)
|
||||
|
||||
union_url_count_by_hosts = UnionNode(
|
||||
ctx, (url_count_by_hosts1, url_count_by_hosts2)
|
||||
)
|
||||
union_url_count_by_hosts_x_urls = UnionNode(
|
||||
ctx,
|
||||
(
|
||||
url_count_by_hosts_x_urls1,
|
||||
url_count_by_hosts_x_urls2,
|
||||
join_count_by_hosts_x_urls1,
|
||||
join_count_by_hosts_x_urls2,
|
||||
),
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
data_sink = DataSinkNode(
|
||||
ctx,
|
||||
(
|
||||
url_count_by_hosts_expected,
|
||||
union_url_count_by_hosts,
|
||||
union_url_count_by_hosts_x_urls,
|
||||
),
|
||||
output_path=output_dir,
|
||||
manifest_only=True,
|
||||
)
|
||||
plan = LogicalPlan(ctx, data_sink)
|
||||
exec_plan = self.execute_plan(plan, remove_empty_parquet=True)
|
||||
# verify results
|
||||
self._compare_arrow_tables(
|
||||
exec_plan.get_output("url_count_by_hosts_x_urls1").to_arrow_table(),
|
||||
exec_plan.get_output("url_count_by_hosts_x_urls2").to_arrow_table(),
|
||||
)
|
||||
self._compare_arrow_tables(
|
||||
exec_plan.get_output("join_count_by_hosts_x_urls1").to_arrow_table(),
|
||||
exec_plan.get_output("join_count_by_hosts_x_urls2").to_arrow_table(),
|
||||
)
|
||||
self._compare_arrow_tables(
|
||||
exec_plan.get_output("url_count_by_hosts_x_urls1").to_arrow_table(),
|
||||
exec_plan.get_output("join_count_by_hosts_x_urls1").to_arrow_table(),
|
||||
)
|
||||
self._compare_arrow_tables(
|
||||
exec_plan.get_output("url_count_by_hosts1").to_arrow_table(),
|
||||
exec_plan.get_output("url_count_by_hosts2").to_arrow_table(),
|
||||
)
|
||||
self._compare_arrow_tables(
|
||||
exec_plan.get_output("url_count_by_hosts_expected").to_arrow_table(),
|
||||
exec_plan.get_output("url_count_by_hosts1").to_arrow_table(),
|
||||
)
|
||||
|
||||
def test_user_defined_partition(self):
|
||||
ctx = Context()
|
||||
parquet_files = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_source = DataSourceNode(ctx, parquet_files)
|
||||
file_partitions1 = CalculatePartitionFromFilename(
|
||||
ctx, (data_source,), npartitions=3, dimension="by_filename_hash1"
|
||||
)
|
||||
url_count1 = SqlEngineNode(
|
||||
ctx,
|
||||
(file_partitions1,),
|
||||
r"select host, count(*) as cnt from {0} group by host",
|
||||
output_name="url_count1",
|
||||
)
|
||||
file_partitions2 = CalculatePartitionFromFilename(
|
||||
ctx, (url_count1,), npartitions=3, dimension="by_filename_hash2"
|
||||
)
|
||||
url_count2 = SqlEngineNode(
|
||||
ctx,
|
||||
(file_partitions2,),
|
||||
r"select host, cnt from {0}",
|
||||
output_name="url_count2",
|
||||
)
|
||||
plan = LogicalPlan(ctx, url_count2)
|
||||
|
||||
exec_plan = self.execute_plan(plan, enable_diagnostic_metrics=True)
|
||||
self._compare_arrow_tables(
|
||||
exec_plan.get_output("url_count1").to_arrow_table(),
|
||||
exec_plan.get_output("url_count2").to_arrow_table(),
|
||||
)
|
||||
|
||||
def test_user_partitioned_data_source(self):
|
||||
ctx = Context()
|
||||
parquet_dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_source = DataSourceNode(ctx, parquet_dataset)
|
||||
evenly_dist_data_source = EvenlyDistributedPartitionNode(
|
||||
ctx, (data_source,), npartitions=parquet_dataset.num_files
|
||||
)
|
||||
|
||||
parquet_datasets = [ParquetDataSet([p]) for p in parquet_dataset.resolved_paths]
|
||||
partitioned_data_source = UserPartitionedDataSourceNode(ctx, parquet_datasets)
|
||||
|
||||
url_count_by_host1 = SqlEngineNode(
|
||||
ctx,
|
||||
(evenly_dist_data_source,),
|
||||
r"select host, count(*) as cnt from {0} group by host",
|
||||
output_name="url_count_by_host1",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
|
||||
url_count_by_host2 = SqlEngineNode(
|
||||
ctx,
|
||||
(evenly_dist_data_source, partitioned_data_source),
|
||||
r"select {1}.host, count(*) as cnt from {0} join {1} on {0}.host = {1}.host group by {1}.host",
|
||||
output_name="url_count_by_host2",
|
||||
cpu_limit=1,
|
||||
memory_limit=1 * GB,
|
||||
)
|
||||
|
||||
plan = LogicalPlan(
|
||||
ctx, UnionNode(ctx, [url_count_by_host1, url_count_by_host2])
|
||||
)
|
||||
exec_plan = self.execute_plan(plan, enable_diagnostic_metrics=True)
|
||||
self._compare_arrow_tables(
|
||||
exec_plan.get_output("url_count_by_host1").to_arrow_table(),
|
||||
exec_plan.get_output("url_count_by_host2").to_arrow_table(),
|
||||
)
|
||||
|
||||
def test_partition_info_in_sql_query(self):
|
||||
"""
|
||||
User can refer to the partition info in the SQL query.
|
||||
"""
|
||||
ctx = Context()
|
||||
parquet_dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_source = DataSourceNode(ctx, parquet_dataset)
|
||||
evenly_dist_data_source = EvenlyDistributedPartitionNode(
|
||||
ctx, (data_source,), npartitions=parquet_dataset.num_files
|
||||
)
|
||||
sql_query = SqlEngineNode(
|
||||
ctx,
|
||||
(evenly_dist_data_source,),
|
||||
r"select host, {__data_partition__} as partition_info from {0}",
|
||||
)
|
||||
plan = LogicalPlan(ctx, sql_query)
|
||||
exec_plan = self.execute_plan(plan)
|
||||
70
tests/test_plan.py
Normal file
70
tests/test_plan.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from examples.fstest import fstest
|
||||
from examples.shuffle_data import shuffle_data
|
||||
from examples.shuffle_mock_urls import shuffle_mock_urls
|
||||
from examples.sort_mock_urls import sort_mock_urls
|
||||
from examples.sort_mock_urls_v2 import sort_mock_urls_v2
|
||||
from smallpond.dataframe import Session
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class TestPlan(TestFabric, unittest.TestCase):
|
||||
def test_sort_mock_urls(self):
|
||||
for engine_type in ("duckdb", "arrow"):
|
||||
with self.subTest(engine_type=engine_type):
|
||||
plan = sort_mock_urls(
|
||||
["tests/data/mock_urls/*.tsv"],
|
||||
npartitions=3,
|
||||
engine_type=engine_type,
|
||||
)
|
||||
self.execute_plan(plan)
|
||||
|
||||
def test_sort_mock_urls_external_output_path(self):
|
||||
with tempfile.TemporaryDirectory(dir=self.output_root_abspath) as output_dir:
|
||||
plan = sort_mock_urls(
|
||||
["tests/data/mock_urls/*.tsv"],
|
||||
npartitions=3,
|
||||
external_output_path=output_dir,
|
||||
)
|
||||
self.execute_plan(plan)
|
||||
|
||||
def test_shuffle_mock_urls(self):
|
||||
for engine_type in ("duckdb", "arrow"):
|
||||
with self.subTest(engine_type=engine_type):
|
||||
plan = shuffle_mock_urls(
|
||||
["tests/data/mock_urls/*.parquet"],
|
||||
npartitions=3,
|
||||
sort_rand_keys=True,
|
||||
)
|
||||
self.execute_plan(plan)
|
||||
|
||||
def test_shuffle_data(self):
|
||||
for engine_type in ("duckdb", "arrow"):
|
||||
with self.subTest(engine_type=engine_type):
|
||||
plan = shuffle_data(
|
||||
["tests/data/mock_urls/*.parquet"],
|
||||
num_data_partitions=3,
|
||||
num_out_data_partitions=3,
|
||||
engine_type=engine_type,
|
||||
)
|
||||
self.execute_plan(plan)
|
||||
|
||||
|
||||
def test_fstest(sp: Session):
|
||||
path = sp._runtime_ctx.output_root
|
||||
fstest(
|
||||
sp,
|
||||
input_path=os.path.join(path, "*"),
|
||||
output_path=path,
|
||||
size="10M",
|
||||
npartitions=3,
|
||||
)
|
||||
|
||||
|
||||
def test_sort_mock_urls_v2(sp: Session):
|
||||
sort_mock_urls_v2(
|
||||
sp, ["tests/data/mock_urls/*.tsv"], sp._runtime_ctx.output_root, npartitions=3
|
||||
)
|
||||
180
tests/test_scheduler.py
Normal file
180
tests/test_scheduler.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import os.path
|
||||
import random
|
||||
import time
|
||||
import unittest
|
||||
from typing import List, Tuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from smallpond.execution.scheduler import ExecutorState
|
||||
from smallpond.execution.task import PythonScriptTask, RuntimeContext
|
||||
from smallpond.logical.dataset import DataSet, ParquetDataSet
|
||||
from smallpond.logical.node import (
|
||||
Context,
|
||||
DataSetPartitionNode,
|
||||
DataSourceNode,
|
||||
LogicalPlan,
|
||||
Node,
|
||||
PythonScriptNode,
|
||||
)
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class RandomSleepTask(PythonScriptTask):
|
||||
def __init__(
|
||||
self, *args, sleep_secs: float, fail_first_try: bool, **kwargs
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.sleep_secs = sleep_secs
|
||||
self.fail_first_try = fail_first_try
|
||||
|
||||
def process(
|
||||
self,
|
||||
runtime_ctx: RuntimeContext,
|
||||
input_datasets: List[DataSet],
|
||||
output_path: str,
|
||||
) -> bool:
|
||||
logger.info(f"sleeping {self.sleep_secs} secs")
|
||||
time.sleep(self.sleep_secs)
|
||||
with open(os.path.join(output_path, self.output_filename), "w") as fout:
|
||||
fout.write(f"{repr(self)}")
|
||||
if self.fail_first_try and self.retry_count == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class RandomSleepNode(PythonScriptNode):
|
||||
def __init__(
|
||||
self,
|
||||
ctx: Context,
|
||||
input_deps: Tuple[Node, ...],
|
||||
*,
|
||||
max_sleep_secs=5,
|
||||
fail_first_try=False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(ctx, input_deps, **kwargs)
|
||||
self.max_sleep_secs = max_sleep_secs
|
||||
self.fail_first_try = fail_first_try
|
||||
|
||||
def spawn(self, *args, **kwargs) -> RandomSleepTask:
|
||||
sleep_secs = (
|
||||
random.random() if len(self.generated_tasks) % 20 else self.max_sleep_secs
|
||||
)
|
||||
return RandomSleepTask(
|
||||
*args, **kwargs, sleep_secs=sleep_secs, fail_first_try=self.fail_first_try
|
||||
)
|
||||
|
||||
|
||||
class TestScheduler(TestFabric, unittest.TestCase):
|
||||
def create_random_sleep_plan(
|
||||
self, npartitions, max_sleep_secs, fail_first_try=False
|
||||
):
|
||||
ctx = Context()
|
||||
dataset = ParquetDataSet(["tests/data/mock_urls/*.parquet"])
|
||||
data_files = DataSourceNode(ctx, dataset)
|
||||
data_partitions = DataSetPartitionNode(
|
||||
ctx, (data_files,), npartitions=npartitions, partition_by_rows=True
|
||||
)
|
||||
random_sleep = RandomSleepNode(
|
||||
ctx,
|
||||
(data_partitions,),
|
||||
max_sleep_secs=max_sleep_secs,
|
||||
fail_first_try=fail_first_try,
|
||||
)
|
||||
return LogicalPlan(ctx, random_sleep)
|
||||
|
||||
def check_executor_state(self, target_state: ExecutorState, nloops=200):
|
||||
for _ in range(nloops):
|
||||
latest_sched_state = self.get_latest_sched_state()
|
||||
if any(
|
||||
executor.state == target_state
|
||||
for executor in latest_sched_state.remote_executors
|
||||
):
|
||||
logger.info(
|
||||
f"found {target_state} executor in: {latest_sched_state.remote_executors}"
|
||||
)
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
self.assertTrue(
|
||||
False,
|
||||
f"cannot find any executor in state {target_state}: {latest_sched_state.remote_executors}",
|
||||
)
|
||||
|
||||
def test_standalone_mode(self):
|
||||
plan = self.create_random_sleep_plan(npartitions=10, max_sleep_secs=1)
|
||||
self.execute_plan(plan, num_executors=0)
|
||||
|
||||
def test_failed_executors(self):
|
||||
num_exec = 6
|
||||
num_fail = 4
|
||||
plan = self.create_random_sleep_plan(npartitions=300, max_sleep_secs=10)
|
||||
|
||||
_, executors, processes = self.start_execution(
|
||||
plan,
|
||||
num_executors=num_exec,
|
||||
secs_wq_poll_interval=0.1,
|
||||
secs_executor_probe_interval=0.5,
|
||||
console_log_level="WARNING",
|
||||
)
|
||||
latest_sched_state = self.get_latest_sched_state()
|
||||
self.check_executor_state(ExecutorState.GOOD)
|
||||
|
||||
for i, (executor, process) in enumerate(
|
||||
random.sample(list(zip(executors, processes[1:])), k=num_fail)
|
||||
):
|
||||
if i % 2 == 0:
|
||||
logger.warning(f"kill executor: {executor}")
|
||||
process.kill()
|
||||
else:
|
||||
logger.warning(f"skip probes: {executor}")
|
||||
executor.skip_probes(latest_sched_state.ctx.max_num_missed_probes * 2)
|
||||
|
||||
self.join_running_procs()
|
||||
latest_sched_state = self.get_latest_sched_state()
|
||||
self.assertTrue(latest_sched_state.success)
|
||||
self.assertGreater(len(latest_sched_state.abandoned_tasks), 0)
|
||||
self.assertLessEqual(
|
||||
1,
|
||||
len(latest_sched_state.stopped_executors),
|
||||
f"remote_executors: {latest_sched_state.remote_executors}",
|
||||
)
|
||||
self.assertLessEqual(
|
||||
num_fail / 2,
|
||||
len(latest_sched_state.failed_executors),
|
||||
f"remote_executors: {latest_sched_state.remote_executors}",
|
||||
)
|
||||
|
||||
def test_speculative_scheduling(self):
|
||||
for speculative_exec in ("disable", "enable", "aggressive"):
|
||||
with self.subTest(speculative_exec=speculative_exec):
|
||||
plan = self.create_random_sleep_plan(npartitions=100, max_sleep_secs=10)
|
||||
self.execute_plan(
|
||||
plan,
|
||||
num_executors=3,
|
||||
secs_wq_poll_interval=0.1,
|
||||
secs_executor_probe_interval=0.5,
|
||||
prioritize_retry=(speculative_exec == "aggressive"),
|
||||
speculative_exec=speculative_exec,
|
||||
)
|
||||
latest_sched_state = self.get_latest_sched_state()
|
||||
if speculative_exec == "disable":
|
||||
self.assertEqual(len(latest_sched_state.abandoned_tasks), 0)
|
||||
else:
|
||||
self.assertGreater(len(latest_sched_state.abandoned_tasks), 0)
|
||||
|
||||
def test_stop_executor_on_failure(self):
|
||||
plan = self.create_random_sleep_plan(
|
||||
npartitions=3, max_sleep_secs=5, fail_first_try=True
|
||||
)
|
||||
exec_plan = self.execute_plan(
|
||||
plan,
|
||||
num_executors=5,
|
||||
secs_wq_poll_interval=0.1,
|
||||
secs_executor_probe_interval=0.5,
|
||||
check_result=False,
|
||||
stop_executor_on_failure=True,
|
||||
)
|
||||
latest_sched_state = self.get_latest_sched_state()
|
||||
self.assertGreater(len(latest_sched_state.abandoned_tasks), 0)
|
||||
54
tests/test_session.py
Normal file
54
tests/test_session.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import os
|
||||
|
||||
from smallpond.dataframe import Session
|
||||
|
||||
|
||||
def test_shutdown_cleanup(sp: Session):
|
||||
assert os.path.exists(sp._runtime_ctx.queue_root), "queue directory should exist"
|
||||
assert os.path.exists(
|
||||
sp._runtime_ctx.staging_root
|
||||
), "staging directory should exist"
|
||||
assert os.path.exists(sp._runtime_ctx.temp_root), "temp directory should exist"
|
||||
|
||||
# create some tasks and complete them
|
||||
df = sp.from_items([1, 2, 3])
|
||||
df.write_parquet(sp._runtime_ctx.output_root)
|
||||
sp.shutdown()
|
||||
|
||||
# shutdown should clean up directories
|
||||
assert not os.path.exists(
|
||||
sp._runtime_ctx.queue_root
|
||||
), "queue directory should be cleared"
|
||||
assert not os.path.exists(
|
||||
sp._runtime_ctx.staging_root
|
||||
), "staging directory should be cleared"
|
||||
assert not os.path.exists(
|
||||
sp._runtime_ctx.temp_root
|
||||
), "temp directory should be cleared"
|
||||
with open(sp._runtime_ctx.job_status_path) as fin:
|
||||
assert "success" in fin.read(), "job status should be success"
|
||||
|
||||
|
||||
def test_shutdown_no_cleanup_on_failure(sp: Session):
|
||||
df = sp.from_items([1, 2, 3])
|
||||
try:
|
||||
# create a task that will fail
|
||||
df.map(lambda x: x / 0).compute()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
raise RuntimeError("task should fail")
|
||||
sp.shutdown()
|
||||
|
||||
# shutdown should not clean up directories
|
||||
assert os.path.exists(
|
||||
sp._runtime_ctx.queue_root
|
||||
), "queue directory should not be cleared"
|
||||
assert os.path.exists(
|
||||
sp._runtime_ctx.staging_root
|
||||
), "staging directory should not be cleared"
|
||||
assert os.path.exists(
|
||||
sp._runtime_ctx.temp_root
|
||||
), "temp directory should not be cleared"
|
||||
with open(sp._runtime_ctx.job_status_path) as fin:
|
||||
assert "failure" in fin.read(), "job status should be failure"
|
||||
50
tests/test_utility.py
Normal file
50
tests/test_utility.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import random
|
||||
import subprocess
|
||||
import time
|
||||
import unittest
|
||||
from typing import Iterable
|
||||
|
||||
from smallpond.utility import ConcurrentIter, execute_command
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class TestUtility(TestFabric, unittest.TestCase):
|
||||
def test_concurrent_iter_no_error(self):
|
||||
def slow_iterator(iter: Iterable[int], sleep_ms: int):
|
||||
for i in iter:
|
||||
time.sleep(sleep_ms / 1000)
|
||||
yield i
|
||||
|
||||
for n in [1, 5, 10, 50, 100]:
|
||||
with ConcurrentIter(slow_iterator(range(n), 2)) as iter1:
|
||||
with ConcurrentIter(slow_iterator(iter1, 5)) as iter2:
|
||||
self.assertEqual(sum(slow_iterator(iter2, 1)), sum(range(n)))
|
||||
|
||||
def test_concurrent_iter_with_error(self):
|
||||
def broken_iterator(iter: Iterable[int], sleep_ms: int):
|
||||
for i in iter:
|
||||
time.sleep(sleep_ms / 1000)
|
||||
if random.randint(1, 10) == 1:
|
||||
raise Exception("raised before yield")
|
||||
yield i
|
||||
if random.randint(1, 10) == 1:
|
||||
raise Exception("raised after yield")
|
||||
raise Exception("raised at the end")
|
||||
|
||||
for n in [1, 5, 10, 50, 100]:
|
||||
with self.assertRaises(Exception):
|
||||
with ConcurrentIter(range(n)) as iter:
|
||||
print(sum(broken_iterator(iter, 1)))
|
||||
with self.assertRaises(Exception):
|
||||
with ConcurrentIter(broken_iterator(range(n), 2)) as iter1:
|
||||
with ConcurrentIter(broken_iterator(iter1, 5)) as iter2:
|
||||
print(sum(iter2))
|
||||
|
||||
def test_execute_command(self):
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
for line in execute_command("ls non_existent_file"):
|
||||
print(line)
|
||||
for line in execute_command("echo hello"):
|
||||
print(line)
|
||||
for line in execute_command("cat /dev/null"):
|
||||
print(line)
|
||||
119
tests/test_workqueue.py
Normal file
119
tests/test_workqueue.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import multiprocessing
|
||||
import multiprocessing.dummy
|
||||
import multiprocessing.queues
|
||||
import queue
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from smallpond.execution.workqueue import (
|
||||
WorkItem,
|
||||
WorkQueue,
|
||||
WorkQueueInMemory,
|
||||
WorkQueueOnFilesystem,
|
||||
)
|
||||
from tests.test_fabric import TestFabric
|
||||
|
||||
|
||||
class PrintWork(WorkItem):
|
||||
def __init__(self, name: str, message: str) -> None:
|
||||
super().__init__(name, cpu_limit=1, gpu_limit=0, memory_limit=0)
|
||||
self.message = message
|
||||
|
||||
def run(self) -> bool:
|
||||
logger.debug(f"{self.key}: {self.message}")
|
||||
return True
|
||||
|
||||
|
||||
def producer(wq: WorkQueue, id: int, numItems: int, numConsumers: int) -> None:
|
||||
print(f"wq.outbound_works: {wq.outbound_works}")
|
||||
for i in range(numItems):
|
||||
wq.push(PrintWork(f"item-{i}", message="hello"), buffering=(i % 3 == 1))
|
||||
# wq.push(PrintWork(f"item-{i}", message="hello"))
|
||||
if i % 5 == 0:
|
||||
wq.flush()
|
||||
for i in range(numConsumers):
|
||||
wq.push(PrintWork(f"stop-{i}", message="stop"))
|
||||
logger.success(f"producer {id} generated {numItems} items")
|
||||
|
||||
|
||||
def consumer(wq: WorkQueue, id: int) -> int:
|
||||
numItems = 0
|
||||
numWaits = 0
|
||||
running = True
|
||||
while running:
|
||||
items = wq.pop(count=1)
|
||||
if not items:
|
||||
numWaits += 1
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
for item in items:
|
||||
assert isinstance(item, PrintWork)
|
||||
if item.message == "stop":
|
||||
running = False
|
||||
break
|
||||
item.exec()
|
||||
numItems += 1
|
||||
logger.success(f"consumer {id} collected {numItems} items, {numWaits} waits")
|
||||
logger.complete()
|
||||
return numItems
|
||||
|
||||
|
||||
class WorkQueueTestBase(object):
|
||||
|
||||
wq: WorkQueue = None
|
||||
pool: multiprocessing.Pool = None
|
||||
|
||||
def setUp(self) -> None:
|
||||
logger.disable("smallpond.execution.workqueue")
|
||||
return super().setUp()
|
||||
|
||||
def test_basics(self):
|
||||
numItems = 200
|
||||
for i in range(numItems):
|
||||
self.wq.push(PrintWork(f"item-{i}", message="hello"))
|
||||
numCollected = 0
|
||||
for _ in range(numItems):
|
||||
items = self.wq.pop()
|
||||
logger.info(f"{len(items)} items")
|
||||
numCollected += len(items)
|
||||
if numItems == numCollected:
|
||||
break
|
||||
|
||||
def test_multi_consumers(self):
|
||||
numConsumers = 10
|
||||
numItems = 200
|
||||
result = self.pool.starmap_async(
|
||||
consumer, [(self.wq, id) for id in range(numConsumers)]
|
||||
)
|
||||
producer(self.wq, 0, numItems, numConsumers)
|
||||
|
||||
logger.info("waiting for result")
|
||||
numCollected = sum(result.get(timeout=20))
|
||||
logger.info(f"expected vs collected: {numItems} vs {numCollected}")
|
||||
self.assertEqual(numItems, numCollected)
|
||||
logger.success("all done")
|
||||
|
||||
self.pool.terminate()
|
||||
self.pool.join()
|
||||
logger.success("workers stopped")
|
||||
|
||||
|
||||
class TestWorkQueueInMemory(WorkQueueTestBase, TestFabric, unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.wq = WorkQueueInMemory(queue_type=queue.Queue)
|
||||
self.pool = multiprocessing.dummy.Pool(10)
|
||||
|
||||
|
||||
class TestWorkQueueOnFilesystem(WorkQueueTestBase, TestFabric, unittest.TestCase):
|
||||
|
||||
workq_root: str
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.workq_root = tempfile.mkdtemp(dir=self.runtime_ctx.queue_root)
|
||||
self.wq = WorkQueueOnFilesystem(self.workq_root, sort=True)
|
||||
self.pool = multiprocessing.Pool(10)
|
||||
Reference in New Issue
Block a user