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 633bb37a84 [python] Remove manifest merging from commits (#10082)
633bb37a84 is described below
commit 633bb37a849f2df99d6d93306aaa3fb61de073ec
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 22 15:19:54 2026 +0800
[python] Remove manifest merging from commits (#10082)
---
docs/docs/pypaimon/writing.md | 17 +-
.../pypaimon/common/options/core_options.py | 39 ---
.../pypaimon/manifest/manifest_file_merger.py | 100 -------
.../pypaimon/tests/file_store_commit_test.py | 330 ++++-----------------
.../manifest/manifest_entry_identifier_test.py | 47 ---
.../pypaimon/tests/test_early_row_range_filter.py | 27 +-
.../pypaimon/tests/write/table_write_test.py | 36 +--
paimon-python/pypaimon/write/file_store_commit.py | 154 +---------
8 files changed, 110 insertions(+), 640 deletions(-)
diff --git a/docs/docs/pypaimon/writing.md b/docs/docs/pypaimon/writing.md
index e1d79ce544..68d2c8aa0f 100644
--- a/docs/docs/pypaimon/writing.md
+++ b/docs/docs/pypaimon/writing.md
@@ -95,16 +95,13 @@ compatibility when upgrading; PyArrow reads do not
currently use these indexes.
### Manifest Merging
-`manifest.merge.skip-on-write-only` defaults to `false` in both Python and
Java,
-so commits keep their automatic manifest merging behavior. Set both this option
-and `write-only` to `true` to retain existing manifest files during commit and
-avoid the cost of reading and rewriting them. This option has no effect when
-`write-only=false`, which is also the default.
-
-Python supports minor manifest compaction, using `manifest.merge-min-count` and
-`manifest.target-file-size`. Python does not support manifest sort rewrite.
-In Java, skipping automatic manifest merging also skips automatic manifest sort
-rewrite; explicit manifest compaction remains available.
+Python commits retain existing manifest files without merging or rewriting
them,
+including during commit retries. Run manifest compaction through a centralized
+maintenance service, for example using the Java engines' `compact_manifest`
procedure.
+
+The table options `manifest.merge-min-count`,
`manifest.merge.skip-on-write-only`,
+and `write-only` do not enable manifest merging in Python.
+`manifest.target-file-size` still controls the size of newly written manifest
files.
### Commit Callback
diff --git a/paimon-python/pypaimon/common/options/core_options.py
b/paimon-python/pypaimon/common/options/core_options.py
index 674cc1dcf6..54bd771310 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -285,16 +285,6 @@ class CoreOptions:
)
)
- WRITE_ONLY: ConfigOption[bool] = (
- ConfigOptions.key("write-only")
- .boolean_type()
- .default_value(False)
- .with_description(
- "Whether to use write-only mode. Automatic manifest merging is
skipped "
- "when both this option and manifest.merge.skip-on-write-only are
true."
- )
- )
-
SCAN_MANIFEST_PARALLELISM: ConfigOption[int] = (
ConfigOptions.key("scan.manifest.parallelism")
.int_type()
@@ -330,26 +320,6 @@ class CoreOptions:
.with_description("Suggested file size of a manifest file.")
)
- MANIFEST_MERGE_SKIP_ON_WRITE_ONLY: ConfigOption[bool] = (
- ConfigOptions.key("manifest.merge.skip-on-write-only")
- .boolean_type()
- .default_value(False)
- .with_description(
- "Whether to skip automatic manifest merging during commit when
write-only is true. "
- "Python only supports minor manifest compaction, without manifest
sort rewrite."
- )
- )
-
- MANIFEST_MERGE_MIN_COUNT: ConfigOption[int] = (
- ConfigOptions.key("manifest.merge-min-count")
- .int_type()
- .default_value(30)
- .with_description(
- "To avoid frequent manifest merges, this parameter specifies the
minimum number "
- "of ManifestFileMeta to merge."
- )
- )
-
# File format options
PARQUET_WRITE_PAGE_INDEX_ENABLED: ConfigOption[bool] = (
ConfigOptions.key("parquet.write-page-index.enabled")
@@ -1329,9 +1299,6 @@ class CoreOptions:
CoreOptions.POSTPONE_TARGET_SIZE_PER_BUCKET, default
).get_bytes()
- def write_only(self, default=None):
- return self.options.get(CoreOptions.WRITE_ONLY, default)
-
def scan_manifest_parallelism(self, default=None):
return self.options.get(CoreOptions.SCAN_MANIFEST_PARALLELISM, default)
@@ -1350,12 +1317,6 @@ class CoreOptions:
def manifest_sort_enabled(self):
return self.options.get(CoreOptions.MANIFEST_SORT_ENABLED)
- def manifest_merge_skip_on_write_only(self, default=None):
- return self.options.get(CoreOptions.MANIFEST_MERGE_SKIP_ON_WRITE_ONLY,
default)
-
- def manifest_merge_min_count(self, default=None):
- return self.options.get(CoreOptions.MANIFEST_MERGE_MIN_COUNT, default)
-
def file_format(self, default=None):
return self.options.get(CoreOptions.FILE_FORMAT, default)
diff --git a/paimon-python/pypaimon/manifest/manifest_file_merger.py
b/paimon-python/pypaimon/manifest/manifest_file_merger.py
deleted file mode 100644
index 821b12aef5..0000000000
--- a/paimon-python/pypaimon/manifest/manifest_file_merger.py
+++ /dev/null
@@ -1,100 +0,0 @@
-# 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 uuid
-from typing import List, Tuple
-
-from pypaimon.manifest.schema.file_entry import FileEntry
-from pypaimon.manifest.schema.manifest_file_meta import ManifestFileMeta
-
-
-class ManifestFileMerger:
- """Minor manifest compaction for Python commits.
-
- This intentionally implements only the minor compaction path from Java
- ManifestFileMerger. It does not do full compaction or manifest sort
rewrite.
- """
-
- def __init__(self, manifest_file_manager, suggested_meta_size: int,
- suggested_min_meta_count: int):
- self.manifest_file_manager = manifest_file_manager
- self.suggested_meta_size = suggested_meta_size
- self.suggested_min_meta_count = suggested_min_meta_count
-
- def merge(self, manifest_files: List[ManifestFileMeta]) ->
Tuple[List[ManifestFileMeta],
-
List[ManifestFileMeta]]:
- new_files = []
- try:
- return self._try_minor_compaction(manifest_files, new_files),
new_files
- except Exception:
- self._delete_manifests(new_files)
- raise
-
- def _try_minor_compaction(self, manifest_files: List[ManifestFileMeta],
- new_files: List[ManifestFileMeta]) ->
List[ManifestFileMeta]:
- result = []
- candidates = []
- total_size = 0
-
- for manifest in manifest_files:
- total_size += manifest.file_size
- candidates.append(manifest)
- if total_size >= self.suggested_meta_size:
- self._merge_candidates(candidates, result, new_files)
- candidates = []
- total_size = 0
-
- if len(candidates) >= self.suggested_min_meta_count:
- self._merge_candidates(candidates, result, new_files)
- else:
- result.extend(candidates)
-
- return result
-
- def _merge_candidates(self, candidates: List[ManifestFileMeta],
- result: List[ManifestFileMeta],
- new_files: List[ManifestFileMeta]):
- if len(candidates) == 1:
- result.append(candidates[0])
- return
-
- entries = []
- for manifest in candidates:
- entries.extend(
- self.manifest_file_manager.read(
- manifest.file_name,
- drop_stats=False,
- )
- )
-
- merged_entries = FileEntry.merge_entries(entries)
- if not merged_entries:
- return
-
- manifest_file = "manifest-{}".format(str(uuid.uuid4()))
- merged_metas = self.manifest_file_manager.rolling_write(
- merged_entries, self.suggested_meta_size, manifest_file)
- result.extend(merged_metas)
- new_files.extend(merged_metas)
-
- def _delete_manifests(self, manifests: List[ManifestFileMeta]):
- for manifest in manifests:
- manifest_path = "{}/{}".format(
- self.manifest_file_manager.manifest_path,
- manifest.file_name,
- )
- self.manifest_file_manager.file_io.delete_quietly(manifest_path)
diff --git a/paimon-python/pypaimon/tests/file_store_commit_test.py
b/paimon-python/pypaimon/tests/file_store_commit_test.py
index 1daefe1fde..649812d7fd 100644
--- a/paimon-python/pypaimon/tests/file_store_commit_test.py
+++ b/paimon-python/pypaimon/tests/file_store_commit_test.py
@@ -35,11 +35,9 @@ from pypaimon.write.commit_message import CommitMessage
from pypaimon.write.file_store_commit import (
CommitFailRetryResult,
FileStoreCommit,
- ManifestMergeResult,
RollbackRetryResult,
RewriteResult,
_abort_commit_messages,
- _try_replace_manifest_files,
)
@@ -69,9 +67,6 @@ class TestFileStoreCommitRowTracking(unittest.TestCase):
self.mock_table.table_path = '/test/table/path'
self.mock_table.file_io = Mock()
self.mock_table.options.manifest_target_size.return_value = 8 * 1024 *
1024
- self.mock_table.options.manifest_merge_min_count.return_value = 30
- self.mock_table.options.write_only.return_value = False
- self.mock_table.options.manifest_merge_skip_on_write_only.return_value
= False
self.mock_snapshot_commit = Mock()
def _create_file_store_commit(self):
@@ -191,8 +186,6 @@ class TestFileStoreCommitRowTracking(unittest.TestCase):
snapshot_commit.commit.return_value = True
file_store_commit.snapshot_commit = snapshot_commit
file_store_commit.manifest_list_manager.read_all.return_value = []
- file_store_commit.manifest_file_merger = Mock()
- file_store_commit.manifest_file_merger.merge.return_value = ([], [])
file_store_commit._generate_partition_statistics = Mock(
return_value=[])
@@ -274,9 +267,6 @@ class TestFileStoreCommit(unittest.TestCase):
self.mock_table.table_path = '/test/table/path'
self.mock_table.file_io = Mock()
self.mock_table.options.manifest_target_size.return_value = 8 * 1024 *
1024
- self.mock_table.options.manifest_merge_min_count.return_value = 30
- self.mock_table.options.write_only.return_value = False
- self.mock_table.options.manifest_merge_skip_on_write_only.return_value
= False
# Mock snapshot commit
self.mock_snapshot_commit = Mock()
@@ -302,92 +292,6 @@ class TestFileStoreCommit(unittest.TestCase):
schema_id=0,
)
- def test_replace_manifest_files_uses_stable_value_equality(
- self, mock_manifest_list_manager, mock_manifest_file_manager):
- previous = [self._manifest_meta('a'), self._manifest_meta('b')]
- current = [
- self._manifest_meta('prefix'),
- self._manifest_meta('a'),
- self._manifest_meta('b'),
- self._manifest_meta('suffix'),
- ]
- replacement = [self._manifest_meta('merged')]
- previous[0].extra_files = ['a.avro.sidecar']
- current[1].extra_files = ['a.avro.sidecar']
-
- result = _try_replace_manifest_files(
- current, previous, replacement)
-
- self.assertEqual(
- ['prefix', 'merged', 'suffix'],
- [manifest.file_name for manifest in result],
- )
- self.assertIsNot(current[1], previous[0])
-
- def test_replace_manifest_files_rejects_changed_metadata(
- self, mock_manifest_list_manager, mock_manifest_file_manager):
- previous = self._manifest_meta('before')
- replacement = [self._manifest_meta('merged')]
- changes = {
- 'min_bucket': 0,
- 'max_bucket': 3,
- 'min_level': 0,
- 'max_level': 2,
- 'total_buckets': 4,
- 'extra_files': ['before.avro.sidecar'],
- }
- for field, value in changes.items():
- with self.subTest(field=field):
- current = replace(previous, **{field: value})
- self.assertIsNone(_try_replace_manifest_files(
- [current], [previous], replacement))
-
- for old, new in [(None, []), ([], ['sidecar']), (['old'], ['new']),
- (['a', 'b'], ['b', 'a'])]:
- with self.subTest(old=old, new=new):
- self.assertIsNone(_try_replace_manifest_files(
- [replace(previous, extra_files=new)],
- [replace(previous, extra_files=old)], replacement))
-
- def test_replace_manifest_files_preserves_order_and_empty_semantics(
- self, mock_manifest_list_manager, mock_manifest_file_manager):
- a = self._manifest_meta('a')
- b = self._manifest_meta('b')
- merged = self._manifest_meta('merged')
-
- self.assertIsNone(_try_replace_manifest_files(
- [a, self._manifest_meta('x'), b], [a, b], [merged]))
- self.assertEqual(
- ['a', 'merged'],
- [manifest.file_name for manifest in _try_replace_manifest_files(
- [a, self._manifest_meta('a'), b], [a, b], [merged])],
- )
- self.assertEqual(
- [merged], _try_replace_manifest_files([], [], [merged]))
- self.assertIsNone(_try_replace_manifest_files([a], [], [merged]))
-
- def test_manifest_merge_result_copies_and_freezes_lists(
- self, mock_manifest_list_manager, mock_manifest_file_manager):
- before = [self._manifest_meta('before')]
- after = [self._manifest_meta('after')]
-
- result = ManifestMergeResult(before, after)
- before.clear()
- after.clear()
-
- self.assertIsInstance(result.merge_before_manifests, tuple)
- self.assertIsInstance(result.merge_after_manifests, tuple)
- self.assertEqual(
- ['before'],
- [manifest.file_name
- for manifest in result.merge_before_manifests],
- )
- self.assertEqual(
- ['after'],
- [manifest.file_name
- for manifest in result.merge_after_manifests],
- )
-
def test_conflict_rollback_retry_skips_history_and_rescans_base(
self, mock_manifest_list_manager, mock_manifest_file_manager):
file_store_commit = self._create_file_store_commit()
@@ -462,7 +366,6 @@ class TestFileStoreCommit(unittest.TestCase):
def _run_manifest_commit_attempt(self, commit_side_effect=None,
commit_result=None, retry_result=None,
existing_manifests=None,
- merged_manifests=None,
latest_watermark=None):
file_store_commit = self._create_file_store_commit()
self.mock_table.identifier = 'default.test_table'
@@ -477,13 +380,9 @@ class TestFileStoreCommit(unittest.TestCase):
file_store_commit.snapshot_commit = snapshot_commit
before = self._manifest_meta('before')
- after = self._manifest_meta('after')
existing_manifests = (
[before] if existing_manifests is None
else existing_manifests)
- merged_manifests = (
- [after] if merged_manifests is None
- else merged_manifests)
delta = self._manifest_meta('delta')
file_store_commit._write_manifest_files = Mock(
return_value=[delta])
@@ -491,11 +390,6 @@ class TestFileStoreCommit(unittest.TestCase):
return_value=[])
file_store_commit.manifest_list_manager.read_all.return_value = (
existing_manifests)
- file_store_commit.manifest_file_merger = Mock()
- file_store_commit.manifest_file_merger.merge.return_value = (
- merged_manifests, merged_manifests)
- file_store_commit._clean_up_reuse_tmp_manifests = Mock()
- file_store_commit._clean_up_no_reuse_tmp_manifests = Mock()
latest_snapshot = Mock(
id=3,
@@ -528,109 +422,73 @@ class TestFileStoreCommit(unittest.TestCase):
file_store_commit.snapshot_commit.commit.call_args[0][1])
self.assertEqual(123, committed_snapshot.watermark)
- def test_false_atomic_commit_retains_manifest_merge_result(
+ def test_commit_retries_preserve_latest_manifests(
self, mock_manifest_list_manager, mock_manifest_file_manager):
- file_store_commit, result = self._run_manifest_commit_attempt(
- commit_result=False)
-
- self.assertIsInstance(result, CommitFailRetryResult)
- self.assertIsNone(result.exception)
- self.assertEqual(
- ['before'],
- [manifest.file_name for manifest
- in result.manifest_merge_result.merge_before_manifests],
- )
- self.assertEqual(
- ['after'],
- [manifest.file_name for manifest
- in result.manifest_merge_result.merge_after_manifests],
- )
- file_store_commit.manifest_file_merger.merge.assert_called_once()
-
- def test_disabled_manifest_merge_preserves_manifests_on_retry(
- self, mock_manifest_list_manager, mock_manifest_file_manager):
- options = CoreOptions(Options({
- 'write-only': 'true',
- 'manifest.merge.skip-on-write-only': 'true',
- }))
- self.mock_table.options.write_only.side_effect = options.write_only
- self.mock_table.options.manifest_merge_skip_on_write_only.side_effect
= (
- options.manifest_merge_skip_on_write_only)
- current = [self._manifest_meta('before-a'),
self._manifest_meta('before-b')]
- first_commit, retry_result = self._run_manifest_commit_attempt(
- commit_result=False, existing_manifests=current)
-
- self.assertIsInstance(retry_result, CommitFailRetryResult)
- self.assertIsNone(retry_result.manifest_merge_result)
- first_commit.manifest_file_merger.merge.assert_not_called()
- self.assertEqual(
- current,
first_commit.manifest_list_manager.write.call_args_list[-1].args[1])
-
- current.append(self._manifest_meta('concurrent'))
- retry_commit, result = self._run_manifest_commit_attempt(
- commit_result=True, retry_result=retry_result,
existing_manifests=current)
-
- self.assertTrue(result.is_success())
- retry_commit.manifest_file_merger.merge.assert_not_called()
- self.assertEqual(
- current,
retry_commit.manifest_list_manager.write.call_args_list[-1].args[1])
-
- def test_atomic_commit_exception_does_not_retain_manifest_merge_result(
+ retry_result = None
+ attempts = [
+ ['before-a', 'before-b'],
+ ['before-a', 'before-b', 'concurrent'],
+ # External maintenance may replace the manifests between attempts.
+ ['compacted', 'concurrent'],
+ ]
+ for i, names in enumerate(attempts):
+ current = [self._manifest_meta(name) for name in names]
+ file_store_commit, retry_result =
self._run_manifest_commit_attempt(
+ commit_result=i == len(attempts) - 1,
+ retry_result=retry_result,
+ existing_manifests=current,
+ )
+ snapshot = file_store_commit.snapshot_commit.commit.call_args[0][1]
+ file_store_commit.manifest_list_manager.write.assert_any_call(
+ snapshot.base_manifest_list, current)
+ self.assertEqual(12, snapshot.total_record_count)
+ self.assertEqual(2, snapshot.delta_record_count)
+ self.mock_table.file_io.delete_quietly.assert_not_called()
+ if i < len(attempts) - 1:
+ self.assertIsInstance(retry_result, CommitFailRetryResult)
+ self.assertFalse(retry_result.commit_result_may_be_uncertain)
+ self.assertIsNone(retry_result.exception)
+ else:
+ self.assertTrue(retry_result.is_success())
+
+ def test_atomic_commit_exception_preserves_manifest_files(
self, mock_manifest_list_manager, mock_manifest_file_manager):
failure = TimeoutError('lost commit response')
- file_store_commit, result = self._run_manifest_commit_attempt(
+ _, result = self._run_manifest_commit_attempt(
commit_side_effect=failure)
self.assertIsInstance(result, CommitFailRetryResult)
self.assertIs(failure, result.exception)
self.assertTrue(result.commit_result_may_be_uncertain)
- self.assertIsNone(result.manifest_merge_result)
- file_store_commit._clean_up_reuse_tmp_manifests.assert_not_called()
- file_store_commit._clean_up_no_reuse_tmp_manifests.assert_not_called()
+ self.mock_table.file_io.delete_quietly.assert_not_called()
- def test_retry_reuses_manifest_merge_and_preserves_surrounding_files(
+ def test_prepare_failure_cleans_new_files_and_preserves_base_manifests(
self, mock_manifest_list_manager, mock_manifest_file_manager):
- previous_before = [
- self._manifest_meta('before-a'),
- self._manifest_meta('before-b'),
- ]
- previous_after = [self._manifest_meta('merged')]
- retry_result = CommitFailRetryResult(
- Mock(id=3),
- manifest_merge_result=ManifestMergeResult(
- previous_before, previous_after),
- )
- current = [
- self._manifest_meta('prefix'),
- self._manifest_meta('before-a'),
- self._manifest_meta('before-b'),
- self._manifest_meta('suffix'),
- ]
+ manager = mock_manifest_list_manager.return_value
+ manager.manifest_path = '/table/manifest'
+ mock_manifest_file_manager.return_value.manifest_path =
'/table/manifest'
+ base = self._manifest_meta('base')
+ base.extra_files = ['base.sidecar']
+ delta = self._manifest_meta('delta')
+ lists = {}
- file_store_commit, result = self._run_manifest_commit_attempt(
- commit_result=False,
- retry_result=retry_result,
- existing_manifests=current,
- )
+ def write_list(name, manifests):
+ lists[name] = manifests
+ if name.endswith('-0'):
+ raise OSError('base manifest list write failed')
- self.assertIsInstance(result, CommitFailRetryResult)
- self.assertEqual(
- ['prefix', 'before-a', 'before-b', 'suffix'],
- [manifest.file_name for manifest
- in result.manifest_merge_result.merge_before_manifests],
- )
- self.assertEqual(
- ['prefix', 'merged', 'suffix'],
- [manifest.file_name for manifest
- in result.manifest_merge_result.merge_after_manifests],
- )
- file_store_commit.manifest_file_merger.merge.assert_not_called()
- base_manifests = (
- file_store_commit.manifest_list_manager.write
- .call_args_list[-1].args[1])
+ manager.write.side_effect = write_list
+ manager.read.side_effect = lambda name: lists[name]
+ with self.assertRaisesRegex(RuntimeError, 'base manifest list write
failed'):
+ self._run_manifest_commit_attempt(existing_manifests=[base])
+
+ deleted = {
+ args[0] for args, _ in
self.mock_table.file_io.delete_quietly.call_args_list
+ }
self.assertEqual(
- ['prefix', 'merged', 'suffix'],
- [manifest.file_name for manifest in base_manifests],
+ {'/table/manifest/' + name for name in lists}
+ | {'/table/manifest/' + delta.file_name},
+ deleted,
)
def test_retry_preserves_concurrently_added_manifest_sidecar(
@@ -639,7 +497,6 @@ class TestFileStoreCommit(unittest.TestCase):
_, retry_result = self._run_manifest_commit_attempt(
commit_result=False,
existing_manifests=[previous],
- merged_manifests=[previous],
)
# A concurrent commit attaches a sidecar to the same manifest file.
current = replace(previous, extra_files=['before.avro.sidecar'])
@@ -650,91 +507,12 @@ class TestFileStoreCommit(unittest.TestCase):
)
self.assertTrue(result.is_success())
- file_store_commit.manifest_file_merger.merge.assert_not_called()
base_manifests = (
file_store_commit.manifest_list_manager.write
.call_args_list[-1][0][1])
self.assertEqual([current], base_manifests)
self.assertEqual(['before.avro.sidecar'],
base_manifests[0].extra_files)
- def test_retry_skips_manifest_merge_when_previous_input_is_not_contiguous(
- self, mock_manifest_list_manager, mock_manifest_file_manager):
- previous_before = [
- self._manifest_meta('before-a'),
- self._manifest_meta('before-b'),
- ]
- retry_result = CommitFailRetryResult(
- Mock(id=3),
- manifest_merge_result=ManifestMergeResult(
- previous_before, [self._manifest_meta('merged')]),
- )
- current = [
- self._manifest_meta('before-a'),
- self._manifest_meta('between'),
- self._manifest_meta('before-b'),
- ]
-
- file_store_commit, result = self._run_manifest_commit_attempt(
- commit_result=False,
- retry_result=retry_result,
- existing_manifests=current,
- )
-
- self.assertIsInstance(result, CommitFailRetryResult)
- self.assertIsNone(result.manifest_merge_result)
- file_store_commit.manifest_file_merger.merge.assert_not_called()
- base_manifests = (
- file_store_commit.manifest_list_manager.write
- .call_args_list[-1].args[1])
- self.assertEqual(current, base_manifests)
-
- def test_manifest_merge_runs_once_across_multiple_retries(
- self, mock_manifest_list_manager, mock_manifest_file_manager):
- first_commit, retry_result = self._run_manifest_commit_attempt(
- commit_result=False,
- existing_manifests=[self._manifest_meta('before')],
- merged_manifests=[self._manifest_meta('merged')],
- )
- first_commit.manifest_file_merger.merge.assert_called_once()
-
- unchanged_retry, retry_result = self._run_manifest_commit_attempt(
- commit_result=False,
- retry_result=retry_result,
- existing_manifests=[self._manifest_meta('before')],
- )
- retry_commits = [unchanged_retry]
- current_names = ['before']
- for suffix in ['concurrent-1', 'concurrent-2']:
- current_names.append(suffix)
- retry_commit, retry_result = self._run_manifest_commit_attempt(
- commit_result=False,
- retry_result=retry_result,
- existing_manifests=[
- self._manifest_meta(name) for name in current_names
- ],
- )
- retry_commits.append(retry_commit)
-
- final_names = current_names + ['concurrent-3']
- final_commit, result = self._run_manifest_commit_attempt(
- commit_result=True,
- retry_result=retry_result,
- existing_manifests=[
- self._manifest_meta(name) for name in final_names
- ],
- )
-
- self.assertTrue(result.is_success())
- for retry_commit in retry_commits + [final_commit]:
- retry_commit.manifest_file_merger.merge.assert_not_called()
- base_manifests = (
- final_commit.manifest_list_manager.write
- .call_args_list[-1].args[1])
- self.assertEqual(
- ['merged', 'concurrent-1', 'concurrent-2', 'concurrent-3'],
- [manifest.file_name for manifest in base_manifests],
- )
-
def test_generate_partition_statistics_single_partition_single_file(
self, mock_manifest_list_manager, mock_manifest_file_manager):
"""Test partition statistics generation with single partition and
single file."""
diff --git
a/paimon-python/pypaimon/tests/manifest/manifest_entry_identifier_test.py
b/paimon-python/pypaimon/tests/manifest/manifest_entry_identifier_test.py
index 9638f04707..9aa6522246 100644
--- a/paimon-python/pypaimon/tests/manifest/manifest_entry_identifier_test.py
+++ b/paimon-python/pypaimon/tests/manifest/manifest_entry_identifier_test.py
@@ -24,7 +24,6 @@ from pypaimon.common.identifier import Identifier
from pypaimon.common.options import Options
from pypaimon.common.options.config import CatalogOptions
from pypaimon.manifest.manifest_file_manager import ManifestFileManager
-from pypaimon.manifest.manifest_file_merger import ManifestFileMerger
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.manifest_entry import ManifestEntry
from pypaimon.manifest.schema.manifest_file_meta import ManifestFileMeta
@@ -153,52 +152,6 @@ class ManifestEntryIdentifierTest(unittest.TestCase):
len(final_entries), 0,
"ADD and DELETE entries with same identifier should both be
removed")
- def test_minor_compaction_cancels_add_delete_matching_same_file(self):
- partition = GenericRow([], [])
- add_entry = ManifestEntry(
- kind=0,
- partition=partition,
- bucket=0,
- total_buckets=1,
- file=self._create_file_meta("data-1.parquet", level=0)
- )
- delete_entry = ManifestEntry(
- kind=1,
- partition=partition,
- bucket=0,
- total_buckets=1,
- file=self._create_file_meta("data-1.parquet", level=0)
- )
-
- manifest_file_1 = ManifestFileMeta(
- file_name="manifest-minor-1.avro",
- file_size=1024,
- num_added_files=1,
- num_deleted_files=0,
- partition_stats=SimpleStats.empty_stats(),
- schema_id=0
- )
- manifest_file_2 = ManifestFileMeta(
- file_name="manifest-minor-2.avro",
- file_size=1024,
- num_added_files=0,
- num_deleted_files=1,
- partition_stats=SimpleStats.empty_stats(),
- schema_id=0
- )
- self.manifest_file_manager.write(manifest_file_1.file_name,
[add_entry])
- self.manifest_file_manager.write(manifest_file_2.file_name,
[delete_entry])
-
- merger = ManifestFileMerger(
- self.manifest_file_manager,
- suggested_meta_size=8 * 1024 * 1024,
- suggested_min_meta_count=2,
- )
- merged_files, new_files = merger.merge([manifest_file_1,
manifest_file_2])
-
- self.assertEqual(merged_files, [])
- self.assertEqual(new_files, [])
-
def test_add_delete_different_levels(self):
"""
Test that entries with different levels are NOT matched.
diff --git a/paimon-python/pypaimon/tests/test_early_row_range_filter.py
b/paimon-python/pypaimon/tests/test_early_row_range_filter.py
index b27184ab55..9961b29970 100644
--- a/paimon-python/pypaimon/tests/test_early_row_range_filter.py
+++ b/paimon-python/pypaimon/tests/test_early_row_range_filter.py
@@ -26,6 +26,8 @@ import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.common.predicate import Predicate
+from pypaimon.manifest.manifest_file_manager import ManifestFileManager
+from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
@@ -50,7 +52,6 @@ class TestManifestReadRowRangePerformance(unittest.TestCase):
schema = Schema.from_pyarrow_schema(pa_schema, options={
'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true',
- 'manifest.merge-min-count': '1',
})
cls.catalog.create_table('default.test_row_range_perf', schema, False)
cls.table = cls.catalog.get_table('default.test_row_range_perf')
@@ -74,16 +75,27 @@ class
TestManifestReadRowRangePerformance(unittest.TestCase):
tw.close()
tc.close()
+ # Build an externally compacted base manifest fixture so entry-level
+ # pruning is exercised even though Python commits never merge
manifests.
+ snapshot = cls.table.snapshot_manager().get_latest_snapshot()
+ manifest_lists = ManifestListManager(cls.table)
+ manifest_files = ManifestFileManager(cls.table)
+ entries = []
+ for meta in manifest_lists.read_base(snapshot):
+ entries.extend(manifest_files.read(meta.file_name,
drop_stats=False))
+ compacted = manifest_files.rolling_write(
+ entries, cls.table.options.manifest_target_size(),
'manifest-compacted-fixture')
+ assert len(compacted) == 1
+ manifest_lists.write(snapshot.base_manifest_list, compacted)
+
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tempdir, ignore_errors=True)
@pytest.mark.python_plan
- def test_scan_constructs_all_entries_without_early_row_range_filter(self):
- """With manifest.merge-min-count=1, all entries are in one manifest.
- Querying with _ROW_ID BETWEEN 5 AND 14 should return 2 files, but
- the current code constructs DataFileMeta for ALL 20 entries because
- the row-range filter runs after full object construction."""
+ def test_scan_constructs_only_matching_entries_in_compacted_manifest(self):
+ """Querying _ROW_ID BETWEEN 5 AND 14 should construct only the two
+ matching files from the externally compacted base manifest."""
construction_count = [0]
original_init = DataFileMeta.__init__
@@ -106,7 +118,7 @@ class
TestManifestReadRowRangePerformance(unittest.TestCase):
self.assertEqual(sorted(actual.column('id').to_pylist()),
list(range(5, 15)))
- # 2 matching files × 2 (ADD + DELETE from manifest merge) = 4
+ # Each matching file is deserialized and then copied without stats.
self.assertLessEqual(
construction_count[0], 2 * total_files,
f"Expected at most {2 * total_files} DataFileMeta constructions, "
@@ -122,7 +134,6 @@ class
TestManifestReadRowRangePerformance(unittest.TestCase):
Schema.from_pyarrow_schema(pa_schema,
options={
'row-tracking.enabled': 'true',
'data-evolution.enabled': 'true',
- 'manifest.merge-min-count': '1',
}), False)
table = self.catalog.get_table('default.test_add_delete_pair')
wb = table.new_batch_write_builder()
diff --git a/paimon-python/pypaimon/tests/write/table_write_test.py
b/paimon-python/pypaimon/tests/write/table_write_test.py
index f2875f4030..8e1bbffaf2 100644
--- a/paimon-python/pypaimon/tests/write/table_write_test.py
+++ b/paimon-python/pypaimon/tests/write/table_write_test.py
@@ -642,17 +642,18 @@ class TableWriteTest(unittest.TestCase):
self.assertEqual(self.expected, actual)
@parameterized.expand([
- ('default', None, None, True),
- ('not_write_only_default', 'false', None, True),
- ('write_only_default', 'true', None, True),
- ('skip_enabled', None, 'true', True),
- ('not_write_only_skip_enabled', 'false', 'true', True),
- ('write_only_skip_enabled', 'true', 'true', False),
- ('skip_disabled', None, 'false', True),
- ('not_write_only_skip_disabled', 'false', 'false', True),
- ('write_only_skip_disabled', 'true', 'false', True),
+ ('default', None, None),
+ ('not_write_only_default', 'false', None),
+ ('write_only_default', 'true', None),
+ ('skip_enabled', None, 'true'),
+ ('not_write_only_skip_enabled', 'false', 'true'),
+ ('write_only_skip_enabled', 'true', 'true'),
+ ('skip_disabled', None, 'false'),
+ ('not_write_only_skip_disabled', 'false', 'false'),
+ ('write_only_skip_disabled', 'true', 'false'),
])
- def test_commit_manifest_merge(self, name, write_only, skip_on_write_only,
merge_enabled):
+ def test_commit_preserves_existing_manifests(self, name, write_only,
skip_on_write_only):
+ # Shared tables may still carry Java manifest maintenance options.
options = {'manifest.merge-min-count': '2'}
if write_only is not None:
options['write-only'] = write_only
@@ -700,16 +701,11 @@ class TableWriteTest(unittest.TestCase):
base_manifests =
manifest_list_manager.read(snapshot.base_manifest_list)
delta_manifests =
manifest_list_manager.read(snapshot.delta_manifest_list)
- if merge_enabled:
- self.assertEqual(len(base_manifests), 1)
- self.assertEqual(base_manifests[0].num_added_files, 2)
- self.assertEqual(base_manifests[0].num_deleted_files, 0)
- else:
- self.assertEqual(len(base_manifests), 2)
- self.assertEqual(
- [manifest.file_name for manifest in previous_manifests],
- [manifest.file_name for manifest in base_manifests],
- )
+ self.assertEqual(len(base_manifests), 2)
+ self.assertEqual(
+ [manifest.file_name for manifest in previous_manifests],
+ [manifest.file_name for manifest in base_manifests],
+ )
self.assertEqual(len(delta_manifests), 1)
expected = pa.Table.from_pydict(expected_data, schema=self.pa_schema)
diff --git a/paimon-python/pypaimon/write/file_store_commit.py
b/paimon-python/pypaimon/write/file_store_commit.py
index a06c6e9d63..3fc49b1243 100644
--- a/paimon-python/pypaimon/write/file_store_commit.py
+++ b/paimon-python/pypaimon/write/file_store_commit.py
@@ -25,17 +25,15 @@ from pypaimon.build_info import full_version as
build_full_version
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.manifest.manifest_file_manager import ManifestFileManager
-from pypaimon.manifest.manifest_file_merger import ManifestFileMerger
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.file_entry import FileEntry
from pypaimon.manifest.schema.manifest_entry import ManifestEntry
-from pypaimon.manifest.schema.manifest_file_meta import ManifestFileMeta
from pypaimon.snapshot.snapshot import Snapshot
from pypaimon.snapshot.snapshot_commit import (PartitionStatistics,
SnapshotCommit)
-from pypaimon.table.row.generic_row import GenericRow, GenericRowSerializer
+from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.row.offset_row import OffsetRow
from pypaimon.write.commit.commit_rollback import CommitRollback
from pypaimon.write.commit.commit_scanner import CommitScanner
@@ -109,82 +107,17 @@ class SuccessResult(CommitResult):
return True
-def _manifest_file_key(manifest: ManifestFileMeta):
- stats = manifest.partition_stats
- return (
- manifest.file_name,
- manifest.file_size,
- manifest.num_added_files,
- manifest.num_deleted_files,
- GenericRowSerializer.to_bytes(stats.min_values),
- GenericRowSerializer.to_bytes(stats.max_values),
- tuple(stats.null_counts) if stats.null_counts is not None else None,
- manifest.schema_id,
- manifest.min_bucket,
- manifest.max_bucket,
- manifest.min_level,
- manifest.max_level,
- manifest.min_row_id,
- manifest.max_row_id,
- manifest.total_buckets,
- tuple(manifest.extra_files) if manifest.extra_files is not None else
None,
- )
-
-
-def _try_replace_manifest_files(current, replaced, replacement):
- """Replace the first contiguous occurrence while preserving list order."""
- current = list(current)
- replaced = list(replaced)
- replacement = list(replacement)
- if not replaced:
- return replacement if not current else None
-
- current_keys = [_manifest_file_key(manifest) for manifest in current]
- replaced_keys = [_manifest_file_key(manifest) for manifest in replaced]
- for start in range(len(current) - len(replaced) + 1):
- if current_keys[start:start + len(replaced)] == replaced_keys:
- return (
- current[:start]
- + replacement
- + current[start + len(replaced):]
- )
- return None
-
-
-class ManifestMergeResult:
- """Manifest merge input and output retained for a deterministic retry."""
-
- def __init__(self, merge_before_manifests, merge_after_manifests):
- self.merge_before_manifests = tuple(merge_before_manifests)
- self.merge_after_manifests = tuple(merge_after_manifests)
-
-
-def _try_reuse_manifest_merge_result(retry_result, current_manifests):
- if (not isinstance(retry_result, CommitFailRetryResult)
- or retry_result.commit_result_may_be_uncertain
- or retry_result.manifest_merge_result is None):
- return None
- previous = retry_result.manifest_merge_result
- return _try_replace_manifest_files(
- current_manifests,
- previous.merge_before_manifests,
- previous.merge_after_manifests,
- )
-
-
class RetryResult(CommitResult):
def __init__(self, latest_snapshot, exception: Optional[Exception] = None,
base_data_files: Optional[List[ManifestEntry]] = None,
- commit_result_may_be_uncertain: bool = False,
- manifest_merge_result: Optional[ManifestMergeResult] = None):
+ commit_result_may_be_uncertain: bool = False):
self.latest_snapshot = latest_snapshot
self.exception = exception
self.commit_result_may_be_uncertain = commit_result_may_be_uncertain
# Base entries as of latest_snapshot, carried so the next attempt
reuses
# them and reads only the incremental changes.
self.base_data_files = base_data_files
- self.manifest_merge_result = manifest_merge_result
def is_success(self) -> bool:
return False
@@ -232,14 +165,6 @@ class FileStoreCommit:
self.manifest_list_manager = ManifestListManager(table)
self.manifest_target_size = table.options.manifest_target_size()
- self.skip_manifest_merge_on_write_only = (
- table.options.write_only() and
table.options.manifest_merge_skip_on_write_only())
- self.manifest_merge_min_count =
table.options.manifest_merge_min_count()
- self.manifest_file_merger = ManifestFileMerger(
- self.manifest_file_manager,
- self.manifest_target_size,
- self.manifest_merge_min_count,
- )
self.commit_max_retries = table.options.commit_max_retries()
self.commit_timeout = table.options.commit_timeout()
@@ -707,10 +632,6 @@ class FileStoreCommit:
changelog_manifest_list_name = None
changelog_manifest_list_size = None
changelog_record_count = None
- merge_before_manifests = []
- merge_after_manifests = []
- merge_new_files = []
- skip_manifest_merge = False
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)
@@ -730,38 +651,17 @@ class FileStoreCommit:
changelog_record_count = sum(
entry.file.row_count for entry in changelog_entries if
entry.kind == 0)
- # process existing_manifest
+ # Manifest compaction is handled by external maintenance.
+ existing_manifests = []
total_record_count = 0
if latest_snapshot:
- merge_before_manifests = self.manifest_list_manager.read_all(
+ existing_manifests = self.manifest_list_manager.read_all(
latest_snapshot)
previous_record_count = latest_snapshot.total_record_count
if previous_record_count:
total_record_count += previous_record_count
- reused_manifests = (
- _try_reuse_manifest_merge_result(retry_result,
merge_before_manifests)
- if not self.skip_manifest_merge_on_write_only else None)
- skip_manifest_merge = (
- self.skip_manifest_merge_on_write_only
- or (reused_manifests is None and retry_result is not None))
- if reused_manifests is not None:
- merge_after_manifests = reused_manifests
- old_names = {
- manifest.file_name for manifest in merge_before_manifests
- }
- merge_new_files = [
- manifest for manifest in merge_after_manifests
- if manifest.file_name not in old_names
- ]
- elif skip_manifest_merge:
- merge_after_manifests = merge_before_manifests
- else:
- merge_after_manifests, merge_new_files = (
- self.manifest_file_merger.merge(
- merge_before_manifests))
- self.manifest_list_manager.write(
- base_manifest_list, merge_after_manifests)
+ self.manifest_list_manager.write(base_manifest_list,
existing_manifests)
delta_record_count = 0
for entry in commit_entries:
@@ -808,10 +708,9 @@ class FileStoreCommit:
statistics = self._generate_partition_statistics(commit_entries)
except Exception as e:
try:
- self._clean_up_reuse_tmp_manifests(
- delta_manifest_list, changelog_manifest_list_name,
new_index_manifest)
- self._clean_up_no_reuse_tmp_manifests(
- base_manifest_list, merge_new_files)
+ self._clean_up_tmp_manifests(
+ base_manifest_list, delta_manifest_list,
+ changelog_manifest_list_name, new_index_manifest)
except Exception as cleanup_err:
logger.warning(f"Failed to clean up temporary files:
{cleanup_err}",
exc_info=True)
@@ -837,19 +736,10 @@ class FileStoreCommit:
commit_kind,
commit_time_s,
)
- manifest_merge_result = (
- None
- if skip_manifest_merge
- else ManifestMergeResult(
- merge_before_manifests,
- merge_after_manifests,
- )
- )
return CommitFailRetryResult(
latest_snapshot,
None,
base_data_files=base_data_files,
- manifest_merge_result=manifest_merge_result,
)
except Exception as e:
# Commit exception, not sure about the situation and should not
clean up the files
@@ -859,7 +749,6 @@ class FileStoreCommit:
e,
base_data_files=base_data_files,
commit_result_may_be_uncertain=True,
- manifest_merge_result=None,
)
logger.info(
@@ -1100,16 +989,16 @@ class FileStoreCommit:
))
return commit_entries
- def _clean_up_reuse_tmp_manifests(
+ def _clean_up_tmp_manifests(
self,
+ base_manifest_list: Optional[str],
delta_manifest_list: Optional[str],
changelog_manifest_list: Optional[str],
index_manifest: Optional[str] = None):
- """Clean up delta/changelog manifests and index manifest.
-
- Mirrors Java CommitCleaner.cleanUpReuseTmpManifests.
- """
+ """Delete files created while preparing a snapshot, preserving base
manifests."""
manifest_path = self.manifest_list_manager.manifest_path
+ if base_manifest_list:
+
self.table.file_io.delete_quietly(f"{manifest_path}/{base_manifest_list}")
for ml_name in (delta_manifest_list, changelog_manifest_list):
if ml_name:
try:
@@ -1122,21 +1011,6 @@ class FileStoreCommit:
if index_manifest:
self.table.file_io.delete_quietly(f"{manifest_path}/{index_manifest}")
- def _clean_up_no_reuse_tmp_manifests(
- self,
- base_manifest_list: Optional[str],
- merge_new_files: List[ManifestFileMeta]):
- """Clean up base manifest list and newly created merge manifests.
-
- Mirrors Java CommitCleaner.cleanUpNoReuseTmpManifests.
- """
- manifest_path = self.manifest_list_manager.manifest_path
- if base_manifest_list:
-
self.table.file_io.delete_quietly(f"{manifest_path}/{base_manifest_list}")
- for meta in merge_new_files:
- self.table.file_io.delete_quietly(
- f"{self.manifest_file_manager.manifest_path}/{meta.file_name}")
-
def abort(self, commit_messages: List[CommitMessage]):
"""Abort commit and delete files. Uses external_path if available to
ensure proper scheme handling."""
_abort_commit_messages(self.table, commit_messages)