Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3a19320
feat(realtime): add primary-key in-memory writes
HaHaJeff Aug 19, 2026
789da66
feat(read): merge primary-key realtime memory with snapshots
HaHaJeff Aug 19, 2026
03ede35
test(realtime): cover primary-key realtime lifecycle
HaHaJeff Aug 19, 2026
3e25630
refactor(realtime): consolidate PK state and validation
HaHaJeff Aug 20, 2026
9f6c99d
fix(read): close PK realtime query readers
HaHaJeff Aug 20, 2026
15d7c91
fix(realtime): close rejected plugin readers
HaHaJeff Aug 20, 2026
2df7b78
fix(read): preserve PK reader metrics after close
HaHaJeff Aug 20, 2026
7148081
refactor(realtime): colocate PK realtime option validation
HaHaJeff Aug 20, 2026
8273045
test(realtime): improve primary key coverage
HaHaJeff Aug 20, 2026
728b97e
fix(realtime): prevent sequence reuse and align nested projections
HaHaJeff Aug 21, 2026
f3df0e3
fix(realtime): align PK reads across schema changes
HaHaJeff Aug 21, 2026
3358304
fix(realtime): align PK projections by field ID
HaHaJeff Aug 21, 2026
8d96153
refactor(mergetree): accept sorted key-value readers
HaHaJeff Aug 24, 2026
df322c1
feat(realtime): adapt prepared primary-key batches
HaHaJeff Aug 24, 2026
87e4548
refactor(realtime): prepare primary-key batches in framework
HaHaJeff Aug 24, 2026
ea90f89
refactor(realtime): simplify primary-key write preparation
HaHaJeff Aug 24, 2026
53f6b02
test(mergetree): reuse reader failure mock
HaHaJeff Aug 24, 2026
141099c
fix(realtime): preserve PK sequence across writer handoff
HaHaJeff Aug 24, 2026
a4f9a0c
refactor(realtime): simplify primary key merge readers
HaHaJeff Aug 24, 2026
d983089
fix(realtime): validate PK reader contracts
HaHaJeff Aug 24, 2026
a9aaa4e
refactor(realtime): simplify reader lifecycle cleanup
HaHaJeff Aug 24, 2026
8ab9817
fix(realtime): harden primary-key prepared batches
HaHaJeff Aug 24, 2026
75f959a
refactor(realtime): simplify primary-key contracts
HaHaJeff Aug 24, 2026
3f0efba
fix(realtime): strengthen primary-key recovery coverage
HaHaJeff Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions include/paimon/realtime/arrow_realtime_store_factory.h
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,7 @@ namespace paimon {
/// Factory for Paimon's default Arrow-backed `RealtimeStore`.
class PAIMON_EXPORT ArrowRealtimeStoreFactory : public RealtimeStoreFactory {
public:
/// Creates an Arrow-backed store for one partition and bucket.
Result<std::shared_ptr<RealtimeStore>> Create(
std::unique_ptr<::ArrowSchema> write_schema, StatisticsMode statistics_mode,
const std::map<std::string, std::string>& options,
const std::shared_ptr<MemoryPool>& memory_pool) override;
Result<std::shared_ptr<RealtimeStore>> Create(RealtimeStoreCreateRequest&& request) override;
};

} // namespace paimon
73 changes: 50 additions & 23 deletions include/paimon/realtime/realtime_store.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
#include <optional>
#include <string>
#include <utility>
#include <variant>
#include <vector>

#include "arrow/c/abi.h"
#include "paimon/reader/batch_reader.h"
#include "paimon/realtime/offset_range.h"
#include "paimon/record_batch.h"
Expand All @@ -41,10 +43,36 @@ namespace paimon {
class MemoryPool;
class Predicate;

/// A table record batch and its framework-assigned contiguous offset range.
struct PAIMON_EXPORT AppendRealtimeStoreCreateConfig {
StatisticsMode statistics_mode;
};

struct PAIMON_EXPORT PrimaryKeyRealtimeStoreCreateConfig {
/// Primary-key fields after removing partition fields, in comparison order.
std::vector<std::string> trimmed_primary_keys;
};

using RealtimeStoreCreateConfig =
std::variant<AppendRealtimeStoreCreateConfig, PrimaryKeyRealtimeStoreCreateConfig>;

struct PAIMON_EXPORT RealtimeStoreCreateRequest {
/// Schema whose ownership is transferred to the factory. Append mode receives the complete
/// table write schema. Primary-key mode receives the prepared transport schema:
/// [_VALUE_KIND, _SEQUENCE_NUMBER, _REALTIME_OFFSET, table write fields].
std::unique_ptr<::ArrowSchema> write_schema;
std::map<std::string, std::string> options;
std::shared_ptr<MemoryPool> memory_pool;
std::map<std::string, std::string> partition;
int32_t bucket = -1;
RealtimeStoreCreateConfig mode_config;
};

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.

Thank you for the contribution. The code looks clear and well organized. Before diving into the detailed review, I would like to discuss two design points.

First, it seems that internal sequence-number assignment and per-batch primary-key sorting are currently handled inside the realtime store implementation. I suggest moving these responsibilities into the Paimon framework instead.

The framework could assign offsets and sequence numbers, append internal fields such as _VALUE_KIND, _SEQUENCE_NUMBER, and _REALTIME_OFFSET, and physically sort each input Arrow batch before passing it to the store plugin. The plugin would then only manage storage concerns, without needing to understand PK sorting rules, sequence fields, or merge-engine semantics.

Query and prepare-commit could convert these already sorted batches into KeyValueRecordReaders and reuse the existing SortMergeReader and merge functions. The flush path could also accept sorted readers directly, avoiding sequence reassignment and repeated per-batch sorting. This would make custom plugins easier to implement and allow realtime reads and writes to reuse the framework’s existing merge-engine and sequence-field behavior.

I think this can be the first-stage solution. If profiling later shows that copying data to produce physically sorted Arrow batches is a real write-path bottleneck, we could introduce a shallow-copy mode based on sorted indices. That would require significantly more interface changes, so I suggest optimizing it only after it becomes an observed hotspot.

Second, the in-memory store could keep PK statistics for each batch, such as min/max values. Predicates on value fields may not be pushable, but predicate_for_keys should be applicable to these statistics so irrelevant in-memory batches can be pruned during reads. This optimization could also be implemented in a follow-up PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thank you for the contribution. The code looks clear and well organized. Before diving into the detailed review, I would like to discuss two design points.

First, it seems that internal sequence-number assignment and per-batch primary-key sorting are currently handled inside the realtime store implementation. I suggest moving these responsibilities into the Paimon framework instead.

The framework could assign offsets and sequence numbers, append internal fields such as _VALUE_KIND, _SEQUENCE_NUMBER, and _REALTIME_OFFSET, and physically sort each input Arrow batch before passing it to the store plugin. The plugin would then only manage storage concerns, without needing to understand PK sorting rules, sequence fields, or merge-engine semantics.

Query and prepare-commit could convert these already sorted batches into KeyValueRecordReaders and reuse the existing SortMergeReader and merge functions. The flush path could also accept sorted readers directly, avoiding sequence reassignment and repeated per-batch sorting. This would make custom plugins easier to implement and allow realtime reads and writes to reuse the framework’s existing merge-engine and sequence-field behavior.

I think this can be the first-stage solution. If profiling later shows that copying data to produce physically sorted Arrow batches is a real write-path bottleneck, we could introduce a shallow-copy mode based on sorted indices. That would require significantly more interface changes, so I suggest optimizing it only after it becomes an observed hotspot.

Second, the in-memory store could keep PK statistics for each batch, such as min/max values. Predicates on value fields may not be pushable, but predicate_for_keys should be applicable to these statistics so irrelevant in-memory batches can be pruned during reads. This optimization could also be implemented in a follow-up PR.

Thanks for the detailed suggestion. I agree that sequence assignment, PK sorting, and merge semantics should belong to the Paimon framework rather than the real-time store plugin.

The current implementation assigns sequence numbers and performs PK sorting and in-memory merging inside the PK store. During prepare-commit, it converts the returned batches back into ordinary RecordBatches and passes them through WriteBuffer, which assigns sequence numbers and sorts the same data again. I plan to revise this design as follows.

Framework-side batch preparation

Before calling RealtimeStore::Write, the Paimon framework will:

  1. assign _REALTIME_OFFSET and _SEQUENCE_NUMBER atomically according to the original per-row write order;
  2. materialize _VALUE_KIND, _SEQUENCE_NUMBER, and _REALTIME_OFFSET;
  3. physically and stably sort the complete Arrow batch by primary key.

All columns will be reordered with the same sort indices, so the value, row kind, sequence number, and real-time offset remain associated with the same mutation.

Sorting will not perform deduplication or early MOR. Every mutation will remain in the prepared batch. The progress counters will advance only after RealtimeStore::Write succeeds.

RealtimeStore responsibility

RealtimeStore will treat the internal fields as opaque Arrow columns and preserve the prepared batches through write, seal, read-view, query-reader, and commit-reader operations.

It will no longer:

  • assign sequence numbers;
  • understand PK sorting rules;
  • depend on merge functions or merge-engine semantics;
  • perform PK deduplication or MOR.

Each physically sorted input batch will represent one independent sorted run. A store may return multiple readers, and Paimon will merge those runs in the framework. The built-in and custom stores will therefore use the same path.

Query path

The framework will provide an adapter from the store's BatchReader to KeyValueRecordReader.

For PK queries:

  1. the store returns the prepared sorted batches;
  2. the adapter uses _REALTIME_OFFSET to remove memory rows already covered by the selected snapshot;
  3. the adapter converts the remaining rows into sorted KeyValueRecordReaders;
  4. the existing SortMergeReader merges the memory readers with disk readers;
  5. the existing merge function performs MOR.

_SEQUENCE_NUMBER remains the row-version field used to resolve versions during disk-memory MOR.

Prepare-commit path

RealtimeStore and MergeTreeWriter will not depend on each other directly. The framework-owned RealtimePrimaryKeyWriter will coordinate them:

  1. call RealtimeStore::SealForCommit to obtain an immutable segment;
  2. call RealtimeStore::CreateCommitReaders for that segment;
  3. adapt the returned BatchReaders into sorted KeyValueRecordReaders;
  4. pass those readers to MergeTreeWriter::WriteSortedReaders;
  5. call the existing MergeTreeWriter::PrepareCommit;
  6. attach the sealed segment's real-time progress to the resulting commit progress.

The resulting path will be:

RealtimeStore
  -> BatchReader
  -> framework BatchReader-to-KeyValueRecordReader adapter
  -> MergeTreeWriter::WriteSortedReaders
  -> existing SortMergeReader and merge functions
  -> existing rolling data-file writer
  -> CommitIncrement

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.

Thank you for your response! The current direction looks good to me. @zjw1111 , could you also take a look?

Also, the offset filtering for PK tables has now been moved to the framework layer, while for append tables it is still handled inside the plugin through the offset_begin parameter in CreateQueryReaders. I plan to align the append-table path later as well, similar to PK tables, by moving the offset filtering into the framework layer. For this PR, I think it’s fine to keep the current interface for now and focus on implementing the PK-table part first.

@zjw1111 zjw1111 Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for your response! The current direction looks good to me. @zjw1111 , could you also take a look?

LGTM

/// A record batch and its framework-assigned contiguous offset range.
///
/// The batch contains only table write fields. Row `i` is associated with
/// `offset_range.begin + i`; the offset is progress metadata and is not a table field.
/// Append-mode batches contain table write fields, and row `i` has offset
/// `offset_range.begin + i`. Primary-key batches use the prepared transport schema, are sorted
/// by full primary key then sequence number, and retain the original offset in
/// `_REALTIME_OFFSET`.
struct PAIMON_EXPORT RealtimeWriteBatch {
/// Input batch whose ownership is transferred to `RealtimeStore::Write`.
std::unique_ptr<RecordBatch> batch;
Expand Down Expand Up @@ -79,7 +107,10 @@ class PAIMON_EXPORT RealtimeReadView {

/// Parameters used by a `RealtimeStore` to create readers for a query.
struct PAIMON_EXPORT RealtimeQueryContext {
/// Requested output fields before the mandatory leading `_VALUE_KIND` field is added.
/// Append mode receives the requested output fields before the mandatory leading
/// `_VALUE_KIND` field is added. Primary-key mode receives the complete prepared schema.
/// This schema is borrowed and remains valid only during `CreateQueryReaders`; plugins must
/// import or copy it synchronously.
::ArrowSchema* read_schema;
/// Predicate using field indexes from `read_schema`.
std::shared_ptr<Predicate> predicate;
Expand Down Expand Up @@ -116,9 +147,10 @@ class PAIMON_EXPORT RealtimeStore {

/// Creates readers that expose all rows in a sealed segment for Paimon file writing.
///
/// Concatenating the returned readers must produce every sealed row exactly once and in write
/// order. Each output batch contains `_VALUE_KIND` followed by all fields from the factory's
/// `write_schema`.
/// The returned readers collectively expose every sealed row exactly once. Append-mode readers
/// preserve write order and contain `_VALUE_KIND` followed by table write fields. Primary-key
/// readers use the prepared transport schema; each reader's complete stream is sorted by full
/// primary key then sequence number.
virtual Result<std::vector<std::unique_ptr<BatchReader>>> CreateCommitReaders(
const std::shared_ptr<RealtimeSegmentHandle>& segment) = 0;

Expand All @@ -128,13 +160,15 @@ class PAIMON_EXPORT RealtimeStore {
/// also provide a consistent snapshot when a write or seal is in progress.
virtual Result<std::shared_ptr<RealtimeReadView>> AcquireReadView() = 0;

/// Creates readers over rows in `view` whose offsets are greater than or equal to
/// `offset_begin`.
/// Creates readers over rows in `view`. Append mode returns rows whose offsets are greater than
/// or equal to `offset_begin`; primary-key mode ignores `offset_begin`.
///
/// Each output batch contains `_VALUE_KIND` first, followed by the fields requested by
/// `context.read_schema` except a duplicate `_VALUE_KIND`. Concatenating all returned readers
/// must produce every matching row once. Paimon retains `view` for the lifetime of the
/// resulting framework reader.
/// Append-mode batches contain `_VALUE_KIND` followed by the requested fields except a duplicate
/// `_VALUE_KIND`, and collectively expose every matching row exactly once. Primary-key batches
/// use the prepared transport schema and may contain multiple mutations per key; each reader's
/// complete stream is sorted by full primary key then sequence number, and the readers
/// collectively expose every raw mutation exactly once. Paimon retains `view` for the lifetime
/// of the resulting framework reader.
virtual Result<std::vector<std::unique_ptr<BatchReader>>> CreateQueryReaders(
const std::shared_ptr<RealtimeReadView>& view, int64_t offset_begin,
const RealtimeQueryContext& context) = 0;
Expand All @@ -157,16 +191,9 @@ class PAIMON_EXPORT RealtimeStoreFactory {
public:
virtual ~RealtimeStoreFactory() = default;

/// Creates a store configured with the supplied schema, statistics, options, and memory pool.
/// @param write_schema Complete table write schema whose ownership is transferred to the
/// factory. The factory may consume it or retain it in the created store.
/// @param statistics_mode Framework-parsed statistics collection mode.
/// @param options Effective table options available to the store.
/// @param memory_pool Memory pool provided by the write context.
virtual Result<std::shared_ptr<RealtimeStore>> Create(
std::unique_ptr<::ArrowSchema> write_schema, StatisticsMode statistics_mode,
const std::map<std::string, std::string>& options,
const std::shared_ptr<MemoryPool>& memory_pool) = 0;
/// Creates a store for the requested table mode and partition-bucket.
/// The factory consumes `request`, including ownership of `request.write_schema`.
virtual Result<std::shared_ptr<RealtimeStore>> Create(RealtimeStoreCreateRequest&& request) = 0;
};

} // namespace paimon
2 changes: 2 additions & 0 deletions include/paimon/utils/special_field_ids.h
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class SpecialFieldIds {

/// Special field ID reserved for index score. Value: CPP_FIELD_ID_END - 1
inline static constexpr int32_t INDEX_SCORE = CPP_FIELD_ID_END - 1;
/// Special field ID reserved for realtime offset. Value: CPP_FIELD_ID_END - 2
inline static constexpr int32_t REALTIME_OFFSET = CPP_FIELD_ID_END - 2;

/// Lowest field ID reserved for system fields; IDs at or above it are excluded from the
/// highest field ID of a schema. Value: INT32_MAX / 2
Expand Down
4 changes: 4 additions & 0 deletions src/paimon/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,12 @@ set(PAIMON_CORE_SRCS
core/operation/write_restore.cpp
core/realtime/arrow_realtime_store.cpp
core/realtime/arrow_realtime_store_factory.cpp
core/realtime/prepared_key_value_reader.cpp
core/realtime/primary_key_realtime_store.cpp
core/realtime/realtime_append_only_writer.cpp
core/realtime/realtime_context.cpp
core/realtime/realtime_context_impl.cpp
core/realtime/realtime_primary_key_writer.cpp
core/postpone/postpone_bucket_writer.cpp
core/schema/arrow_schema_validator.cpp
core/schema/schema_manager.cpp
Expand Down Expand Up @@ -780,6 +783,7 @@ if(PAIMON_BUILD_TESTS)
core/manifest/index_manifest_file_handler_test.cpp
core/memory/writer_memory_manager_test.cpp
core/realtime/arrow_realtime_store_test.cpp
core/realtime/primary_key_realtime_store_test.cpp
core/realtime/realtime_context_test.cpp
core/realtime/realtime_reader_test.cpp
core/mergetree/levels_test.cpp
Expand Down
10 changes: 9 additions & 1 deletion src/paimon/core/io/merged_key_value_record_reader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,21 @@ Status MergedKeyValueRecordReader::Iterator::LoadNextRawKeyValue() const {
}

Result<std::unique_ptr<KeyValueRecordReader::Iterator>> MergedKeyValueRecordReader::NextBatch() {
if (initialization_error_.has_value()) {
return initialization_error_.value();
}
if (visited_) {
return std::unique_ptr<KeyValueRecordReader::Iterator>();
}
visited_ = true;

auto iterator = std::make_unique<Iterator>(this);
PAIMON_ASSIGN_OR_RAISE(bool has_next, iterator->HasNext());
Result<bool> has_next_result = iterator->HasNext();
if (!has_next_result.ok()) {
initialization_error_ = has_next_result.status();
return initialization_error_.value();
}
bool has_next = std::move(has_next_result).value();
if (!has_next) {
return std::unique_ptr<KeyValueRecordReader::Iterator>();
}
Expand Down
1 change: 1 addition & 0 deletions src/paimon/core/io/merged_key_value_record_reader.h
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ class MergedKeyValueRecordReader : public KeyValueRecordReader {

private:
bool visited_ = false;
std::optional<Status> initialization_error_;
std::unique_ptr<KeyValueRecordReader> reader_;
std::shared_ptr<FieldsComparator> key_comparator_;
std::shared_ptr<MergeFunctionWrapper<KeyValue>> merge_function_wrapper_;
Expand Down
Loading