Skip to content

feat(aggregate): add Java-compatible field aggregators - #463

Open
liangjie3138 wants to merge 2 commits into
alibaba:mainfrom
liangjie3138:feat/java-compatible-aggregators
Open

feat(aggregate): add Java-compatible field aggregators#463
liangjie3138 wants to merge 2 commits into
alibaba:mainfrom
liangjie3138:feat/java-compatible-aggregators

Conversation

@liangjie3138

Copy link
Copy Markdown
Contributor

Purpose

Linked issue: close #457

Port the six practical field aggregators missing on the C++ side — collect,
merge_map, nested_update, rbm64, hll_sketch, theta_sketch .

Also adds GenericArray / GenericMap, the in-memory InternalArray /
InternalMap implementations these aggregators need to build ARRAY and MAP
results.

Tests

18 new unit test cases in paimon-core-test, covering each aggregator's
aggregate / retract / null / type-validation paths.

Three pin behaviour that is easy to "fix" incorrectly, and each was checked by
reverting the corresponding production change and confirming it fails:

  • BinaryAggMergeFunctionTest.OwnedAccumulatorSurvivesNullInput — drives a real
    AggregateMergeFunction so the use-after-free is exercised through the
    production path.
  • FieldNestedUpdateAggTest.CountLimitCountsNullElementsOfAccumulator — count
    limit is measured against the raw element count, matching Java.
  • FieldIgnoreRetractAggTest.ReversedAggBypassesWrappedOverride — the wrapper
    does not preserve the wrapped aggregator's reversed-aggregation override, also
    matching Java.

No integration tests added.

API and Format

Additive only, no signatures changed:

  • include/paimon/defs.h: four new Options constants — NESTED_KEY,
    NESTED_KEY_NULL_STRATEGY, NESTED_SEQUENCE_FIELD, COUNT_LIMIT.
  • CoreOptions: a NestedKeyNullStrategy enum plus accessors.
  • FieldAggregator: new virtuals default to the existing Agg / AggReversed.

New dependency: Apache DataSketches 5.2.0 (Apache-2.0), resolved through
resolve_dependency() like the other bundled dependencies.

Documentation

None.

Generative AI tooling

Generated-by: Claude Code 2.1.220 (Claude Opus 5)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PAIMON_RETURN_NOT_OK(input_bitmap.Deserialize(input_bytes.data(), input_bytes.size()));
accumulator_bitmap |= input_bitmap;
return VariantType(std::shared_ptr<Bytes>(accumulator_bitmap.Serialize(/*pool=*/nullptr)));
}

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.

There is an issue here: the bitmap serialization currently used in Java is not cross-language compatible. To ensure compatibility, it needs to use Roaring64NavigableMap.serializePortable() instead. In some scenarios, data written by Java cannot be deserialized by C++ or other languages, and vice versa. Should we temporarily remove bitmap64 aggregation support until Java serialization is adjusted for compatibility, to avoid cases where data written in one language cannot be read in another?

merged_field = aggregators_[i]->Agg(accumulator, input_field);
PAIMON_ASSIGN_OR_RAISE(merged_field,
aggregators_[i]->AggResult(accumulator, input_field));
}

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.

We can still use Agg in the naming here; the only difference is that it now has a return value.

const VariantType& input_field) {
return Agg(accumulator, input_field);
}

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.

Could we remove the newly added AggResult / AggReversedResult functions and simply update the return values of Agg / AggReversed instead?

if (!lhs || !rhs || lhs->GetFieldCount() != rhs->GetFieldCount() ||
lhs->GetFieldCount() != type->num_fields()) {
return lhs == rhs;
}

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.

Should we also add a comparison for getRowKind() here?

FieldAggregateUtils::Equals(value, candidate, element_type));
if (equal) {
return true;
}

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.

It looks like there may be some performance regression here. Given the size of this PR, maybe we could add a TODO or note documenting the current O(n²) complexity and optimize it in a follow-up.

}

Result<std::vector<std::string>> CoreOptions::FieldNestedUpdateAggNestedKey(
const std::string& field_name) const {

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.

Please add tests for the newly added CoreOptions APIs to CoreOptionsTest.

pooled_unique_ptr<Bytes> result =
Bytes::AllocateBytes(serialized.size() * sizeof(T), GetDefaultPool().get());
if (!serialized.empty()) {
std::memcpy(result->data(), serialized.data(), result->size());

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.

Just to clarify: I noticed many places are using GetDefaultPool(). Will the data volume during merging be relatively large? In paimon-cpp, batches are merged together, and data that has not yet been converted into Arrow arrays may stay held in memory for a while. Is it appropriate for that memory to be allocated from a default pool that is separate from the user-provided memory pool?

bool input_null = DataDefine::IsVariantNull(input_field);
if (accumulator_null || input_null) {
return accumulator_null ? input_field : FieldAggregateUtils::OwnedBinary(accumulator);
}

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.

Annotation 1

[P1] Preserve binary ownership in the reversed null path

When PartialUpdateMergeFunction processes an older sequence-group record, it calls AggReversedResult(accumulator, field). The base implementation swaps the arguments and invokes AggResult(field, accumulator).

For hll_sketch, and theta_sketch, if the older field is null, the null branch returns input_field directly. This value is a non-owning std::string_view produced by the use_view=true getter. If the current accumulator was created by a previous aggregation, its buffer is owned solely by the shared_ptr<Bytes> stored in row_->fields_[i].

The subsequent row_->SetField(i, result) replaces that shared_ptr<Bytes> with the returned view, releases the underlying buffer, and leaves a dangling view in the row. Later access may cause corrupted data, deserialization failures, or a use-after-free.

A valid trigger sequence is:

seq=10, sketch=A
seq=20, sketch=B       // union creates owned Bytes
seq=15, sketch=NULL    // enters the reversed null path

Please ensure that either null-argument order returns an owning copy of the non-null binary value, while preserving null when both arguments are null. Also add a partial-update regression test covering a sequence group, a previously aggregated binary value, and an older record with a null field. The existing OwnedAccumulatorSurvivesNullInput test only covers the forward AggResult() path.

name, option_name, row_type->ToString()));
}
if (std::find(fields.begin(), fields.end(), index) != fields.end()) {
return Status::Invalid(

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.

I recall that GetFieldIndex() also returns -1 when it encounters duplicate fields, so this find check seems unnecessary.

}

Result<bool> FieldNestedUpdateAgg::RowsEqual(const InternalRow& lhs, const InternalRow& rhs) const {
for (int32_t field = 0; field < row_type_->num_fields(); ++field) {

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.

Why not use FieldAggregateUtils::Equals directly here?

CoreOptions::FromMap({{"fields.f.count-limit", "-1"}}));
ASSERT_NOK(FieldNestedUpdateAgg::Create(NestedType(), negative_limit, "f"));
}

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.

[P1] Please align the new aggregator tests with the Java test matrix

The current C++ tests mainly cover basic happy paths and are substantially narrower than the corresponding Java tests.

The Java behavior is primarily covered by:

  • paimon-core/src/test/java/org/apache/paimon/mergetree/compact/aggregate/FieldAggregatorTest.java
  • paimon-core/src/test/java/org/apache/paimon/mergetree/compact/aggregate/FieldAggregatorRetractNullTest.java

Important missing cases include:

  • collect: distinct and non-distinct aggregation/retraction for primitive, ROW, ARRAY, and MAP elements.
  • nested_update: composite nested keys, multiple sequence fields, count-limit boundary cases, all null-key strategies on both accumulator and retract input, and retraction without a nested key.
  • rbm64, hll_sketch, and theta_sketch: the complete null-input matrix and the unsupported-retraction contract.

These gaps allow Java compatibility issues to go unnoticed. Please port the relevant Java cases or add equivalent parameterized tests.

Please also add end-to-end write/compaction/read tests for the new aggregators to verify the final data read from files, rather than testing only the in-memory aggregation result.

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] Add practical field aggregators available in Java Paimon

2 participants