Skip to content

[feature](function) Add MIN_TOPK/MAX_TOPK and ARRAY_SORTED_MERGE functions - #69456

Merged
hello-stephen merged 37 commits into
apache:masterfrom
yujun777:feature/ivm-minmax-topk-functions
Oct 30, 2026
Merged

hello-stephen merged 37 commits into
apache:masterfrom
yujun777:feature/ivm-minmax-topk-functions

Conversation

@yujun777

@yujun777 yujun777 commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: N/A (tracked in #65418)

Related PR: #69286

Problem Summary:

Adds the generic building blocks that incremental MIN/MAX maintenance builds on; the IVM
integration itself is a separate PR.

  • min_topk(x, k) / max_topk(x, k): an aggregate returning
    ARRAY<STRUCT<value T, count BIGINT>> — the k smallest (largest) distinct non-NULL values
    with their exact row counts, ordered by value. The array carries no truncation flag: a
    caller that needs one infers it from the length, treating a list of exactly k entries as
    possibly truncated. With k = 0 the array holds at most the single (extreme, count)
    pair. k is a constant BIGINT with 0 <= k <= 65536.
  • array_sorted_merge(merge_fn, arr1, arr2, is_asc): a generic higher-order function that
    merges two arrays already sorted by the elements' natural order. merge_fn receives the
    two current candidates and returns the combined element, or NULL when the two elements
    must be kept separately. is_asc states the sort direction, so one function covers
    ascending and descending inputs, and calls can be chained to merge more than two arrays.

The aggregates keep a sorted (value, count) state with checked int64 counting, so partial
aggregation and merge stay exact (topK(A ∪ B) ⊆ topK(A) ∪ topK(B)). FLOAT/DOUBLE order NaN
as greater than every number so the distinct list keeps a strict order.

Returning the array alone keeps the aggregate in the same shape as the other array
aggregates and leaves the truncation flag to the caller that actually persists a bound:
incremental MIN/MAX maintenance still stores one, but as its own state column rather than
inside the aggregate result.

array_sorted_merge keeps its non-lambda is_asc argument through the higher-order binding:
the existing pipeline passed only the lambda arguments to the bound function, so the binding
contract gained a way to return extra inputs.

Release note

Add the min_topk/max_topk aggregate functions and the array_sorted_merge
scalar function.

Check List (For Author)

  • Test: Regression test
    • regression-test/suites/query_p0/aggregate/min_max_topk.groovy: k = 0/1/2/8/65536/65537, empty input, all-NULL groups, NOT NULL input types, string/decimal/datetime, -0.0/NaN, agreement with MIN/MAX, the array shape (cardinality and element_at(...).value/count), two-phase partial+final merge and the error paths.
    • regression-test/suites/query_p0/sql_functions/array_functions/test_array_sorted_merge.groovy: ascending and descending merges, struct keys, empty and NULL arrays, and the analysis errors.
    • FE unit test TopKFunctionSignatureTest.
    • BE unit test agg_topk_test.cpp (k = 0, truncation, MIN/MAX merge, serialize/deserialize round-trip, count overflow, NULL count).
  • Behavior changed: No (new functions only)
  • Does this need documentation: Yes (function docs will come with the IVM user documentation)

@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@yujun777

Copy link
Copy Markdown
Contributor Author

run buildall

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@yujun777

Copy link
Copy Markdown
Contributor Author

run buildall

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@yujun777

Copy link
Copy Markdown
Contributor Author

run buildall

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Request changes: six verified findings, including incorrect per-row scalar bounds. Reviewed all 22 changed paths at ddda17f using static analysis and independent BE, FE/test and risk-focused passes. There were no existing inline threads to duplicate and no additional user focus points.

Critical checkpoint conclusions:

  • Goal and evidence: the PR adds the four top-K foundation functions; FE IVM integration is explicitly deferred. Small-case aggregate/delta semantics and matching FE/BE schemas are covered by added tests, but the findings prevent the implementation from satisfying the full supported-input contract.
  • Scope and reuse: registration, shared MIN/MAX implementations, visitor additions and foreach exclusion are focused. No unrelated functional change was found in the string-test API adaptation.
  • Concurrency and locks: no new threads or locks; aggregate state belongs to each group/evaluator, scalar temporaries are invocation-local, and function objects have no new mutable execution state. No new lock-order issue found.
  • Lifecycle and initialization: create/reset/destroy and generic serialization scratch cleanup follow existing helpers; factory initialization follows existing singletons and constexpr limits add no cross-TU initialization dependency. Count-buffer accounting remains a finding.
  • Configuration: no configuration or dynamic-update behavior added.
  • Compatibility and serialization: existing function layouts are unchanged and aggregate intermediate data uses the existing string path. The new compression-size assertions and AggState capacity compatibility need correction; no runtime rolling-upgrade validation was performed.
  • Shared definitions: no Thrift/protobuf fields or enum IDs changed.
  • Parallel paths: checked MIN/MAX, k=0 and positive k, nullable/nonnullable, empty/all-NULL, grouped/ungrouped, partial/final aggregation, windows, foreach and public AggState combinators. Multirow scalar bounds and differing-capacity AggState merges expose findings.
  • Special conditions and errors: range, overflow and negative trusted-count failures propagate explicitly. Const columns are materialized before row handling. Malformed lists and heterogeneous states were assessed against the documented homogeneous, sorted, unique producer-state contract; no additional substantiated finding was established there.
  • Test coverage: BE and FE unit tests and regression tests exist, including negative k/DISTINCT cases. Missing cases include differing bounds within one block, compressed states, memory attribution, rejected-value cost and incompatible stored-state capacities. Builds and tests were not run, as required by this review contract.
  • Test results: reviewed every added expected result against its inputs. Signed-zero string output is schedule-dependent and needs a stable oracle; the other small-case expected values match static reasoning. Output generation was not independently verified.
  • Observability: existing expression error reporting provides function/argument context; no new background service needs separate logs or metrics. Missing query memory attribution is reported inline.
  • Transactions and persistence: no EditLog, visible-version, delete-bitmap, failover or transactional mutation path changed. Persistable AggState values expose the reported k compatibility problem.
  • Data writes and atomicity: these changes compute expression results; they add no storage write/commit protocol.
  • FE/BE passing: function names, supported primitive families, struct fields/nullability and intermediate types agree for homogeneous calls; no new transmitted variable or sending point is added.
  • Performance and memory: the bounded list still rebuilds unchanged retained data for rejected distinct values, and the count buffers bypass query accounting. Both are separate inline findings.
  • Additional issues and limits: all initial risks and subsequent candidates have explicit dispositions in the review ledger. No other substantiated issue or uncovered changed path remains. Validation is static only; execution behavior and full application-level IVM integration were not exercised.

Review converged after 2 round(s).

}
value_column = &nullable->get_nested_column();
}
view.value = value_column;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Preserve the row index in bound views

make_bound_view checks the current row's NULL bit but retains only the whole column; effective-bound selection, trusted-range filtering and bound output then all read index 0. For a two-row MIN block with k=2, n=3, dn=0, empty I/D and NULL delta bounds, let S/b be [(1,1),(2,1)]/2 and [(10,1),(20,1)]/20. The second state should stay unchanged, but it becomes an empty list with bound 2. Nonconstant table arguments remain vectorized, so this affects normal multi-group apply calls; MAX is symmetric. Carry and use the bound row index throughout, and cover differing bounds in one block.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Adding a concrete counterexample for the original implementation at ddda17f849: this can produce a wrong nonempty candidate and suppress a required fallback.

Consider two rows in the same execution block, with k = 1:

Row Original group data Stored S / b Inserts I / bi Deletes D / bd
0 {10,20} [(10,1)] / 10 [] / NULL [] / NULL
1 {1,3} [(1,1)] / 1 [(5,1)] / NULL [(1,1)] / NULL

For row 1, n = 2, dn = 0, and the actual post-update data is {3,5}, so MIN is 3. Its old state has discarded 3, and only values <= 1 remain within the trusted range.

Expected: use row 1's bound, bE = 1. The count of 1 becomes 1 + 0 - 1 = 0; 5 is outside the trusted range. Return (values=[], bound=1). Since n' = 2, the empty candidate list tells the caller to fall back to a complete refresh, which finds 3.

Original behavior: the lists are taken from row 1, but the bound is read from row 0, giving bE = 10. After removing 1, the merge incorrectly admits 5 and returns (values=[(5,1)], bound=10). The candidate list is nonempty, so the documented exhaustion check does not trigger fallback; using the first candidate as MIN would return 5, although 3 still exists.

I checked the latest head, e4acad346d: TopKBoundView now carries the row index, and effective-bound selection, range filtering, and bound output all use it. This fixes the identified indexing cause by source inspection. The added multirow regression uses empty insert/delete lists; please also cover this delete-plus-insert case and its MAX counterpart, with both rows in one execution block, to guard against suppressing a required fallback.

This example is derived from the code; I have not executed it against Doris.

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.

Fixed in 56a6223. TopKBoundView now carries the row index, and the effective-bound selection, the trusted-range comparison and the bound output all read that row instead of index 0. order_qt_merge_multi_row_bounds covers two rows in the same block with different bounds (2 and 20).

state.k = other.k;
} else {
// Both sides are partial states of the same constant k.
DCHECK_EQ(state.k, other.k);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Reject incompatible k values across AggState merges

The same-k assumption holds for ordinary partial aggregation, but these functions also expose _state/_merge/_union. AggState type identity records argument types and nullability, not literal k, so a UNION ALL of topk_min_count_state(2, CAST(1 AS BIGINT)) and topk_min_count_state(1, CAST(0 AS BIGINT)) can feed topk_min_count_merge(s). With the k=1 state merged first, release builds ignore the k=0 state's extreme and return value 2 as a complete state; reversing the order changes the result. Checked builds hit this DCHECK. Reject mismatched serialized capacities with a query error, or disable AggState support until this case is defined.

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.

Fixed in 56a6223. merge() now rejects two partial states with different k with INVALID_ARGUMENT instead of assuming they match, and TopKCountTest.MergeRejectsIncompatibleK merges states built with k=2 and k=1.

buf.resize(size_bytes);
auto* pos =
this->argument_types[0]->serialize(column, buf.data(), IAggregateFunction::version);
DCHECK_EQ(pos, buf.data() + size_bytes);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Distinguish serialized capacity from the encoded length

get_uncompressed_serialized_bytes returns an upper bound when a column exceeds the 256-byte compression threshold, while serialize returns the actual compressed end pointer. A partial INT state containing 0..64 with k=65 therefore fails this equality in debug/ASAN builds; a compressible string longer than 256 bytes can fail even with k=0. read_column repeats the same invalid equality. The existing round-trip test only stores two INT values. Frame the actual encoded length, or explicitly support padded capacity without asserting equality, and cover compressed states.

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.

Fixed in 56a6223. write_column declares get_uncompressed_serialized_bytes as the capacity and pads the buffer when serialize returns an earlier pointer; read_column skips the same declared capacity, so a compressed payload is not misread. TopKCountTest.SerializeDeserializeRoundTrip now round-trips 65 distinct INTs (260 bytes, above the compression threshold) with k=65.

bool has_bound = false;
MutableColumnPtr bound_column;
MutableColumnPtr value_column;
std::vector<int64_t> counts;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Track the owned count buffers with a Doris allocator

Both this persistent counts vector and merged_counts use the default allocator, bypassing Doris query memory accounting and allocation-limit checks. At k=65536 the count payload alone is at least 512 KiB per group, with additional merge scratch; many groups can retain substantial unreported memory. The aggregate helper reports only sizeof(Data), and the aggregation sink counts arenas/container/hash-table storage, so neither accounts for these separate heaps. Use DorisVector<int64_t> or another tracked container for both buffers so growth, moves and destruction participate in query memory tracking.

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.

Fixed in 56a6223. Both the per-group counts and the merged_counts scratch buffer are now DorisVector<int64_t>, so the 512 KiB-per-group payload at k=65536 is charged to the query memory tracker.

return;
}

insert_entry(state, left, column, row_num, 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Reject worse values before rebuilding the retained column

Once a MIN state holds k values, every new distinct value larger than its last entry is immediately discarded, yet this call clones the column and copies all k retained values before popping the new entry. MAX has the symmetric path. One million ascending distinct inputs at k=65536 cause over 61 billion retained-value copies after filling the list; string inputs also copy the retained bytes. Detect this full-list rejection before insert_entry, update the truncation flag/bound, and return without rebuilding unchanged state.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The full-list rejection fast path is fixed at e4acad346d. There is still an avoidable rebuild while filling the candidate list: insert_entry() clones the value column and copies every existing value even when pos == state.value_column->size().

For topk_min_count(x, 65536) with distinct ascending input 1..65536, every insertion is at the end and the list has room. Filling one group's state therefore copies 0 + 1 + ... + 65535 = 2,147,450,880 existing elements, plus repeated column allocations. MAX with descending input has the symmetric case. This is a source-derived operation count, not a measured runtime result.

Please append directly before the clone/copy path:

if (pos == state.value_column->size()) {
    state.value_column->insert_from(column, row_num);
    state.counts.push_back(count);
    return;
}

The state already owns a mutable value column, and insert_from() appends to it. The caller already rejects worse values when the list is full, so this handles the remaining tail-insertion case without rebuilding the retained prefix. A benchmark with sorted input and large k would quantify the benefit.

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.

Fixed as suggested in e7a3dbf: insert_entry() appends in place when pos == state.value_column->size(), before the clone/copy path, and state.counts.push_back(count) keeps the parallel vector in sync. Together with the full-list rejection in add_value(), sorted input at large k only appends while the list fills. I did not add the benchmark; TopKCountTest.* and the regression suite still pass.

e {"values":[], "bound":null} {"values":[], "bound":null}

-- !double_special_values --
e {"values":[{"value":-0, "count":2}, {"value":1, "count":1}, {"value":NaN, "count":2}], "bound":null} {"values":[{"value":NaN, "count":2}, {"value":1, "count":1}, {"value":-0, "count":2}], "bound":null}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Avoid requiring a particular signed-zero representative

This expected struct requires -0, but the input contains both +0 and -0 on an unordered two-bucket scan. add_value keeps the first representative when CompareFloat reports equality, and merge_values keeps the left representative, so a valid scan/merge order can return +0 with the same count. Number JSON formatting preserves the sign, while the regression framework compares STRUCT cells as exact strings; order_qt only orders result rows. Canonicalize zero representations or project numeric values/counts for comparison, then regenerate the output so this test does not depend on aggregate input order.

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.

Fixed in 56a6223. The case no longer depends on scan order: it is an explicit topk_merge_min with -0.0 on the left and 0.0 inserted on the right, so the retained representative is deterministic, and the golden pins that single merge result.

@yujun777

Copy link
Copy Markdown
Contributor Author

run buildall

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review of exact head 56a6223 across all 22 changed paths. Four new findings: decimal precision lost during signature promotion, lossy list coercion breaking distinct counts, nested NULL fields silently consumed in release, and runtime-NULL aggregate k bypassing validation. The previous six inline findings are fixed at this head; none is reposted, and no existing P0/P1 remains applicable. There was no additional user-provided focus.

The reviewed flow is SQL binding/custom signature -> implicit casts -> BE aggregate add/partial serialization/merge -> ordered values/bound state -> scalar S+I-D within the trusted bound -> result state. IVM plan integration is explicitly a separate PR.

Critical checkpoint conclusions:

  1. Goal and proof: the four functions implement ordered top-k counts/bounds and a delta apply step; the four findings prevent full correctness. New FE/BE/regression tests cover normal paths but omit these cases.
  2. Scope and reuse: changes are focused on the functions and registries, use common MIN/MAX implementations, and reuse existing aggregate/column helpers. The incidental string-test edits update existing API calls.
  3. Concurrency: no new threads, locks or shared mutable function state; aggregate state and scalar scratch follow existing evaluator ownership. No new lock-order or lock-held work issue found.
  4. Lifecycle/static initialization: nontrivial aggregate state is argument-constructed, destroyed and reset through existing helpers; scratch cleanup follows their exception paths. No new ownership cycle or cross-translation-unit initializer dependency found.
  5. Configuration: no new configuration or dynamic-update path.
  6. Compatibility: names are registered on both FE/BE; symbols and the aggregate format are new, and no existing storage/protocol layout changes. Datatype serialization receives the inherited execution version. Decimal precision selection is finding 1; normal analysis recomputes the result type after narrowing its arguments. Mixed-version execution of new functions was not run.
  7. Thrift/protobuf: no gensrc changes or field-ID changes.
  8. Parallel paths: MIN/MAX share the reported defects; nullable aggregates, partial/final aggregation, empty input, AggState combinators and foreach exclusion were traced. Cloud/non-cloud use the same function path.
  9. Conditions/error handling: truncation/exactly-k/zero-count/bounds/overflow branches were checked. Previous incompatible-k and bound-row defects are fixed. Nested NULL validation is finding 3; backend-evaluated NULL k can bypass validation (finding 4).
  10. Test coverage: regression, BE unit and JUnit 5 signature tests exist for empty/all-NULL groups, types, bounds, k limits, partial merge, compressed numeric serde, mismatch-k and overflow. Mixed-type/decimal scalar, nested-NULL negative and runtime-NULL k cases need coverage.
  11. Expected results: every added output cell was checked statically against inputs/algorithms; the signed-zero representative is now deterministic. Actual output-generation history and runtime results were not verified.
  12. Observability: existing local query errors are appropriate for these pure functions; no additional metrics requirement identified.
  13. Transactions/persistence/failover: no EditLog, transaction, visible-version, rowset or persistence lifecycle changes.
  14. Data writes/atomicity: no storage-write path changes; no new crash-recovery or atomicity mechanism.
  15. FE-BE transport: no new variables or send paths; existing expression/function metadata carries the types, with decimal type narrowing noted above.
  16. Performance/memory: retained and merge counts use tracked DorisVector; rejected worse values now avoid full-list copying. Sorted replacements remain linear in k, overlapping the prior performance topic. No additional distinct performance finding verified.
  17. Other checks: registrations, arities, visitor dispatch, nullability and CMake discovery were covered. The text-only build hygiene gates passed header rules/budgets/PCH, extern-template pairing and unity coverage.

Validation was static only, as required: no builds, runtime tests or source changes. Review converged in 3 rounds: all three latest reviewers returned NO_NEW_VALUABLE_FINDINGS on the same final four-comment set. All 22 changed paths and initial risk areas are covered; no unresolved candidate or coverage gap remains. The P1 decimal finding warrants changes requested.

Review converged after 3 round(s).

+ toSql());
}
ArrayType listType = ArrayType.of(TopKCountFunction.pairType(valueType));
return FunctionSignature.ret(resultType(valueType)).args(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Preserve nested decimal precision when bounds are NULL

The inherited decimal precision pass changes the value type chosen here even for homogeneous input lists. With S=[(1.23,1)] and empty I/D all typed as ARRAY<STRUCTvalue:DECIMAL(4,2),count:BIGINT>, plain NULL bounds, n=1, dn=0 and k=2, extractBySignature skips the STRUCT entries, so only the NULL bounds contribute and select DECIMAL(1,0). defaultDecimalV3PrecisionPromotion then rewrites every nested decimal argument to that type. Coercion changes 1.23 to 1, and normal analysis recomputes the result type from the narrowed children, returning 1 instead of 1.23. Both MIN and MAX are affected. Override precision computation to include the nested value types and ignore untyped NULL bounds, so a complete decimal state retains its values; add a decimal merge regression with plain NULL bounds.

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.

Fixed in e4acad3. TopKMergeFunction now overrides computePrecision and returns the signature unchanged, so a NULL bound can no longer contribute DECIMAL(1,0) and narrow the nested lists. TopKFunctionSignatureTest.testMergeKeepsDecimalPrecisionWithNullBounds covers S=[(1.23,1)] with NULL bounds, and order_qt_merge_decimal_null_bounds covers it end-to-end.

if (fields.size() != 2) {
continue;
}
return TopKCountFunction.normalizeValueType(fields.get(0).getDataType());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Preserve distinct values when coercing the input lists

Taking T from the first typed list forces later lists through potentially lossy casts. With empty INT S/D, sorted DOUBLE I=[(1.1,1),(1.9,1)], NULL bounds, n=0, dn=2 and k=2, FE implicitly casts I to [(1,1),(1,1)]. consume_equal advances a side only once per head, so the scalar returns two entries for value 1 instead of one entry with the summed count. Each source list was ordered, distinct and non-NULL before this function coerced it; MAX has the symmetric case. Choose a lossless common value type across all lists/bounds, or reject incompatible input types before coercion, and add mixed-type merge coverage.

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.

Fixed in e4acad3. inferValueType now requires the same value type in every typed list and bound and raises an analysis error for mixed types, so no list is coerced to a narrower type. TopKFunctionSignatureTest.testMergeRejectsMixedValueTypes covers empty INT lists with a DOUBLE list, and the regression asserts the same error.

}
// The top-K producers never emit a NULL entry, a NULL value or a NULL count.
DCHECK(view.null_map == nullptr || (*view.null_map)[index] == 0);
DCHECK(view.value_null_map == nullptr || (*view.value_null_map)[index] == 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Reject NULL state fields before consuming their nested payload

These DCHECKs are the only checks for actual NULL entries/fields, but the public scalar signature does not guarantee their absence: FE complex coercion preserves source struct-field nullability. For example, S=array(named_struct('value', CAST(NULL AS INT), 'count', CAST(1 AS BIGINT))), empty typed INT I/D, NULL bounds, n=1, dn=0 and k=1 is accepted. In release the value NULL map is ignored and its default INT payload is emitted as a non-NULL value 0 with count 1; checked builds hit the assertion. NULL counts/elements have the same unchecked path, and MAX shares it. Return a query error for actual NULL state entries/fields before comparing or consuming their payload, while continuing to allow NULL top-level lists and nullable wrappers containing non-NULL data.

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.

Fixed in e4acad3. check_entry() runs before any comparison or consumption and returns INVALID_ARGUMENT when an entry, its value or its count is NULL. NULL top-level lists are still accepted as empty states (order_qt_merge_all_null), and the NULL-value case is in the regression.

checkLegalityBeforeTypeCoercion();
Expression count = getArgument(1);
// A constant FE cannot fold is left to BE, which reads the value from the count column.
if (!(count instanceof Literal)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Validate evaluated k before nullable argument filtering

Deferring validation here does not ensure BE ever reads the count. With BE constant folding disabled (the default), SELECT g, topk_min_count(v, bitmap_min(bitmap_from_string(''))) FROM (SELECT 1 AS g, 1 AS v UNION ALL SELECT 1 AS g, 2 AS v) t GROUP BY g passes the constant check and remains nonliteral in FE, but its count evaluates to NULL. The variadic nullable aggregate wrapper then skips every input row before AggregateFunctionTopKCount::check_k is called, so this nonempty group silently returns an empty state instead of the required non-null-count error. MAX shares this path. Validate the evaluated constant k independently of value-row null filtering, and cover a backend-evaluated NULL count as well as a literal NULL.

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.

Fixed in e4acad3. The aggregate is now constructed directly instead of through the nullable-argument adapter, which dropped rows before add() ran, so add() calls check_k for every row, including rows whose x is NULL, and a NULL or out-of-range evaluated k fails the query. The bitmap_min regression covers the non-foldable NULL count.

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.

Fixed in 1c73d49: TopKFunction no longer implements NullIgnoringAggregateFunction. The marker's contract (an input consisting only of rows whose argument is NULL behaves like an empty input) does not hold while a backend-evaluated count is validated per input row, so InferAggNotNull must not filter those rows away for these aggregates.

Without the marker the global aggregate keeps its rows, add validates the count before skipping a NULL value, and an all-NULL input with a non-literal NULL count now reports the count of min_topk and max_topk must be a non-null constant instead of returning an empty array. NULL values of x are still ignored by add itself, so the aggregate's result is unchanged for valid counts.

Covered in query_p0/aggregate/min_max_topk: the all-NULL global input with bitmap_min(bitmap_from_string('')) fails for both aggregates, and the same shape with the valid non-literal count bitmap_min(bitmap_from_string('1')) still returns the empty array.

@yujun777

Copy link
Copy Markdown
Contributor Author

run buildall

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Static review of e4acad3, covering all 22 changed paths and relevant callers/callees. Two new P2 findings are inline: NULL scalar counters reach a fatal invariant path in checked builds, and a valid insert/delete cancellation can falsely overflow. No P0/P1 finding remains independently confirmed for this head.

Existing review context: both prior P1 concerns are repaired (per-row bound indexes and nested decimal precision). The earlier incompatible-k, column framing, tracked buffers, rejected-value copies, mixed-type coercion, NULL state-field repro and signed-zero expectation concerns are also repaired. The evaluated-NULL aggregate k concern remains partially applicable: for a global aggregate over a nonempty table whose x values are all NULL, InferAggNotNull can insert x IS NOT NULL and discard every row before check_k. This is already covered by thread 4347517695 and is not reposted. Its grouped/non-NULL-x repro is repaired. No additional user focus was supplied.

Critical checkpoint conclusions:

  1. Goal and proof: the four functions implement counted top-K state and delta apply; registrations and nested return metadata agree. Supplied tests cover core behavior, but the two inline edge cases remain untested. FE IVM integration is outside this PR's stated scope.
  2. Scope and reuse: shared MIN/MAX implementations and type helpers keep the change focused; the string unit-test API adjustments are small and unrelated to runtime behavior.
  3. Concurrency and locking: per-group aggregate state and stateless scalar functions introduce no new threads, shared mutable state, locks or lock ordering.
  4. Lifecycle and initialization: helpers destroy/recreate state on reset; columns and DorisVector buffers own tracked memory. No new cross-translation-unit initialization dependency was found.
  5. Configuration: none added.
  6. Compatibility: new function names and a new aggregate-state format; existing function/storage formats are unchanged. The codecs use the negotiated execution version. Newly introduced builtins require supporting BEs.
  7. Thrift/protobuf: no changes.
  8. Parallel paths: MIN/MAX, nullable/nonnullable, constants, batch/selected/single-place, streaming, window and AggState paths were traced. Constants are materialized before aggregate row access; foreach is explicitly excluded.
  9. Conditions and errors: truncation/bound branches have concrete explanations. The NULL scalar assertion and cancellable intermediate overflow need the inline fixes; residual aggregate validation is tracked in the existing thread.
  10. Test coverage: BE and FE unit tests plus regression SQL cover empty/all-NULL input, k boundaries, truncation, partial merges, compressed serialization, metadata, precision, mixed types, NULL state fields and multirow bounds. Nullable scalar counters and count-limit cancellation need coverage.
  11. Expected results: all supplied output entries were reconciled with the SQL and algorithms; signed zero is now deterministic. Execution and output-generation provenance were not verified.
  12. Observability: existing function/argument-specific errors are adequate for these local expressions; no new service metrics are needed.
  13. Transactions/persistence/failover: no transaction, EditLog or persisted catalog behavior changes; partial-state transport follows the existing aggregate infrastructure.
  14. Data writes and atomicity: no table-write path changes; propagated query errors discard local state.
  15. FE/BE passing: function names, arity, supported value families, BIGINT counts and nested nullability align; no new wire variables.
  16. Performance: full-list worse-value rejection avoids the known redundant copy, scalar merge scans only the required prefix, and count buffers are tracked. Accepted distinct insertions retain the sorted-column O(k) rebuild cost covered by existing review context.
  17. Other issues: malformed struct arities are rejected by common analysis checks; decimal/time precision, NULL coercion, NaN/zero comparison, ownership and special execution paths were rechecked without another distinct verified issue.

Review converged in two rounds after all three reviewers rechecked the unchanged final findings, duplicate dispositions and complete assigned scope. No unresolved static-review candidate or uncovered changed path remains. Validation is static only: no builds, tests, formatting changes or source modifications were performed, as required by this review contract.

Review converged after 2 round(s).

const IColumn* nested = &column;
if (const auto* nullable = check_and_get_column<ColumnNullable>(&column)) {
// The contract declares these arguments as non-NULL BIGINT.
DORIS_CHECK(!nullable->is_null_at(row)) << argument_name << " must not be NULL";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Reject NULL scalar counters with a query error

The FE signature accepts nullable BIGINT arguments, so SELECT topk_merge_min(CAST(NULL AS ARRAY<STRUCT<value:INT,count:BIGINT>>),NULL,NULL,NULL,NULL,NULL,0,0,CAST(NULL AS BIGINT)) reaches this check. DORIS_CHECK calls Status::FatalError, which executes LOG(FATAL) in builds without NDEBUG, terminating the BE for invalid SQL input; release builds instead return FATAL_ERROR. NULL n/dn and topk_merge_max follow the same path. Return INVALID_ARGUMENT for actual NULL counter values, including nullable nonliteral inputs, and cover all three counters.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please replace the DORIS_CHECK in read_int64() with an explicit INVALID_ARGUMENT exception:

if (nullable->is_null_at(row)) {
    throw Exception(ErrorCode::INVALID_ARGUMENT,
                    "{} must not be NULL", argument_name);
}

NULL n, dn, or k can reach this helper through the public SQL signature. They are invalid argument values and should fail the query with a normal parameter error. The current assertion reaches LOG(FATAL) in builds without NDEBUG, terminating the BE; release builds report FATAL_ERROR. Throwing INVALID_ARGUMENT also follows the existing check_k() and overflow-checking error-handling pattern.

Please cover NULL values for all three counters in both MIN/MAX variants, including nullable column inputs.

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.

Fixed in e7a3dbf: read_int64 returns a Status and reports INVALID_ARGUMENT for a NULL n, dn or k. b0ae93f adds the coverage requested here: NULL for all three counters on both topk_merge_min and topk_merge_max, plus a table whose nullable BIGINT columns feed the counters, so the nullable-column input path is exercised as well.

RETURN_IF_ERROR(consume_equal(i_view, i_index, *head, head_row, type, &count_i));
RETURN_IF_ERROR(consume_equal(d_view, d_index, *head, head_row, type, &count_d));

int64_t post = checked_add(count_s, count_i, name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Check overflow after insert/delete cancellation

With complete S=[(1,9223372036854775807)], I=D=[(1,1)], NULL bounds, n=9223372036854775807, dn=0 and k=1, a valid delta replaces one occurrence with the identical value and the result should remain S. This addition overflows before the deletion is subtracted, although every input and final count fits BIGINT. MAX shares the path. Evaluate S+I-D with a wider intermediate or cancellation-safe ordering, range-check the final count, and cover this boundary cancellation.

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.

Fixed in e7a3dbf. S + I - D is evaluated in __int128 and range-checked before narrowing to int64, so the INT64_MAX cancel pair returns the original state instead of overflowing. order_qt_merge_min_cancel_at_int64_max pins it.

@yujun777

Copy link
Copy Markdown
Contributor Author

run buildall

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@yujun777

Copy link
Copy Markdown
Contributor Author

run buildall

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Static review of b0ae93f: all 22 changed paths reviewed. No new unique inline findings. All three reviewers returned NO_NEW_VALUABLE_FINDINGS; all candidates and risk focuses have evidence-backed dispositions.

Both existing P1 findings are corrected: row-specific bounds (#4346834936) and nested decimal precision with NULL bounds (#4347517663). Neither remains a blocking finding for this head. The other supplied fixes were revalidated, including tracked count buffers, serialization framing, incompatible-k rejection, append/rejection fast paths, mixed-type rejection, NULL field/counter errors, cancellation-safe arithmetic, and deterministic signed-zero coverage.

One residual path is already covered by existing P2 #4347517695, so it is not reposted: with only NULL values in a nullable INT x column, a global topk_min_count(x, bitmap_min(bitmap_from_string(''))) can acquire an x IS NOT NULL filter through InferAggNotNull. The reduced plan is Aggregate(topk_min_count(x,k)) -> Filter(x IS NOT NULL) -> Scan; removing all rows bypasses add/check_k, even though k evaluates to NULL. MAX shares this path. Direct aggregate creation fixes the originally reported BE nullable-adapter instance, but validation can still be suppressed upstream.

Critical checkpoint conclusions:

  1. Goal/correctness: counted MIN/MAX prefixes and trusted-range S+I-D apply preserve exact retained values/counts for valid producer states; IVM plan integration is a separate PR.
  2. Scope/reuse: shared MIN/MAX templates and FE result-type helpers keep the feature focused; the string-test changes only update existing API usage.
  3. Concurrency/locks: mutable aggregate data is per group; scalar state is local to execution. No new threads, shared mutable state, locks or lock-order risk identified.
  4. Lifecycle/static initialization: create/reset/destroy, streaming temporaries, deserialization and window buffering were traced; owned columns/vectors clean up through existing helpers. No new cross-TU initialization dependency.
  5. Configuration: no new configuration or dynamic-update path.
  6. Compatibility: new function names and state encoding only; no existing function/storage format replaced. Negotiated execution versions flow through aggregate/state serialization; older binaries do not implement these new functions.
  7. Thrift/protobuf: no definitions changed.
  8. Parallel paths: MIN/MAX, k=0/positive k, grouped/global/window, partial/final and AggState paths checked; unsupported foreach is rejected.
  9. Conditions/errors: reachable NULL fields/counters, invalid k, mismatched state k and overflow now fail through normal errors. Constant-column assumptions are supported by upstream materialization; the known NULL-k filtering exception is described above.
  10. Tests: BE state/merge/serialization and FE signature tests plus regression success/error cases were inspected. No builds or tests were run.
  11. Expected results: all added output blocks reconcile statically with the inputs and merge rules; signed-zero representative selection is controlled in the explicit scalar case.
  12. Observability: existing function-specific query errors/profiles are adequate for these local expressions; no new service lifecycle requiring metrics was identified.
  13. Persistence/failover: no EditLog, transaction, rowset/version or delete-bitmap change; temporary/public aggregate-state serialization was reviewed separately.
  14. Data writes/atomicity: no new durable write or transactional mutation path.
  15. FE/BE propagation: registered names, supported families, intermediate string state and nested STRUCT/ARRAY nullability agree through existing metadata/translation paths; no new wire variable.
  16. Performance/memory: significant count/column buffers are tracked; full-list rejection and tail append remove the prior redundant copies. Interior sorted insertion remains linear in retained size; scalar merge stops after k+1 positive candidates.
  17. Other concerns/user focus: no additional user focus was supplied. No unresolved new candidate or unreviewed changed path remains.

Limits: this is static validation, not evidence of passing builds, runtime tests or benchmarks. Generated-output provenance was not independently established. Plain untyped NULL decimal bounds are covered by the FE signature test, while the SQL regression uses typed NULLs; full supported-type and multi-step IVM runtime coverage was not executed.

Review converged after 1 round(s).

@yujun777
yujun777 force-pushed the feature/ivm-minmax-topk-functions branch from b0ae93f to a742080 Compare October 22, 2026 14:06
@yujun777 yujun777 changed the title [feature](ivm) Add TOPK_MIN_COUNT/TOPK_MAX_COUNT and TOPK_MERGE_MIN/MAX functions [feature](function) Add TOPK_MIN_COUNT/TOPK_MAX_COUNT and ARRAY_SORTED_MERGE functions Oct 22, 2026
@yujun777

Copy link
Copy Markdown
Contributor Author

run buildall

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Static review of all 26 changed paths at a7420809fc69a4ea822cfd1f29ed9f8b03afbb3a found two new P1 issues in array_sorted_merge: captured columns can produce incorrect results, and nonnullable merge bodies reach a fatal nullable-column cast. Both are reported inline. Request changes.

Review checkpoint conclusions:

  • Goal and proof: TOPK_MIN_COUNT/TOPK_MAX_COUNT implement the intended distinct values/counts/bound shape; the sorted-merge invocation chain is wired, but the two accepted lambda cases above are broken. Added tests cover normal behavior and miss these cases.
  • Scope and reuse: changes are focused on the functions, registrations, lambda extra-input plumbing and tests; the string-test changes are mechanical API spelling repairs. Existing aggregate helpers and column machinery are reused.
  • Concurrency and locks: no new workers, shared query state or lock ordering; registration uses call_once and states are local to aggregation places/execution calls.
  • Lifecycle and initialization: reset destroys/reconstructs owned aggregate state; temporary columns and lambda frames have bounded synchronous lifetimes. No cross-translation-unit initializer dependency or ownership cycle found.
  • Configuration: none added; no dynamic-update obligation.
  • Compatibility: no changes to existing storage/wire layouts or existing function symbols; the new aggregate state uses existing datatype serialization and execution-version plumbing. Mixed-version execution was not exercised.
  • Thrift/protobuf: no changes.
  • Parallel paths: MIN/MAX share implementation; k=0, partial/final, serialized and state-combinator paths were traced. Both merge directions share the same machinery; unsupported foreach variants are explicitly excluded.
  • Conditions, errors and nulls: aggregate k/overflow checks and ordinary NULL handling were traced. The new lambda result-shape error is inline; captured-input resolution is independently inline.
  • Test coverage: FE signatures/legality, BE aggregate tests and both regression suites were inspected. Missing coverage includes captures/nested lambdas, nonnullable bodies, broad scalar types, window/reset and compressed STRING/MAX states.
  • Expected results: all added golden results agree with the supplied test inputs by source inspection. The current special-float input has only positive zero, removing the earlier sign-dependent expected result. Golden generation was not verified by execution.
  • Observability: existing query errors suffice for these local functions; the fatal error on valid input is reported. No new distributed diagnostic mechanism is needed.
  • Transactions and persistence: none changed; no edit-log/replay or failover obligation introduced.
  • Data writes and atomicity: no persistent write path changed.
  • FE/BE transport: the ascending flag survives binding and translation; result schema and column shapes were traced. Capture transport and nonnullable body output are the two reported gaps.
  • Performance and memory: count buffers use DorisVector; prior full-list rejection and tail-append fixes are present. No additional independent performance finding; no benchmark or release-artifact ABI audit performed.
  • Other concerns: every candidate/risk has an accepted, duplicate or evidence-backed dismissed disposition. No unresolved concrete review question remains.

Existing threads were revalidated and not reposted. The two earlier P1 findings concern scalar topk_merge APIs absent from this head, so no existing P0/P1 blocker IDs remain applicable. Earlier aggregate mismatch/serde/accounting/copy issues are fixed. The existing evaluated-NULL-k P2 thread remains relevant when global optimization filters all-NULL x before add(); that continuation is already covered by discussion r4347517695.

No additional user focus was supplied. Review converged after two rounds: all three latest reviewers returned NO_NEW_VALUABLE_FINDINGS on the unchanged final comment set, and all changed paths and risk focuses were covered. This is static validation only: no builds, tests, runtime queries, formatting/hygiene execution, benchmarks or golden regeneration were performed under the review contract.

Review converged after 2 round(s).

argument_columns[1]->insert_from(*data[1], right);

int lambda_res_id = lambda_result_base;
RETURN_IF_ERROR(children[0]->execute(context, &lambda_block, &lambda_res_id));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Bind captured inputs before executing the merge lambda

The FE accepts enclosing slots in the merge lambda, but this executes them against lambda_block, which contains only a and b. For a reduced input plan Project(array_sorted_merge((a,b)->IF(a=b AND a<lim,a,NULL),l,r,true)) -> Scan(lim INT,l ARRAY<INT>,r ARRAY<INT>), the row lim=2,l=[1],r=[1] should return [1]. The captured nullable INT lim retains input column index 0, so VSlotRef reads candidate a=1 instead; the predicate becomes a=b AND a<a, and the result is [1,1]. With lim at index 2 the same path instead reports a missing-column error. Materialize and rebind current-row captures and enclosing lambda bindings, or reject unsupported captures during prepare as array_sort does. Add table-column and outer-lambda capture regressions.

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.

Fixed in 42efc96: captured input columns are materialized at their original positions in the one-row lambda block for the current row, so an enclosing slot reads the correct value. A nested lambda that captures one of the merge arguments is rejected in prepare with NotSupported instead of resolving against the wrong column. Regression covers a table-column capture at index 0 (previously resolved to candidate a) and one beyond the two candidates (previously a missing column), plus the rejected nested capture.

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.

Fixed in 6ac1f1a: an enclosing lambda's argument now resolves correctly. The function collects the named bindings visible at the current execution point (LambdaExecutionContext::collect_visible_bindings) and publishes their values under those names in its one-row merge block, before the two merge arguments so that a merge argument with the same name still shadows an outer one. The values are read at the same row as the arrays, so short-circuit evaluation and batched lambda execution keep reading the right value.

order_qt_capture_enclosing_lambda in test_array_sorted_merge covers array_map(x -> array_sorted_merge((a, b) -> IF(a = b AND a < x, a, NULL), [1], [1], true), [0, 2]) and returns [[1, 1], [1]] (x=0 keeps both values, x=2 merges them). A nested lambda that captures one of the merge arguments is still rejected during prepare, because those arguments are not published into nested frames.

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.

Fixed in 51bd4e1: the merge frame now rebases every binding it can see onto the private block instead of hiding them. It publishes the enclosing bindings with their positions in that block, then the two merge arguments, so a same-named a/b shadows an outer binding. parent_bindings_visible stays false, because the parent frame positions do not describe this block.

A lambda nested inside the merge body therefore resolves x through the merge frame: the nested function collects that binding position and materializes it from the one-row merge block, and the value is cut at the same source row as the arrays. The reduced plan

SELECT array_map(x -> array_sorted_merge(
           (a, b) -> IF(a = b AND a < element_at(array_map(y -> x, [1]), 1), a, NULL),
           CAST([1] AS ARRAY<INT>), CAST([1] AS ARRAY<INT>), true), [0, 2])

now returns [[1, 1], [1]], pinned by order_qt_capture_enclosing_lambda_nested in test_array_sorted_merge. Nested captures of the merge-local a/b are still rejected during prepare, and the legacy positional frame is unchanged when the lambda carries no argument names.

merged_is_null = nullable->is_null_at(0);
}
if (!merged_is_null) {
result_nested->insert_from(*merged_column, 0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Wrap nonnullable lambda results before inserting array elements

SELECT array_sorted_merge((a,b)->COALESCE(a,b,CAST(0 AS INT)), CAST([1,2] AS ARRAY<INT>), CAST([1,2] AS ARRAY<INT>), true) passes FE type validation and should return [1,2]. The fallback makes the body nonnullable, so VectorizedCoalesceExpr returns a raw ColumnInt32; VLambdaFunctionExpr preserves that body type, and materializing constants does not add a nullable wrapper. However, result_nested is ColumnNullable(Int32) because DataTypeArray always makes its elements nullable. This call therefore reaches ColumnNullable::insert_from with a nonnullable source and fails assert_cast (FATAL_ERROR, or LOG(FATAL) without NDEBUG). Normalize the lambda result to the nullable item representation before inserting it, and cover nonnullable COALESCE/IFNULL or constant bodies.

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.

Fixed in 42efc96: the lambda result is normalized to the nullable array element representation before insertion -- a non-nullable body inserts its payload into the nested column and appends a 0 to the null map. A regression case with COALESCE(a, b, 0) now returns [1, 2].

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28308 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit a7420809fc69a4ea822cfd1f29ed9f8b03afbb3a, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17580	5121	5031	5031
q2	2143	332	245	245
q3	10204	1376	817	817
q4	4681	469	334	334
q5	7429	821	543	543
q6	182	182	151	151
q7	742	788	604	604
q8	9248	1330	1082	1082
q9	5526	4207	4168	4168
q10	6831	1291	1026	1026
q11	427	241	228	228
q12	639	396	277	277
q13	18094	3663	3014	3014
q14	278	265	244	244
q15	q16	740	726	649	649
q17	1182	747	563	563
q18	7767	6184	6110	6110
q19	1142	954	586	586
q20	404	335	223	223
q21	5556	2167	2257	2167
q22	365	306	246	246
Total cold run time: 101160 ms
Total hot run time: 28308 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	5610	5585	5614	5585
q2	664	571	532	532
q3	5209	5369	4916	4916
q4	2000	2137	1544	1544
q5	5302	5175	5798	5175
q6	254	196	157	157
q7	2363	2158	1893	1893
q8	3077	2928	2722	2722
q9	8112	8205	8143	8143
q10	3685	3649	3226	3226
q11	606	428	399	399
q12	675	683	512	512
q13	3518	3899	3152	3152
q14	302	316	294	294
q15	q16	708	702	667	667
q17	8212	7521	7227	7227
q18	12921	12166	12905	12166
q19	883	760	766	760
q20	2154	2162	1926	1926
q21	5541	4563	4675	4563
q22	488	472	413	413
Total cold run time: 72284 ms
Total hot run time: 65972 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 24.27 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit d9d17e663629a2bc41b45deb64a2e384b1f41df2, data reload: false

query1	0.01	0.01	0.01
query2	0.10	0.05	0.04
query3	0.26	0.09	0.09
query4	1.66	0.11	0.10
query5	0.27	0.24	0.24
query6	1.17	0.68	0.69
query7	0.04	0.01	0.00
query8	0.05	0.04	0.04
query9	0.40	0.37	0.34
query10	0.58	0.57	0.60
query11	0.20	0.14	0.14
query12	0.19	0.14	0.14
query13	0.48	0.50	0.49
query14	1.03	1.01	1.02
query15	0.63	0.59	0.60
query16	0.34	0.34	0.31
query17	1.16	1.16	1.18
query18	0.22	0.18	0.20
query19	2.13	2.00	1.95
query20	0.02	0.01	0.01
query21	15.47	0.19	0.15
query22	4.97	0.06	0.05
query23	16.27	0.33	0.13
query24	3.00	0.46	0.36
query25	0.10	0.06	0.03
query26	0.89	0.21	0.16
query27	0.05	0.05	0.04
query28	4.02	0.87	0.36
query29	12.51	4.11	3.24
query30	0.29	0.12	0.11
query31	2.78	0.56	0.33
query32	3.23	0.60	0.50
query33	3.15	3.28	3.21
query34	15.47	4.08	3.39
query35	3.31	3.36	3.34
query36	0.55	0.44	0.43
query37	0.09	0.06	0.07
query38	0.05	0.03	0.04
query39	0.04	0.03	0.03
query40	0.18	0.15	0.14
query41	0.08	0.03	0.03
query42	0.04	0.03	0.03
query43	0.05	0.04	0.03
Total cold run time: 97.53 s
Total hot run time: 24.27 s

@yujun777

Copy link
Copy Markdown
Contributor Author

Correction to the memory table above, and a re-measurement.

The table above varied the number of rows per group at a fixed total row count (10M rows), so the 8-rows-per-group case also had eight times fewer groups (1.25M instead of 10M). That is not the shape the change is about. The correct comparison keeps the group count fixed and grows the rows per group, i.e. 10M groups with 1 row each (10M rows) against 10M groups with 8 rows each (80M rows).

Setup for the numbers below: release build, single BE restarted before every query (cold page cache on both sides), enable_sql_cache=false, exec_mem_limit raised, RSS peak delta of one query. The MIN runs are unaffected by this change, so the differences between their before/after values show the method's noise, roughly ±10%.

BIGINT (10M groups)

rows per group total rows query before after
1 10M MIN(v) 956 MB 1060 MB
1 10M min_topk(v, 8) 1851 MB 1877 MB
8 80M MIN(v) 2044 MB 1933 MB
8 80M min_topk(v, 8) 5183 MB 5071 MB

VARCHAR (10M groups)

rows per group total rows query before after
1 10M MIN(v) 1419 MB 1348 MB
1 10M min_topk(v, 8) 3085 MB 3200 MB
8 80M MIN(v) 2931 MB 2842 MB
8 80M min_topk(v, 8) 8395 MB 8346 MB

State kept on top of MIN, per group:

value type rows per group before after
BIGINT 1 ~90 B ~82 B
BIGINT 8 ~314 B ~314 B
VARCHAR 1 ~167 B ~185 B
VARCHAR 8 ~546 B ~550 B

What this says: in a release build the change is at the edge of, or below, what this method resolves. At eight distinct values per group it changes nothing measurable, because the slots dominate and a slot costs the same either way (24-byte metadata plus the 8-byte value against one 32-byte slot carrying the value inline). At one value per group the state is ~24 bytes and one heap allocation smaller, which is the whole effect and is about the size of the measurement noise.

The larger gain reported earlier (1959 MB to 606 MB at 10M groups) was measured on a debug/ASAN build, where every allocation carries a redzone, so the second vector per group is what dominated. The change is worth keeping for that build, and because it removes a container, but it should not be sold as a release-memory win.

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: Review recovery stopped: Command '['gh', 'api', 'repos/apache/doris/pulls/69456']' returned non-zero exit status 1.
Workflow run: https://github.com/apache/doris/actions/runs/40841771541

Please inspect the workflow logs and rerun the review after the underlying issue is resolved.

@github-actions

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: Selected model is at capacity. Please try a different model.
Workflow run: https://github.com/apache/doris/actions/runs/40848908479

Please inspect the workflow logs and rerun the review after the underlying issue is resolved.

The state carried a separate has_k flag and 64-bit slot counters next to the value payload.
k is the aggregate's non-negative count argument, so -1 can stand in for "this partial state
has seen no row", and both counters are bounded by the slot count that the int32_t treap links
already cap. The priority seed only feeds 32-bit priorities, so a 32-bit splitmix covers it.

Key changes:
- Drop has_k: k == -1 means unset; the serialized form keeps the historical flag byte, derived from k
- live/dead and the priority seed become 32-bit; the state is 56 bytes instead of 72 for fixed-width values
- Keep k and the per-value row count 64-bit: an int32 count would cap a value at 2^31 rows per group, which is legal data

Unit Test:
- be/test/exprs/aggregate/agg_topk_test.cpp (18 tests, including the serialized-format round trip)
- regression query_p0/aggregate/min_max_topk
@yujun777

Copy link
Copy Markdown
Contributor Author

Memory of the final top-K state, for min_topk against MIN.

Setup: release build, single BE restarted before every query (cold page cache on both sides), enable_sql_cache=false, exec_mem_limit raised, RSS peak delta of one query. Two value shapes per type: 10M groups with 1 row each (10M rows) and 10M groups with 8 rows each (80M rows).

value type rows per group total rows MIN(v) min_topk(v, 8) state per group
BIGINT 1 10M 999 MB 1687 MB ~69 B
BIGINT 8 80M 1886 MB 5038 MB ~315 B
VARCHAR 1 10M 1501 MB 2987 MB ~149 B
VARCHAR 8 80M 2742 MB 8214 MB ~547 B

"state per group" is the min_topk query minus the MIN query of the same shape, divided by the group count.

Query profile cross-check (10M groups, 1 row per group, per-operator MemoryUsagePeak): the aggregation sink peaks at 774 MB for MIN(v) and 1.25 GB for min_topk(v, 8) on BIGINT, and at 2.35 GB for min_topk(v, 8) on VARCHAR. The profile counts the state record but not the slot vector's payload, so it reads lower than RSS at 8 values per group.

What the state costs: 56 bytes of fixed part per group (k, the slot vector header, the cached worst slot, the live/dead counters and the priority seed) plus 32 bytes per retained value for BIGINT and 40 bytes for VARCHAR (count, both treap links, the priority and the value or its span). A MIN state is a single 8-byte value, so the comparison is dominated by that fixed part at one value per group and by the slots at eight.

@yujun777

Copy link
Copy Markdown
Contributor Author

run buildall

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 88.37% (547/619) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 67.24% (32587/48467)
Line Coverage 52.09% (345092/662502)
Region Coverage 47.51% (280293/589976)
Branch Coverage 49.18% (132338/269081)

@github-actions

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: Selected model is at capacity. Please try a different model.
Workflow run: https://github.com/apache/doris/actions/runs/40860648656

Please inspect the workflow logs and rerun the review after the underlying issue is resolved.

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 28.07% (48/171) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-H: Total hot run time: 28694 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpch-tools
Tpch sf100 test result on commit 29fb3c47891613984533a1602cf3c9e6d15e2bb2, data reload: false

------ Round 1 ----------------------------------
============================================
q1	17596	5109	5017	5017
q2	2174	319	261	261
q3	10221	1452	819	819
q4	4675	471	335	335
q5	7422	793	531	531
q6	175	179	149	149
q7	742	789	599	599
q8	9253	1373	1086	1086
q9	5540	4187	4203	4187
q10	6831	1275	1032	1032
q11	418	243	230	230
q12	638	400	280	280
q13	18116	3677	3011	3011
q14	275	274	247	247
q15	q16	710	719	647	647
q17	1214	773	573	573
q18	7034	6138	6093	6093
q19	1115	960	589	589
q20	385	330	220	220
q21	5400	2495	2509	2495
q22	383	334	293	293
Total cold run time: 100317 ms
Total hot run time: 28694 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	5916	5904	6103	5904
q2	722	597	589	589
q3	5722	5801	5295	5295
q4	2002	2146	1563	1563
q5	5709	5793	5606	5606
q6	235	187	142	142
q7	2259	1996	1791	1791
q8	2970	2634	2662	2634
q9	7931	7416	7444	7416
q10	3604	3573	3178	3178
q11	568	405	381	381
q12	654	683	513	513
q13	3302	3643	3048	3048
q14	291	293	270	270
q15	q16	651	672	609	609
q17	7654	7016	6918	6918
q18	12929	12105	12974	12105
q19	921	786	800	786
q20	2136	2141	1913	1913
q21	5535	4780	4695	4695
q22	484	427	426	426
Total cold run time: 72195 ms
Total hot run time: 65782 ms

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 96.94% (602/621) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 77.43% (36383/46989)
Line Coverage 62.72% (411305/655822)
Region Coverage 59.12% (347801/588265)
Branch Coverage 60.06% (161381/268701)

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 151336 ms
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/tpcds-tools
TPC-DS sf100 test result on commit 29fb3c47891613984533a1602cf3c9e6d15e2bb2, data reload: false

query5	4310	597	437	437
query6	426	212	192	192
query7	4807	431	224	224
query8	320	163	149	149
query9	8799	3246	3240	3240
query10	481	308	265	265
query11	6029	3851	3570	3570
query12	155	98	91	91
query13	1253	437	359	359
query14	6117	3946	3641	3641
query14_1	3417	3432	3413	3413
query15	224	204	181	181
query16	942	446	375	375
query17	869	657	532	532
query18	2426	425	315	315
query19	204	173	144	144
query20	85	87	82	82
query21	208	130	109	109
query22	12982	13133	12833	12833
query23	13372	12950	12479	12479
query23_1	12524	12579	12655	12579
query24	7358	848	474	474
query24_1	488	491	497	491
query25	541	430	384	384
query26	1270	260	143	143
query27	2761	443	288	288
query28	4605	2075	2062	2062
query29	1577	601	470	470
query30	302	222	184	184
query31	1022	923	819	819
query32	164	96	92	92
query33	531	311	244	244
query34	895	869	520	520
query35	740	749	659	659
query36	831	826	712	712
query37	132	102	83	83
query38	1776	1758	1668	1668
query39	788	786	735	735
query39_1	730	727	741	727
query40	213	115	95	95
query41	70	67	72	67
query42	88	87	87	87
query43	345	366	322	322
query44	1323	713	709	709
query45	180	185	169	169
query46	880	969	573	573
query47	3025	2847	2627	2627
query48	296	303	229	229
query49	569	397	291	291
query50	665	263	210	210
query51	10596	10532	10396	10396
query52	81	78	69	69
query53	193	207	150	150
query54	230	205	178	178
query55	67	64	63	63
query56	232	227	207	207
query57	1687	1618	1565	1565
query58	280	261	278	261
query59	2206	2158	2000	2000
query60	299	244	227	227
query61	145	142	146	142
query62	390	380	277	277
query63	191	158	158	158
query64	2738	958	763	763
query65	3318	3258	3255	3255
query66	1787	416	304	304
query67	20192	20170	20008	20008
query68	2993	1014	621	621
query69	380	272	242	242
query70	837	800	783	783
query71	291	237	214	214
query72	2695	2562	2275	2275
query73	530	531	297	297
query74	4655	4498	4312	4312
query75	2322	2265	1920	1920
query76	2310	1011	587	587
query77	338	370	267	267
query78	9927	9693	9173	9173
query79	1552	838	543	543
query80	1234	436	346	346
query81	590	320	273	273
query82	875	133	103	103
query83	309	200	179	179
query84	311	124	104	104
query85	859	434	367	367
query86	396	239	234	234
query87	1977	1962	1782	1782
query88	3874	2781	2789	2781
query89	339	281	243	243
query90	1857	166	172	166
query91	166	153	130	130
query92	96	90	89	89
query93	1142	997	583	583
query94	679	313	299	299
query95	570	342	321	321
query96	680	477	241	241
query97	2477	2451	2347	2347
query98	164	153	144	144
query99	761	756	635	635
Total cold run time: 236556 ms
Total hot run time: 151336 ms

@hello-stephen

Copy link
Copy Markdown
Contributor
ClickBench: Total hot run time: 24.38 s
machine: 'aliyun_ecs.c7a.8xlarge_32C64G'
scripts: https://github.com/apache/doris/tree/master/tools/clickbench-tools
ClickBench test result on commit 29fb3c47891613984533a1602cf3c9e6d15e2bb2, data reload: false

query1	0.01	0.01	0.01
query2	0.11	0.05	0.04
query3	0.26	0.08	0.08
query4	1.60	0.10	0.10
query5	0.23	0.23	0.24
query6	1.17	0.69	0.69
query7	0.04	0.01	0.00
query8	0.06	0.04	0.04
query9	0.40	0.35	0.35
query10	0.59	0.61	0.57
query11	0.20	0.14	0.13
query12	0.19	0.15	0.15
query13	0.50	0.49	0.49
query14	1.01	1.03	1.00
query15	0.63	0.60	0.61
query16	0.34	0.34	0.36
query17	1.15	1.15	1.13
query18	0.21	0.20	0.19
query19	2.12	2.00	2.01
query20	0.02	0.01	0.01
query21	15.50	0.19	0.13
query22	4.86	0.05	0.06
query23	16.26	0.32	0.14
query24	3.02	0.51	0.42
query25	0.14	0.05	0.06
query26	1.12	0.21	0.16
query27	0.05	0.05	0.05
query28	3.88	0.82	0.37
query29	12.46	4.13	3.29
query30	0.28	0.12	0.10
query31	2.77	0.54	0.32
query32	3.22	0.58	0.50
query33	3.15	3.22	3.17
query34	15.34	4.01	3.39
query35	3.34	3.32	3.36
query36	0.56	0.44	0.42
query37	0.10	0.06	0.07
query38	0.04	0.04	0.03
query39	0.03	0.03	0.03
query40	0.18	0.14	0.14
query41	0.08	0.03	0.03
query42	0.04	0.03	0.03
query43	0.04	0.03	0.04
Total cold run time: 97.3 s
Total hot run time: 24.38 s

@hello-stephen

Copy link
Copy Markdown
Contributor

FE Regression Coverage Report

Increment line coverage 81.29% (139/171) 🎉
Increment coverage report
Complete coverage report

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: Selected model is at capacity. Please try a different model.
Workflow run: https://github.com/apache/doris/actions/runs/40988566702

Please inspect the workflow logs and rerun the review after the underlying issue is resolved.

@yujun777

Copy link
Copy Markdown
Contributor Author

/review

@HappenLee HappenLee left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head 29fb3c4. No remaining blocking code issue found.

Checked FE registration, signatures and lambda binding/translation through BE aggregation and sorted-array execution, including count overflow, incompatible k, compressed state serialization, string padding/reclamation, singleton merges, candidate alias lifetimes, selected/virtual/enclosing captures and nullable result insertion. The previous relevant correctness and performance findings are addressed in this implementation. Shared state is query-local; MIN/MAX reuse the same implementation, and existing lambda bindings retain their default behavior. No existing storage/protocol/configuration or transaction path changes are introduced.

Validation: local build-hygiene checks passed; the current head's compile, FE/BE UT and main regression CI statuses are successful. This review did not rerun a full local build, tests or performance benchmarks. Automated code-review status is still pending.

Nonblocking documentation nit: please synchronize the PR description's k <= 65536 statement with the current code/tests, which allow larger nonnegative BIGINT k values (subject to the representable state-size limit).

@github-actions

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: Selected model is at capacity. Please try a different model.
Workflow run: https://github.com/apache/doris/actions/runs/41017087584

Please inspect the workflow logs and rerun the review after the underlying issue is resolved.

@hello-stephen
hello-stephen merged commit 96f2558 into apache:master Oct 30, 2026
37 of 38 checks passed
@github-actions github-actions Bot added the approved Indicates a PR has been approved by one committer. label Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR approved by at least one committer and no changes requested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by one committer. incremental-computation incremental-computation-picked

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants