HappenLee commented on code in PR #67140:
URL: https://github.com/apache/doris/pull/67140#discussion_r4023559414


##########
be/src/exec/operator/streaming_aggregation_operator.cpp:
##########
@@ -342,12 +350,19 @@ Status 
StreamingAggLocalState::_pre_agg_with_serialized_key(doris::Block* in_blo
     _places.resize(rows);
 
     if (_should_not_do_pre_agg(rows)) {
+        // Serializing a row as a single-row state may allocate from the arena 
(collect, map,
+        // foreach, ...). Those bytes are dead once the state is serialized, 
so they must not
+        // accumulate in the operator-lifetime `_agg_arena_pool` for every 
pass-through block.
+        Arena pass_through_arena;
         if (limit > 0) {
             DCHECK(do_sort_limit);
             if (need_do_sort_limit == -1) {
+                // Only latch once the hash table can seed the heap. A smaller 
table keeps the
+                // state undecided so that aggregation resumed after a 
transient pass-through

Review Comment:
   [P1] Preserve distinct TopN candidates across pass-through and recovery
   
   The memory-triggered pass-through/recovery path can make the TopN heap count 
the same group twice and then discard a group that belongs in the result. For a 
plan shaped like `TopN(k ASC, 2) -> global aggregation -> exchange -> streaming 
aggregation`, consider these input blocks:
   
   | Step | Aggregation hash table | TopN candidates | Boundary |
   | --- | --- | --- | --- |
   | Aggregate `[10, 20]` | `{10, 20}` | `[10, 20]` | `20` |
   | Lower the budget; pass through `[5, 30]` | `{10, 20}` | `[5, 10]` | `10` |
   | Restore the budget; aggregate `[5]` | `{5, 10, 20}` | `[5, 5]` | `5` |
   | Read `[7]` | Unchanged: `{5, 10, 20}` | `[5, 5]` | `5`; key `7` is 
discarded |
   
   The mixed block `[5, 30]` matters: it enters the filtering branch, where 
[`_add_limit_heap_top()`](https://github.com/apache/doris/blob/6ef7d2447ef526d679e931595d01e4b01c0c7649/be/src/exec/operator/streaming_aggregation_operator.cpp#L710)
 inserts `5` into the candidate heap without inserting it into the aggregation 
hash table. On recovery, `5` is new to the hash table, so the [new-group 
callback](https://github.com/apache/doris/blob/6ef7d2447ef526d679e931595d01e4b01c0c7649/be/src/exec/operator/streaming_aggregation_operator.cpp#L761)
 invokes `_refresh_limit_heap()` and inserts a second `5` into the heap. 
Neither heap update checks candidate uniqueness. The boundary now represents 
two rows of one group, rather than two distinct groups.
   
   For `SELECT k, SUM(v) FROM t GROUP BY k ORDER BY k LIMIT 2`, the expected 
keys in this example are `5, 7`; the downstream stage cannot recover key `7` 
once the local stage has discarded it.
   
   I reproduced the operator-level failure with a temporary BE unit test using 
the production `_should_not_do_pre_agg()` decision, a pushed TopN of 2, and 
real `sum` serialization. The test changes the mock query limit from `5000 MiB` 
to `10 bytes` and back with 5 tasks to force the transition deterministically. 
It observes:
   
   ```text
   After recovery: expected heap boundary 10, actual 5.
   After reading 7: expected hash-table size 4, actual 3.
   15 filtered tests ran: the PR's existing 14 passed; the added reproducer 
failed.
   ```
   
   Command: `./run-be-ut.sh -j 48 --run 
--filter='StreamingAggMemoryLimitTest.*:StreamingAggOperatorTest.*:DistinctStreamingAggOperatorTest.*'`.
 This validates the local filtering failure; I have not run an end-to-end SQL 
reproducer.
   
   The non-deduplicating heap code predates this PR, but this PR makes 
memory-triggered pass-through and recovery reachable for additional non-spill 
queries. Please cover this interaction before merging. A simple fix to consider 
is to keep using the existing conservative TopN boundary during pass-through, 
without tightening it from raw, non-deduplicated rows; normal hash-table 
insertion can update the boundary after aggregation resumes. This avoids adding 
another deduplication structure.
   



##########
be/src/exec/operator/distinct_streaming_aggregation_operator.cpp:
##########
@@ -176,8 +195,13 @@ Status 
DistinctStreamingAggLocalState::_distinct_pre_agg_with_serialized_key(
     const uint32_t rows = (uint32_t)in_block->rows();
     _distinct_row.clear();
 
-    if (_parent->cast<DistinctStreamingAggOperatorX>()._is_streaming_preagg && 
low_memory_mode()) {
-        _stop_emplace_flag = true;
+    auto& parent = _parent->cast<DistinctStreamingAggOperatorX>();
+    if (parent._is_streaming_preagg) {
+        const auto memory_limit = parent._memory_limit(state());
+        COUNTER_SET(_memory_use_limit, static_cast<int64_t>(memory_limit));
+        if (low_memory_mode() || (memory_limit > 0 && _memory_usage() > 
memory_limit)) {
+            _stop_emplace_flag = true;

Review Comment:
   [P2] A temporary budget reduction can permanently disable distinct 
pre-aggregation
   
   Following up on this thread against the current head, 
`6ef7d2447ef526d679e931595d01e4b01c0c7649`: the memory condition still 
permanently sets `_stop_emplace_flag`, and subsequent blocks never resume 
deduplication even if the live budget is restored above the retained usage.
   
   I understand the simplicity argument for retaining the existing latch. 
However, a budget reduction does not always coincide with entering low-memory 
mode. For example, with the DYNAMIC slot-memory policy and hard limits enabled, 
an increase in active query slots can reduce this query's weighted limit while 
the workload group remains below its low watermark. When the other queries 
finish, the limit can recover without this query ever having entered low-memory 
mode. The current latch still makes the remainder of the query pass through 
every raw key.
   
   For a long, duplicate-heavy input, a short period of slot contention can 
therefore cause substantially more exchange traffic and downstream aggregation 
work for the rest of the query, while the ordinary streaming aggregate can 
resume after the same budget recovery. This is a performance/state-transition 
concern, not a wrong-result claim.
   
   Please consider keeping a temporary memory-budget stop separate from the 
permanent low-reduction/low-memory stops. If the permanent memory latch is 
intentional, please document the recovery asymmetry explicitly and add a 
lower-then-restore test plus a representative duplicate-heavy benchmark so the 
tradeoff is measurable. The current `refresh_memory_limit` test stops after the 
budget is lowered and does not check restoration.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to