blob: 2ca91f5776903c1bfdaf170175ec3fc20826fc72 [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 gc
import os
import tempfile
import threading
import time
import unittest
import weakref
from concurrent.futures import Future, ThreadPoolExecutor
from unittest.mock import patch
import pyarrow as pa
import pyarrow.fs as pafs
import pyarrow.parquet as pq
from fsspec.implementations.local import LocalFileSystem as FsspecLocalFileSystem
from pypaimon.common.options import Options
from pypaimon.common.options.config import CatalogOptions
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.filesystem.local_file_io import LocalFileIO
from pypaimon.read.reader import format_pyarrow_reader as reader_module
from pypaimon.read.reader.format_pyarrow_reader import FormatPyArrowReader
from pypaimon.schema.data_types import AtomicType, DataField
DEFAULT_CACHE_SIZE = 50 * 1024 * 1024
class _CountingInputFile:
def __init__(self, wrapped, file_system):
self._wrapped = wrapped
self._file_system = file_system
def read(self, size=-1):
offset = self._wrapped.tell()
data = self._wrapped.read(size)
self._file_system.reads.append((offset, len(data)))
return data
def readinto(self, buffer):
offset = self._wrapped.tell()
size = self._wrapped.readinto(buffer)
self._file_system.reads.append((offset, size))
return size
def __getattr__(self, name):
return getattr(self._wrapped, name)
class _CountingLocalFileSystem(FsspecLocalFileSystem):
def __init__(self):
super().__init__()
self.opens = 0
self.reads = []
def _open(self, path, mode="rb", **kwargs):
wrapped = super()._open(path, mode=mode, **kwargs)
if "r" not in mode:
return wrapped
self.opens += 1
return _CountingInputFile(wrapped, self)
def reset_counts(self):
self.opens = 0
self.reads = []
class FileFormatMetadataCacheTest(unittest.TestCase):
def setUp(self):
reader_module._reset_file_format_dataset_cache()
self.temp_dir = tempfile.TemporaryDirectory()
self.file_io = LocalFileIO(self.temp_dir.name, Options({}))
self.paths = []
for index in range(3):
path = os.path.join(self.temp_dir.name, "data-{}.parquet".format(index))
pq.write_table(
pa.table({"value": list(range(index * 10, index * 10 + 10))}),
path,
row_group_size=2,
)
self.paths.append(path)
def tearDown(self):
reader_module._reset_file_format_dataset_cache()
self.temp_dir.cleanup()
def _file_io(self, max_size="50 mb"):
return LocalFileIO(self.temp_dir.name, Options({
"file-format.metadata-cache.max-size": max_size,
}))
def _read(self, path, file_io=None, options=None):
reader = FormatPyArrowReader(
file_io or self.file_io,
"parquet",
path,
[DataField(0, "value", AtomicType("BIGINT"))],
None,
options=options,
)
values = []
try:
while True:
batch = reader.read_arrow_batch()
if batch is None:
return values
values.extend(batch.column(0).to_pylist())
finally:
reader.close()
def test_enabled_by_default(self):
self.assertEqual(
DEFAULT_CACHE_SIZE,
self.file_io.properties.get(
CatalogOptions.FILE_FORMAT_METADATA_CACHE_MAX_SIZE).get_bytes())
original = reader_module.ds.dataset
with patch.object(reader_module.ds, "dataset", wraps=original) as dataset:
self._read(self.paths[0])
self._read(self.paths[0])
self.assertEqual(1, dataset.call_count)
def test_zero_size_bypasses_and_removes_entry(self):
enabled = self._file_io()
disabled = self._file_io("0 b")
original = reader_module.ds.dataset
with patch.object(reader_module.ds, "dataset", wraps=original) as dataset:
self._read(self.paths[0], enabled)
self._read(self.paths[0], disabled)
self._read(self.paths[0], enabled)
self.assertEqual(3, dataset.call_count)
def test_zero_size_clears_other_entries(self):
enabled = self._file_io()
disabled = self._file_io("0 b")
original = reader_module.ds.dataset
with patch.object(reader_module.ds, "dataset", wraps=original) as dataset:
self._read(self.paths[0], enabled)
self._read(self.paths[1], enabled)
self._read(self.paths[0], disabled)
self._read(self.paths[1], enabled)
self.assertEqual(4, dataset.call_count)
def test_reuses_dataset(self):
file_io = self._file_io()
original = reader_module.ds.dataset
with patch.object(reader_module.ds, "dataset", wraps=original) as dataset:
first = self._read(self.paths[0], file_io)
second = self._read(self.paths[0], file_io)
self.assertEqual(list(range(10)), first)
self.assertEqual(first, second)
self.assertEqual(1, dataset.call_count)
def test_repeated_scan_skips_footer_io(self):
path = os.path.join(self.temp_dir.name, "footer-io.parquet")
pq.write_table(
pa.table({
"value": list(range(10000)),
"payload": ["x" * 100] * 10000,
}),
path,
row_group_size=100,
compression="none",
)
counting = _CountingLocalFileSystem()
file_io = self._file_io("0 b")
file_io.filesystem = pafs.PyFileSystem(pafs.FSSpecHandler(counting))
uncached = self._read(path, file_io)
uncached_opens = counting.opens
uncached_reads = len(counting.reads)
counting.reset_counts()
reader_module._reset_file_format_dataset_cache()
file_io.properties.set(
CatalogOptions.FILE_FORMAT_METADATA_CACHE_MAX_SIZE, "50 mb")
self._read(path, file_io)
counting.reset_counts()
cached = self._read(path, file_io)
self.assertEqual(uncached, cached)
self.assertLess(counting.opens, uncached_opens)
self.assertLess(len(counting.reads), uncached_reads)
def test_evicts_least_recently_used_entry_by_estimated_size(self):
cache = reader_module._FileFormatDatasetCache(10)
first_key = (None, "parquet", "first")
second_key = (None, "parquet", "second")
third_key = (None, "parquet", "third")
cache.get_or_load(first_key, lambda: "first", lambda _: 4)
cache.get_or_load(second_key, lambda: "second", lambda _: 4)
cache.get_or_load(first_key, lambda: "unused", lambda _: 4)
cache.get_or_load(third_key, lambda: "third", lambda _: 4)
self.assertEqual([first_key, third_key], list(cache._entries.keys()))
self.assertEqual(8, cache.estimated_size)
def test_evicts_least_recently_used_entry_by_entry_count(self):
cache = reader_module._FileFormatDatasetCache(
1024, max_entries=2)
first_key = (None, "parquet", "first")
second_key = (None, "parquet", "second")
third_key = (None, "parquet", "third")
cache.get_or_load(first_key, lambda: "first", lambda _: 1)
cache.get_or_load(second_key, lambda: "second", lambda _: 1)
cache.get_or_load(third_key, lambda: "third", lambda _: 1)
self.assertEqual(
[second_key, third_key], list(cache._entries.keys()))
self.assertEqual(2, cache.estimated_size)
def test_does_not_retain_entry_larger_than_size_limit(self):
cache = reader_module._FileFormatDatasetCache(5)
loads = []
key = (None, "parquet", "large")
def load():
loads.append(True)
return "large"
self.assertEqual(
"large", cache.get_or_load(key, load, lambda _: 6))
self.assertEqual(
"large", cache.get_or_load(key, load, lambda _: 6))
self.assertEqual(2, len(loads))
self.assertEqual(0, len(cache._entries))
self.assertEqual(0, cache.estimated_size)
def test_does_not_retain_entry_without_size_estimate(self):
cache = reader_module._FileFormatDatasetCache(10)
key = (None, "unknown", "data")
loads = []
def load():
loads.append(True)
return "unknown"
self.assertEqual(
"unknown", cache.get_or_load(key, load, lambda _: None))
self.assertEqual(
"unknown", cache.get_or_load(key, load, lambda _: None))
self.assertEqual(2, len(loads))
self.assertEqual(0, len(cache._entries))
def test_coalesces_load_while_uncached_result_completes(self):
cache = reader_module._FileFormatDatasetCache(10)
key = (None, "unknown", "data")
setting_result = threading.Event()
waiter_started = threading.Event()
release_result = threading.Event()
loads = []
future_count = []
def new_future():
future = Future()
future_count.append(future)
if len(future_count) == 1:
original_set_result = future.set_result
original_result = future.result
def delayed_set_result(result):
setting_result.set()
release_result.wait()
original_set_result(result)
def observed_result(*args, **kwargs):
waiter_started.set()
return original_result(*args, **kwargs)
future.set_result = delayed_set_result
future.result = observed_result
return future
def load():
loads.append(True)
return "unknown"
with patch.object(reader_module, "Future", side_effect=new_future):
with ThreadPoolExecutor(max_workers=2) as executor:
first = executor.submit(
cache.get_or_load, key, load, lambda _: None)
self.assertTrue(setting_result.wait(1))
second = executor.submit(
cache.get_or_load, key, load, lambda _: None)
waited = waiter_started.wait(1)
release_result.set()
self.assertTrue(waited)
self.assertEqual("unknown", first.result())
self.assertEqual("unknown", second.result())
self.assertEqual(1, len(loads))
def test_estimates_serialized_parquet_footer_size(self):
dataset = reader_module.ds.dataset(self.paths[0], format="parquet")
expected = sum(
fragment.metadata.serialized_size
for fragment in dataset.get_fragments()
)
self.assertGreater(expected, 0)
self.assertEqual(
expected,
reader_module._estimate_file_format_dataset_size(
dataset, "parquet"))
def test_cache_entry_estimate_has_conservative_floor(self):
dataset = reader_module.ds.dataset(
self.paths[0], format="parquet")
key = (
reader_module._FilesystemIdentity(self.file_io.filesystem),
"parquet",
self.paths[0],
)
estimated = reader_module._estimate_file_format_cache_entry_size(
key, dataset, "parquet")
self.assertGreaterEqual(
estimated,
reader_module._FILE_FORMAT_METADATA_CACHE_MIN_ENTRY_SIZE)
def test_process_cache_can_shrink_requested_capacity(self):
cache = reader_module._file_format_dataset_cache(10)
cache.get_or_load(("first", "parquet", "first"),
lambda: "first", lambda _: 4)
cache.get_or_load(("second", "parquet", "second"),
lambda: "second", lambda _: 4)
same_cache = reader_module._file_format_dataset_cache(5)
self.assertIs(cache, same_cache)
self.assertEqual(5, cache.max_size)
self.assertEqual(
[("second", "parquet", "second")],
list(cache._entries.keys()))
self.assertEqual(4, cache.estimated_size)
def test_table_option_does_not_configure_process_cache(self):
table_options = CoreOptions(Options({
"file-format.metadata-cache.max-size": "0 b",
}))
original = reader_module.ds.dataset
with patch.object(reader_module.ds, "dataset", wraps=original) as dataset:
self._read(self.paths[0], options=table_options)
self._read(self.paths[0], options=table_options)
self.assertEqual(1, dataset.call_count)
def test_shares_cache_across_file_io_with_same_filesystem(self):
other_file_io = LocalFileIO(self.temp_dir.name, Options({}))
other_file_io.filesystem = self.file_io.filesystem
original = reader_module.ds.dataset
with patch.object(reader_module.ds, "dataset", wraps=original) as dataset:
reader_module._file_format_dataset(
self.file_io, "parquet", self.paths[0], DEFAULT_CACHE_SIZE)
reader_module._file_format_dataset(
other_file_io, "parquet", self.paths[0], DEFAULT_CACHE_SIZE)
self.assertEqual(1, dataset.call_count)
def test_does_not_share_across_filesystems(self):
other_file_io = LocalFileIO(self.temp_dir.name, Options({}))
original = reader_module.ds.dataset
with patch.object(reader_module.ds, "dataset", wraps=original) as dataset:
reader_module._file_format_dataset(
self.file_io, "parquet", self.paths[0], DEFAULT_CACHE_SIZE)
reader_module._file_format_dataset(
other_file_io, "parquet", self.paths[0], DEFAULT_CACHE_SIZE)
self.assertEqual(2, dataset.call_count)
def test_does_not_share_across_file_formats(self):
parquet_dataset = object()
orc_dataset = object()
with patch.object(
reader_module.ds, "dataset",
side_effect=[parquet_dataset, orc_dataset]) as dataset:
with patch.object(
reader_module, "_estimate_file_format_dataset_size",
return_value=1):
first = reader_module._file_format_dataset(
self.file_io, "parquet", self.paths[0], DEFAULT_CACHE_SIZE)
second = reader_module._file_format_dataset(
self.file_io, "orc", self.paths[0], DEFAULT_CACHE_SIZE)
self.assertIs(parquet_dataset, first)
self.assertIs(orc_dataset, second)
self.assertEqual(2, dataset.call_count)
def test_cache_key_retains_filesystem_wrapper(self):
root = pafs.LocalFileSystem()
filesystem = pafs.SubTreeFileSystem(self.temp_dir.name, root)
filesystem_ref = weakref.ref(filesystem)
file_io = LocalFileIO(self.temp_dir.name, Options({}))
file_io.filesystem = filesystem
reader_module._file_format_dataset(
file_io, "parquet", os.path.basename(self.paths[0]),
DEFAULT_CACHE_SIZE)
file_io.filesystem = root
del filesystem
gc.collect()
self.assertIsNotNone(filesystem_ref())
def test_filesystem_hash_collision_does_not_share_dataset(self):
first_dir = tempfile.TemporaryDirectory()
second_dir = tempfile.TemporaryDirectory()
try:
file_name = "same.parquet"
pq.write_table(
pa.table({"value": [1]}), os.path.join(first_dir.name, file_name))
pq.write_table(
pa.table({"value": [2]}), os.path.join(second_dir.name, file_name))
file_io = LocalFileIO(first_dir.name, Options({}))
first_filesystem = pafs.SubTreeFileSystem(
first_dir.name, pafs.LocalFileSystem())
second_filesystem = pafs.SubTreeFileSystem(
second_dir.name, pafs.LocalFileSystem())
with patch.object(
reader_module._FilesystemIdentity, "__hash__", return_value=1):
file_io.filesystem = first_filesystem
first = reader_module._file_format_dataset(
file_io, "parquet", file_name,
DEFAULT_CACHE_SIZE).to_table()
file_io.filesystem = second_filesystem
second = reader_module._file_format_dataset(
file_io, "parquet", file_name,
DEFAULT_CACHE_SIZE).to_table()
self.assertEqual([1], first.column("value").to_pylist())
self.assertEqual([2], second.column("value").to_pylist())
finally:
first_dir.cleanup()
second_dir.cleanup()
def test_resets_after_process_change(self):
parent_cache = reader_module._file_format_dataset_cache(DEFAULT_CACHE_SIZE)
with patch.object(reader_module.os, "getpid", return_value=os.getpid() + 1):
child_cache = reader_module._file_format_dataset_cache(DEFAULT_CACHE_SIZE)
self.assertIsNot(parent_cache, child_cache)
def test_coalesces_concurrent_loads(self):
original = reader_module.ds.dataset
def delayed_dataset(*args, **kwargs):
time.sleep(0.05)
return original(*args, **kwargs)
with patch.object(
reader_module.ds, "dataset", side_effect=delayed_dataset) as dataset:
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(
lambda _: self._read(self.paths[0]),
range(8),
))
self.assertEqual(1, dataset.call_count)
self.assertTrue(all(value == list(range(10)) for value in results))
if __name__ == "__main__":
unittest.main()