Skip to content

feat(core): read source-backed primary-key BTree indexes - #194

Merged
SteNicholas merged 19 commits into
apache:mainfrom
wangyong9999:feat/pk-scalar-index-scan
Aug 19, 2026
Merged

feat(core): read source-backed primary-key BTree indexes#194
SteNicholas merged 19 commits into
apache:mainfrom
wangyong9999:feat/pk-scalar-index-scan

Conversation

@wangyong9999

@wangyong9999 wangyong9999 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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:

  • Decode and serialize PrimaryKeyIndexSourceMeta v1 using its big-endian layout and length-prefixed file-name bytes, with validation for unsupported versions, invalid counts, truncation, and trailing bytes.
  • Parse the pk-btree, pk-bitmap, pk-vector, and pk-full-text column definitions, including field-scoped JSON options and duplicate or cross-family assignment checks.
  • Validate index payload coverage against the active ordered COMPACT files of each positive data level, including source names, order, row counts, field ID, index type, complete row range, and known zero delete-row counts.
  • Decode BTree file metadata through checked Result APIs and reject missing, truncated, type-invalid, one-sided, empty non-null, or reversed boundaries before pruning.
  • Normalize predicates in GlobalIndexEvaluatorImpl and evaluate indexed predicates conservatively: AND may use safely evaluable children, while OR requires every branch to be evaluable.
  • Evaluate each validated BTree group once, localize group ordinals to file-local physical row ranges, and produce IndexedSplit instances.
  • Route indexed primary-key splits through the physical-position read path, recheck legacy raw-read eligibility, intersect row ranges with file-index pruning, subtract deletion vectors, and retain the complete original predicate for residual filtering.
  • Add PkSortedIndexFile::Build for writing one BTree payload from value-sorted source-group input.
  • Add Java-generated Parquet and ORC fixtures covering historical snapshots, mixed indexed and unindexed files, multi-file source groups, deletion vectors, partitions, buckets, fallback branches, and disabled-index behavior.

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 IndexedSplit instances 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

  • Built paimon-common-test and paimon-core-test with GCC 8.3.0, C++17, Debug mode, ORC enabled, and -Wall -Werror.
  • Ran the complete paimon-common-test suite: 1,441/1,441 passed.
  • Ran the complete paimon-core-test suite: 1,728/1,728 passed.
  • Ran paimon-primary-key-sorted-index-inte-test: 12/12 passed across Parquet and ORC.
  • Ran the Apache Paimon Java 2.0 fixture generator test: 1/1 passed.
  • Ran ci/scripts/test_cmake_modules.sh; all AArch64 -march and target-architecture checks passed.
  • Ran pre-commit run --all-files.
  • Ran check-clang-tidy on all changed C++ files.
  • Ran Apache RAT over all 26 newly added non-fixture files: 26/26 Apache-licensed.
  • Ran 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/OR fallback, disabled indexes, partitions and buckets, fallback-branch routing, and unsupported scored splits.

API and Format

  • Adds public option constants for pk-btree.index.columns, pk-bitmap.index.columns, pk-vector.index.columns, and pk-full-text.index.columns.
  • Keeps the existing GlobalIndexer::CreateReader API. BTree readers create their private four-thread executor only when multiple payloads can submit work; single-payload reads, including primary-key groups, stay inline.
  • Introduces no new storage format or protocol. It consumes the existing GlobalIndexMeta._SOURCE_META carrier and source-metadata v1 layout used by Java 2.0.
  • File-name encoding is compatible with Java writeUTF for 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.rst and 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)

王勇 and others added 4 commits August 11, 2026 02:25
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
@wangyong9999 wangyong9999 changed the title feat(core): add primary-key index source metadata and definitions feat(core): read source-backed primary-key BTree indexes Aug 11, 2026
@wangyong9999

Copy link
Copy Markdown
Contributor Author

cc @lxy-9602 @lucasfang could you please take a look when convenient? Thanks~

// 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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move the Family family parameter before options?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. Family family now precedes options; the member and accessor order and all call sites were changed consistently.

}
}
return Status::OK();
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please make the error message explicit. I’d recommend using ASSERT_NOK_WITH_MSG.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please try to reuse DataOutputStream, DataInputStream, MemorySegmentOutputStream for endianness conversion and data input/output.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid using default arguments in production code.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems in Java, rawConvertible is always false here. The current implementation would make the scan results inconsistent with Java.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. Create now returns std::shared_ptr, and bucket state and file plans reuse the same group instance.

CONTAINS,
LIKE,
};

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could ScalarDefinitions be handling something similar here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ASSERT_NOK_WITH_MSG(Func(), error_message); is OK here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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());
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ASSERT_NOK(PrimaryKeySortedIndexScan::CreatePlan(
kSnapshotId, {split}, definitions_, MakeEntries(payload)));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. ASSERT_NOK now checks CreatePlan directly.

ASSERT_NOK(plan.status());
}

} // namespace paimon::test

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Index written by Java and read by C++

    Let Java write a primary-key table with a pk-btree index, 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 IndexedSplit with the expected single data file and file-local row ranges.

  2. 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 IndexedSplit and regular DataSplit instances, and that the final read returns rows from both groups without false negatives.

  3. 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 IndexedSplit instances. Execute TableRead and verify that matching rows from all source files are returned.

  4. 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.

  5. Safe fallback for AND/OR predicates

    Test both:

    score = 10 AND tag = 'keep'
    score = 10 OR tag = 'keep'
    

    For the AND predicate, the score index may narrow the row ranges while tag remains 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.

  6. Global index disabled

    Set the scan option:

    {{Options::GLOBAL_INDEX_ENABLED, "false"}}

    or equivalently:

    global-index.enabled=false
    

    Verify that no IndexedSplit is produced, the regular scan/read path is used, and the returned rows are identical to those returned with the index enabled.

  7. 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.

  8. 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 IndexedSplit has the correct partition, bucket, source data file, and index-file path, and validate the final read result.

  9. Routing IndexedSplit through FallbackTableRead

    Configure scan.fallback-branch and create a scenario where the main branch produces an IndexedSplit.

    Verify that:

    • an IndexedSplit from the main branch is sent to the main table reader;
    • a split from the fallback branch is sent to the fallback table reader;
    • an IndexedSplit is not rejected or routed to the wrong reader because of its split type;
    • the final read result is correct.
  10. 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 + TableRead path 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lxy-9602
lxy-9602 requested a review from lszskye August 17, 2026 00:49
const std::shared_ptr<Executor>& executor) const {
static_cast<void>(executor);
return CreateReader(arrow_schema, file_reader, files, pool);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since pk is currently always passed as nullptr, is it really necessary to add this extra public interface with the executor?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@lxy-9602 lxy-9602 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

@SteNicholas SteNicholas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wangyong9999, I have found two correctness issues in the source-backed primary-key BTree index read path. PTAL.

Comment thread src/paimon/core/table/source/primary_key_sorted_index_scan.cpp
Comment thread src/paimon/core/table/source/key_value_table_read.cpp Outdated

@SteNicholas SteNicholas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

@SteNicholas
SteNicholas merged commit 2b7b983 into apache:main Aug 19, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Read source-backed primary-key BTree indexes

3 participants