blob: e36f6c8bcba6763fb4581046940d266832e0bfec [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
import random
import unittest
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.manifest_entry import ManifestEntry
from pypaimon.manifest.schema.simple_stats import SimpleStats
from pypaimon.read.scanner.data_evolution_split_generator import DataEvolutionSplitGenerator
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.utils.range import Range
class _F:
def __init__(self, tag: int, from_: int = None, to: int = None):
self.tag = tag
self.file_name = f"f{tag}"
self._range = Range(from_, to) if from_ is not None else None
def row_id_range(self):
return self._range
def non_null_row_id_range(self) -> Range:
if self._range is None:
raise ValueError(f"First row id of '{self.file_name}' should not be null.")
return self._range
def _reference_split(files):
"""The original O(n^2) linear scan, kept to lock equivalence."""
list_ranges = [f.row_id_range() for f in files]
if not list_ranges:
return []
sorted_ranges = Range.sort_and_merge_overlap(list_ranges, True, False)
range_to_files = {}
for f in files:
file_range = f.row_id_range()
for r in sorted_ranges:
if r.overlaps(file_range):
range_to_files.setdefault(r, []).append(f)
break
return list(range_to_files.values())
def _shape(groups):
"""Group structure by file tag: locks grouping + order."""
return [[f.tag for f in g] for g in groups]
def _grouping(groups):
"""Files grouped together, ignoring order -- the functional invariant."""
return {frozenset(f.tag for f in g) for g in groups}
class SplitByRowIdEquivalenceTest(unittest.TestCase):
def test_empty(self):
self.assertEqual(DataEvolutionSplitGenerator._split_by_row_id([]), [])
def test_raises_on_file_missing_first_row_id(self):
# A file without first_row_id must fail fast with a readable error.
with self.assertRaisesRegex(ValueError, "should not be null"):
DataEvolutionSplitGenerator._split_by_row_id([_F(0, 0, 4), _F(1)])
def test_disjoint_files_each_its_own_group(self):
files = [_F(0, 0, 4), _F(1, 5, 9), _F(2, 10, 14)]
self.assertEqual(_shape(DataEvolutionSplitGenerator._split_by_row_id(files)),
[[0], [1], [2]])
def test_evolution_delta_grouped_with_original(self):
original = _F(0, 0, 9)
delta = _F(1, 3, 5) # sub-range of original -> same merged range
groups = DataEvolutionSplitGenerator._split_by_row_id([original, delta])
self.assertEqual(_shape(groups), [[0, 1]])
def test_groups_ordered_by_range_start(self):
files = [_F(0, 10, 14), _F(1, 0, 4), _F(2, 5, 9)] # unsorted input
self.assertEqual(_shape(DataEvolutionSplitGenerator._split_by_row_id(files)),
[[1], [2], [0]]) # groups come out ordered by range start
def test_matches_reference_grouping_on_random_inputs(self):
rng = random.Random(1234)
for _ in range(1000):
n = rng.randint(0, 50)
files, cursor = [], 0
for tag in range(n):
roll = rng.random()
if roll < 0.6: # disjoint
from_ = cursor + rng.randint(1, 5)
to = from_ + rng.randint(0, 10)
elif roll < 0.85: # overlapping (evolution-like)
from_ = rng.randint(max(0, cursor - 8), max(0, cursor))
to = from_ + rng.randint(0, 6)
else: # duplicate / same start
from_ = rng.randint(0, cursor + 1)
to = from_ + rng.randint(0, 12)
cursor = max(cursor, to)
files.append(_F(tag, from_, to))
rng.shuffle(files)
self.assertEqual(
_grouping(DataEvolutionSplitGenerator._split_by_row_id(files)),
_grouping(_reference_split(files)))
class SplitOrderTest(unittest.TestCase):
class _Options:
options = {}
class _Table:
table_path = '/table'
options = None
_Table.options = _Options()
@staticmethod
def _entry(name, sequence, first_row_id=0, external_path=None):
empty_row = GenericRow([], [])
empty_stats = SimpleStats(empty_row, empty_row, [])
file = DataFileMeta.create(
file_name=name,
file_size=1,
row_count=10,
min_key=empty_row,
max_key=empty_row,
key_stats=empty_stats,
value_stats=empty_stats,
min_sequence_number=sequence,
max_sequence_number=sequence,
schema_id=0,
level=0,
extra_files=[],
external_path=external_path,
first_row_id=first_row_id,
)
return ManifestEntry(
kind=0,
partition=empty_row,
bucket=0,
total_buckets=1,
file=file,
)
def test_preserves_manifest_order_within_row_id_group(self):
entries = [
self._entry('a.parquet', 1),
self._entry('b.parquet', 3),
self._entry('c.parquet', 2),
]
splits = DataEvolutionSplitGenerator(
self._Table(), target_split_size=1024, open_file_cost=0
).create_splits(entries)
self.assertEqual(
['a.parquet', 'b.parquet', 'c.parquet'],
[file.file_name for file in splits[0].files],
)
def test_slice_and_shard_preserve_blob_manifest_order(self):
entries = [
self._entry('a.blob', 1),
self._entry('b.parquet', 2),
self._entry('c.blob', 3),
]
expected = ['a.blob', 'b.parquet', 'c.blob']
generators = [
DataEvolutionSplitGenerator(
self._Table(), target_split_size=1024, open_file_cost=0
).with_slice(0, 5),
DataEvolutionSplitGenerator(
self._Table(), target_split_size=1024, open_file_cost=0
).with_shard(0, 2),
]
for generator in generators:
with self.subTest(generator=type(generator).__name__):
splits = generator.create_splits(entries)
self.assertEqual(
expected,
[file.file_name for file in splits[0].files],
)
def test_slice_and_shard_distinguish_same_external_file_name(self):
entries = [
self._entry(
'same.parquet', 1, first_row_id=0,
external_path='s3://bucket-a/data/same.parquet',
),
self._entry(
'same.parquet', 2, first_row_id=10,
external_path='s3://bucket-b/data/same.parquet',
),
]
expected = ['s3://bucket-a/data/same.parquet']
generators = [
DataEvolutionSplitGenerator(
self._Table(), target_split_size=1024, open_file_cost=0
).with_slice(0, 10),
DataEvolutionSplitGenerator(
self._Table(), target_split_size=1024, open_file_cost=0
).with_shard(0, 2),
]
for generator in generators:
with self.subTest(generator=type(generator).__name__):
splits = generator.create_splits(entries)
self.assertEqual(
expected,
[file.external_path for split in splits
for file in split.files],
)
if __name__ == "__main__":
unittest.main()