Skip to content

Commit 4876d7c

Browse files
authored
feat: implement incremental changelog scan (#611)
1 parent 633965f commit 4876d7c

5 files changed

Lines changed: 872 additions & 10 deletions

File tree

src/iceberg/table_scan.cc

Lines changed: 154 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,22 @@
1919

2020
#include "iceberg/table_scan.h"
2121

22+
#include <cstdint>
2223
#include <cstring>
2324
#include <iterator>
25+
#include <utility>
2426

2527
#include "iceberg/expression/binder.h"
2628
#include "iceberg/expression/expression.h"
29+
#include "iceberg/expression/residual_evaluator.h"
2730
#include "iceberg/file_reader.h"
2831
#include "iceberg/manifest/manifest_entry.h"
2932
#include "iceberg/manifest/manifest_group.h"
3033
#include "iceberg/result.h"
3134
#include "iceberg/schema.h"
3235
#include "iceberg/snapshot.h"
3336
#include "iceberg/table_metadata.h"
37+
#include "iceberg/util/content_file_util.h"
3438
#include "iceberg/util/macros.h"
3539
#include "iceberg/util/snapshot_util_internal.h"
3640
#include "iceberg/util/timepoint.h"
@@ -294,6 +298,26 @@ Result<ArrowArrayStream> FileScanTask::ToArrow(
294298
return MakeArrowArrayStream(std::move(reader));
295299
}
296300

301+
// ChangelogScanTask implementation
302+
303+
int64_t ChangelogScanTask::size_bytes() const {
304+
int64_t total_size = data_file_->file_size_in_bytes;
305+
for (const auto& delete_file : delete_files_) {
306+
ICEBERG_DCHECK(delete_file->content_size_in_bytes.has_value(),
307+
"Delete file content size must be available");
308+
total_size +=
309+
(delete_file->IsDeletionVector() ? delete_file->content_size_in_bytes.value()
310+
: delete_file->file_size_in_bytes);
311+
}
312+
return total_size;
313+
}
314+
315+
int32_t ChangelogScanTask::files_count() const { return 1 + delete_files_.size(); }
316+
317+
int64_t ChangelogScanTask::estimated_row_count() const {
318+
return data_file_->record_count;
319+
}
320+
297321
// Generic template implementation for Make
298322
template <typename ScanType>
299323
Result<std::unique_ptr<TableScanBuilder<ScanType>>> TableScanBuilder<ScanType>::Make(
@@ -747,11 +771,13 @@ Result<std::vector<std::shared_ptr<FileScanTask>>> IncrementalAppendScan::PlanFi
747771
// IncrementalChangelogScan implementation
748772

749773
Result<std::unique_ptr<IncrementalChangelogScan>> IncrementalChangelogScan::Make(
750-
[[maybe_unused]] std::shared_ptr<TableMetadata> metadata,
751-
[[maybe_unused]] std::shared_ptr<Schema> schema,
752-
[[maybe_unused]] std::shared_ptr<FileIO> io,
753-
[[maybe_unused]] internal::TableScanContext context) {
754-
return NotImplemented("IncrementalChangelogScan is not implemented");
774+
std::shared_ptr<TableMetadata> metadata, std::shared_ptr<Schema> schema,
775+
std::shared_ptr<FileIO> io, internal::TableScanContext context) {
776+
ICEBERG_PRECHECK(metadata != nullptr, "Table metadata cannot be null");
777+
ICEBERG_PRECHECK(schema != nullptr, "Schema cannot be null");
778+
ICEBERG_PRECHECK(io != nullptr, "FileIO cannot be null");
779+
return std::unique_ptr<IncrementalChangelogScan>(new IncrementalChangelogScan(
780+
std::move(metadata), std::move(schema), std::move(io), std::move(context)));
755781
}
756782

757783
Result<std::vector<std::shared_ptr<ChangelogScanTask>>>
@@ -762,7 +788,129 @@ IncrementalChangelogScan::PlanFiles() const {
762788
Result<std::vector<std::shared_ptr<ChangelogScanTask>>>
763789
IncrementalChangelogScan::PlanFiles(std::optional<int64_t> from_snapshot_id_exclusive,
764790
int64_t to_snapshot_id_inclusive) const {
765-
return NotImplemented("IncrementalChangelogScan::PlanFiles is not implemented");
791+
ICEBERG_ASSIGN_OR_RAISE(
792+
auto ancestors_snapshots,
793+
SnapshotUtil::AncestorsBetween(*metadata_, to_snapshot_id_inclusive,
794+
from_snapshot_id_exclusive));
795+
796+
std::vector<std::pair<std::shared_ptr<Snapshot>, std::unique_ptr<SnapshotCache>>>
797+
changelog_snapshots;
798+
799+
for (const auto& snapshot : std::ranges::reverse_view(ancestors_snapshots)) {
800+
auto operation = snapshot->Operation();
801+
if (!operation.has_value() || operation.value() != DataOperation::kReplace) {
802+
auto snapshot_cache = std::make_unique<SnapshotCache>(snapshot.get());
803+
ICEBERG_ASSIGN_OR_RAISE(auto delete_manifests,
804+
snapshot_cache->DeleteManifests(io_));
805+
if (!delete_manifests.empty()) {
806+
return NotSupported(
807+
"Delete files are currently not supported in changelog scans");
808+
}
809+
changelog_snapshots.emplace_back(snapshot, std::move(snapshot_cache));
810+
}
811+
}
812+
if (changelog_snapshots.empty()) {
813+
return std::vector<std::shared_ptr<ChangelogScanTask>>{};
814+
}
815+
816+
std::unordered_set<int64_t> snapshot_ids;
817+
std::unordered_map<int64_t, int32_t> snapshot_ordinals;
818+
for (const auto& snapshot : changelog_snapshots) {
819+
ICEBERG_PRECHECK(
820+
std::cmp_less_equal(snapshot_ids.size(), std::numeric_limits<int32_t>::max()),
821+
"Number of snapshots in changelog scan exceeds maximum supported");
822+
snapshot_ids.insert(snapshot.first->snapshot_id);
823+
snapshot_ordinals.try_emplace(snapshot.first->snapshot_id,
824+
static_cast<int32_t>(snapshot_ordinals.size()));
825+
}
826+
827+
std::vector<ManifestFile> data_manifests;
828+
std::unordered_set<std::string> seen_manifest_paths;
829+
for (const auto& snapshot : changelog_snapshots) {
830+
ICEBERG_ASSIGN_OR_RAISE(auto manifests, snapshot.second->DataManifests(io_));
831+
for (auto& manifest : manifests) {
832+
if (snapshot_ids.contains(manifest.added_snapshot_id) &&
833+
seen_manifest_paths.insert(manifest.manifest_path).second) {
834+
data_manifests.push_back(manifest);
835+
}
836+
}
837+
}
838+
if (data_manifests.empty()) {
839+
return std::vector<std::shared_ptr<ChangelogScanTask>>{};
840+
}
841+
842+
TableMetadataCache metadata_cache(metadata_.get());
843+
ICEBERG_ASSIGN_OR_RAISE(auto specs_by_id, metadata_cache.GetPartitionSpecsById());
844+
845+
ICEBERG_ASSIGN_OR_RAISE(
846+
auto manifest_group,
847+
ManifestGroup::Make(io_, schema_, specs_by_id, std::move(data_manifests),
848+
/*delete_manifests=*/{}));
849+
850+
manifest_group->CaseSensitive(context_.case_sensitive)
851+
.Select(ScanColumns())
852+
.FilterData(filter())
853+
.FilterManifestEntries([&snapshot_ids](const ManifestEntry& entry) {
854+
return entry.snapshot_id.has_value() &&
855+
snapshot_ids.contains(entry.snapshot_id.value());
856+
})
857+
.IgnoreExisting()
858+
.ColumnsToKeepStats(context_.columns_to_keep_stats);
859+
860+
if (context_.ignore_residuals) {
861+
manifest_group->IgnoreResiduals();
862+
}
863+
864+
auto create_tasks_func =
865+
[&snapshot_ordinals](
866+
std::vector<ManifestEntry>&& entries,
867+
const TaskContext& ctx) -> Result<std::vector<std::shared_ptr<ScanTask>>> {
868+
std::vector<std::shared_ptr<ScanTask>> tasks;
869+
tasks.reserve(entries.size());
870+
871+
for (auto& entry : entries) {
872+
ICEBERG_PRECHECK(entry.snapshot_id.has_value() && entry.data_file,
873+
"Invalid manifest entry with missing snapshot id or data file");
874+
875+
int64_t commit_snapshot_id = entry.snapshot_id.value();
876+
auto ordinal_it = snapshot_ordinals.find(commit_snapshot_id);
877+
ICEBERG_PRECHECK(ordinal_it != snapshot_ordinals.end(),
878+
"Invalid manifest entry with missing snapshot ordinal");
879+
880+
int32_t change_ordinal = ordinal_it->second;
881+
882+
if (ctx.drop_stats) {
883+
ContentFileUtil::DropAllStats(*entry.data_file);
884+
} else if (!ctx.columns_to_keep_stats.empty()) {
885+
ContentFileUtil::DropUnselectedStats(*entry.data_file, ctx.columns_to_keep_stats);
886+
}
887+
888+
ICEBERG_ASSIGN_OR_RAISE(auto residual,
889+
ctx.residuals->ResidualFor(entry.data_file->partition));
890+
891+
switch (entry.status) {
892+
case ManifestStatus::kAdded:
893+
tasks.push_back(std::make_shared<AddedRowsScanTask>(
894+
change_ordinal, commit_snapshot_id, std::move(entry.data_file),
895+
std::vector<std::shared_ptr<DataFile>>{}, std::move(residual)));
896+
break;
897+
case ManifestStatus::kDeleted:
898+
tasks.push_back(std::make_shared<DeletedDataFileScanTask>(
899+
change_ordinal, commit_snapshot_id, std::move(entry.data_file),
900+
std::vector<std::shared_ptr<DataFile>>{}, std::move(residual)));
901+
break;
902+
case ManifestStatus::kExisting:
903+
return InvalidArgument("Unexpected entry status: EXISTING");
904+
}
905+
}
906+
return tasks;
907+
};
908+
909+
ICEBERG_ASSIGN_OR_RAISE(auto tasks, manifest_group->Plan(create_tasks_func));
910+
return tasks | std::views::transform([](const auto& task) {
911+
return std::static_pointer_cast<ChangelogScanTask>(task);
912+
}) |
913+
std::ranges::to<std::vector>();
766914
}
767915

768916
} // namespace iceberg

src/iceberg/table_scan.h

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,122 @@ class ICEBERG_EXPORT FileScanTask : public ScanTask {
102102
std::shared_ptr<Expression> residual_filter_;
103103
};
104104

105+
enum class ChangelogOperation : uint8_t {
106+
kInsert,
107+
kDelete,
108+
kUpdateBefore,
109+
kUpdateAfter,
110+
};
111+
105112
/// \brief A scan task for reading changelog entries between snapshots.
106113
class ICEBERG_EXPORT ChangelogScanTask : public ScanTask {
107114
public:
115+
/// \brief Construct an AddedRowsScanTask.
116+
///
117+
/// \param change_ordinal Position in the changelog order (0-based).
118+
/// \param commit_snapshot_id The snapshot ID that committed this change.
119+
/// \param data_file The data file containing the added rows.
120+
/// \param delete_files Delete files that apply to this data file.
121+
/// \param residual_filter Optional residual filter to apply after reading.
122+
ChangelogScanTask(int32_t change_ordinal, int64_t commit_snapshot_id,
123+
std::shared_ptr<DataFile> data_file,
124+
std::vector<std::shared_ptr<DataFile>> delete_files = {},
125+
std::shared_ptr<Expression> residual_filter = nullptr)
126+
: change_ordinal_(change_ordinal),
127+
commit_snapshot_id_(commit_snapshot_id),
128+
data_file_(std::move(data_file)),
129+
delete_files_(std::move(delete_files)),
130+
residual_filter_(std::move(residual_filter)) {}
131+
108132
Kind kind() const override { return Kind::kChangelogScanTask; }
109-
// TODO(): Return actual values once member fields are implemented
110-
int64_t size_bytes() const override { return 0; }
111-
int32_t files_count() const override { return 0; }
112-
int64_t estimated_row_count() const override { return 0; }
133+
134+
int64_t size_bytes() const override;
135+
int32_t files_count() const override;
136+
int64_t estimated_row_count() const override;
137+
138+
virtual ChangelogOperation operation() const = 0;
139+
140+
/// \brief The position of this change in the changelog order (0-based).
141+
int32_t change_ordinal() const { return change_ordinal_; }
142+
143+
/// \brief The snapshot ID that committed this change.
144+
int64_t commit_snapshot_id() const { return commit_snapshot_id_; }
145+
146+
/// \brief Residual filter to apply after reading.
147+
const std::shared_ptr<Expression>& residual_filter() const { return residual_filter_; }
148+
149+
protected:
150+
int32_t change_ordinal_;
151+
int64_t commit_snapshot_id_;
152+
std::shared_ptr<DataFile> data_file_;
153+
std::vector<std::shared_ptr<DataFile>> delete_files_;
154+
std::shared_ptr<Expression> residual_filter_;
155+
};
156+
157+
/// \brief A scan task for inserts generated by adding a data file to the table.
158+
///
159+
/// This task represents data files that were added to the table, along with any
160+
/// delete files that should be applied when reading the data.
161+
///
162+
/// Added data files may have matching delete files. This may happen if a
163+
/// matching position delete file is committed in the same snapshot or if changes
164+
/// for multiple snapshots are squashed together.
165+
///
166+
/// Suppose snapshot S1 adds data files F1, F2, F3 and a position delete file,
167+
/// D1, that marks particular records in F1 as deleted. A scan for changes
168+
/// generated by S1 should include the following tasks:
169+
/// - AddedRowsScanTask(file=F1, deletes=[D1], snapshot=S1)
170+
/// - AddedRowsScanTask(file=F2, deletes=[], snapshot=S1)
171+
/// - AddedRowsScanTask(file=F3, deletes=[], snapshot=S1)
172+
///
173+
/// Readers consuming these tasks should produce added records with metadata
174+
/// like change ordinal and commit snapshot ID.
175+
class ICEBERG_EXPORT AddedRowsScanTask : public ChangelogScanTask {
176+
public:
177+
using ChangelogScanTask::ChangelogScanTask;
178+
179+
ChangelogOperation operation() const override { return ChangelogOperation::kInsert; }
180+
181+
/// \brief The data file containing the added rows.
182+
const std::shared_ptr<DataFile>& data_file() const { return data_file_; }
183+
184+
/// \brief A list of delete files to apply when reading the data file in this task.
185+
///
186+
/// @return A list of delete files to apply
187+
const std::vector<std::shared_ptr<DataFile>>& delete_files() const {
188+
return delete_files_;
189+
}
190+
};
191+
192+
/// \brief A scan task for deletes generated by removing a data file from the table.
193+
///
194+
/// All historical delete files added earlier must be applied while reading the data file.
195+
/// This is required to output only those data records that were live when the data file
196+
/// was removed.
197+
///
198+
/// Suppose snapshot S1 contains data files F1, F2, F3. Then snapshot S2 adds a position
199+
/// delete file, D1, that deletes records from F2 and snapshot S3 removes F2 entirely. A
200+
/// scan for changes generated by S3 should include the following task:
201+
/// - DeletedDataFileScanTask(file=F2, existing-deletes=[D1], snapshot=S3)
202+
///
203+
/// Readers consuming these tasks should produce deleted records with metadata like
204+
/// change ordinal and commit snapshot ID.
205+
class ICEBERG_EXPORT DeletedDataFileScanTask : public ChangelogScanTask {
206+
public:
207+
using ChangelogScanTask::ChangelogScanTask;
208+
209+
ChangelogOperation operation() const override { return ChangelogOperation::kDelete; }
210+
211+
/// \brief The data file that was deleted.
212+
const std::shared_ptr<DataFile>& data_file() const { return data_file_; }
213+
214+
/// \brief A list of previously added delete files to apply when reading the
215+
/// data file in this task.
216+
///
217+
/// \return A list of delete files to apply
218+
const std::vector<std::shared_ptr<DataFile>>& existing_deletes() const {
219+
return delete_files_;
220+
}
113221
};
114222

115223
namespace internal {

src/iceberg/test/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ if(ICEBERG_BUILD_BUNDLE)
182182
SOURCES
183183
file_scan_task_test.cc
184184
incremental_append_scan_test.cc
185+
incremental_changelog_scan_test.cc
185186
table_scan_test.cc)
186187

187188
add_iceberg_test(table_update_test

0 commit comments

Comments
 (0)