Skip to content

Commit 877ff3e

Browse files
committed
feat: add deletion vector compaction planning and execution
Treat empty plans as no-ops and centralize owned-output cleanup without weakening unknown-commit or stale-plan safety. Consolidate fault injection tests and cover successful multi-file rewriting with a fully deleted group.
1 parent f256e78 commit 877ff3e

8 files changed

Lines changed: 1957 additions & 0 deletions

‎src/iceberg/CMakeLists.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ set(ICEBERG_SOURCES
2626
catalog/memory/in_memory_catalog.cc
2727
catalog/session_catalog.cc
2828
catalog/session_context.cc
29+
compaction_planner.cc
2930
delete_file_index.cc
3031
deletes/dv_util.cc
3132
deletes/dv_writer.cc
@@ -237,6 +238,7 @@ if(MSVC_TOOLCHAIN)
237238
endif()
238239

239240
set(ICEBERG_DATA_SOURCES
241+
data/compaction_executor.cc
240242
data/data_writer.cc
241243
data/delete_filter.cc
242244
data/delete_loader.cc

‎src/iceberg/compaction_planner.cc‎

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
#include "iceberg/compaction_planner.h"
21+
22+
#include <algorithm>
23+
#include <cmath>
24+
#include <compare>
25+
#include <iterator>
26+
#include <limits>
27+
#include <map>
28+
#include <string>
29+
#include <string_view>
30+
#include <utility>
31+
32+
#include "iceberg/expression/literal.h"
33+
#include "iceberg/manifest/manifest_entry.h"
34+
#include "iceberg/table_scan.h"
35+
#include "iceberg/util/content_file_util.h"
36+
#include "iceberg/util/data_file_set.h"
37+
#include "iceberg/util/macros.h"
38+
39+
namespace iceberg {
40+
namespace {
41+
42+
struct CanonicalPartitionKey {
43+
int32_t spec_id;
44+
std::string values;
45+
46+
auto operator<=>(const CanonicalPartitionKey&) const = default;
47+
};
48+
49+
struct Candidate {
50+
CompactionFile file;
51+
bool has_delete_pressure;
52+
};
53+
54+
struct PartitionCandidates {
55+
int32_t spec_id;
56+
std::vector<Candidate> files;
57+
};
58+
59+
Status ValidateConfig(const CompactionPlannerConfig& config) {
60+
ICEBERG_PRECHECK(config.target_file_size_bytes > 0,
61+
"target_file_size_bytes must be greater than zero");
62+
ICEBERG_PRECHECK(std::isfinite(config.min_file_size_ratio) &&
63+
config.min_file_size_ratio >= 0 && config.min_file_size_ratio <= 1,
64+
"min_file_size_ratio must be finite and in [0, 1]");
65+
ICEBERG_PRECHECK(config.min_input_files > 0,
66+
"min_input_files must be greater than zero");
67+
ICEBERG_PRECHECK(config.delete_file_threshold >= 0,
68+
"delete_file_threshold must not be negative");
69+
ICEBERG_PRECHECK(std::isfinite(config.delete_ratio_threshold) &&
70+
config.delete_ratio_threshold >= 0 &&
71+
config.delete_ratio_threshold <= 1,
72+
"delete_ratio_threshold must be finite and in [0, 1]");
73+
return {};
74+
}
75+
76+
Result<int64_t> CheckedAddNonNegative(int64_t lhs, int64_t rhs,
77+
std::string_view description) {
78+
ICEBERG_PRECHECK(lhs >= 0 && rhs >= 0, "{} must not be negative", description);
79+
ICEBERG_PRECHECK(lhs <= std::numeric_limits<int64_t>::max() - rhs, "{} overflow",
80+
description);
81+
return lhs + rhs;
82+
}
83+
84+
void AppendFramed(std::string& output, std::string_view value) {
85+
output.append(std::to_string(value.size()));
86+
output.push_back(':');
87+
output.append(value);
88+
}
89+
90+
Result<CanonicalPartitionKey> MakePartitionKey(const DataFile& data_file) {
91+
ICEBERG_PRECHECK(data_file.partition_spec_id.has_value(),
92+
"Data file '{}' is missing partition_spec_id", data_file.file_path);
93+
94+
std::string encoded;
95+
encoded.append(std::to_string(data_file.partition.num_fields()));
96+
encoded.push_back(':');
97+
for (const auto& literal : data_file.partition.values()) {
98+
AppendFramed(encoded, literal.type()->ToString());
99+
if (literal.IsNull()) {
100+
encoded.push_back('N');
101+
continue;
102+
}
103+
104+
encoded.push_back('V');
105+
if (literal.IsNaN()) {
106+
encoded.push_back('N');
107+
continue;
108+
}
109+
110+
encoded.push_back('B');
111+
ICEBERG_ASSIGN_OR_RAISE(auto bytes, literal.Serialize());
112+
std::string_view serialized;
113+
if (!bytes.empty()) {
114+
serialized =
115+
std::string_view(reinterpret_cast<const char*>(bytes.data()), bytes.size());
116+
}
117+
AppendFramed(encoded, serialized);
118+
}
119+
return CanonicalPartitionKey{.spec_id = *data_file.partition_spec_id,
120+
.values = std::move(encoded)};
121+
}
122+
123+
Result<CompactionFile> BuildCompactionFile(
124+
const std::shared_ptr<FileScanTask>& scan_task) {
125+
CompactionFile result{.scan_task = scan_task};
126+
const auto& data_file = scan_task->data_file();
127+
ICEBERG_PRECHECK(data_file->record_count >= 0,
128+
"Data file '{}' has negative record count", data_file->file_path);
129+
ICEBERG_PRECHECK(data_file->file_size_in_bytes >= 0,
130+
"Data file '{}' has negative file size", data_file->file_path);
131+
132+
DeleteFileSet applicable_deletes;
133+
for (const auto& delete_file : scan_task->delete_files()) {
134+
ICEBERG_PRECHECK(delete_file != nullptr, "Data file '{}' has a null delete file",
135+
data_file->file_path);
136+
if (delete_file->content != DataFile::Content::kPositionDeletes) {
137+
continue;
138+
}
139+
140+
ICEBERG_ASSIGN_OR_RAISE(auto referenced_file,
141+
ContentFileUtil::ReferencedDataFile(*delete_file));
142+
if (!referenced_file.has_value() || *referenced_file != data_file->file_path) {
143+
continue;
144+
}
145+
146+
if (!applicable_deletes.insert(delete_file).second) {
147+
continue;
148+
}
149+
150+
ICEBERG_PRECHECK(delete_file->record_count >= 0,
151+
"Delete file '{}' has negative record count",
152+
delete_file->file_path);
153+
if (delete_file->IsDeletionVector()) {
154+
ICEBERG_PRECHECK(
155+
delete_file->record_count <= data_file->record_count,
156+
"Deletion vector '{}' cardinality {} exceeds data file '{}' record count {}",
157+
delete_file->file_path, delete_file->record_count, data_file->file_path,
158+
data_file->record_count);
159+
}
160+
161+
ICEBERG_ASSIGN_OR_RAISE(result.file_scoped_delete_count,
162+
CheckedAddNonNegative(result.file_scoped_delete_count, 1,
163+
"File-scoped delete-file count"));
164+
ICEBERG_ASSIGN_OR_RAISE(result.file_scoped_delete_record_count,
165+
CheckedAddNonNegative(result.file_scoped_delete_record_count,
166+
delete_file->record_count,
167+
"File-scoped delete-record count"));
168+
}
169+
170+
result.file_scoped_delete_record_count =
171+
std::min(result.file_scoped_delete_record_count, data_file->record_count);
172+
return result;
173+
}
174+
175+
bool CompactionFileLess(const CompactionFile& lhs, const CompactionFile& rhs) {
176+
return lhs.scan_task->data_file()->file_path < rhs.scan_task->data_file()->file_path;
177+
}
178+
179+
bool CandidateLess(const Candidate& lhs, const Candidate& rhs) {
180+
return CompactionFileLess(lhs.file, rhs.file);
181+
}
182+
183+
bool PackingCandidateLess(const Candidate& lhs, const Candidate& rhs) {
184+
const int64_t lhs_size = lhs.file.scan_task->data_file()->file_size_in_bytes;
185+
const int64_t rhs_size = rhs.file.scan_task->data_file()->file_size_in_bytes;
186+
return lhs_size != rhs_size ? lhs_size > rhs_size : CandidateLess(lhs, rhs);
187+
}
188+
189+
Result<void> AddToGroup(CompactionGroup& group, Candidate candidate,
190+
bool& has_delete_pressure) {
191+
const auto& data_file = *candidate.file.scan_task->data_file();
192+
ICEBERG_ASSIGN_OR_RAISE(
193+
group.data_file_size_bytes,
194+
CheckedAddNonNegative(group.data_file_size_bytes, data_file.file_size_in_bytes,
195+
"Compaction group data-file size"));
196+
ICEBERG_ASSIGN_OR_RAISE(
197+
group.file_scoped_delete_record_count,
198+
CheckedAddNonNegative(group.file_scoped_delete_record_count,
199+
candidate.file.file_scoped_delete_record_count,
200+
"Compaction group delete-record count"));
201+
has_delete_pressure |= candidate.has_delete_pressure;
202+
group.files.push_back(std::move(candidate.file));
203+
return {};
204+
}
205+
206+
struct PendingGroup {
207+
CompactionGroup group;
208+
bool has_delete_pressure = false;
209+
};
210+
211+
Result<std::vector<CompactionGroup>> PackPartition(
212+
PartitionCandidates partition, const CompactionPlannerConfig& config) {
213+
const auto representative = std::ranges::min_element(partition.files, CandidateLess)
214+
->file.scan_task->data_file()
215+
->partition;
216+
std::ranges::sort(partition.files, PackingCandidateLess);
217+
218+
std::vector<PendingGroup> bins;
219+
for (auto& candidate : partition.files) {
220+
const int64_t file_size = candidate.file.scan_task->data_file()->file_size_in_bytes;
221+
size_t best_bin = bins.size();
222+
int64_t best_remaining = std::numeric_limits<int64_t>::max();
223+
for (size_t i = 0; i < bins.size(); ++i) {
224+
const int64_t bin_size = bins[i].group.data_file_size_bytes;
225+
if (bin_size > config.target_file_size_bytes ||
226+
file_size > config.target_file_size_bytes - bin_size) {
227+
continue;
228+
}
229+
230+
const int64_t remaining = config.target_file_size_bytes - bin_size - file_size;
231+
if (remaining < best_remaining) {
232+
best_bin = i;
233+
best_remaining = remaining;
234+
}
235+
}
236+
237+
if (best_bin == bins.size()) {
238+
bins.push_back(PendingGroup{
239+
.group = CompactionGroup{.partition_spec_id = partition.spec_id,
240+
.partition = representative},
241+
});
242+
best_bin = bins.size() - 1;
243+
}
244+
ICEBERG_RETURN_UNEXPECTED(AddToGroup(bins[best_bin].group, std::move(candidate),
245+
bins[best_bin].has_delete_pressure));
246+
}
247+
248+
std::vector<CompactionGroup> groups;
249+
for (auto& bin : bins) {
250+
if (!bin.has_delete_pressure && bin.group.files.size() < config.min_input_files) {
251+
continue;
252+
}
253+
std::ranges::sort(bin.group.files, CompactionFileLess);
254+
groups.push_back(std::move(bin.group));
255+
}
256+
std::ranges::sort(groups, [](const CompactionGroup& lhs, const CompactionGroup& rhs) {
257+
return CompactionFileLess(lhs.files.front(), rhs.files.front());
258+
});
259+
return groups;
260+
}
261+
262+
} // namespace
263+
264+
Result<CompactionPlan> CompactionPlanner::Plan(
265+
int64_t source_snapshot_id, std::span<const std::shared_ptr<FileScanTask>> scan_tasks,
266+
const CompactionPlannerConfig& config) {
267+
ICEBERG_PRECHECK(source_snapshot_id >= 0, "source_snapshot_id must not be negative");
268+
ICEBERG_RETURN_UNEXPECTED(ValidateConfig(config));
269+
std::map<CanonicalPartitionKey, PartitionCandidates> partitions;
270+
DataFileSet seen_data_files;
271+
272+
for (const auto& scan_task : scan_tasks) {
273+
ICEBERG_PRECHECK(scan_task != nullptr, "File scan task must not be null");
274+
ICEBERG_PRECHECK(scan_task->data_file() != nullptr,
275+
"File scan task data file must not be null");
276+
const auto& data_file = *scan_task->data_file();
277+
ICEBERG_PRECHECK(seen_data_files.insert(scan_task->data_file()).second,
278+
"Duplicate scan task for data file '{}'", data_file.file_path);
279+
ICEBERG_ASSIGN_OR_RAISE(auto partition_key, MakePartitionKey(data_file));
280+
ICEBERG_ASSIGN_OR_RAISE(auto file, BuildCompactionFile(scan_task));
281+
282+
const bool is_small =
283+
static_cast<double>(data_file.file_size_in_bytes) <
284+
static_cast<double>(config.target_file_size_bytes) * config.min_file_size_ratio;
285+
const bool has_many_delete_files =
286+
config.delete_file_threshold > 0 &&
287+
file.file_scoped_delete_count >= config.delete_file_threshold;
288+
const bool has_high_delete_ratio =
289+
config.delete_ratio_threshold > 0 && data_file.record_count > 0 &&
290+
static_cast<double>(file.file_scoped_delete_record_count) /
291+
static_cast<double>(data_file.record_count) >=
292+
config.delete_ratio_threshold;
293+
const bool has_delete_pressure = has_many_delete_files || has_high_delete_ratio;
294+
if (!is_small && !has_delete_pressure) {
295+
continue;
296+
}
297+
298+
auto partition =
299+
partitions
300+
.try_emplace(std::move(partition_key),
301+
PartitionCandidates{.spec_id = *data_file.partition_spec_id})
302+
.first;
303+
partition->second.files.push_back(
304+
Candidate{.file = std::move(file), .has_delete_pressure = has_delete_pressure});
305+
}
306+
307+
CompactionPlan plan{.source_snapshot_id = source_snapshot_id};
308+
for (auto& [_, partition] : partitions) {
309+
ICEBERG_ASSIGN_OR_RAISE(auto groups, PackPartition(std::move(partition), config));
310+
plan.groups.insert(plan.groups.end(), std::make_move_iterator(groups.begin()),
311+
std::make_move_iterator(groups.end()));
312+
}
313+
return plan;
314+
}
315+
316+
} // namespace iceberg

0 commit comments

Comments
 (0)