Skip to content

[fix](be) Suppress no-op updates in min delta scans - #66849

Open
gavinchou wants to merge 2 commits into
apache:masterfrom
gavinchou:gavin-binlog-update
Open

[fix](be) Suppress no-op updates in min delta scans#66849
gavinchou wants to merge 2 commits into
apache:masterfrom
gavinchou:gavin-binlog-update

Conversation

@gavinchou

Copy link
Copy Markdown
Contributor

What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary:

MIN_DELTA row-binlog scans previously classified each primary-key window only from its first and last operation. As a result, an update emitted UPDATE_BEFORE and UPDATE_AFTER rows even when the complete row image was unchanged, including multi-update chains that eventually returned to their original values.

This change reads all AFTER/BEFORE value columns needed by MIN_DELTA, compares the first BEFORE image with the final AFTER image, and converts a net-zero update into SKIP. Comparison-only columns remain hidden from the SQL projection. Missing, incompatible, or unsupported BEFORE images preserve the existing UPDATE output conservatively.

The tests cover direct no-op updates, changes in unprojected columns, multiple keys and columns, pending output across batch boundaries, and complex insert/delete/update chains that either return to the original image or finish changed.

Release note

MIN_DELTA row-binlog queries no longer return update rows when the complete row image is unchanged across the query window.

Check List (For Author)

  • Test

    • Regression test
    • Unit Test
      • BlockReaderChangeNextBlockTest.*: 18/18 passed under ASAN.
      • Release BE and Cloud targets compiled successfully.
    • Manual test
    • No need to test or manual test.
  • Behavior changed:

    • No.
    • Yes. Net-zero updates are omitted from MIN_DELTA results.
  • Does this need documentation?

    • No.
    • Yes. A follow-up documentation update is planned.

Check List (For Reviewer who merge this PR)

  • Confirm the release note
  • Confirm test cases
  • Confirm document
  • Add branch pick label

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: MIN_DELTA row-binlog scans previously classified a key window only from its first and last operation. A physical update therefore produced UPDATE_BEFORE and UPDATE_AFTER rows even when the complete row image did not change. Read all AFTER and BEFORE value columns for MIN_DELTA scans, compare the initial BEFORE image with the final AFTER image, and turn the result into SKIP when every comparable value is equal. Comparison-only columns remain hidden from the SQL projection, and incomplete or unsupported row images retain the existing update output conservatively.

### Release note

MIN_DELTA row-binlog queries no longer return update rows when the complete row image is unchanged across the query window.

### Check List (For Author)

- Test: Unit Test
    - BlockReaderChangeNextBlockTest.* (16/16 passed under ASAN)
    - Release BE and Cloud targets compiled successfully
- Behavior changed: Yes. Net-zero updates are omitted from MIN_DELTA results.
- Does this need documentation: Yes (follow-up documentation update; no doc PR yet)
### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The MIN_DELTA BlockReader tests covered only short operation sequences and single-row multi-column comparisons. Add table-driven cases with repeated insert, delete, and update operations, including delete/reinsert cycles that either return to the original row image or finish changed. Also add a multi-key, multi-column case that verifies complete-row equality, hidden comparison columns, skipped groups, and the pending-row batch boundary together.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - BlockReaderChangeNextBlockTest.* (18/18 passed under ASAN)
- Behavior changed: No. Test coverage only.
- Does this need documentation: No
@gavinchou
gavinchou requested a review from yiguolei as a code owner August 17, 2026 12:47
@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?

@gavinchou

Copy link
Copy Markdown
Contributor Author

run buildall

@gavinchou

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.

Requesting changes. The endpoint-folding idea is sound for complete, comparable row images, but seven blockers remain: three false-suppression/data-correctness cases (unavailable BEFORE images, generated-name collisions, and signed zero), one production query-failure case (AGG_STATE), and three performance regressions (two independent O(C^2) setup passes and avoidable full I/O for unsupported schemas).

Critical checkpoint conclusions:

  • Goal and tests: the new integer unit cases cover first/last endpoint folding, hidden unprojected values, multi-key groups, EOF, pending rows, and batch-size boundaries. They do not cover AGG_STATE concrete columns, writer-side missing/placeholder BEFORE images, generated-name collisions, unsupported schemas, wide-table initialization, signed zero, or end-to-end writer/scanner integration.
  • Correctness, nullability, and compatibility: the AGG_STATE, unavailable-BEFORE, generated-name, and signed-zero issues in the inline comments are reachable on supported row-binlog paths. Legacy/compatibility placeholder images need explicit per-row validity; ordering and name equality are not sufficient row-state identity.
  • Scope and lifecycle: FE schema generation, the row-binlog writer/retriever, scanner projection, BlockReader grouping, schema evolution, cloud/non-cloud paths, DETAIL/APPEND_ONLY modes, key boundaries, EOF, and pending UPDATE_AFTER rows were traced. Ordinary grouping and batch lifecycle are sound; DETAIL/APPEND_ONLY are outside the new equality path, and schema mismatches conservatively retain UPDATE.
  • Concurrency, configuration, persistence, and transactions: no new shared state, locking, configuration, on-disk format, or transaction-protocol change was found. The read-side false suppressions can nevertheless leave downstream incremental state incorrect.
  • Performance: the scanner projection and BlockReader name resolution are separate quadratic per-scanner setup passes; unsupported schemas also read all comparison-only values even though suppression is statically impossible.
  • Coverage and CI: no tests were run in this review environment. Completed style, license, and related checks pass; BE unit-test, compile, and performance checks are still pending at review time.
  • User focus: no additional focus was provided.
  • Review completion: capped/incomplete. Three review rounds were executed; the signed-zero issue first surfaced in the final permitted round, so this run cannot claim convergence even though all current candidates were independently adjudicated.

// families. Row binlog currently rejects VARIANT, while the remaining types are kept here as a
// conservative guard so an old or malformed schema cannot turn a MIN_DELTA query into an error.
bool supports_min_delta_value_comparison(PrimitiveType type) {
return !is_var_len_object(type) && type != TYPE_VARIANT && type != TYPE_FIXED_LENGTH_OBJECT &&

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] Guard the concrete AGG_STATE representation before comparing. TYPE_AGG_STATE passes this allowlist, but DataTypeAggState creates aggregate-specific serialized columns: fixed states such as sum/count use ColumnFixedLengthObject (no compare_at override), while bitmap-style states use ColumnComplexType whose override is BE_TEST-only. Row-binlog schema construction permits AGG_STATE and wraps value cells nullable, so a non-NULL update reaches _min_delta_values_equal() and throws NOT_IMPLEMENTED_ERROR instead of conservatively retaining the UPDATE. Please inspect the actual serialized column capability or exclude AGG_STATE here.

auto result = binlog::AggregateFunctionMinDelta::calculate_result(first_op, last_op);
if (result == binlog::AggregateFunctionMinDelta::ResultType::UPDATE_BEFORE_AFTER &&
binlog::is_valid_row_binlog_op(first_op) && binlog::is_valid_row_binlog_op(last_op) &&
_min_delta_values_equal(group_size - 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.

[P1] Do not treat an unavailable BEFORE image as a real all-NULL row. build_before_block() fills every BEFORE value with NULL when no historical row exists. A delete sign for a missing key is still recorded as DELETE, and a later reinsertion after that tombstone becomes APPEND. If all value columns are NULL, DELETE-to-APPEND first yields UPDATE_BEFORE_AFTER, then this gate sees NULL equal to NULL and drops the net absent-to-present INSERT as SKIP. Compatibility placeholder BEFORE rows have the same ambiguity. Only suppress when the first BEFORE image is known valid, or conservatively retain ambiguous all-NULL images.

const uint32_t col_num = src_block.columns();
_before_column_idx.resize(col_num);
std::iota(_before_column_idx.begin(), _before_column_idx.end(), 0);
std::vector<bool> is_before_value_column(col_num, false);

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] Build the name map once before resolving mirrors. MIN_DELTA now feeds every AFTER and BEFORE column into this block, but the loop below calls Block::get_position_by_name()--an O(C) scan--for each physical column. A table with V values has roughly 2V value/mirror columns, so every BlockReader performs O(C^2) string comparisons, repeated per tablet scanner. Please use Block::get_name_to_pos_map() once here and resolve every BEFORE name from it.

// No-op UPDATE detection compares the complete row state at the two ends of the
// window. Read every AFTER/BEFORE value column even when SQL projects only a subset;
// BlockReader's return-column mapping keeps these comparison-only columns hidden.
for (uint32_t cid = tablet_schema->num_key_columns();

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] Preflight unsupported types before widening the projection. If any value is BITMAP, HLL, QUANTILE_STATE, or another rejected shape, BlockReader permanently sets comparison completeness to false, so this optimization can never suppress a group. This loop nevertheless reads every unrequested AFTER and BEFORE value on every block; even a key-only MIN_DELTA query on a BITMAP table now pays the large extra I/O for no behavior change. When complete comparison is statically impossible, preserve the old narrow projection (and keep the runtime check conservative).

std::string before_name = binlog::build_before_column_name(name);
int tmp_idx = src_block.get_position_by_name(before_name);
_before_column_idx[i] = tmp_idx < 0 ? i : tmp_idx;
if (tmp_idx >= 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] Pair BEFORE values by schema identity, not first matching name. Doris permits a user value named BEFORE__v, while row-binlog generation also gives v's mirror that name. Block::get_position_by_name() selects the earlier user AFTER column, so this loop builds a complete-looking but wrong graph. For two same-typed values v and BEFORE__v, updates (0,1)->(1,2)->(2,1) make all three erroneous comparisons equal and this PR emits SKIP even though v changed from 0 to 2. Please disambiguate generated names or resolve pairs by unique id/ordinal, and cover this collision end to end.

// BlockReader's return-column mapping keeps these comparison-only columns hidden.
for (uint32_t cid = tablet_schema->num_key_columns();
cid < tablet_schema->num_columns(); ++cid) {
add_return_column_if_absent(cid);

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] Build this full projection without a linear search per column. add_return_column_if_absent() runs std::find over the growing return_columns vector, and this new loop calls it for all C physical binlog columns, so each tablet/range scanner now does roughly C^2/2 integer comparisons even for a narrow query. That is separate from the quadratic name lookup later in BlockReader. Please track selected dense column ids with a bitmap/set or append the remaining schema columns in one linear pass while preserving the required output order.

return false;
}
for (const auto& [after_idx, before_idx] : _min_delta_value_column_pairs) {
if (_stored_data_columns[before_idx]->compare_at(

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] Do not suppress an observable signed-zero update. FLOAT and DOUBLE pass the new gate, but CompareFloat (used by ColumnVector::compare_at) returns 0 for +0.0 versus -0.0. Doris preserves and displays both signs, and signbit(+0.0) / signbit(-0.0) returns false / true, so a valid MOW update between them changes row state. This comparison turns that update into SKIP and can leave an incremental consumer's derived state stale; nested floats inherit the same behavior. Please use state equality that preserves signed zero, or conservatively exclude floating shapes, and add an end-to-end MIN_DELTA test.

@github-actions

Copy link
Copy Markdown
Contributor

Codex automated review failed and did not complete.

Error: Codex completed, but the workflow could not verify pull request reviews through GitHub API.
Workflow run: https://github.com/apache/doris/actions/runs/32031618412

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

@hello-stephen

Copy link
Copy Markdown
Contributor
TPC-DS: Total hot run time: 82087 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 56a134171dca2848291e92b4a733c0ed9e63967a, data reload: false

query5	4291	431	346	346
query6	404	165	160	160
query7	4848	434	292	292
query8	290	135	116	116
query9	8669	2951	2966	2951
query10	415	274	232	232
query11	5357	1056	923	923
query12	116	73	72	72
query13	1209	456	331	331
query14	6182	2277	2140	2140
query14_1	2034	2023	2016	2016
query15	179	122	109	109
query16	924	379	367	367
query17	803	461	384	384
query18	2329	346	273	273
query19	166	160	116	116
query20	72	73	72	72
query21	214	119	102	102
query22	5328	5396	5380	5380
query23	6773	6201	6101	6101
query23_1	6032	6282	6224	6224
query24	7277	1112	770	770
query24_1	765	812	784	784
query25	443	290	237	237
query26	1253	264	170	170
query27	2732	464	292	292
query28	4579	1494	1530	1494
query29	919	427	346	346
query30	275	175	154	154
query31	838	433	363	363
query32	105	51	48	48
query33	462	219	183	183
query34	979	849	482	482
query35	393	409	330	330
query36	566	556	535	535
query37	121	79	71	71
query38	1016	844	823	823
query39	495	498	492	492
query39_1	483	493	469	469
query40	220	121	112	112
query41	52	54	52	52
query42	106	76	82	76
query43	247	250	218	218
query44	
query45	111	103	100	100
query46	768	879	554	554
query47	776	783	710	710
query48	320	319	251	251
query49	534	239	182	182
query50	812	327	291	291
query51	8174	8297	8249	8249
query52	85	75	76	75
query53	218	225	214	214
query54	230	206	176	176
query55	82	60	56	56
query56	244	239	231	231
query57	700	653	661	653
query58	221	208	195	195
query59	1234	1258	1097	1097
query60	269	214	194	194
query61	119	133	136	133
query62	357	228	189	189
query63	193	158	173	158
query64	2908	855	691	691
query65	
query66	1922	321	306	306
query67	9895	9865	9938	9865
query68	
query69	387	216	213	213
query70	643	660	618	618
query71	309	268	261	261
query72	2728	1830	1726	1726
query73	697	605	358	358
query74	2019	1289	1200	1200
query75	1305	1198	1074	1074
query76	2536	733	584	584
query77	263	262	229	229
query78	4100	3666	3203	3203
query79	3640	768	586	586
query80	1587	405	343	343
query81	509	196	179	179
query82	638	140	98	98
query83	319	250	238	238
query84	
query85	876	456	393	393
query86	486	184	165	165
query87	1011	1000	907	907
query88	4485	2172	2172	2172
query89	298	224	202	202
query90	2111	142	148	142
query91	151	136	124	124
query92	63	47	41	41
query93	3055	1141	751	751
query94	646	262	223	223
query95	674	455	336	336
query96	762	626	277	277
query97	1069	1094	1018	1018
query98	182	136	132	132
query99	453	346	316	316
Total cold run time: 178553 ms
Total hot run time: 82087 ms

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.

2 participants