Skip to content

Commit 9da018c

Browse files
[python] Support structured-data local cache selection (#10225)
1 parent bbad1a0 commit 9da018c

10 files changed

Lines changed: 468 additions & 32 deletions

File tree

‎docs/docs/program-api/file-cache.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ The cache classifies files by path. Set `local-cache.whitelist` to select the fi
5050
| GLOBAL_INDEX | global-index | BTree and full-text index files | Yes |
5151
| BUCKET_INDEX | bucket-index | Hash, deletion vector index files | No |
5252
| DATA | data | Data files (ORC, Parquet, etc.) | No |
53+
| STRUCTURED_DATA | structured-data | Parquet, ORC, Avro, ROW, Mosaic (PyPaimon/Rust) | No |
5354
| FILE_INDEX | file-index | Data-file level bloom filter, bitmap | No |
5455

5556
The default whitelist is `meta,global-index`, and `*` selects every file type. Files rewritten in
@@ -128,12 +129,15 @@ The Java snippet is a method-body fragment; declare or handle the catalog except
128129
| `local-cache.dir` | String | (none) | Directory for storing cached blocks on disk. If not configured, memory cache is used. |
129130
| `local-cache.max-size` | MemorySize | See below | Maximum cached block bytes per cache manager. Least recently used blocks are evicted when the limit is exceeded. |
130131
| `local-cache.block-size` | MemorySize | 1 mb | Block size for caching. Files are logically divided into fixed-size blocks and cached independently. |
131-
| `local-cache.whitelist` | String | meta,global-index | Comma-separated list of file types to cache. Supported values: `meta`, `global-index`, `bucket-index`, `data`, `file-index`, or `*` for all of them. |
132+
| `local-cache.whitelist` | String | meta,global-index | File types to cache (see [Cached File Types](#cached-file-types)); `*` selects all. |
132133

133134
When `local-cache.max-size` is omitted, Java has no configured size limit. PyPaimon uses a 256 MiB
134135
memory limit or a 10 GiB disk limit. Set the option explicitly when you want the same limit across
135136
clients. The limit accounts for cached block bytes, not all reader buffers or process memory.
136137

138+
Use `local-cache.whitelist=meta,global-index,structured-data` to cache structured files without
139+
BLOB or video bodies. PyPaimon's Vortex and Lance readers bypass this cache.
140+
137141
## How It Works
138142

139143
1. The reader requests bytes from an eligible file. The cache maps the byte range to fixed-size

‎paimon-python/pypaimon/common/options/core_options.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,8 +1150,8 @@ class CoreOptions:
11501150
.default_value("meta,global-index")
11511151
.with_description(
11521152
"Comma-separated list of file types to cache. "
1153-
"Supported values: meta, global-index, bucket-index, data, file-index, "
1154-
"or * for all of them."
1153+
"Supported values: meta, global-index, bucket-index, data, "
1154+
"structured-data, file-index, or * for all of them."
11551155
)
11561156
)
11571157

‎paimon-python/pypaimon/execution/python/read/reader/format_avro_reader.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ class FormatAvroReader(RecordBatchReader):
3636
def __init__(self, file_io: FileIO, file_path: str, read_fields: List[str], full_fields: List[DataField],
3737
push_down_predicate: Any, batch_size: int = 1024,
3838
nested_name_paths: Optional[List[List[str]]] = None):
39-
file_path_for_io = file_io.to_filesystem_path(file_path)
40-
self._file = file_io.filesystem.open_input_file(file_path_for_io)
39+
from pypaimon.filesystem.caching_file_io import CachingFileIO
40+
if isinstance(file_io, CachingFileIO) and file_io._is_cacheable(file_path):
41+
self._file = file_io.new_input_stream(file_path)
42+
else:
43+
file_path_for_io = file_io.to_filesystem_path(file_path)
44+
self._file = file_io.filesystem.open_input_file(file_path_for_io)
4145
self._avro_reader = fastavro.reader(self._file)
4246
self._batch_size = batch_size
4347
self._push_down_predicate = push_down_predicate

‎paimon-python/pypaimon/execution/python/read/reader/format_pyarrow_reader.py‎

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,18 @@ def _file_format_metadata_cache_max_size(file_io: FileIO) -> int:
275275
CatalogOptions.FILE_FORMAT_METADATA_CACHE_MAX_SIZE).get_bytes()
276276

277277

278+
def _uses_block_cache(file_io: FileIO, file_path: str) -> bool:
279+
from pypaimon.filesystem.caching_file_io import CachingFileIO
280+
return isinstance(file_io, CachingFileIO) and file_io._is_cacheable(file_path)
281+
282+
283+
def _open_pyarrow_input_file(file_io: FileIO, file_path: str, file_size=None):
284+
if _uses_block_cache(file_io, file_path):
285+
return pa.PythonFile(
286+
file_io._new_input_stream_with_known_size(file_path, file_size), mode='r')
287+
return file_io.filesystem.open_input_file(file_io.to_filesystem_path(file_path))
288+
289+
278290
def _file_format_dataset(file_io: FileIO, file_format: str, file_path: str,
279291
cache_max_size: int,
280292
file_size: Optional[int] = None):
@@ -291,6 +303,11 @@ def _file_format_dataset(file_io: FileIO, file_format: str, file_path: str,
291303
if known_size is not None and register_file_size is not None:
292304
register_file_size(file_path_for_pyarrow, known_size)
293305

306+
if _uses_block_cache(file_io, file_path):
307+
from pypaimon.filesystem.caching_file_system import CachedFileSystemHandler
308+
import pyarrow.fs as pafs
309+
filesystem = pafs.PyFileSystem(CachedFileSystemHandler(file_io, file_path, known_size))
310+
294311
def load():
295312
if file_format == 'parquet':
296313
parquet_format = ds.ParquetFileFormat()
@@ -315,7 +332,8 @@ def load():
315332
file_path_for_pyarrow, format=file_format, filesystem=filesystem)
316333

317334
key = (
318-
_FilesystemIdentity(filesystem), file_format, file_path_for_pyarrow,
335+
_FilesystemIdentity(file_io if _uses_block_cache(file_io, file_path) else filesystem),
336+
file_format, file_path_for_pyarrow,
319337
known_size)
320338
if cache_max_size <= 0:
321339
_reset_file_format_dataset_cache()
@@ -335,8 +353,7 @@ def _orc_schema_with_field_metadata(file_io: FileIO, file_path: str,
335353
return fallback
336354

337355
import pyarrow.orc as orc
338-
source = file_io.filesystem.open_input_file(
339-
file_io.to_filesystem_path(file_path))
356+
source = _open_pyarrow_input_file(file_io, file_path)
340357
try:
341358
metadata = orc.ORCFile(source).metadata
342359
arrow_schema = metadata.get(b"ARROW:schema")
@@ -383,7 +400,7 @@ def __init__(self, file_io: FileIO, file_format: str, file_path: str,
383400
file_path_for_pyarrow = file_io.to_filesystem_path(file_path)
384401
self._row_group_cache = row_group_cache
385402
self._row_group_cache_filesystem = _FilesystemIdentity(
386-
file_io.filesystem)
403+
file_io if _uses_block_cache(file_io, file_path) else file_io.filesystem)
387404
self._row_group_cache_path = file_path_for_pyarrow
388405
cache_max_size = _file_format_metadata_cache_max_size(file_io)
389406
self.dataset = _file_format_dataset(
@@ -561,8 +578,7 @@ def __init__(self, file_io: FileIO, file_format: str, file_path: str,
561578
and self._selected_shared_map_paths)):
562579
import pyarrow.parquet as pq
563580
# ParquetFile(filesystem=...) is unavailable in PyArrow 6.
564-
self._parquet_source = file_io.filesystem.open_input_file(
565-
file_path_for_pyarrow)
581+
self._parquet_source = _open_pyarrow_input_file(file_io, file_path, file_size)
566582
try:
567583
self._parquet_file = pq.ParquetFile(self._parquet_source)
568584
if (self._selected_parquet_row_groups is not None
@@ -581,8 +597,7 @@ def __init__(self, file_io: FileIO, file_format: str, file_path: str,
581597
raise
582598
if file_format == 'orc' and self._selected_shared_map_paths:
583599
import pyarrow.orc as orc
584-
self._orc_source = file_io.filesystem.open_input_file(
585-
file_path_for_pyarrow)
600+
self._orc_source = _open_pyarrow_input_file(file_io, file_path)
586601
self._orc_file = orc.ORCFile(self._orc_source)
587602
if self._exhausted:
588603
self._raw_batches = iter(())

‎paimon-python/pypaimon/filesystem/caching_file_io.py‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"""
2727

2828
import hashlib
29+
import io
2930
import os
3031
import threading
3132
import uuid
@@ -262,7 +263,7 @@ def put_file_size(self, file_path: str, size: int) -> None:
262263
self._file_size_cache[file_path] = size
263264

264265

265-
class CachingInputStream:
266+
class CachingInputStream(io.RawIOBase):
266267
"""Wraps a remote stream with block-level caching."""
267268

268269
def __init__(self, file_io, file_path: str, cache, file_size=None):
@@ -275,6 +276,18 @@ def __init__(self, file_io, file_path: str, cache, file_size=None):
275276
self._io_lock = threading.Lock()
276277
self._remote_supports_pread = None
277278

279+
def readable(self):
280+
return True
281+
282+
def seekable(self):
283+
return True
284+
285+
def readinto(self, buffer):
286+
view = memoryview(buffer).cast('B')
287+
data = self.read(len(view))
288+
view[:len(data)] = data
289+
return len(data)
290+
278291
def _get_file_size(self) -> int:
279292
if self._file_size == -1:
280293
cached = self._cache.get_file_size(self._file_path)
@@ -320,14 +333,14 @@ def read_at(self, nbytes: int, offset: int) -> bytes:
320333
first_block = offset // block_size
321334
last_block = (end - 1) // block_size
322335

323-
result = bytearray()
336+
parts = []
324337
for bi, block_data in self._read_blocks(first_block, last_block):
325338
block_start = bi * block_size
326339
start_in_block = max(offset - block_start, 0)
327340
end_in_block = min(end - block_start, len(block_data))
328-
result.extend(block_data[start_in_block:end_in_block])
341+
parts.append(block_data[start_in_block:end_in_block])
329342

330-
return bytes(result)
343+
return b''.join(parts)
331344

332345
def _read_blocks(self, first: int, last: int):
333346
block_size = self._cache.block_size
@@ -393,6 +406,7 @@ def close(self):
393406
if self._stream is not None:
394407
self._stream.close()
395408
self._stream = None
409+
super().close()
396410

397411
def __enter__(self):
398412
return self
@@ -471,12 +485,17 @@ def wrap_with_caching_if_needed(file_io, options, cache=None):
471485
def properties(self):
472486
return self._delegate.properties
473487

488+
def _is_cacheable(self, path: str):
489+
file_type = FileType.classify(path)
490+
return (self._cache is not None
491+
and file_type in self._whitelist
492+
and not FileType.is_mutable(path))
493+
474494
def new_input_stream(self, path: str):
475495
return self._new_input_stream_with_known_size(path, None)
476496

477497
def _new_input_stream_with_known_size(self, path: str, file_size):
478-
file_type = FileType.classify(path)
479-
if self._cache is None or file_type not in self._whitelist or FileType.is_mutable(path):
498+
if not self._is_cacheable(path):
480499
return self._delegate.new_input_stream(path)
481500
return CachingInputStream(self._delegate, path, self._cache, file_size)
482501

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
import pyarrow as pa
19+
import pyarrow.fs as pafs
20+
21+
22+
class CachedFileSystemHandler(pafs.FileSystemHandler):
23+
"""Route one fragment's input through FileIO while preserving filesystem operations."""
24+
25+
def __init__(self, file_io, path, file_size=None):
26+
self._file_io = file_io
27+
self._path = path
28+
self._delegate = file_io.filesystem
29+
self._filesystem_path = self._delegate.normalize_path(file_io.to_filesystem_path(path))
30+
self._file_size = file_size
31+
32+
def get_type_name(self):
33+
return 'paimon-cached-file'
34+
35+
def open_input_file(self, path):
36+
if self._delegate.normalize_path(path) != self._filesystem_path:
37+
return self._delegate.open_input_file(path)
38+
return pa.PythonFile(
39+
self._file_io._new_input_stream_with_known_size(self._path, self._file_size), mode='r')
40+
41+
def open_input_stream(self, path):
42+
return self.open_input_file(path)
43+
44+
def normalize_path(self, path):
45+
return self._delegate.normalize_path(path)
46+
47+
def get_file_info(self, paths):
48+
return self._delegate.get_file_info(paths)
49+
50+
def get_file_info_selector(self, selector):
51+
return self._delegate.get_file_info(selector)
52+
53+
def create_dir(self, path, recursive):
54+
return self._delegate.create_dir(path, recursive=recursive)
55+
56+
def delete_dir(self, path):
57+
return self._delegate.delete_dir(path)
58+
59+
def delete_dir_contents(self, path, missing_dir_ok):
60+
return self._delegate.delete_dir_contents(path, missing_dir_ok=missing_dir_ok)
61+
62+
def delete_root_dir_contents(self):
63+
return self._delegate.delete_dir_contents("", accept_root_dir=True)
64+
65+
def delete_file(self, path):
66+
return self._delegate.delete_file(path)
67+
68+
def move(self, src, dest):
69+
return self._delegate.move(src, dest)
70+
71+
def copy_file(self, src, dest):
72+
return self._delegate.copy_file(src, dest)
73+
74+
def open_output_stream(self, path, metadata):
75+
return self._delegate.open_output_stream(path, metadata=metadata)
76+
77+
def open_append_stream(self, path, metadata):
78+
return self._delegate.open_append_stream(path, metadata=metadata)

‎paimon-python/pypaimon/tests/caching_file_io_test.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import shutil
2727
import tempfile
2828
import threading
29+
import tracemalloc
2930
import unittest
3031
from concurrent.futures import ThreadPoolExecutor
3132
from unittest.mock import MagicMock, patch
@@ -242,6 +243,25 @@ def test_read_spanning_multiple_blocks(self):
242243
result = stream.read(30) # 90..120, spans blocks 0 and 1
243244
self.assertEqual(data[90:120], result)
244245

246+
def test_large_cached_read_uses_one_result_buffer(self):
247+
block_size = 256 * 1024
248+
data = b'x' * (32 * block_size)
249+
cache = LocalMemoryCacheManager(len(data), block_size=block_size)
250+
for i in range(32):
251+
cache.put_block('test', i, data[i * block_size:(i + 1) * block_size])
252+
for positioned in (False, True):
253+
with self.subTest(positioned=positioned):
254+
stream = CachingInputStream(self._make_file_io(data), 'test', cache)
255+
tracemalloc.start()
256+
try:
257+
result = (stream.read_at(len(data), 0) if positioned else stream.read())
258+
_, peak = tracemalloc.get_traced_memory()
259+
finally:
260+
tracemalloc.stop()
261+
self.assertEqual(data, result)
262+
self.assertEqual(0 if positioned else len(data), stream.tell())
263+
self.assertLess(peak, len(data) * 3 // 2)
264+
245265
def test_cache_hit_avoids_remote_read(self):
246266
data = b"0123456789abcdef"
247267
cache = LocalDiskCacheManager(self.cache_dir, 2 ** 63 - 1, block_size=8)
@@ -472,6 +492,57 @@ def test_custom_whitelist_meta_only(self):
472492
result = caching_io.new_input_stream("global-index-uuid.index")
473493
self.assertNotIsInstance(result, CachingInputStream)
474494

495+
def test_structured_data_cache_preserves_existing_selectors(self):
496+
from pypaimon.filesystem.caching_file_io import LocalMemoryCacheManager
497+
from pypaimon.utils.file_type import FileType
498+
499+
for disk in (False, True):
500+
for whitelist in ('meta,global-index', 'data', 'structured-data',
501+
'meta,global-index,structured-data', '*'):
502+
for name in ('data.parquet', 'data.blob', 'data.orc', 'data.avro',
503+
'data.row', 'data.mosaic', 'data.vortex', 'data.lance',
504+
'data.video', 'data.unknown', 'data.parquet.index',
505+
'snapshot-1', 'snapshot-1.blob'):
506+
with self.subTest(disk=disk, whitelist=whitelist, name=name):
507+
with tempfile.TemporaryDirectory() as directory:
508+
cache = (LocalDiskCacheManager(directory, 1024, block_size=4) if disk
509+
else LocalMemoryCacheManager(1024, block_size=4))
510+
delegate = self._make_delegate({name: b'abcdefgh'})
511+
caching_io = CachingFileIO(
512+
delegate, cache, FileType.parse_whitelist(whitelist))
513+
if whitelist == '*':
514+
cached = True
515+
elif whitelist == 'data':
516+
cached = name.startswith('data.') and not name.endswith('.index')
517+
elif whitelist == 'structured-data':
518+
cached = name in (
519+
'data.parquet', 'data.orc', 'data.avro',
520+
'data.row', 'data.mosaic')
521+
elif whitelist == 'meta,global-index,structured-data':
522+
cached = name in (
523+
'data.parquet', 'data.orc', 'data.avro', 'data.row',
524+
'data.mosaic', 'snapshot-1', 'snapshot-1.blob')
525+
else:
526+
cached = name.startswith('snapshot-')
527+
for _ in range(2):
528+
with caching_io.new_input_stream(name) as stream:
529+
stream.seek(1)
530+
self.assertEqual(b'bcdefg', stream.read(6))
531+
self.assertEqual(1 if cached else 2,
532+
delegate.new_input_stream.call_count)
533+
if not cached:
534+
delegate.get_file_size.assert_not_called()
535+
536+
def test_data_whitelist_still_caches_blob(self):
537+
from pypaimon.utils.file_type import FileType
538+
delegate = self._make_delegate({"data.blob": b"abcdefgh"})
539+
cache = LocalDiskCacheManager(self.cache_dir, 1024, block_size=4)
540+
caching_io = CachingFileIO(delegate, cache, {FileType.DATA})
541+
for _ in range(2):
542+
with caching_io.new_input_stream("data.blob") as stream:
543+
self.assertEqual(b"abcdefgh", stream.read())
544+
delegate.new_input_stream.assert_called_once()
545+
475546
def test_data_file_not_cached(self):
476547
data = b"data content"
477548
delegate = self._make_delegate({"data-abc.orc": data})

0 commit comments

Comments
 (0)