Skip to content

Commit 0e44dfe

Browse files
committed
feat(read): support deletion vectors for data-evolution tables
Port the read side of Apache Paimon d2be7eace (#8380). A data-evolution table may now enable deletion-vectors.enabled: the deletion vector of a row range group is maintained against the group's anchor file, so every reader of the group applies it shifted by the file's offset inside the anchor row id range, and the blob fallback path drops the deleted row ids from its placeholder gap segments. That keeps the readers of a group positionally aligned for the column merge. - Pick the anchor by Java's rule, the oldest normal file compared by (max_sequence_number, file_name), since the engine writing the vectors keys them by the same file. - Support committing a deletion vector index file for a table without buckets, which a data-evolution table always is: combine such index files by file name, as Java's GlobalCombiner does. - Narrow the conflict detection rule that refused deletion vectors in BUCKET_UNAWARE mode outright, which kept the deletes another engine issues from reaching the table through this commit path at all, to refusing a delta that deletes data files. That is the only shape whose conflicts need the data file to deletion vector pairing Java builds in buildBaseEntriesWithDV, which is not ported; adding files cannot orphan a vector, and Paimon C++ produces no other shape here, since it never writes deletion vectors and never compacts such a table. - Subtract deletion vector cardinality from DataSplit::MergedRowCount and report it unavailable when a deletion file has no cardinality. - Apply the row ranges selection and the group deletion vectors to the blob view pre-read, so a reference held only by a dropped row is not resolved. - Read a row range group made only of blob files, which a split can pack next to a group carrying a deletion vector, without an anchor: no writer can key a vector by such a group, so it reads undeleted rather than failing the whole split on the missing anchor. Paimon Java throws here. Paimon C++ does not produce these deletion vectors; bitmap64 vectors and compaction of such a table remain unsupported. The read behavior and its limitations are documented in docs/source/user_guide/read.rst, the compaction restriction in docs/source/user_guide/compaction.rst. Two behavior changes worth calling out, since they reach append and primary key tables too. ApplyPushDownLimit used to consider only raw convertible splits, silently leaving a merged split out of the pruned plan. It now asks every split for its row count and abandons the push down as soon as one cannot be counted from metadata, which is the same conservative answer the raw convertible path already gave. That is what lets a data-evolution split, which is not raw convertible but does have an exact count, take part in the pruning. The new CanPushDownLimit guard also fixes a wrong result that predates deletion vectors: the push down never consulted the non-partition predicate, so a limited query carrying a value filter pruned splits on row counts the filter had not been applied to yet, and could return fewer rows than the limit asked for while more still existed. It is now skipped whenever a non-partition filter or a row range index is present, since the metadata count is only an upper bound of what such a read returns.
1 parent fa37cb0 commit 0e44dfe

31 files changed

Lines changed: 2647 additions & 182 deletions

docs/source/user_guide/compaction.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,21 @@ not block writes.
4747
(``bucket > 0``). Dynamic bucketing (``bucket = -1``) does not support
4848
compaction. Tables with blob columns also skip compaction.
4949

50+
.. _data-evolution-deletion-vectors-compaction:
51+
52+
.. note::
53+
A data-evolution table (``data-evolution.enabled = true``) may enable
54+
``deletion-vectors.enabled``. Paimon C++ never compacts such a table: auto
55+
compaction never runs on it, since data evolution requires ``bucket = -1``,
56+
and ``AppendCompactCoordinator::Run``, the dedicated compaction entry point,
57+
rejects it outright rather than dropping the deletes. This matches the
58+
Paimon Java revision this support was ported from, which ends the compaction
59+
scan as soon as deletion vectors are enabled; later Java releases do compact
60+
such a table, and Paimon C++ has not caught up.
61+
62+
Reading such a table is supported; see
63+
:ref:`data-evolution-deletion-vectors`.
64+
5065
Auto Compaction
5166
~~~~~~~~~~~~~~~
5267
During each flush, the writer triggers a best-effort auto compaction. The

docs/source/user_guide/read.rst

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,45 @@ Implementation Guidance
308308
- Expect per-file schema variability; design readers to align by field IDs rather than positional indices.
309309
- Do not assume identical overflow semantics across C++ and Java; tests should validate acceptable ranges and nullability.
310310
- For timestamp handling, consider precision/range constraints in C++ when interoperating with Java-produced data splits.
311+
312+
.. _data-evolution-deletion-vectors:
313+
314+
Deletion Vectors on Data-Evolution Tables
315+
-----------------------------------------
316+
317+
A data-evolution table (``data-evolution.enabled = true``) may enable
318+
``deletion-vectors.enabled``. Reading such a table is supported: a deleted row disappears
319+
from the result, including from the columns merged out of the other files that cover it.
320+
321+
How the Deletion Vector Is Located
322+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
323+
324+
A data-evolution split holds several files per row id range, one per group of columns. The
325+
deletion vector of such a row range group is not per file: it is maintained against the
326+
group's *anchor file*, the oldest normal file of the group, compared by
327+
``(max_sequence_number, file_name)`` and skipping blob and vector-store files. Its positions
328+
are therefore relative to the anchor file's row id range.
329+
330+
Reading applies that one vector to every file of the group, shifted by the file's offset
331+
inside the anchor range, so all the readers being merged drop the same rows and stay
332+
positionally aligned. The blob fallback path has no file reader to wrap for the placeholder
333+
gaps it pads uncovered row ids with, so it removes the deleted row ids from those gap ranges
334+
instead.
335+
336+
The rule that picks the anchor has to stay identical to the engine that writes the vectors:
337+
a vector keyed by any other file of the group is never found, and its deleted rows silently
338+
come back.
339+
340+
Limitations
341+
~~~~~~~~~~~
342+
343+
- Only the default 32-bit deletion vectors can be read. ``deletion-vectors.bitmap64`` is not
344+
supported yet, and a read fails when it actually encounters a 64-bit deletion vector.
345+
- Paimon C++ does not write deletion vectors for data-evolution tables, so the deletes
346+
themselves have to be issued by another engine.
347+
- A commit that drops data files from such a table, an overwrite for instance, is refused.
348+
Whether it conflicts with a concurrent commit rewriting those files' deletion vectors cannot
349+
be decided yet, so it fails rather than committing against a stale state. Appending is
350+
unaffected, and so is another engine replacing a deletion vector.
351+
- Such a table is never compacted; see
352+
:ref:`the compaction note <data-evolution-deletion-vectors-compaction>`.

include/paimon/defs.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,9 @@ struct PAIMON_EXPORT Options {
358358
/// files containing deletion vectors are generated when data is written, which marks the data
359359
/// for deletion. During read operations, by applying these index files, merging can be avoided.
360360
/// Default value is false.
361+
/// @note On a data-evolution table (`DATA_EVOLUTION_ENABLED`), Paimon C++ reads deletion
362+
/// vectors but does not write them: the deletes have to be issued by another engine, and such
363+
/// a table is never compacted.
361364
static const char DELETION_VECTORS_ENABLED[];
362365

363366
/// "deletion-vector.index-file.target-size" - The target size of deletion vector index file.

src/paimon/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ set(PAIMON_CORE_SRCS
411411
core/utils/branch_manager.cpp
412412
core/utils/blob_view_lookup.cpp
413413
core/utils/consumer_manager.cpp
414+
core/utils/data_evolution_utils.cpp
414415
core/utils/field_mapping.cpp
415416
core/utils/nested_projection_utils.cpp
416417
core/utils/file_store_path_factory.cpp
@@ -862,6 +863,7 @@ if(PAIMON_BUILD_TESTS)
862863
core/utils/blob_view_lookup_test.cpp
863864
core/utils/branch_manager_test.cpp
864865
core/utils/consumer_manager_test.cpp
866+
core/utils/data_evolution_utils_test.cpp
865867
core/utils/file_store_path_factory_cache_test.cpp
866868
core/utils/field_mapping_test.cpp
867869
core/utils/nested_projection_utils_test.cpp

src/paimon/common/reader/blob_fallback_batch_reader.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,9 @@ namespace paimon {
5050
/// first) and, inside each group, orders them by first row id. Row id ranges the group's
5151
/// files do not cover are represented by gap segments, which stand for all-placeholder rows.
5252
/// 2. All groups span the same overall row id range, so with the same row-ranges selection
53-
/// applied they yield the same number of rows and can be stepped in lockstep.
53+
/// applied they yield the same number of rows and can be stepped in lockstep. A deletion
54+
/// vector has to reach every group the same way, through the file segments' readers and
55+
/// through the row ids the caller leaves in a gap segment's `gap_selected_ranges`.
5456
/// 3. Each output row takes the first group, in max-sequence order, whose row is not a
5557
/// placeholder. Placeholder rows are identified by exact equality with the
5658
/// BlobDefs::kPlaceholderSentinel bytes, emitted by the blob format reader when

src/paimon/core/manifest/index_manifest_file_handler.cpp

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,19 @@
1919
#include "paimon/core/manifest/index_manifest_file_handler.h"
2020

2121
#include <set>
22+
#include <string>
2223
#include <unordered_map>
2324
#include <utility>
2425

26+
#include "fmt/format.h"
27+
#include "paimon/common/utils/linked_hash_map.h"
2528
#include "paimon/core/deletionvectors/deletion_vectors_index_file.h"
29+
#include "paimon/core/index/index_file_meta.h"
2630
namespace paimon {
2731

2832
using BucketIdentifier = std::tuple<BinaryRow, int32_t, std::string>;
2933

30-
std::vector<IndexManifestEntry> IndexManifestFileHandler::BucketedCombiner::Combine(
34+
Result<std::vector<IndexManifestEntry>> IndexManifestFileHandler::BucketedCombiner::Combine(
3135
const std::vector<IndexManifestEntry>& prev_index_files,
3236
const std::vector<IndexManifestEntry>& new_index_files) const {
3337
std::unordered_map<BucketIdentifier, IndexManifestEntry> index_entries;
@@ -67,7 +71,7 @@ std::vector<IndexManifestEntry> IndexManifestFileHandler::BucketedCombiner::Comb
6771
return result_entries;
6872
}
6973

70-
std::vector<IndexManifestEntry> IndexManifestFileHandler::GlobalFileNameCombiner::Combine(
74+
Result<std::vector<IndexManifestEntry>> IndexManifestFileHandler::GlobalFileNameCombiner::Combine(
7175
const std::vector<IndexManifestEntry>& prev_index_files,
7276
const std::vector<IndexManifestEntry>& new_index_files) const {
7377
std::map<std::string, IndexManifestEntry> index_entries;
@@ -104,6 +108,92 @@ std::vector<IndexManifestEntry> IndexManifestFileHandler::GlobalFileNameCombiner
104108
return result_entries;
105109
}
106110

111+
namespace {
112+
using DeletionVectorRanges = LinkedHashMap<std::string, DeletionVectorMeta>;
113+
114+
/// The deletion vectors an index file holds, keyed by the data file each covers. Null when the
115+
/// entry holds none.
116+
const DeletionVectorRanges* GetDeletionVectorRanges(const IndexManifestEntry& entry) {
117+
const std::optional<DeletionVectorRanges>& dv_ranges = entry.index_file->DvRanges();
118+
return dv_ranges == std::nullopt ? nullptr : &dv_ranges.value();
119+
}
120+
} // namespace
121+
122+
Result<std::vector<IndexManifestEntry>>
123+
IndexManifestFileHandler::GlobalDeletionVectorCombiner::Combine(
124+
const std::vector<IndexManifestEntry>& prev_index_files,
125+
const std::vector<IndexManifestEntry>& new_index_files) const {
126+
std::map<std::string, IndexManifestEntry> index_entries;
127+
std::set<std::string> covered_data_files;
128+
for (const auto& entry : prev_index_files) {
129+
index_entries.insert_or_assign(entry.index_file->FileName(), entry);
130+
const DeletionVectorRanges* dv_ranges = GetDeletionVectorRanges(entry);
131+
if (dv_ranges == nullptr) {
132+
continue;
133+
}
134+
for (const auto& [data_file, _] : *dv_ranges) {
135+
covered_data_files.insert(data_file);
136+
}
137+
}
138+
139+
std::vector<const IndexManifestEntry*> removed;
140+
std::vector<const IndexManifestEntry*> added;
141+
for (const auto& entry : new_index_files) {
142+
if (entry.kind == FileKind::Delete()) {
143+
removed.push_back(&entry);
144+
} else if (entry.kind == FileKind::Add()) {
145+
added.push_back(&entry);
146+
}
147+
}
148+
149+
// The deleted entry is processed first, so that an index file taking over the data files of
150+
// the one it replaces is not rejected as a second vector for them. Paimon Java's
151+
// GlobalCombiner is order sensitive here; its two sibling combiners are not.
152+
for (const IndexManifestEntry* entry : removed) {
153+
const std::string& file_name = entry->index_file->FileName();
154+
if (index_entries.erase(file_name) == 0) {
155+
return Status::Invalid(fmt::format(
156+
"Trying to delete deletion vector index file {} which does not exist.", file_name));
157+
}
158+
const DeletionVectorRanges* dv_ranges = GetDeletionVectorRanges(*entry);
159+
if (dv_ranges == nullptr) {
160+
continue;
161+
}
162+
for (const auto& [data_file, _] : *dv_ranges) {
163+
if (covered_data_files.erase(data_file) == 0) {
164+
return Status::Invalid(
165+
fmt::format("Trying to delete the deletion vector of data file {}, which does "
166+
"not exist.",
167+
data_file));
168+
}
169+
}
170+
}
171+
for (const IndexManifestEntry* entry : added) {
172+
const std::string& file_name = entry->index_file->FileName();
173+
if (index_entries.find(file_name) != index_entries.end()) {
174+
return Status::Invalid(fmt::format(
175+
"Trying to add deletion vector index file {} which is already added.", file_name));
176+
}
177+
const DeletionVectorRanges* dv_ranges = GetDeletionVectorRanges(*entry);
178+
if (dv_ranges != nullptr) {
179+
for (const auto& [data_file, _] : *dv_ranges) {
180+
if (!covered_data_files.insert(data_file).second) {
181+
return Status::Invalid(fmt::format(
182+
"Trying to add a second deletion vector for data file {}.", data_file));
183+
}
184+
}
185+
}
186+
index_entries.insert_or_assign(file_name, *entry);
187+
}
188+
189+
std::vector<IndexManifestEntry> result_entries;
190+
result_entries.reserve(index_entries.size());
191+
for (const auto& [_, entry] : index_entries) {
192+
result_entries.push_back(entry);
193+
}
194+
return result_entries;
195+
}
196+
107197
Result<std::string> IndexManifestFileHandler::Write(
108198
const std::optional<std::string>& previous_index_manifest,
109199
const std::vector<IndexManifestEntry>& new_index_entries, int32_t bucket_mode,
@@ -138,8 +228,8 @@ Result<std::string> IndexManifestFileHandler::Write(
138228
GetIndexManifestFileCombine(index_type, bucket_mode));
139229
std::vector<IndexManifestEntry> typed_previous_entries = previous[index_type];
140230
std::vector<IndexManifestEntry> typed_current_entries = current[index_type];
141-
std::vector<IndexManifestEntry> combined_entries =
142-
combiner->Combine(typed_previous_entries, typed_current_entries);
231+
PAIMON_ASSIGN_OR_RAISE(std::vector<IndexManifestEntry> combined_entries,
232+
combiner->Combine(typed_previous_entries, typed_current_entries));
143233

144234
index_entries.insert(index_entries.end(), combined_entries.begin(), combined_entries.end());
145235
}
@@ -167,8 +257,14 @@ IndexManifestFileHandler::GetIndexManifestFileCombine(const std::string& index_t
167257
if (index_type != DeletionVectorsIndexFile::DELETION_VECTORS_INDEX && index_type != "HASH") {
168258
return std::make_unique<GlobalFileNameCombiner>();
169259
}
260+
// `bucket_mode` is the configured bucket, not a resolved BucketMode, standing in for Paimon
261+
// Java's BucketMode.BUCKET_UNAWARE check. The two agree on every table that can exist:
262+
// SchemaValidation rejects the other unaware bucket, 0, and BucketIdCalculator refuses to
263+
// write the primary key table on which -1 means HASH_DYNAMIC instead. Lifting either
264+
// restriction means passing the resolved BucketMode here, or a dynamic bucket table would
265+
// combine its per-bucket deletion vectors by index file name.
170266
if (index_type == DeletionVectorsIndexFile::DELETION_VECTORS_INDEX && bucket_mode == -1) {
171-
return Status::NotImplemented("not yet support dv with BUCKET_UNAWARE mode");
267+
return std::make_unique<GlobalDeletionVectorCombiner>();
172268
}
173269
return std::make_unique<BucketedCombiner>();
174270
}

src/paimon/core/manifest/index_manifest_file_handler.h

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,36 @@ class IndexManifestFileHandler {
4343
class IndexManifestFileCombiner {
4444
public:
4545
virtual ~IndexManifestFileCombiner() = default;
46-
virtual std::vector<IndexManifestEntry> Combine(
46+
virtual Result<std::vector<IndexManifestEntry>> Combine(
4747
const std::vector<IndexManifestEntry>& prev_index_files,
4848
const std::vector<IndexManifestEntry>& new_index_files) const = 0;
4949
};
5050

5151
/// Combine previous and new index files by partition, bucket and index type.
5252
class BucketedCombiner : public IndexManifestFileCombiner {
5353
public:
54-
std::vector<IndexManifestEntry> Combine(
54+
Result<std::vector<IndexManifestEntry>> Combine(
5555
const std::vector<IndexManifestEntry>& prev_index_files,
5656
const std::vector<IndexManifestEntry>& new_index_files) const override;
5757
};
5858

5959
/// Combine previous and new index files by file name.
6060
class GlobalFileNameCombiner : public IndexManifestFileCombiner {
6161
public:
62-
std::vector<IndexManifestEntry> Combine(
62+
Result<std::vector<IndexManifestEntry>> Combine(
63+
const std::vector<IndexManifestEntry>& prev_index_files,
64+
const std::vector<IndexManifestEntry>& new_index_files) const override;
65+
};
66+
67+
/// Combine previous and new deletion vector index files by file name, for a table without
68+
/// buckets, where the bucket cannot tell two index files apart.
69+
///
70+
/// A data file is covered by at most one deletion vector, so a delta that adds a second one
71+
/// for a data file, or drops one that is not there, was built against a different base and
72+
/// is rejected rather than silently changing which rows the read returns.
73+
class GlobalDeletionVectorCombiner : public IndexManifestFileCombiner {
74+
public:
75+
Result<std::vector<IndexManifestEntry>> Combine(
6376
const std::vector<IndexManifestEntry>& prev_index_files,
6477
const std::vector<IndexManifestEntry>& new_index_files) const override;
6578
};

0 commit comments

Comments
 (0)