blob: 62606c183a6891eccf582fcae669418591b3b24c [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.
"""
Conflict detection for commit operations.
"""
import bisect
from pypaimon.manifest.manifest_list_manager import ManifestListManager
from pypaimon.manifest.index_manifest_file import IndexManifestFile
from pypaimon.manifest.schema.data_file_meta import DataFileMeta
from pypaimon.manifest.schema.file_entry import FileEntry
from pypaimon.table.special_fields import SpecialFields
from pypaimon.utils.range import Range
from pypaimon.utils.range_helper import RangeHelper
from pypaimon.write.commit.commit_scanner import CommitScanner
class RowIdColumnConflictChecker:
"""Checks for row ID × column conflicts between delta files and committed files.
Built from the current commit's delta files. For each committed file,
checks whether it overlaps with the delta files on BOTH dimensions:
row-id range AND write columns.
"""
def __init__(self, write_ranges, schema_manager):
self._write_ranges = write_ranges
self._schema_manager = schema_manager
self._field_id_cache = {}
@classmethod
def from_data_files(cls, schema_manager, delta_files):
files_with_row_id = [f for f in delta_files if f.first_row_id is not None]
if not files_with_row_id:
return None
range_helper = RangeHelper(lambda f: f.row_id_range())
groups = range_helper.merge_overlapping_ranges(files_with_row_id)
write_ranges = []
for group in groups:
merged_from = min(f.first_row_id for f in group)
merged_to = max(f.first_row_id + f.row_count - 1 for f in group)
merged_range = Range(merged_from, merged_to)
field_ids = set()
for f in group:
cls._add_write_field_ids(field_ids, f, schema_manager)
write_ranges.append(_WriteRange(merged_range, field_ids))
write_ranges.sort(key=lambda wr: (wr.range.from_, wr.range.to))
return cls(write_ranges, schema_manager)
def is_empty(self):
return len(self._write_ranges) == 0
def conflicts_with(self, file):
if file.first_row_id is None:
return False
file_range = Range(file.first_row_id, file.first_row_id + file.row_count - 1)
index = self._first_possible_range(file_range)
while index < len(self._write_ranges):
wr = self._write_ranges[index]
if wr.range.from_ > file_range.to:
return False
if wr.range.overlaps(file_range) and self._contains_any_write_field(wr.field_ids, file):
return True
index += 1
return False
def _first_possible_range(self, target):
keys = [wr.range.to for wr in self._write_ranges]
return bisect.bisect_left(keys, target.from_)
def _contains_any_write_field(self, field_ids, file):
if file.write_cols is None:
return True
for col_name in file.write_cols:
fid = self._field_id(file, col_name)
if fid is not None and fid in field_ids:
return True
return False
def _field_id(self, file, col_name):
if SpecialFields.is_system_field(col_name):
return None
name_to_id = self._field_id_by_name(file.schema_id)
fid = name_to_id.get(col_name)
if fid is None:
raise RuntimeError(
f"Column '{col_name}' not found in schema {file.schema_id}")
return fid
def _field_id_by_name(self, schema_id):
if schema_id not in self._field_id_cache:
schema = self._schema_manager.get_schema(schema_id)
if schema is None:
raise RuntimeError(f"Schema {schema_id} not found")
self._field_id_cache[schema_id] = {
field.name: field.id for field in schema.fields
}
return self._field_id_cache[schema_id]
@classmethod
def _add_write_field_ids(cls, field_ids, file, schema_manager):
if file.write_cols is None:
schema = schema_manager.get_schema(file.schema_id)
if schema is not None:
for field in schema.fields:
if not SpecialFields.is_system_field(field.name):
field_ids.add(field.id)
else:
name_to_id = {}
schema = schema_manager.get_schema(file.schema_id)
if schema is not None:
name_to_id = {field.name: field.id for field in schema.fields}
for col_name in file.write_cols:
if SpecialFields.is_system_field(col_name):
continue
fid = name_to_id.get(col_name)
if fid is not None:
field_ids.add(fid)
class _WriteRange:
def __init__(self, range_, field_ids):
self.range = range_
self.field_ids = field_ids
class CommitConflictError(RuntimeError):
"""A deterministic pre-snapshot conflict which is safe to abort."""
class RowIdExistenceConflict(RuntimeError):
"""A staged row-id file no longer matches the current base-file layout."""
def __init__(self, entry):
self.entry = entry
super().__init__(
"Row ID existence conflict: file '{}' references "
"firstRowId={}, rowCount={} in bucket {}, "
"but no matching file exists in the current snapshot. "
"The referenced file may have been rewritten by a "
"concurrent compaction or removed by an overwrite.".format(
entry.file.file_name,
entry.file.first_row_id,
entry.file.row_count,
entry.bucket))
class ConflictDetection:
"""Detects conflicts between base and delta files during commit."""
def __init__(self, data_evolution_enabled, snapshot_manager,
manifest_list_manager: ManifestListManager, table, commit_scanner: CommitScanner):
self.data_evolution_enabled = data_evolution_enabled
self.snapshot_manager = snapshot_manager
self.manifest_list_manager = manifest_list_manager
self.table = table
self._row_id_check_from_snapshot = None
self.commit_scanner = commit_scanner
def should_be_overwrite_commit(self, append_file_entries=None, append_index_files=None):
for entry in append_file_entries or []:
if entry.kind == 1:
return True
for entry in append_index_files or []:
if entry.index_file.index_type == IndexManifestFile.DELETION_VECTORS_INDEX:
return True
return False
def has_row_id_check_from_snapshot(self):
return self._row_id_check_from_snapshot is not None
@staticmethod
def has_global_index_additions(index_entries=None):
return bool(ConflictDetection.global_index_file_additions(index_entries))
@staticmethod
def has_hash_index_changes(index_entries=None):
return any(
entry.index_file.index_type == IndexManifestFile.HASH_INDEX
for entry in (index_entries or [])
)
def check_conflicts(
self,
latest_snapshot,
base_entries,
delta_entries,
commit_kind,
delta_index_entries=None):
try:
FileEntry.merge_entries(delta_entries)
except Exception as e:
return RuntimeError(
"File deletion conflicts detected! Give up committing. " + str(e))
all_entries = list(base_entries) + list(delta_entries)
try:
merged_entries = FileEntry.merge_entries(all_entries)
except Exception as e:
return RuntimeError(
"File deletion conflicts detected! Give up committing. " + str(e))
for entry in merged_entries:
if entry.kind == 1:
return RuntimeError(
"File deletion conflicts detected! Give up committing. "
"Trying to delete file {} which is not previously added.".format(
entry.file.file_name))
conflict = self.check_bucket_num_conflicts(merged_entries)
if conflict is not None:
return conflict
conflict = self.check_overwrite_from_snapshot(
latest_snapshot, delta_entries, commit_kind)
if conflict is not None:
return conflict
conflict = self.check_deletion_vector_index_conflicts(
latest_snapshot, delta_index_entries, base_entries, delta_entries)
if conflict is not None:
return conflict
conflict = self.check_hash_index_conflicts(
latest_snapshot, delta_index_entries)
if conflict is not None:
return conflict
if commit_kind != "COMPACT":
next_row_id = latest_snapshot.next_row_id if latest_snapshot else None
conflict = self.check_row_id_existence(
base_entries, delta_entries, next_row_id)
if conflict is not None:
return conflict
conflict = self.check_row_id_range_conflicts(commit_kind, merged_entries)
if conflict is not None:
return conflict
conflict = self.check_global_index_row_id_existence(
base_entries, delta_index_entries)
if conflict is not None:
return conflict
return self.check_row_id_from_snapshot(latest_snapshot, delta_entries)
@staticmethod
def check_bucket_num_conflicts(entries):
total_buckets = {}
for entry in entries:
if entry.kind != 0 or entry.total_buckets <= 0:
continue
partition = tuple(entry.partition.values)
previous = total_buckets.get(partition)
if previous is not None and previous != entry.total_buckets:
return RuntimeError(
"Total buckets of partition {} differ between committed "
"files: {} and {}. Give up committing.".format(
partition, previous, entry.total_buckets,
)
)
total_buckets[partition] = entry.total_buckets
return None
def check_hash_index_conflicts(
self, latest_snapshot, delta_index_entries=None):
"""Detect stale full-file replacements of dynamic-bucket HASH indexes."""
hash_entries = [
entry for entry in (delta_index_entries or [])
if entry.index_file.index_type == IndexManifestFile.HASH_INDEX
]
if not hash_entries:
return None
delete_entries = [entry for entry in hash_entries if entry.kind == 1]
add_entries = [entry for entry in hash_entries if entry.kind == 0]
delete_names = {
entry.index_file.file_name for entry in delete_entries
}
current_entries = []
if latest_snapshot is not None and latest_snapshot.index_manifest is not None:
current_entries = [
entry for entry in IndexManifestFile(self.table).read(
latest_snapshot.index_manifest)
if entry.kind == 0
and entry.index_file.index_type == IndexManifestFile.HASH_INDEX
]
current_names = {
entry.index_file.file_name for entry in current_entries
}
for delete in delete_entries:
if delete.index_file.file_name not in current_names:
return RuntimeError(
"HASH index conflict detected: index file {} is not "
"present in the latest snapshot.".format(
delete.index_file.file_name
)
)
additions_by_bucket = {}
for add in add_entries:
key = (tuple(add.partition.values), add.bucket)
previous_add = additions_by_bucket.get(key)
if previous_add is not None:
return RuntimeError(
"HASH index conflict detected: multiple index files {} "
"and {} were added for partition {}, bucket {} in one "
"commit.".format(
previous_add.index_file.file_name,
add.index_file.file_name,
key[0],
key[1],
)
)
additions_by_bucket[key] = add
retained = [
entry for entry in current_entries
if entry.index_file.file_name not in delete_names
and tuple(entry.partition.values) == key[0]
and entry.bucket == key[1]
]
if retained:
return RuntimeError(
"HASH index conflict detected: partition {}, bucket {} "
"already has newer index file {}.".format(
key[0],
key[1],
retained[0].index_file.file_name,
)
)
return None
def check_deletion_vector_index_conflicts(self,
latest_snapshot,
delta_index_entries=None,
base_entries=None,
delta_entries=None):
dv_entries = [
entry for entry in (delta_index_entries or [])
if entry.index_file.index_type == IndexManifestFile.DELETION_VECTORS_INDEX
]
if not dv_entries:
return None
delete_entries = [entry for entry in dv_entries if entry.kind == 1]
add_entries = [entry for entry in dv_entries if entry.kind == 0]
delete_names = {entry.index_file.file_name for entry in delete_entries}
current_entries = []
if latest_snapshot is not None and latest_snapshot.index_manifest is not None:
current_entries = [
entry for entry in IndexManifestFile(self.table).read(
latest_snapshot.index_manifest)
if entry.kind == 0
and entry.index_file.index_type == IndexManifestFile.DELETION_VECTORS_INDEX
]
current_names = {entry.index_file.file_name for entry in current_entries}
for delete in delete_entries:
if delete.index_file.file_name not in current_names:
return RuntimeError(
"Deletion vector index conflict detected: index file {} "
"is not present in the latest snapshot.".format(
delete.index_file.file_name))
existing_data_files = {
(tuple(entry.partition.values), entry.bucket, entry.file.file_name)
for entry in list(base_entries or []) + list(delta_entries or [])
if entry.kind == 0
}
affected_files = []
for add in add_entries:
for data_file_name in self._deletion_vector_data_file_names(add.index_file):
affected_files.append((add.partition, add.bucket, data_file_name))
for partition, bucket, data_file_name in affected_files:
data_file_key = (tuple(partition.values), bucket, data_file_name)
if data_file_key not in existing_data_files:
return RuntimeError(
"Deletion vector index conflict detected: data file {} "
"is not present in the latest snapshot.".format(
data_file_name))
for current in current_entries:
if current.index_file.file_name in delete_names:
continue
if current.partition != partition or current.bucket != bucket:
continue
if data_file_name in self._deletion_vector_data_file_names(current.index_file):
return RuntimeError(
"Deletion vector index conflict detected: data file {} "
"already has a newer deletion vector index file {}.".format(
data_file_name, current.index_file.file_name))
return None
@staticmethod
def _deletion_vector_data_file_names(index_file):
return [
meta.data_file_name
for meta in (index_file.dv_ranges or {}).values()
]
def check_global_index_row_id_existence(self, base_entries, delta_index_entries=None):
if not self.data_evolution_enabled:
return None
indexes_to_check = self.global_index_file_additions(delta_index_entries)
if not indexes_to_check:
return None
data_ranges = {}
for entry in base_entries or []:
row_range = entry.file.row_id_range()
if entry.kind == 0 and row_range is not None:
key = (tuple(entry.partition.values), entry.bucket)
data_ranges.setdefault(key, []).append(row_range)
data_ranges = {
key: Range.sort_and_merge_overlap(ranges, True, True)
for key, ranges in data_ranges.items()
}
for index_entry in indexes_to_check:
global_index = index_entry.index_file.global_index_meta
index_range = Range(
global_index.row_range_start,
global_index.row_range_end,
)
key = (tuple(index_entry.partition.values), index_entry.bucket)
if index_range.exclude(data_ranges.get(key, [])):
return RuntimeError(
"Global index row ID existence conflict: index file '{}' "
"references row range {}, but this range is not fully "
"covered by current data files. The referenced row IDs "
"may have been reassigned or removed by a concurrent "
"commit.".format(index_entry.index_file.file_name, index_range))
return None
@staticmethod
def global_index_file_additions(index_entries=None):
return [
entry for entry in (index_entries or [])
if entry.kind == 0 and entry.index_file.global_index_meta is not None
]
def check_overwrite_from_snapshot(self, latest_snapshot, delta_entries, commit_kind):
if commit_kind != "OVERWRITE":
return None
if self._row_id_check_from_snapshot is None:
return None
if latest_snapshot is None or latest_snapshot.id <= self._row_id_check_from_snapshot:
return None
if not any(entry.kind == 1 for entry in delta_entries):
return None
check_snapshot = self.snapshot_manager.get_snapshot_by_id(
self._row_id_check_from_snapshot)
if check_snapshot is None:
return RuntimeError(
"Overwrite conflict detected: base snapshot {} cannot be found.".format(
self._row_id_check_from_snapshot))
for snapshot_id in range(
self._row_id_check_from_snapshot + 1,
latest_snapshot.id + 1):
snapshot = self.snapshot_manager.get_snapshot_by_id(snapshot_id)
if snapshot is None:
return RuntimeError(
"Overwrite conflict detected: snapshot {} cannot be found.".format(
snapshot_id))
incremental_entries = (
self.commit_scanner.read_incremental_raw_entries_from_changed_partitions(
snapshot, delta_entries))
if incremental_entries:
return RuntimeError(
"Overwrite conflict detected: target partitions were modified "
"after snapshot {}.".format(self._row_id_check_from_snapshot))
return None
def check_row_id_existence(self, base_entries, delta_entries, next_row_id=None):
if not self.data_evolution_enabled:
return None
if next_row_id is None:
return None
files_to_check = [
entry for entry in delta_entries
if entry.kind == 0
and entry.file.first_row_id is not None
and entry.file.first_row_id < next_row_id
]
if not files_to_check:
return None
existing_index = set()
existing_ranges = {}
for base in base_entries:
if base.file.first_row_id is not None:
existing_index.add((
base.partition, base.bucket,
base.file.first_row_id, base.file.row_count))
if not self._is_dedicated_file(base.file.file_name):
existing_ranges.setdefault((base.partition, base.bucket), []).append(
base.file.row_id_range())
existing_ranges = {
key: Range.sort_and_merge_overlap(ranges, True, True)
for key, ranges in existing_ranges.items()
}
for entry in files_to_check:
if self._is_dedicated_file(entry.file.file_name):
base_ranges = existing_ranges.get((entry.partition, entry.bucket), [])
if not entry.file.row_id_range().exclude(base_ranges):
continue
key = (entry.partition, entry.bucket,
entry.file.first_row_id, entry.file.row_count)
if key not in existing_index:
return RowIdExistenceConflict(entry)
return None
def check_row_id_range_conflicts(self, commit_kind, commit_entries):
if not self.data_evolution_enabled:
return None
if self._row_id_check_from_snapshot is None and commit_kind != "COMPACT":
return None
entries_with_row_id = [
entry for entry in commit_entries
if entry.file.first_row_id is not None
]
if not entries_with_row_id:
return None
range_helper = RangeHelper(lambda entry: entry.file.row_id_range())
data_files = [
entry for entry in entries_with_row_id
if not self._is_dedicated_file(entry.file.file_name)
]
conflict = self._check_data_file_row_id_range_conflicts(
range_helper, data_files)
if conflict is not None:
return conflict
dedicated_files = [
entry for entry in entries_with_row_id
if self._is_dedicated_file(entry.file.file_name)
]
conflict = self._check_dedicated_file_row_id_range_conflicts(
data_files, dedicated_files)
if conflict is not None:
return conflict
return None
def _check_data_file_row_id_range_conflicts(self, range_helper, data_files):
for data_file_group in range_helper.merge_overlapping_ranges(data_files):
if not range_helper.are_all_ranges_same(data_file_group):
file_descriptions = [
self._file_description(entry) for entry in data_file_group
]
return RuntimeError(
"For Data Evolution table, multiple 'MERGE INTO' and 'COMPACT' "
"operations have encountered conflicts, data files: "
+ str(file_descriptions))
return None
def _check_dedicated_file_row_id_range_conflicts(
self, data_files, dedicated_files):
if not dedicated_files:
return None
data_ranges = self._data_file_row_ranges(data_files)
for dedicated_file in dedicated_files:
dedicated_range = dedicated_file.file.row_id_range()
if any(self._contains(row_range, dedicated_range) for row_range in data_ranges):
continue
intersecting_ranges = [
row_range for row_range in data_ranges
if row_range.overlaps(dedicated_range)
]
intersecting_files = [
self._file_description(entry)
for entry in data_files
if entry.file.row_id_range().overlaps(dedicated_range)
]
conflict_reason = (
"spans multiple data file ranges"
if len(intersecting_ranges) > 1
else "is not covered by one data file range"
)
return RuntimeError(
"For Data Evolution table, multiple 'MERGE INTO' and 'COMPACT' "
"operations have encountered conflicts, dedicated file "
"{file} {row_range} {reason}: {groups}".format(
file=self._file_description(dedicated_file),
row_range=dedicated_range,
reason=conflict_reason,
groups=intersecting_files))
return None
@staticmethod
def _data_file_row_ranges(data_files):
return Range.sort_and_merge_overlap(
[entry.file.row_id_range() for entry in data_files],
True,
False,
)
@staticmethod
def _contains(container, row_range):
return container.from_ <= row_range.from_ and container.to >= row_range.to
@staticmethod
def _is_dedicated_file(file_name):
return (DataFileMeta.is_blob_file(file_name)
or DataFileMeta.is_vector_file(file_name))
@staticmethod
def _file_description(entry):
return "{name}(rowId={row_id}, count={count})".format(
name=entry.file.file_name,
row_id=entry.file.first_row_id,
count=entry.file.row_count,
)
def check_row_id_from_snapshot(
self, latest_snapshot, commit_entries, check_compaction=True):
if not self.data_evolution_enabled:
return None
if self._row_id_check_from_snapshot is None:
return None
delta_files = [entry.file for entry in commit_entries]
column_checker = RowIdColumnConflictChecker.from_data_files(
self.table.schema_manager, delta_files)
if column_checker is None or column_checker.is_empty():
return None
check_snapshot = self.snapshot_manager.get_snapshot_by_id(
self._row_id_check_from_snapshot)
if check_snapshot is None or check_snapshot.next_row_id is None:
raise RuntimeError(
"Next row id cannot be null for snapshot "
"{snapshot}.".format(snapshot=self._row_id_check_from_snapshot))
check_next_row_id = check_snapshot.next_row_id
# Pair each delta with its anchor file type so a parquet-only
# compact does not flag a blob delta whose .blob anchor is intact.
delta_signatures = []
for f in delta_files:
r = f.row_id_range()
if r is not None:
delta_signatures.append(
(DataFileMeta.is_blob_file(f.file_name), r.from_, r.to))
for snapshot_id in range(
self._row_id_check_from_snapshot + 1,
latest_snapshot.id + 1):
snapshot = self.snapshot_manager.get_snapshot_by_id(snapshot_id)
if snapshot is None:
continue
if snapshot.commit_kind == "COMPACT":
if check_compaction:
err = self._compact_conflicts_with_delta(
snapshot, delta_signatures, column_checker, commit_entries)
if err is not None:
return err
continue
incremental_entries = self.commit_scanner.read_incremental_entries_from_changed_partitions(
snapshot, commit_entries)
for entry in incremental_entries:
file_range = entry.file.row_id_range()
if file_range is None:
continue
if file_range.from_ < check_next_row_id:
if column_checker.conflicts_with(entry.file):
return RuntimeError(
"For Data Evolution table, multiple 'MERGE INTO' "
"operations have encountered conflicts, updating "
"the same file, which can render some updates "
"ineffective.")
return None
def _compact_conflicts_with_delta(self, snapshot, delta_signatures,
column_checker, commit_entries):
"""Return RuntimeError if a COMPACT snapshot deleted a same-kind
anchor file whose row-id range AND write columns overlap any
staged delta; otherwise None.
File-type match guards against `write_cols=None` ambiguity (an
initial full-row parquet does not actually contain blob columns);
column_checker guards against unrelated column-write shards
(compacting an f1-only parquet must not block an f2 update on
the same row range).
"""
if not delta_signatures:
return None
raw_entries = self.commit_scanner.read_incremental_raw_entries_from_changed_partitions(
snapshot, commit_entries)
for entry in raw_entries:
if entry.kind != 1:
continue
file_range = entry.file.row_id_range()
if file_range is None:
continue
deleted_is_blob = DataFileMeta.is_blob_file(entry.file.file_name)
for delta_is_blob, from_, to in delta_signatures:
if delta_is_blob != deleted_is_blob:
continue
if file_range.from_ > to or from_ > file_range.to:
continue
if not column_checker.conflicts_with(entry.file):
continue
return RuntimeError(
"Blob/row-id update conflicts with concurrent COMPACT "
"(snapshot {sid}): anchor file {name} [{ff}, {ft}] "
"was compacted away, overlaps staged delta "
"[{df}, {dt}].".format(
sid=snapshot.id,
name=entry.file.file_name,
ff=file_range.from_,
ft=file_range.to,
df=from_,
dt=to))
return None