This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 6632646cfd [python] Avoid redundant manifest size lookups (#10310)
6632646cfd is described below
commit 6632646cfdd565691c41392e125daccdbecbfd84
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Oct 5 21:20:53 2026 +0800
[python] Avoid redundant manifest size lookups (#10310)
---
.../pypaimon/filesystem/caching_file_io.py | 15 ++++-
.../pypaimon/manifest/manifest_file_manager.py | 15 ++++-
.../pypaimon/manifest/manifest_list_manager.py | 27 +++++---
.../pypaimon/manifest/manifest_sidecar.py | 5 +-
.../tests/manifest/manifest_manager_test.py | 77 ++++++++++++++++++++++
.../tests/manifest/manifest_sidecar_test.py | 15 ++++-
.../pypaimon/tests/reader_append_only_test.py | 4 +-
.../pypaimon/write/commit/commit_scanner.py | 2 +-
.../write/commit/overwrite_changes_provider.py | 3 +-
paimon-python/pypaimon/write/file_store_commit.py | 16 +++--
10 files changed, 150 insertions(+), 29 deletions(-)
diff --git a/paimon-python/pypaimon/filesystem/caching_file_io.py
b/paimon-python/pypaimon/filesystem/caching_file_io.py
index 3fad543d8d..1ddbb76ee0 100644
--- a/paimon-python/pypaimon/filesystem/caching_file_io.py
+++ b/paimon-python/pypaimon/filesystem/caching_file_io.py
@@ -195,11 +195,11 @@ class LocalDiskCacheManager:
class CachingInputStream:
"""Wraps a remote stream with block-level caching."""
- def __init__(self, file_io, file_path: str, cache):
+ def __init__(self, file_io, file_path: str, cache, file_size=None):
self._file_io = file_io
self._stream = None
self._file_path = file_path
- self._file_size = -1
+ self._file_size = file_size if file_size is not None and file_size > 0
else -1
self._cache = cache
self._pos = 0
self._io_lock = threading.Lock()
@@ -397,10 +397,13 @@ class CachingFileIO(FileIO):
return self._delegate.properties
def new_input_stream(self, path: str):
+ return self._new_input_stream_with_known_size(path, None)
+
+ def _new_input_stream_with_known_size(self, path: str, file_size):
file_type = FileType.classify(path)
if self._cache is None or file_type not in self._whitelist or
FileType.is_mutable(path):
return self._delegate.new_input_stream(path)
- return CachingInputStream(self._delegate, path, self._cache)
+ return CachingInputStream(self._delegate, path, self._cache, file_size)
def new_output_stream(self, path: str):
return self._delegate.new_output_stream(path)
@@ -471,3 +474,9 @@ class CachingFileIO(FileIO):
def close(self):
self._delegate.close()
+
+
+def _open_input_stream_with_known_size(file_io, path, file_size):
+ if isinstance(file_io, CachingFileIO) and file_size is not None and
file_size > 0:
+ return file_io._new_input_stream_with_known_size(path, file_size)
+ return file_io.new_input_stream(path)
diff --git a/paimon-python/pypaimon/manifest/manifest_file_manager.py
b/paimon-python/pypaimon/manifest/manifest_file_manager.py
index 08fac1bf88..27ae7b32d8 100644
--- a/paimon-python/pypaimon/manifest/manifest_file_manager.py
+++ b/paimon-python/pypaimon/manifest/manifest_file_manager.py
@@ -31,6 +31,8 @@ except ImportError: # pragma: no cover - supported fastavro
versions provide th
from datetime import datetime
+from pypaimon.filesystem.caching_file_io import (
+ CachingInputStream, _open_input_stream_with_known_size)
from pypaimon.manifest.manifest_sidecar import (
Query, read_sidecar, read_selected_bytes,
)
@@ -152,7 +154,8 @@ class ManifestFileManager:
early_entry_filter=early_entry_filter,
early_record_filter=early_record_filter,
partition_filter=partition_filter,
- selected_blocks=selected)
+ selected_blocks=selected,
+ file_size=manifest_file.file_size)
def _entry_identifier(e: ManifestEntry) -> tuple:
return (
@@ -196,6 +199,7 @@ class ManifestFileManager:
early_record_filter: Optional[Callable[[dict], bool]] = None,
partition_filter=None,
selected_blocks=None,
+ file_size: Optional[int] = None,
) -> List[ManifestEntry]:
"""
early_entry_filter: ``(bucket, total_buckets) -> bool``, skip before
deserializing _FILE.
@@ -210,10 +214,15 @@ class ManifestFileManager:
entries = []
if selected_blocks is not None:
- avro_bytes = read_selected_bytes(self.file_io, manifest_file_path,
selected_blocks)
+ avro_bytes = read_selected_bytes(
+ self.file_io, manifest_file_path, selected_blocks, file_size)
else:
- with self.file_io.new_input_stream(manifest_file_path) as
input_stream:
+ with _open_input_stream_with_known_size(
+ self.file_io, manifest_file_path, file_size) as
input_stream:
avro_bytes = input_stream.read()
+ if (isinstance(input_stream, CachingInputStream) and file_size
is not None
+ and file_size > 0 and len(avro_bytes) != file_size):
+ raise EOFError('Truncated manifest file
{}'.format(manifest_file_path))
buffer = BytesIO(avro_bytes)
records = _read_manifest_records(
buffer, early_entry_filter, partition_filter,
diff --git a/paimon-python/pypaimon/manifest/manifest_list_manager.py
b/paimon-python/pypaimon/manifest/manifest_list_manager.py
index 70ec8f2ae6..7a8e269d00 100644
--- a/paimon-python/pypaimon/manifest/manifest_list_manager.py
+++ b/paimon-python/pypaimon/manifest/manifest_list_manager.py
@@ -19,6 +19,9 @@ from io import BytesIO
from typing import List, Optional
import fastavro
+
+from pypaimon.filesystem.caching_file_io import (
+ CachingInputStream, _open_input_stream_with_known_size)
from pypaimon.manifest.schema.manifest_file_meta import (
MANIFEST_FILE_META_SCHEMA, ManifestFileMeta)
from pypaimon.manifest.schema.simple_stats import SimpleStats
@@ -45,35 +48,40 @@ class ManifestListManager:
if snapshot is None:
return []
manifest_files = []
- base_manifests = self.read(snapshot.base_manifest_list)
+ base_manifests = self.read(snapshot.base_manifest_list,
snapshot.base_manifest_list_size)
manifest_files.extend(base_manifests)
- delta_manifests = self.read(snapshot.delta_manifest_list)
+ delta_manifests = self.read(snapshot.delta_manifest_list,
snapshot.delta_manifest_list_size)
manifest_files.extend(delta_manifests)
return manifest_files
def read_base(self, snapshot: Snapshot) -> List[ManifestFileMeta]:
"""Read only the base manifest list for the given snapshot."""
- return self.read(snapshot.base_manifest_list)
+ return self.read(snapshot.base_manifest_list,
snapshot.base_manifest_list_size)
def read_delta(self, snapshot: Snapshot) -> List[ManifestFileMeta]:
- return self.read(snapshot.delta_manifest_list)
+ return self.read(snapshot.delta_manifest_list,
snapshot.delta_manifest_list_size)
def read_changelog(self, snapshot: Snapshot) -> List[ManifestFileMeta]:
"""Read changelog manifest files from snapshot, or empty list if
none."""
if snapshot.changelog_manifest_list is None:
return []
- return self.read(snapshot.changelog_manifest_list)
+ return self.read(snapshot.changelog_manifest_list,
snapshot.changelog_manifest_list_size)
- def read(self, manifest_list_name: str) -> List[ManifestFileMeta]:
- return self._read_from_storage(manifest_list_name)
+ def read(self, manifest_list_name: str, file_size: Optional[int] = None)
-> List[ManifestFileMeta]:
+ return self._read_from_storage(manifest_list_name, file_size)
- def _read_from_storage(self, manifest_list_name: str) ->
List[ManifestFileMeta]:
+ def _read_from_storage(self, manifest_list_name: str,
+ file_size: Optional[int] = None) ->
List[ManifestFileMeta]:
"""Read manifest list from storage."""
manifest_files = []
manifest_list_path = f"{self.manifest_path}/{manifest_list_name}"
- with self.file_io.new_input_stream(manifest_list_path) as input_stream:
+ with _open_input_stream_with_known_size(
+ self.file_io, manifest_list_path, file_size) as input_stream:
avro_bytes = input_stream.read()
+ if (isinstance(input_stream, CachingInputStream) and file_size is
not None
+ and file_size > 0 and len(avro_bytes) != file_size):
+ raise EOFError('Truncated manifest list
{}'.format(manifest_list_path))
buffer = BytesIO(avro_bytes)
reader = fastavro.reader(buffer)
for record in reader:
@@ -147,3 +155,4 @@ class ManifestListManager:
except Exception as e:
self.file_io.delete_quietly(list_path)
raise RuntimeError(f"Failed to write manifest list file: {e}")
from e
+ return len(avro_bytes)
diff --git a/paimon-python/pypaimon/manifest/manifest_sidecar.py
b/paimon-python/pypaimon/manifest/manifest_sidecar.py
index 8c1397333c..e6c1704430 100644
--- a/paimon-python/pypaimon/manifest/manifest_sidecar.py
+++ b/paimon-python/pypaimon/manifest/manifest_sidecar.py
@@ -28,6 +28,7 @@ from typing import Tuple
from pyarrow import ArrowCancelled
+from pypaimon.filesystem.caching_file_io import
_open_input_stream_with_known_size
from pypaimon.utils.range import Range
from pypaimon.table.row.generic_row import GenericRowSerializer,
GenericRowDeserializer
@@ -505,14 +506,14 @@ def read_sidecar(file_io, manifest_path, manifest, query,
partition_filter=None,
return None
-def read_selected_bytes(file_io, manifest_path, selected):
+def read_selected_bytes(file_io, manifest_path, selected, file_size=None):
"""Read complete selected blocks with seek; adjacent blocks share one
contiguous span.
The concatenated original header and blocks form a valid Avro OCF. Partial
entries
must not be stored in a cache keyed by the complete manifest.
"""
data = bytearray(selected.header)
- with file_io.new_input_stream(manifest_path) as stream:
+ with _open_input_stream_with_known_size(file_io, manifest_path, file_size)
as stream:
block_position = 0
while block_position < len(selected.blocks):
block = selected.blocks[block_position]
diff --git a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
index 7389ea02da..043e405907 100644
--- a/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
+++ b/paimon-python/pypaimon/tests/manifest/manifest_manager_test.py
@@ -45,6 +45,7 @@ from pypaimon.manifest.schema.simple_stats import SimpleStats
from pypaimon.schema.data_types import AtomicType, DataField
from pypaimon.schema.schema import Schema
from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.commit.commit_scanner import CommitScanner
_EMPTY_ROW = GenericRow([], [])
_EMPTY_STATS = SimpleStats(min_values=_EMPTY_ROW, max_values=_EMPTY_ROW,
null_counts=[])
@@ -725,6 +726,82 @@ class ManifestListManagerTest(_ManifestManagerSetup):
self.assertEqual(len(result), 1)
self.assertEqual(result[0].file_name, "manifest-base.avro")
+ def test_scan_uses_manifest_sizes_without_file_status(self):
+ for disk in (False, True):
+ with self.subTest(disk=disk), tempfile.TemporaryDirectory() as
warehouse:
+ options = {'warehouse': warehouse, 'local-cache.enabled':
'true',
+ 'local-cache.block-size': '64 b'}
+ if disk:
+ options['local-cache.dir'] = os.path.join(warehouse,
'cache')
+
+ catalog = FileSystemCatalog(Options(options))
+ catalog.create_database('default', False)
+ schema = Schema.from_pyarrow_schema(pa.schema([('v',
pa.int32())]))
+ catalog.create_table('default.t', schema, False)
+ table = catalog.get_table('default.t')
+ builder = table.new_batch_write_builder()
+ writer, commit = builder.new_write(), builder.new_commit()
+ try:
+ writer.write_arrow(pa.table({'v': pa.array([1, 2, 3],
type=pa.int32())}))
+ commit.commit(writer.prepare_commit())
+ finally:
+ writer.close()
+ commit.close()
+
+ snapshot = table.snapshot_manager().get_latest_snapshot()
+ for name, size in ((snapshot.base_manifest_list,
+ snapshot.base_manifest_list_size),
+ (snapshot.delta_manifest_list,
+ snapshot.delta_manifest_list_size)):
+ self.assertGreater(size, 0)
+ self.assertEqual(size, table.file_io.get_file_size(
+ '{}/manifest/{}'.format(table.table_path, name)))
+
+ # A fresh catalog has no in-process file-size cache.
+ fresh_catalog = FileSystemCatalog(Options(options))
+ fresh_table = fresh_catalog.get_table('default.t')
+ delegate = fresh_table.file_io._delegate
+ with mock.patch.object(delegate, 'get_file_size',
+ wraps=delegate.get_file_size) as
get_size:
+ splits =
fresh_table.new_read_builder().new_scan().plan().splits()
+ self.assertTrue(splits)
+ self.assertEqual(
+
fresh_table.new_read_builder().new_read().to_arrow(splits).column(
+ 'v').to_pylist(), [1, 2, 3])
+ self.assertFalse([call for call in get_size.call_args_list
+ if '/manifest/' in call[0][0]])
+
+ # Conflict retries read raw delta manifests through
CommitScanner.
+ retry_catalog = FileSystemCatalog(Options(options))
+ retry_table = retry_catalog.get_table('default.t')
+ retry_delegate = retry_table.file_io._delegate
+ scanner = CommitScanner(retry_table,
ManifestListManager(retry_table))
+ with mock.patch.object(retry_delegate, 'get_file_size',
+ wraps=retry_delegate.get_file_size) as
get_size:
+ entries =
scanner.read_incremental_raw_entries_from_changed_partitions(
+ snapshot, [])
+ self.assertTrue(entries)
+ self.assertFalse([call for call in get_size.call_args_list
+ if '/manifest/' in call[0][0]])
+
+ # Old snapshots without list sizes retain the status lookup.
+ legacy = replace(snapshot, base_manifest_list_size=None,
+ delta_manifest_list_size=None)
+ legacy_catalog = FileSystemCatalog(Options(options))
+ legacy_table = legacy_catalog.get_table('default.t')
+ delegate = legacy_table.file_io._delegate
+ with mock.patch.object(delegate, 'get_file_size',
+ wraps=delegate.get_file_size) as
get_size:
+
self.assertTrue(ManifestListManager(legacy_table).read_all(legacy))
+ self.assertEqual(len([call for call in get_size.call_args_list
+ if '/manifest/' in call[0][0]]), 2)
+
+ wrong_size_catalog = FileSystemCatalog(Options(options))
+ wrong_size_table = wrong_size_catalog.get_table('default.t')
+ with self.assertRaisesRegex(EOFError, 'Truncated manifest
list'):
+ ManifestListManager(wrong_size_table).read(
+ snapshot.base_manifest_list,
snapshot.base_manifest_list_size + 1)
+
@unittest.skipIf(
sys.version_info < (3, 9),
'PyPI backports.zstd only supports Python 3.9–3.13',
diff --git a/paimon-python/pypaimon/tests/manifest/manifest_sidecar_test.py
b/paimon-python/pypaimon/tests/manifest/manifest_sidecar_test.py
index 405c4c92b1..868b1518cd 100644
--- a/paimon-python/pypaimon/tests/manifest/manifest_sidecar_test.py
+++ b/paimon-python/pypaimon/tests/manifest/manifest_sidecar_test.py
@@ -32,7 +32,7 @@ from unittest.mock import Mock, call, patch
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.options.options import Options
-from pypaimon.filesystem.caching_file_io import CachingFileIO
+from pypaimon.filesystem.caching_file_io import CachingFileIO,
LocalMemoryCacheManager
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.manifest import manifest_sidecar
from pypaimon.manifest.manifest_sidecar import (
@@ -131,6 +131,19 @@ class FailingIndexInput(BytesIO):
class ManifestSidecarReadTest(unittest.TestCase):
+ def test_selected_manifest_blocks_use_known_size(self):
+ data = b'headfoo'
+ delegate = SimpleNamespace(
+ new_input_stream=Mock(side_effect=lambda path: BytesIO(data)),
+ get_file_size=Mock(side_effect=AssertionError('source stat')))
+ file_io = CachingFileIO(delegate, LocalMemoryCacheManager(1024,
block_size=4))
+ selection = Selection(b'head', (Block(4, 3, 0, 1),))
+
+ self.assertEqual(
+ read_selected_bytes(file_io, '/manifest/manifest-1', selection,
len(data)),
+ data)
+ delegate.get_file_size.assert_not_called()
+
def test_local_cache_shares_sidecar_bytes_across_queries_and_readers(self):
data, meta = golden(), golden_meta()
meta.extra_files = ['custom' + SUFFIX]
diff --git a/paimon-python/pypaimon/tests/reader_append_only_test.py
b/paimon-python/pypaimon/tests/reader_append_only_test.py
index 032b7fdc94..dccc884b63 100644
--- a/paimon-python/pypaimon/tests/reader_append_only_test.py
+++ b/paimon-python/pypaimon/tests/reader_append_only_test.py
@@ -1125,7 +1125,7 @@ class AoReaderTest(unittest.TestCase):
manifest_entry_filter=None,
drop_stats=True, early_entry_filter=None,
early_record_filter=None, partition_filter=None,
- selected_blocks=None):
+ selected_blocks=None, file_size=None):
# avro_total = every entry in the manifest (no manifest-file
pruning
# here: single file, is_in spans its partition stats).
path = f"{self_mgr.manifest_path}/{manifest_file_name}"
@@ -1136,7 +1136,7 @@ class AoReaderTest(unittest.TestCase):
self_mgr, manifest_file_name,
manifest_entry_filter, drop_stats,
early_entry_filter, early_record_filter, partition_filter,
- selected_blocks=selected_blocks)
+ selected_blocks=selected_blocks, file_size=file_size)
def counting_dfm_init(self_dfm, *args, **kwargs):
entry_counts['constructed'] += 1
diff --git a/paimon-python/pypaimon/write/commit/commit_scanner.py
b/paimon-python/pypaimon/write/commit/commit_scanner.py
index 77a7c9c74b..b08bafcb81 100644
--- a/paimon-python/pypaimon/write/commit/commit_scanner.py
+++ b/paimon-python/pypaimon/write/commit/commit_scanner.py
@@ -121,7 +121,7 @@ class CommitScanner:
mfm = ManifestFileManager(self.table)
entries = []
for mf in delta_manifests:
- for entry in mfm.read(mf.file_name):
+ for entry in mfm.read(mf.file_name, file_size=mf.file_size):
if partition_filter is not None and not
partition_filter.test(entry.partition):
continue
entries.append(entry)
diff --git a/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
b/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
index 94241aaed7..31e969ab7b 100644
--- a/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
+++ b/paimon-python/pypaimon/write/commit/overwrite_changes_provider.py
@@ -111,7 +111,8 @@ class OverwriteChangesProvider:
# snapshots are applied instead of being discarded by FileScanner.
entries = []
for manifest_file in delta_manifests:
- for entry in manifest_file_manager.read(manifest_file.file_name):
+ for entry in manifest_file_manager.read(
+ manifest_file.file_name,
file_size=manifest_file.file_size):
if (self.partition_filter is not None
and not self.partition_filter.test(entry.partition)):
continue
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index e46346e5eb..5259be2461 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -682,7 +682,8 @@ class FileStoreCommit:
changelog_record_count = None
try:
new_manifest_file_metas =
self._write_manifest_files(commit_entries, new_manifest_file)
- self.manifest_list_manager.write(delta_manifest_list,
new_manifest_file_metas)
+ delta_manifest_list_size = self.manifest_list_manager.write(
+ delta_manifest_list, new_manifest_file_metas)
# Write changelog manifest if changelog entries exist
if changelog_entries:
@@ -690,11 +691,8 @@ class FileStoreCommit:
changelog_manifest_file_metas = self._write_manifest_files(
changelog_entries, changelog_manifest_file)
changelog_manifest_list_name =
f"manifest-list-{unique_id}-changelog"
- self.manifest_list_manager.write(
+ changelog_manifest_list_size =
self.manifest_list_manager.write(
changelog_manifest_list_name,
changelog_manifest_file_metas)
- manifest_path = self.manifest_list_manager.manifest_path
- changelog_manifest_list_size =
self.table.file_io.get_file_size(
- f"{manifest_path}/{changelog_manifest_list_name}")
# kind==0 means ADD; pypaimon producers only support additions
currently
changelog_record_count = sum(
entry.file.row_count for entry in changelog_entries if
entry.kind == 0)
@@ -709,7 +707,8 @@ class FileStoreCommit:
if previous_record_count:
total_record_count += previous_record_count
- self.manifest_list_manager.write(base_manifest_list,
existing_manifests)
+ base_manifest_list_size = self.manifest_list_manager.write(
+ base_manifest_list, existing_manifests)
delta_record_count = 0
for entry in commit_entries:
@@ -733,7 +732,9 @@ class FileStoreCommit:
id=new_snapshot_id,
schema_id=self.table.table_schema.id,
base_manifest_list=base_manifest_list,
+ base_manifest_list_size=base_manifest_list_size,
delta_manifest_list=delta_manifest_list,
+ delta_manifest_list_size=delta_manifest_list_size,
changelog_manifest_list=changelog_manifest_list_name,
changelog_manifest_list_size=changelog_manifest_list_size,
changelog_record_count=changelog_record_count,
@@ -900,7 +901,8 @@ class FileStoreCommit:
for manifest in self.manifest_list_manager.read_delta(
snapshot):
entries.extend(self.manifest_file_manager.read(
- manifest.file_name, drop_stats=False))
+ manifest.file_name, drop_stats=False,
+ file_size=manifest.file_size))
path_factory = self.table.path_factory()
for entry in entries:
file = entry.file