wangyong9999 commented on code in PR #227:
URL: https://github.com/apache/paimon-cpp/pull/227#discussion_r3859646100
##########
src/paimon/core/operation/file_store_commit_impl.cpp:
##########
@@ -107,6 +111,26 @@ bool MatchPartitionSpec(const std::map<std::string,
std::string>& partition,
return true;
}
+/// The (partition, bucket) pairs whose deletion vectors this commit
materializes: it replaced
+/// normal files and wrote every output without a row id, so the commit
assigns fresh ones and
+/// the global indexes over the old ids no longer address anything.
+MaterializedBuckets CollectMaterializedBuckets(
+ const std::vector<std::shared_ptr<CommitMessage>>& commit_messages) {
+ MaterializedBuckets result;
+ for (const std::shared_ptr<CommitMessage>& message : commit_messages) {
+ auto message_impl =
std::dynamic_pointer_cast<CommitMessageImpl>(message);
+ if (message_impl == nullptr) {
+ continue;
+ }
+ const CompactIncrement& compact_increment =
message_impl->GetCompactIncrement();
+ if
(DataEvolutionUtils::IsMaterializedCompaction(compact_increment.CompactBefore(),
Review Comment:
`IsMaterializedCompaction` only asks whether every output lacks a
`first_row_id`, and `KeyValueDataFileWriter::CreateResult` always builds its
`DataFileMeta` with `/*first_row_id=*/std::nullopt`
(key_value_data_file_writer.cpp:152). So every ordinary primary-key compaction
— which replaces normal files and produces a merged file without a row id —
satisfies the predicate and lands in `materialized_buckets`.
The commit is then routed through `MaterializedIndexChangesProvider`, which
re-scans the index manifest and emits `FileKind::Delete()` for *every*
global-index entry of that (partition, bucket). A plain compaction of a
primary-key table carrying a pk-sorted index therefore drops an index it never
touched. Tables without a global index still pay an unfiltered index-manifest
read on every compaction attempt.
The other two `IsMaterializedCompaction` call sites are already inside the
data-evolution path; this one is on the generic commit path and needs its own
gate — e.g. only collecting when `options_.DataEvolutionEnabled()`, or
requiring the replaced `CompactBefore` files to actually carry a `first_row_id`.
##########
src/paimon/core/append/data_evolution_compact_planner.cpp:
##########
@@ -0,0 +1,319 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/append/data_evolution_compact_planner.h"
+
+#include <algorithm>
+#include <iterator>
+#include <limits>
+#include <string>
+#include <utility>
+
+#include "fmt/format.h"
+#include "fmt/ranges.h"
+#include "paimon/common/data/blob_utils.h"
+#include "paimon/common/predicate/predicate_filter.h"
+#include "paimon/common/utils/range_helper.h"
+#include "paimon/common/utils/vector_store_utils.h"
+#include "paimon/core/core_options.h"
+#include "paimon/core/manifest/manifest_file_meta.h"
+#include "paimon/core/manifest/manifest_list.h"
+#include "paimon/core/snapshot.h"
+#include "paimon/core/stats/simple_stats.h"
+#include "paimon/logging.h"
+
+namespace paimon {
+
+namespace {
+
+/// A bin that packs evolved field groups of one contiguous row id run into
one compact task.
+class CompactBin {
+ public:
+ Status Add(std::vector<std::shared_ptr<DataFileMeta>>&& file_group,
int64_t weight) {
+ if (weight_ > std::numeric_limits<int64_t>::max() - weight) {
+ return Status::Invalid("Data evolution compaction bin weight
overflows.");
+ }
+ files_.insert(files_.end(),
std::make_move_iterator(file_group.begin()),
+ std::make_move_iterator(file_group.end()));
+ weight_ += weight;
+ return Status::OK();
+ }
+
+ int64_t Weight() const {
+ return weight_;
+ }
+
+ std::vector<std::shared_ptr<DataFileMeta>> Drain() {
+ std::vector<std::shared_ptr<DataFileMeta>> result = std::move(files_);
+ files_.clear();
+ weight_ = 0;
+ return result;
+ }
+
+ private:
+ std::vector<std::shared_ptr<DataFileMeta>> files_;
+ int64_t weight_ = 0;
+};
+
+/// Emits a task for the bin's files when there are enough of them; too few
files are simply
+/// left as they are (they will be reconsidered by a later coordinator run
once neighbors
+/// appear).
+Status TriggerTask(std::vector<std::shared_ptr<DataFileMeta>>&& files, const
BinaryRow& partition,
+ int32_t compact_min_file_num,
+ std::vector<DataEvolutionNormalCompactTask>* tasks) {
+ if (static_cast<int32_t>(files.size()) < compact_min_file_num) {
+ return Status::OK();
+ }
+ PAIMON_ASSIGN_OR_RAISE(DataEvolutionNormalCompactTask task,
+ DataEvolutionNormalCompactTask::Create(partition,
files));
+ tasks->push_back(std::move(task));
+ return Status::OK();
+}
+
+int64_t FileWeight(const std::shared_ptr<DataFileMeta>& file, int64_t
open_file_cost) {
+ return std::max(file->file_size, open_file_cost);
+}
+
+Status PlanPartition(const BinaryRow& partition,
+ const std::vector<std::shared_ptr<DataFileMeta>>& files,
+ int64_t target_file_size, int64_t open_file_cost, int32_t
compact_min_file_num,
+ std::vector<DataEvolutionNormalCompactTask>* tasks) {
+ // Blob files are dedicated storage: they are not rewritten and never
enter a task, and
+ // their row ranges stay covered by the rewritten data files. Vector-store
files were
+ // already rejected by PlanCompactTasks, so everything else is a normal
file.
+ std::vector<std::shared_ptr<DataFileMeta>> data_files;
+ data_files.reserve(files.size());
+ for (const auto& file : files) {
+ if (BlobUtils::IsBlobFile(file->file_name)) {
+ continue;
+ }
+ data_files.push_back(file);
+ }
+ if (data_files.empty()) {
+ return Status::OK();
+ }
+
+ // Contiguous runs extend each file's range to [first, first + row_count],
one past its
+ // last row: adjacent files share an endpoint and overlap, so a run only
breaks on a real
+ // row id gap.
+ RangeHelper<std::shared_ptr<DataFileMeta>> adjacency_helper(
+ [](const std::shared_ptr<DataFileMeta>& file) -> Result<int64_t> {
+ return file->NonNullFirstRowId();
+ },
+ [](const std::shared_ptr<DataFileMeta>& file) -> Result<int64_t> {
+ PAIMON_ASSIGN_OR_RAISE(int64_t first_row_id,
file->NonNullFirstRowId());
+ return first_row_id + file->row_count;
+ });
+ // Field groups use the closed range [first, first + row_count - 1]: only
files holding the
+ // same rows overlap here, and a group must cover the exact same range in
every file.
+ RangeHelper<std::shared_ptr<DataFileMeta>> field_group_helper(
+ [](const std::shared_ptr<DataFileMeta>& file) -> Result<int64_t> {
+ return file->NonNullFirstRowId();
+ },
+ [](const std::shared_ptr<DataFileMeta>& file) -> Result<int64_t> {
+ PAIMON_ASSIGN_OR_RAISE(int64_t first_row_id,
file->NonNullFirstRowId());
+ return first_row_id + file->row_count - 1;
+ });
+
+
PAIMON_ASSIGN_OR_RAISE(std::vector<std::vector<std::shared_ptr<DataFileMeta>>>
components,
+
adjacency_helper.MergeOverlappingRanges(std::move(data_files)));
+ for (auto& component : components) {
+
PAIMON_ASSIGN_OR_RAISE(std::vector<std::vector<std::shared_ptr<DataFileMeta>>>
field_groups,
+
field_group_helper.MergeOverlappingRanges(std::move(component)));
+ CompactBin bin;
+ for (auto& field_group : field_groups) {
+ PAIMON_ASSIGN_OR_RAISE(bool same_range,
+
field_group_helper.AreAllRangesSame(field_group));
+ if (!same_range) {
+ std::vector<std::string> file_names;
+ file_names.reserve(field_group.size());
+ for (const auto& file : field_group) {
+ file_names.push_back(file->file_name);
+ }
+ return Status::Invalid(fmt::format(
+ "Files of one data evolution field group should share the
same row range, "
+ "but got [{}].",
+ fmt::join(file_names, ", ")));
+ }
+ int64_t weight = 0;
+ for (const auto& file : field_group) {
+ int64_t file_weight = FileWeight(file, open_file_cost);
+ if (weight > std::numeric_limits<int64_t>::max() -
file_weight) {
+ return Status::Invalid(
+ "Data evolution compaction field group weight
overflows.");
+ }
+ weight += file_weight;
+ }
+ if (weight > target_file_size) {
+ // A heavy group cuts the current bin and is considered on its
own (still
+ // subject to the min-file-num gate): merging its files is
worthwhile, but
+ // nothing else is packed on top of it.
+ PAIMON_RETURN_NOT_OK(
+ TriggerTask(bin.Drain(), partition, compact_min_file_num,
tasks));
+ CompactBin single_group_bin;
+
PAIMON_RETURN_NOT_OK(single_group_bin.Add(std::move(field_group), weight));
+ PAIMON_RETURN_NOT_OK(
+ TriggerTask(single_group_bin.Drain(), partition,
compact_min_file_num, tasks));
+ continue;
+ }
+ PAIMON_RETURN_NOT_OK(bin.Add(std::move(field_group), weight));
Review Comment:
Blob files are dropped before packing (line 100-105), and a bin then packs
several adjacent field groups, so the rewritten file spans a wider row range
than the dedicated `.blob` files it left behind. The read side needs those
ranges to match exactly, not just to be covered: `MergeRangesAndSort` groups
files by *overlapping* closed ranges, and `CreateUnionReader` then requires
every bunch of a group to report the same row count and the same first row id.
Concretely, on a data-evolution table with a blob column: commit 1 writes
`{id}` for rows [0,2] (data file D1, no blob file); commit 2 writes `{id,
payload}` for rows [3,5] (data file D2 + blob file B2 [3,5]). Both read fine
today. A compaction with `compaction.min.file-num=2` packs the two field groups
into one bin and rewrites them into a single file O [0,5]. The live set is then
{O[0,5], B2[3,5]}: they overlap, land in one group, and `CreateUnionReader`
fails with "All files in a field merge split should have the same row count."
Every scan of that range fails from then on, and the commit is already durable.
`TestBlobFilesExcluded` only covers one field group whose blob file shares
its exact range, which is why this shape is not caught.
Blob coverage needs to be part of the packing decision: pass the partition's
blob files into `PlanPartition`, compute for each field group the set of blob
field ids whose files cover its exact range, and drain the bin whenever the
next group's coverage set differs — so a rewritten file never spans a change in
dedicated-blob coverage.
##########
src/paimon/core/io/managed_blob_reference_file.cpp:
##########
@@ -0,0 +1,399 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/io/managed_blob_reference_file.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <string_view>
+#include <utility>
+
+#include "arrow/util/crc32.h"
+#include "fmt/format.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/common/utils/string_utils.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/status.h"
+
+namespace paimon {
+
+namespace {
+
+constexpr int32_t kMagic = 0x50424C52; // "PBLR"
+constexpr int8_t kVersion = 1;
+
+void AppendBigEndianInt32(int32_t value, std::string* out) {
+ auto bits = static_cast<uint32_t>(value);
+ out->push_back(static_cast<char>((bits >> 24) & 0xFF));
+ out->push_back(static_cast<char>((bits >> 16) & 0xFF));
+ out->push_back(static_cast<char>((bits >> 8) & 0xFF));
+ out->push_back(static_cast<char>(bits & 0xFF));
+}
+
+/// A sequential big-endian reader over an in-memory buffer.
+class BigEndianBufferReader {
+ public:
+ BigEndianBufferReader(const char* data, size_t size) : data_(data),
size_(size) {}
+
+ Result<int32_t> ReadInt32() {
+ PAIMON_RETURN_NOT_OK(Require(4));
+ uint32_t bits =
(static_cast<uint32_t>(static_cast<uint8_t>(data_[pos_])) << 24) |
+ (static_cast<uint32_t>(static_cast<uint8_t>(data_[pos_
+ 1])) << 16) |
+ (static_cast<uint32_t>(static_cast<uint8_t>(data_[pos_
+ 2])) << 8) |
+ static_cast<uint32_t>(static_cast<uint8_t>(data_[pos_
+ 3]));
+ pos_ += 4;
+ return static_cast<int32_t>(bits);
+ }
+
+ Result<int8_t> ReadInt8() {
+ PAIMON_RETURN_NOT_OK(Require(1));
+ return static_cast<int8_t>(data_[pos_++]);
+ }
+
+ Result<uint16_t> ReadUInt16() {
+ PAIMON_RETURN_NOT_OK(Require(2));
+ auto bits = static_cast<uint16_t>((static_cast<uint8_t>(data_[pos_])
<< 8) |
+ static_cast<uint8_t>(data_[pos_ +
1]));
+ pos_ += 2;
+ return bits;
+ }
+
+ Result<std::string_view> ReadBytes(size_t length) {
+ PAIMON_RETURN_NOT_OK(Require(length));
+ std::string_view view(data_ + pos_, length);
+ pos_ += length;
+ return view;
+ }
+
+ size_t Position() const {
+ return pos_;
+ }
+
+ size_t Remaining() const {
+ return size_ - pos_;
+ }
+
+ private:
+ Status Require(size_t length) const {
+ if (pos_ + length > size_) {
+ return Status::Invalid("Managed blob reference file is
truncated.");
+ }
+ return Status::OK();
+ }
+
+ const char* data_;
+ size_t size_;
+ size_t pos_ = 0;
+};
+
+/// Encodes a UTF-8 string the way the sidecar format stores it: a uint16
big-endian byte
+/// length followed by modified UTF-8 bytes. Modified UTF-8 equals standard
UTF-8 except that
+/// U+0000 becomes the two-byte form 0xC0 0x80 and supplementary characters
are encoded as a
+/// CESU-8 surrogate pair (two three-byte groups).
+Status AppendJavaUtf(const std::string& value, std::string* out) {
+ std::string encoded;
+ encoded.reserve(value.size());
+ size_t i = 0;
+ const auto* bytes = reinterpret_cast<const uint8_t*>(value.data());
+ while (i < value.size()) {
+ uint8_t lead = bytes[i];
+ uint32_t code_point = 0;
+ size_t sequence_length = 0;
+ if (lead < 0x80) {
+ code_point = lead;
+ sequence_length = 1;
+ } else if ((lead >> 5) == 0x6) {
+ code_point = lead & 0x1F;
+ sequence_length = 2;
+ } else if ((lead >> 4) == 0xE) {
+ code_point = lead & 0x0F;
+ sequence_length = 3;
+ } else if ((lead >> 3) == 0x1E) {
+ code_point = lead & 0x07;
+ sequence_length = 4;
+ } else {
+ return Status::Invalid("Managed blob reference contains invalid
UTF-8.");
+ }
+ if (i + sequence_length > value.size()) {
+ return Status::Invalid("Managed blob reference contains truncated
UTF-8.");
+ }
+ for (size_t j = 1; j < sequence_length; j++) {
+ uint8_t continuation = bytes[i + j];
+ if ((continuation >> 6) != 0x2) {
+ return Status::Invalid("Managed blob reference contains
invalid UTF-8.");
+ }
+ code_point = (code_point << 6) | (continuation & 0x3F);
+ }
+ i += sequence_length;
Review Comment:
`AppendJavaUtf` classifies a lead byte by its bit pattern but never
range-checks the decoded code point, so it accepts two inputs it cannot
round-trip — even though every other malformed lead is rejected with "Managed
blob reference contains invalid UTF-8.":
- `(lead >> 3) == 0x1E` matches `F0`–`F7`, so code points above U+10FFFF
pass. For `F5 80 80 80` (U+140000), `offset >> 10` gives `high = 0xDCC0`, which
is a *low* surrogate, and the writer emits `ED B3 80 ED B0 80`. `ReadJavaUtf`
sees no high surrogate, copies both groups verbatim, and returns six bytes
where four went in — the sidecar records a pack path that does not exist.
- A path already holding a CESU-8 lone surrogate (`ED A0 80`) takes the
`code_point < 0x10000` branch and is copied through unchanged. `Write` reports
success, and `ReadJavaUtf` then rejects that sidecar forever with "holds an
unpaired surrogate".
One check after the continuation loop makes the writer refuse exactly what
the reader refuses to decode:
```cpp
i += sequence_length;
if (code_point > 0x10FFFF || (code_point >= 0xD800 && code_point <= 0xDFFF))
{
return Status::Invalid("Managed blob reference contains invalid UTF-8.");
}
```
##########
src/paimon/core/operation/commit/uncommitted_file_cleaner.cpp:
##########
@@ -0,0 +1,129 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/operation/commit/uncommitted_file_cleaner.h"
+
+#include <set>
+#include <string>
+
+#include "paimon/commit_message.h"
+#include "paimon/core/index/index_file_meta.h"
+#include "paimon/core/index/index_path_factory.h"
+#include "paimon/core/io/compact_increment.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/data_file_path_factory.h"
+#include "paimon/core/io/data_increment.h"
+#include "paimon/core/table/sink/commit_message_impl.h"
+#include "paimon/core/utils/file_store_path_factory.h"
+#include "paimon/fs/file_system.h"
+#include "paimon/logging.h"
+
+namespace paimon {
+
+Status UncommittedFileCleaner::Delete(
+ const std::shared_ptr<FileStorePathFactory>& path_factory,
+ const std::shared_ptr<FileSystem>& fs,
+ const std::vector<std::shared_ptr<CommitMessage>>& commit_messages,
Logger* logger) {
+ // A message this cleaner cannot handle stops that message, never the ones
after it: giving
+ // up on the whole list would strand every file the remaining messages
describe, which is
+ // the leak this class exists to prevent. The first such failure is
reported once the list
+ // has been walked.
+ Status first_error = Status::OK();
+ for (const auto& message : commit_messages) {
+ auto* msg = dynamic_cast<CommitMessageImpl*>(message.get());
+ if (msg == nullptr) {
+ if (first_error.ok()) {
+ first_error = Status::Invalid("fail to cast commit message to
impl");
+ }
+ continue;
+ }
+ Result<std::shared_ptr<DataFilePathFactory>>
data_file_path_factory_result =
+ path_factory->CreateDataFilePathFactory(msg->Partition(),
msg->Bucket());
+ Result<std::unique_ptr<IndexPathFactory>>
index_file_path_factory_result =
+ path_factory->CreateIndexFileFactory(msg->Partition(),
msg->Bucket());
+ if (!data_file_path_factory_result.ok() ||
!index_file_path_factory_result.ok()) {
+ const Status& status = data_file_path_factory_result.ok()
+ ?
index_file_path_factory_result.status()
+ :
data_file_path_factory_result.status();
+ if (first_error.ok()) {
+ first_error = status;
+ }
+ PAIMON_LOG_WARN(logger,
+ "Cannot resolve the paths of an uncommitted
message in bucket %d: %s. "
+ "Its files are left behind.",
+ msg->Bucket(), status.ToString().c_str());
+ continue;
+ }
+ std::shared_ptr<DataFilePathFactory> data_file_path_factory =
+ std::move(data_file_path_factory_result).value();
+ std::unique_ptr<IndexPathFactory> index_file_path_factory =
+ std::move(index_file_path_factory_result).value();
+
+ const DataIncrement& new_files_increment = msg->GetNewFilesIncrement();
+ const CompactIncrement& compact_increment = msg->GetCompactIncrement();
+
+ std::vector<std::shared_ptr<DataFileMeta>> data_files_to_delete;
+ auto append_data_files =
+ [&data_files_to_delete](const
std::vector<std::shared_ptr<DataFileMeta>>& files) {
+ data_files_to_delete.insert(data_files_to_delete.end(),
files.begin(), files.end());
+ };
+ append_data_files(new_files_increment.NewFiles());
+ append_data_files(new_files_increment.ChangelogFiles());
+ append_data_files(compact_increment.CompactAfter());
Review Comment:
An LSM upgrade puts the *same* file name in both `CompactBefore` and
`CompactAfter`: `MergeTreeCompactRewriter::Upgrade` returns
`CompactResult({file}, {file->Upgrade(level)})`, and `DataFileMeta::Upgrade`
copies `file_name` through and only changes the level. Deleting
`CompactAfter()` unconditionally therefore deletes a file the current snapshot
still references.
`MergeTreeWriter::DoClose` skips those on purpose ("Upgrade file is required
by previous snapshot"), and so does `UpdateCompactResult`. This cleaner does
not, and the new `prepare_guard` in `AbstractFileStoreWrite::PrepareCommit`
makes it reachable: one writer drains an upgrade-only increment, a later writer
in the same loop fails, and the guard removes a live data file. The rows are
gone from the committed snapshot.
Same guard as `DoClose`:
```cpp
std::unordered_set<std::string> before_names;
for (const auto& file : compact_increment.CompactBefore()) {
before_names.insert(file->file_name);
}
for (const auto& file : compact_increment.CompactAfter()) {
if (before_names.count(file->file_name) == 0) {
data_files_to_delete.push_back(file);
}
}
```
##########
src/paimon/core/append/append_compact_coordinator.cpp:
##########
@@ -281,6 +395,75 @@ std::vector<AppendCompactTask> GenerateCompactTasks(
return tasks;
}
+/// Cleans up the rewritten output files of already finished tasks, best
effort. Used when a
+/// later task fails: the collected commit messages are discarded, so their
outputs would
+/// otherwise linger until an orphan clean. Failures are logged and never mask
the original
+/// compaction error the caller returns.
+void CleanupCompactOutputs(const std::vector<std::shared_ptr<CommitMessage>>&
commit_messages,
+ const std::shared_ptr<FileStorePathFactory>&
path_factory,
+ const CoreOptions& core_options) {
+ auto logger = Logger::GetLogger("AppendCompactCoordinator");
+ for (const auto& message : commit_messages) {
+ auto message_impl =
std::dynamic_pointer_cast<CommitMessageImpl>(message);
+ if (!message_impl) {
+ // The log macros require at least one format argument.
+ PAIMON_LOG_WARN(logger, "%s",
+ "Skipping cleanup of a compact output: unexpected
commit message "
+ "type");
+ continue;
+ }
+ Result<std::shared_ptr<DataFilePathFactory>> data_file_path_factory =
+ path_factory->CreateDataFilePathFactory(message_impl->Partition(),
+ message_impl->Bucket());
+ if (!data_file_path_factory.ok()) {
+ PAIMON_LOG_WARN(logger,
+ "Skipping cleanup of compact outputs in partition
%s bucket %d: %s",
+ message_impl->Partition().ToString().c_str(),
message_impl->Bucket(),
+
data_file_path_factory.status().ToString().c_str());
+ continue;
+ }
+ for (const auto& file :
message_impl->GetCompactIncrement().CompactAfter()) {
Review Comment:
This loop cleans up `CompactAfter()` data files only, so the deletion-vector
index files `DataEvolutionCompactDeletionVectorRewriter` already wrote
(`CompactIncrement::NewIndexFiles()`) stay on disk when the round's commit
fails.
The comment on the failure path says they are "left to orphan cleaning,
which is what collects unreferenced index files", but
`OrphanFilesCleanerImpl::SupportToClean` accepts only `manifest-*`,
`manifest-list-*`, `*.tmp`, and `data-*` with a data-format or `.blobref`
suffix. An index file is named `index-<uuid>`
(`IndexPathFactory::INDEX_PREFIX`), so it matches nothing and is never
reclaimed.
Losing the optimistic-commit race is a normal outcome here —
`CheckDeletionVectorMigrationIsComplete` explicitly tells the caller to "give
up committing and plan again" — so every retry on a contended table leaves more
index files behind for good. `RunMaterializeRound` has the same gap and does
not even pass `index_messages` to the cleanup when `DropGlobalIndexes` fails
after the rewrite succeeded.
`UncommittedFileCleaner::Delete` in this same PR already deletes data files,
sidecars and index files for a message; calling it here (and passing the index
messages on the materialize path) would cover this without new code.
##########
src/paimon/core/append/data_evolution_compact_deletion_vector_rewriter.cpp:
##########
@@ -0,0 +1,407 @@
+/*
+ * 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.
+ */
+
+#include "paimon/core/append/data_evolution_compact_deletion_vector_rewriter.h"
+
+#include <map>
+#include <optional>
+#include <string>
+#include <unordered_set>
+#include <utility>
+
+#include "fmt/format.h"
+#include "paimon/common/data/binary_row.h"
+#include "paimon/common/utils/linked_hash_map.h"
+#include "paimon/common/utils/range_helper.h"
+#include "paimon/core/core_options.h"
+#include "paimon/core/deletionvectors/deletion_vector.h"
+#include "paimon/core/deletionvectors/deletion_vectors_index_file.h"
+#include "paimon/core/index/deletion_vector_meta.h"
+#include "paimon/core/index/index_file_handler.h"
+#include "paimon/core/index/index_file_meta.h"
+#include "paimon/core/io/compact_increment.h"
+#include "paimon/core/io/data_file_meta.h"
+#include "paimon/core/io/data_increment.h"
+#include "paimon/core/snapshot.h"
+#include "paimon/core/table/sink/commit_message_impl.h"
+#include "paimon/core/table/source/deletion_file.h"
+#include "paimon/core/utils/data_evolution_utils.h"
+#include "paimon/logging.h"
+#include "paimon/utils/range.h"
+
+namespace paimon {
+
+namespace {
+
+/// Bucket every data-evolution compact message writes to. Unaware-bucket
tables keep the
+/// legacy single bucket, and the rewritten index files have to land in the
same one.
+constexpr int32_t kUnawareBucket = 0;
+
+/// One deletion-vector index file of a partition, described by metadata
alone: which data
+/// files it stores a vector for and where in the file each vector sits.
+///
+/// Nothing is deserialized to build this. A vector is read only when a move
actually takes it
+/// away, and the vectors that stay are read only for the index files a move
touched, so an
+/// untouched index file is never opened.
+struct IndexFileState {
+ std::shared_ptr<IndexFileMeta> meta;
+ std::string path;
+ /// The vectors this index file still owns, in write order.
+ LinkedHashMap<std::string, DeletionVectorMeta> dv_metas;
+ bool dirty;
+};
+
+/// The recorded position of one vector inside its index file.
+DeletionFile ToDeletionFile(const std::string& index_file_path, const
DeletionVectorMeta& dv_meta) {
+ return DeletionFile(index_file_path, dv_meta.GetOffset(),
dv_meta.GetLength(),
+ dv_meta.GetCardinality());
+}
+
+/// The files a rewritten deletion vector has to be keyed by after one compact
task: the
+/// task's inputs, whose per-group anchors hold the deletions today, and the
single output
+/// file they were merged into.
+///
+/// A materialized task has no such output. It applied the deletions while
rewriting, so its
+/// rows carry fresh row ids the commit assigns and the old vectors are
dropped rather than
+/// moved; `after` is null and `after_range` unused for one of those.
+struct CompactedGroup {
+ std::vector<std::shared_ptr<DataFileMeta>> before;
+ std::shared_ptr<DataFileMeta> after;
+ Range after_range;
+ bool materialized = false;
+};
+
+std::vector<std::shared_ptr<DataFileMeta>> NormalFiles(
+ const std::vector<std::shared_ptr<DataFileMeta>>& files) {
+ std::vector<std::shared_ptr<DataFileMeta>> result;
+ result.reserve(files.size());
+ for (const auto& file : files) {
+ if (DataEvolutionUtils::IsNormalFile(file->file_name)) {
+ result.push_back(file);
+ }
+ }
+ return result;
+}
+
+Result<Range> RowRangeOf(const std::shared_ptr<DataFileMeta>& file) {
+ PAIMON_ASSIGN_OR_RAISE(int64_t first_row_id, file->NonNullFirstRowId());
+ return Range(first_row_id, first_row_id + file->row_count - 1);
+}
+
+/// Merges the files of one compact task into row range groups: files covering
the same rows
+/// belong to the same group and share one deletion vector, keyed by the
group's anchor.
+Result<std::vector<std::vector<std::shared_ptr<DataFileMeta>>>> RowRangeGroups(
+ std::vector<std::shared_ptr<DataFileMeta>>&& files) {
+ RangeHelper<std::shared_ptr<DataFileMeta>> helper(
+ [](const std::shared_ptr<DataFileMeta>& file) -> Result<int64_t> {
+ return file->NonNullFirstRowId();
+ },
+ [](const std::shared_ptr<DataFileMeta>& file) -> Result<int64_t> {
+ PAIMON_ASSIGN_OR_RAISE(int64_t first_row_id,
file->NonNullFirstRowId());
+ return first_row_id + file->row_count - 1;
+ });
+ return helper.MergeOverlappingRanges(std::move(files));
+}
+
+/// Collects the compacted groups of each partition, validating the shape
data-evolution
+/// compaction is expected to produce.
+Result<LinkedHashMap<BinaryRow, std::vector<CompactedGroup>>>
CollectCompactedGroups(
+ const std::vector<std::shared_ptr<CommitMessage>>& compact_messages) {
+ LinkedHashMap<BinaryRow, std::vector<CompactedGroup>> result;
+ for (const auto& message : compact_messages) {
+ auto message_impl =
std::dynamic_pointer_cast<CommitMessageImpl>(message);
+ if (message_impl == nullptr) {
+ return Status::Invalid(
+ "Data evolution compaction produced an unexpected commit
message type.");
+ }
+ const CompactIncrement& compact_increment =
message_impl->GetCompactIncrement();
+ // The rewriter owns every index change of this round; a task that
already produced
+ // one would be silently dropped by the index-only messages built
below.
+ if (!compact_increment.NewIndexFiles().empty() ||
+ !compact_increment.DeletedIndexFiles().empty()) {
+ return Status::Invalid(
+ "Data evolution compaction should not produce index changes
before the "
+ "deletion vector rewrite.");
+ }
+ if (message_impl->Bucket() != kUnawareBucket ||
+ message_impl->TotalBuckets() != std::nullopt) {
+ return Status::Invalid(fmt::format(
+ "Data evolution compaction should only produce unaware-bucket
commit messages, "
+ "but got bucket {}.",
+ message_impl->Bucket()));
+ }
+
+ std::vector<std::shared_ptr<DataFileMeta>> before =
+ NormalFiles(compact_increment.CompactBefore());
+ std::vector<std::shared_ptr<DataFileMeta>> after =
+ NormalFiles(compact_increment.CompactAfter());
+ // A blob-only or index-only message carries no rows whose deletions
could move.
+ if (before.empty() && after.empty()) {
+ continue;
+ }
+ // A materialized task wrote its rows without row ids, so the commit
assigns fresh ones
+ // and the deletions it applied must not follow them: the old vectors
are dropped
+ // rather than moved. The global index dropper and the commit's
conflict check decide
+ // the same shape through the same helper.
+ bool materialized = DataEvolutionUtils::IsMaterializedCompaction(
+ compact_increment.CompactBefore(),
compact_increment.CompactAfter());
+ if (materialized) {
+ result[message_impl->Partition()].push_back(
+ CompactedGroup{std::move(before), /*after=*/nullptr, Range(0,
0),
+ /*materialized=*/true});
+ continue;
+ }
+ if (after.size() != 1) {
+ return Status::Invalid(
+ "One data evolution compact task should produce exactly one
normal file.");
+ }
+ PAIMON_ASSIGN_OR_RAISE(Range after_range, RowRangeOf(after[0]));
+ // A task that keeps its rows in place must cover exactly the rows it
replaced, or the
+ // deletions would move onto the wrong ones.
+ PAIMON_ASSIGN_OR_RAISE(Range before_range,
+
DataEvolutionUtils::CheckContiguousRowRange(before));
+ if (!(before_range == after_range)) {
+ return Status::Invalid(fmt::format(
+ "Data evolution compaction must keep the same row id range,
but compacted {} "
+ "into {}.",
+ before_range.ToString(), after_range.ToString()));
+ }
+ result[message_impl->Partition()].push_back(
+ CompactedGroup{std::move(before), after[0], after_range,
/*materialized=*/false});
+ }
+ return result;
+}
+
+/// Moves one row range group's deletion vector from its anchor file onto
`merged`, whose
+/// positions are relative to `after_range`.
+Status MoveDeletionVector(const std::shared_ptr<DeletionVector>& old_vector,
+ const Range& anchor_range, const Range& after_range,
+ const std::shared_ptr<DeletionVector>& merged) {
+ if (anchor_range == after_range) {
+ // The group already spans the whole compacted file: the vector only
changes its key.
+ return merged->Merge(old_vector);
Review Comment:
`merged` is built from the table option
(`DeletionVector::Create(core_options.DeletionVectorsBitmap64())`) while
`old_vector` is whatever kind is stored on disk, and both `Merge`
implementations reject a mismatched dynamic type. The general path just below —
`ForEachDeletedPosition` + `Delete` — has no such requirement, so the same data
succeeds when a task packs two groups and fails when it packs one.
A table that has bitmap32 vectors on disk and later turns on
`deletion-vectors.bitmap64` reaches exactly that state: Java supports it
(`Bitmap64DeletionVector.fromBitmapDeletionVector` exists for this conversion)
and nothing marks the option immutable — `ValidateImmutableOptions` only
compares the caller's override against the schema, so both read `true` here.
Since the planner deterministically produces the same task shape, such a group
can never be compacted again, while a task that happens to pack two groups over
the same data silently converts.
`shift` is 0 when the ranges are equal, so dropping the special case and
always taking the `ForEachDeletedPosition` path handles both kinds uniformly
and keeps one behaviour for both shapes.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]