feat(core): read source-backed primary-key BTree indexes - #194
Conversation
Add the core types for Paimon 2.0 primary-key source-backed scalar indexes: PrimaryKeyIndexSourceMeta v1 decoding (big-endian layout, Java modified UTF-8 file names, defensive count cap, trailing-byte rejection), the COMPACT level>0 source policy, pk-btree / pk-bitmap / pk-vector / pk-full-text definition parsing with field-scoped JSON option validation, and the exact per-level source group validation (PkSortedIndexGroup / PkSortedBucketIndexState) that decides whether a payload covers its data level. The metadata carrier (GlobalIndexMeta source_meta, commit message v12) landed in apache#179; this change decodes and validates what it carries. Planning and reading follow in the next part. part of apache#192
Wire the source-backed scalar indexes into ordinary batch scans of primary-key tables, mirroring the Java release-2.0.0 planner: organize same-snapshot data splits and index manifest ADD entries into validated groups, evaluate the indexed part of the scan predicate once per group with a query cache, localize group ordinals to per-file physical row positions by the source row-count prefix, and convert results to indexed splits with the 4096-range fragmentation guard, deletion files kept aligned by file index, and per-file fallback on any untrusted state. The raw read path accepts file-local row ranges (intersected with file index selection, deletion vectors subtracted) and KeyValueTableRead routes indexed splits through it; the reader still applies the complete original predicate. AND may narrow partially, OR is used only when every branch is evaluable, and redundant IS NOT NULL leaves are pruned under AND. Only the BTree payload reader is wired; bitmap / vector / full-text definitions are recognized and conservatively fall back to normal scans. Gated by global-index.enabled (default true). Integration tests arrive with the payload builder in the next part. part of apache#192
Add PkSortedIndexFile::Build, which writes exactly one BTree payload for an ordered source group from value-sorted input and returns an IndexFileMeta carrying the serialized PrimaryKeyIndexSourceMeta, plus the end-to-end integration tests that exercise the full cycle with real BTree payloads: build -> plan -> evaluate -> localize -> splits, covering equality narrowing, ranges crossing file boundaries, empty hits omitting files, fallback on unindexed columns / uncovered files / corrupted metadata / out-of-range ordinals / over-fragmented results, deletion file alignment, and same-snapshot rejection. A user guide page documents the table requirements, semantics and current scope. close apache#192
|
cc @lxy-9602 @lucasfang could you please take a look when convenient? Thanks~ |
# Conflicts: # src/paimon/core/table/source/table_scan.cpp
| // Readers are created per payload group and may coexist for many buckets. Share the | ||
| // process-wide executor instead of creating a dedicated thread pool for every group. | ||
| std::shared_ptr<Executor> executor = GetGlobalDefaultExecutor(); | ||
| return std::make_shared<LazyFilteredBTreeReader>(read_buffer_size, files, key_type, file_reader, |
There was a problem hiding this comment.
@lszskye Please evaluate whether using GlobalDefaultExecutor here could cause any issues. I’d lean toward passing the same executor to each reader instead.
Also, the executor thread count in Java is based on GLOBAL_INDEX_THREAD_NUM, rather than using the machine core count as GetGlobalDefaultExecutor() does.
A more complete approach would be to let C++ GlobalIndexer: accept an external executor and, like Java, have the scan layer create and pass it in based on global-index.thread-num, instead of having BTreeGlobalIndexer implicitly choose GetGlobalDefaultExecutor().
There was a problem hiding this comment.
Updated. GlobalIndexer::CreateReader now accepts an external executor. PrimaryKeyIndexBatchScan creates one scan-scoped executor from global-index.thread-num and shares it across BTree readers; when unset, it preserves the existing C++ CPU-count default. The legacy GlobalIndexScanImpl path keeps its outer executor separate to avoid nested synchronous work on the same fixed-size pool. No executor is created when the plan has no valid index group.
There was a problem hiding this comment.
Follow-up correction: each primary-key index group currently contains exactly one payload, and UnionGlobalIndexReader evaluates a single reader inline, so the scan-scoped executor could never receive work. It has been removed, and the PK path now explicitly passes nullptr. The four-argument BTree reader retains its pre-PR private four-thread executor behavior for GlobalIndexScanImpl; global-index.thread-num is not applied to the single-payload PK path.
| /// four-byte standard UTF-8 form. | ||
| class JavaModifiedUtf8 { | ||
| public: | ||
| JavaModifiedUtf8() = delete; |
There was a problem hiding this comment.
This seems to be for binary compatibility of the source data file name in PrimaryKeyIndexSourceMeta, but in Java, under what scenarios would the file name contain Chinese characters or other special characters?
There was a problem hiding this comment.
I think I have a rough understanding of the issue now. It’s not limited to this spot—FileIndexFormat, DataSplit.bucketPath, and DeletionFile.path all have similar problems with Chinese characters when converting string to UTF.
I’d suggest removing this part from the current PR for now, and then submitting a separate PR later to fully address all writeUTF / readUTF related issues.
There was a problem hiding this comment.
Configured data-file.prefix can introduce Unicode. However, this is a stream-wide issue, so the PR-local codec has been removed and source metadata now uses the existing stream primitives. A separate change can address writeUTF / readUTF consistently across FileIndexFormat, DataSplit.bucketPath, DeletionFile.path, and source metadata. The remaining supplementary-code-point limitation is documented.
|
|
||
| PrimaryKeyIndexDefinition(std::string column, int32_t field_id, std::string index_type, | ||
| std::map<std::string, std::string> options, Family family) | ||
| : column_(std::move(column)), |
There was a problem hiding this comment.
Could we move the Family family parameter before options?
There was a problem hiding this comment.
Updated. Family family now precedes options; the member and accessor order and all call sites were changed consistently.
| } | ||
| } | ||
| return Status::OK(); | ||
| } |
There was a problem hiding this comment.
I’m a bit curious whether ValidateNoDuplicates and ValidateUniqueColumns could be refactored into a shared helper function, with different error reporting as needed. Also, could we move the output parameter to the end of the parameter list?
There was a problem hiding this comment.
Updated. Both validation paths now reuse AddUniqueColumns with a caller-supplied duplicate-error callback, and the output set parameter is last.
| if (ObjectUtils::Contains(btree_columns, column)) { | ||
| Result<std::map<std::string, std::string>> definition_options = | ||
| SortedIndexOptions(options, column, kBTreeOptionFamily, kBTreeAlgorithmPrefix); | ||
| PAIMON_RETURN_NOT_OK(definition_options.status()); |
There was a problem hiding this comment.
Please use ASSERT_OK_AND_ASSIGN instead of calling PAIMON_RETURN_NOT_OK first and then accessing value(). If there are similar cases elsewhere, could you fix them as well?
There was a problem hiding this comment.
Updated. This is production code, so it now uses PAIMON_ASSIGN_OR_RAISE rather than the test-only ASSERT_OK_AND_ASSIGN. Similar patterns in the new tests were changed to ASSERT_OK_AND_ASSIGN or EXPECT_OK_AND_ASSIGN.
| ASSERT_OK_AND_ASSIGN(std::unique_ptr<TableSchema> schema, | ||
| MakeSchema({{Options::PK_BTREE_INDEX_COLUMNS, "price,price"}})); | ||
| ASSERT_NOK(PrimaryKeyIndexDefinitions::Create(*schema)); | ||
| } |
There was a problem hiding this comment.
Please make the error message explicit. I’d recommend using ASSERT_NOK_WITH_MSG.
There was a problem hiding this comment.
Updated. Both duplicate-column tests now use ASSERT_NOK_WITH_MSG and check the exact diagnostics.
| out->push_back(static_cast<char>((bits >> shift) & 0xFF)); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Please try to reuse DataOutputStream, DataInputStream, MemorySegmentOutputStream for endianness conversion and data input/output.
There was a problem hiding this comment.
Updated. Deserialization now uses ByteArrayInputStream with DataInputStream, and serialization uses MemorySegmentOutputStream for pooled big-endian output. DataOutputStream cannot wrap MemorySegmentOutputStream without an adapter or extra copy. The string length and bytes remain explicit because MemorySegmentOutputStream::WriteString narrows through int16_t, while this wire field supports the full uint16_t range. Boundary and null-input tests were added.
| std::optional<std::string> external_path; | ||
| if (is_external_path) { | ||
| external_path = io_meta.file_path; | ||
| } |
There was a problem hiding this comment.
Please change this to something like:
if (is_external_path) {
PAIMON_ASSIGN_OR_RAISE(Path path, PathUtil::ToPath(io_meta.file_path));
external_path = path.ToString();
}Could we normalize the path here? Regular global index writing does this in global_index_write_task.cpp. In Java, org.apache.paimon.fs.Path parses and normalizes the URI during construction, so the final stored value is the normalized form from Path.toString().
There was a problem hiding this comment.
Updated. External payload paths are now normalized through PathUtil::ToPath and Path::ToString, with regression coverage for duplicate separators.
| const std::shared_ptr<arrow::Array>& sorted_values, std::vector<int64_t> sorted_ordinals, | ||
| const std::shared_ptr<GlobalIndexFileWriter>& file_writer, bool is_external_path, | ||
| const std::shared_ptr<MemoryPool>& pool) { | ||
| PAIMON_ASSIGN_OR_RAISE(PrimaryKeyIndexSourceMeta source_meta, |
There was a problem hiding this comment.
The current implementation puts all planned sorted data for an entire index file into a single sorted_values, which could lead to excessive memory usage. While Java uses an external sort buffer here.
Given the scope of the current PR, I’d suggest adding a TODO to clearly document this as a known issue and plan to fix it in a follow-up PR.
There was a problem hiding this comment.
Added an author-tagged TODO to replace the in-memory input with an external sort buffer and bounded writer batches.
| Result<std::unique_ptr<BatchReader>> CreateReader( | ||
| const BinaryRow& partition, int32_t bucket, | ||
| const std::vector<std::shared_ptr<DataFileMeta>>& files, DeletionVector::Factory dv_factory, | ||
| const std::optional<std::vector<Range>>& local_row_ranges = std::nullopt); |
There was a problem hiding this comment.
Please avoid using default arguments in production code.
There was a problem hiding this comment.
Also, I’m not sure it’s necessary to add a new CreateReader function. Could we just extend the existing one with a local_row_ranges parameter instead?
There was a problem hiding this comment.
Updated. The existing overloads now take local_row_ranges explicitly; the forwarding overload and production default argument were removed, and callers pass std::nullopt explicitly.
| if (inner_split_impl->DataFiles().size() != 1) { | ||
| return Status::Invalid( | ||
| "indexed splits with file-local row ranges must contain exactly one file"); | ||
| } |
There was a problem hiding this comment.
If the indexed split contains scores, is read supported right now? I don’t see any handling logic for that. If it’s not supported, please fail fast and add a TODO to mark it clearly.
There was a problem hiding this comment.
Updated. Primary-key reads now fail fast for scored IndexedSplits before routing or force-keep-delete fallback; a TODO and regression coverage were added.
| builder.WithSnapshot(source->SnapshotId()) | ||
| .WithTotalBuckets(source->TotalBuckets()) | ||
| .IsStreaming(false) | ||
| .RawConvertible(source->RawConvertible()); |
There was a problem hiding this comment.
It seems in Java, rawConvertible is always false here. The current implementation would make the scan results inconsistent with Java.
There was a problem hiding this comment.
Updated. Derived single-file splits now use rawConvertible(false), and IndexedSplits are routed explicitly to the physical-position reader while unindexed splits use the normal merge path.
| public: | ||
| FilePlan(std::shared_ptr<DataSplitImpl> source_split, int32_t file_index, | ||
| std::map<int32_t, std::shared_ptr<PkSortedIndexGroup>> groups) | ||
| : source_split_(std::move(source_split)), |
There was a problem hiding this comment.
PkSortedIndexGroup::Create returns optional<PkSortedIndexGroup>, but the call sites seem to use std::shared_ptr<PkSortedIndexGroup>. Should we adjust the return type of Create for consistency?
There was a problem hiding this comment.
Updated. Create now returns std::shared_ptr, and bucket state and file plans reuse the same group instance.
| CONTAINS, | ||
| LIKE, | ||
| }; | ||
|
|
There was a problem hiding this comment.
Could you help clarify the distinction between QueryOperation and Function? They seem somewhat overlapping to me, so I’m wondering why both are needed separately.
There was a problem hiding this comment.
Agreed. QueryOperation was only a cache-key tag, so it has been removed and the cache now uses Function::Type directly.
| scalar_definitions.push_back(definition); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Could ScalarDefinitions be handling something similar here?
There was a problem hiding this comment.
Updated. Scalar-family filtering is centralized in PrimaryKeyIndexDefinitions::ScalarDefinitions and reused by planning and evaluation.
| ordinals[1] = 0; | ||
| ASSERT_NOK_WITH_MSG(BuildPayload(std::move(ordinals)).status(), | ||
| "Row id 0 appears more than once"); | ||
| } |
There was a problem hiding this comment.
ASSERT_NOK_WITH_MSG(Func(), error_message); is OK here.
There was a problem hiding this comment.
Updated. The Result is now passed directly to ASSERT_NOK_WITH_MSG.
| Result<PrimaryKeySortedIndexScan::Plan> plan = PrimaryKeySortedIndexScan::CreatePlan( | ||
| kSnapshotId, {split}, definitions_, MakeEntries(payload)); | ||
| ASSERT_NOK(plan.status()); | ||
| } |
There was a problem hiding this comment.
ASSERT_NOK(PrimaryKeySortedIndexScan::CreatePlan(
kSnapshotId, {split}, definitions_, MakeEntries(payload)));
There was a problem hiding this comment.
Updated. ASSERT_NOK now checks CreatePlan directly.
| ASSERT_NOK(plan.status()); | ||
| } | ||
|
|
||
| } // namespace paimon::test |
There was a problem hiding this comment.
The newly added tests mainly cover internal logic such as CreatePlan, Evaluate, and ToSplits. Please also add E2E tests similar to test/inte/global_index_test.cpp, covering the complete path:
Java writes the table and PK BTree index
→ C++ TableScan::CreatePlan
→ IndexedSplit / DataSplit
→ TableRead::CreateReader
→ validate the returned rows
These tests should not only invoke PkSortedIndexFile::Build, PrimaryKeySortedIndexScan::Evaluate, or mock readers directly. Otherwise, they do not cover Java/C++ format compatibility, manifest/source metadata parsing, index reader creation, split conversion, and actual data-file reading.
Please consider covering the following scenarios:
-
Index written by Java and read by C++
Let Java write a primary-key table with a
pk-btreeindex, then query it from C++ using a predicate on the indexed field.In addition to validating the returned rows, verify that the scan plan contains an
IndexedSplitwith the expected single data file and file-local row ranges. -
A mixture of indexed and non-indexed files
First compact the table to generate indexed files, then append new L0 files without another compaction. The query range should match rows from both:
- a compacted file covered by a PK BTree index;
- a newly appended L0 file without an index.
Verify that the plan contains both
IndexedSplitand regularDataSplitinstances, and that the final read returns rows from both groups without false negatives. -
One index payload covering multiple source data files
Use Java to perform a compaction that produces multiple data files while creating only one PK BTree index group. The query result should span at least two source files.
Verify that C++ correctly converts group-global row ordinals into file-local row ranges and creates the corresponding single-file
IndexedSplitinstances. ExecuteTableReadand verify that matching rows from all source files are returned. -
Deletion Vector together with a residual predicate
Let Java compact the table and build the index, then perform updates or deletes to generate a deletion vector. Use a predicate containing both an indexed and a non-indexed field, for example:
score = 10 AND tag = 'keep'Verify that the PK BTree row ranges, deletion vector, and complete residual predicate are all applied. Deleted rows and rows that fail the residual predicate must not be returned.
-
Safe fallback for AND/OR predicates
Test both:
score = 10 AND tag = 'keep' score = 10 OR tag = 'keep'For the AND predicate, the
scoreindex may narrow the row ranges whiletagremains a residual predicate.For the OR predicate, if one branch cannot be evaluated by the index, the scan must not prune data using only the indexed branch. It should safely fall back to a regular scan. Both cases should execute the complete scan-and-read path and validate the final rows.
-
Global index disabled
Set the scan option:
{{Options::GLOBAL_INDEX_ENABLED, "false"}}or equivalently:
global-index.enabled=falseVerify that no
IndexedSplitis produced, the regular scan/read path is used, and the returned rows are identical to those returned with the index enabled. -
Historical snapshots and index versions
Read the following snapshots from the same Java-generated fixture:
- snapshot 2: use the index generated by the first compaction;
- snapshot 3: use the old index for the compacted file and fall back for the new L0 file;
- snapshot 5: use the index rebuilt by the later compaction.
Validate both the plan and the final rows for each snapshot. This should ensure that C++ only applies index payloads belonging to the requested snapshot and matching the exact source files, rather than incorrectly applying the snapshot 5 index to files from snapshot 2 or 3.
-
Partitions and multiple buckets
Prepare a Java fixture with multiple partitions and multiple buckets, with PK BTree index groups in different buckets. The query should match rows across partitions and buckets.
Verify that each
IndexedSplithas the correct partition, bucket, source data file, and index-file path, and validate the final read result. -
Routing
IndexedSplitthroughFallbackTableReadConfigure
scan.fallback-branchand create a scenario where the main branch produces anIndexedSplit.Verify that:
- an
IndexedSplitfrom the main branch is sent to the main table reader; - a split from the fallback branch is sent to the fallback table reader;
- an
IndexedSplitis not rejected or routed to the wrong reader because of its split type; - the final read result is correct.
- an
-
Both Parquet and ORC
Please generate equivalent Java fixtures for Parquet and ORC and parameterize the tests with
TEST_P, for example:using ParamType = std::string; INSTANTIATE_TEST_SUITE_P( FileFormat, PrimaryKeySortedIndexE2ETest, ::testing::Values("parquet", "orc"));
Every case should cover the actual
TableScan + TableReadpath rather than only validating the plan. Depending on the scenario, the tests should also verify the split type, source data file, file-local row ranges, and deletion file.
These tests could be added to a dedicated primary_key_sorted_index_inte_test.cpp or to the existing global_index_test.cpp. The Java-generated fixtures should be stored under test/test_data/{parquet,orc}/..., with a README documenting the schema, writes, compactions, and expected state of each snapshot.
There was a problem hiding this comment.
Added Java-generated Parquet/ORC fixtures and parameterized TableScan-to-TableRead E2E coverage for indexed plus visible unindexed APPEND-source files, multi-file payloads and file-local ranges, deletion vectors with AND residual filtering, safe OR fallback, disabled indexes, snapshots 2/3/5, partitions and buckets, and fallback-branch routing. The fixture READMEs document the exact snapshot construction.
| const std::shared_ptr<Executor>& executor) const { | ||
| static_cast<void>(executor); | ||
| return CreateReader(arrow_schema, file_reader, files, pool); | ||
| } |
There was a problem hiding this comment.
Since pk is currently always passed as nullptr, is it really necessary to add this extra public interface with the executor?
There was a problem hiding this comment.
Good point. PK groups contain one payload, so the executor overload was unnecessary. I removed it and kept the existing four-argument API; BTree now creates its private executor only for multiple payloads, preserving legacy parallel evaluation while single-payload PK reads stay inline.
There was a problem hiding this comment.
@wangyong9999, I have found two correctness issues in the source-backed primary-key BTree index read path. PTAL.
Purpose
Linked issue: Closes #192
This PR adds C++ read-side support for source-backed primary-key BTree indexes produced by Apache Paimon Java 2.0, together with an internal payload builder used by tooling and tests.
The main changes are:
PrimaryKeyIndexSourceMetav1 using its big-endian layout and length-prefixed file-name bytes, with validation for unsupported versions, invalid counts, truncation, and trailing bytes.pk-btree,pk-bitmap,pk-vector, andpk-full-textcolumn definitions, including field-scoped JSON options and duplicate or cross-family assignment checks.COMPACTfiles of each positive data level, including source names, order, row counts, field ID, index type, complete row range, and known zero delete-row counts.ResultAPIs and reject missing, truncated, type-invalid, one-sided, empty non-null, or reversed boundaries before pruning.GlobalIndexEvaluatorImpland evaluate indexed predicates conservatively:ANDmay use safely evaluable children, whileORrequires every branch to be evaluable.IndexedSplitinstances.PkSortedIndexFile::Buildfor writing one BTree payload from value-sorted source-group input.The planner falls back conservatively when metadata or coverage is incomplete, BTree metadata is malformed, any source file has an unknown or nonzero delete-row count, index evaluation fails, positions are invalid, or a file would require more than 4096 ranges. Snapshot-loading and index-evaluation failures are logged before fallback. Indexed splits retain their deletion-file association, and externally supplied indexed splits are rechecked before raw dispatch, so using the index does not change row visibility or query results.
The optimization applies to snapshot-scoped, non-read-optimized primary-key batch scans outside the Data Evolution path. Source splits that are not eligible for file-local reads remain unchanged; generated
IndexedSplitinstances are routed explicitly through the physical-position reader.Only the BTree payload reader is wired into scan planning. Bitmap, vector, and full-text definitions are recognized but fall back to ordinary scans. Automatic payload construction and maintenance during compaction remain outside this change.
Tests
paimon-common-testandpaimon-core-testwith GCC 8.3.0, C++17, Debug mode, ORC enabled, and-Wall -Werror.paimon-common-testsuite: 1,441/1,441 passed.paimon-core-testsuite: 1,728/1,728 passed.paimon-primary-key-sorted-index-inte-test: 12/12 passed across Parquet and ORC.ci/scripts/test_cmake_modules.sh; all AArch64-marchand target-architecture checks passed.pre-commit run --all-files.check-clang-tidyon all changed C++ files.git diff --check.The integration and regression tests cover snapshots 2/3/5, mixed indexed and unindexed files, multi-source payload localization, deletion vectors with residual predicates, legacy unknown/nonzero delete counts, malformed BTree metadata, safe
AND/ORfallback, disabled indexes, partitions and buckets, fallback-branch routing, and unsupported scored splits.API and Format
pk-btree.index.columns,pk-bitmap.index.columns,pk-vector.index.columns, andpk-full-text.index.columns.GlobalIndexer::CreateReaderAPI. BTree readers create their private four-thread executor only when multiple payloads can submit work; single-payload reads, including primary-key groups, stay inline.GlobalIndexMeta._SOURCE_METAcarrier and source-metadata v1 layout used by Java 2.0.writeUTFfor ASCII and non-NUL BMP UTF-8 names. Complete modified UTF-8 support for NUL and supplementary code points is not included.Documentation
Adds
docs/source/user_guide/primary_key_global_index.rstand links it from the user guide index. The page documents prerequisites, validation rules, fallback behavior, compatibility, and current limitations.Generative AI tooling
Generated-by: OpenAI Codex (GPT-5) and Claude Code (Fable 5)