Skip to content

[feature](query-cache) Support incremental merge for stale query cache entries#65482

Open
asdf2014 wants to merge 2 commits into
apache:masterfrom
asdf2014:query-cache-incremental-merge
Open

[feature](query-cache) Support incremental merge for stale query cache entries#65482
asdf2014 wants to merge 2 commits into
apache:masterfrom
asdf2014:query-cache-incremental-merge

Conversation

@asdf2014

Copy link
Copy Markdown
Member

What problem does this PR solve?

Related PR: N/A

Problem Summary:

For hourly batch-load workloads, every load bumps the tablet version of the
hot partition, so its query cache entries never hit again: each "last N days"
aggregation recomputes the whole hot partition every hour, and BE CPU spikes
during load windows.

This PR adds incremental merge for the query cache (experimental, default
off). When a cache entry's version falls behind, BE reuses it instead of
discarding it: it scans only the delta rowsets in
(cached_version, current_version], emits their partial aggregation state
side by side with the cached partial blocks so the existing upstream merge
aggregation combines both, and writes the merged entry back under the new
version. The execution plan is unchanged.

Full recompute vs incremental merge

Measured impact (80M-row duplicate-key table, 200k-row hourly appends,
group by a non-distribution column): a stale query drops from ~307 ms
(full recompute) to ~19 ms with incremental merge, about 16x and on
par with an exact hit (~17 ms); the cost no longer grows with the base data
size (8M rows: 48 ms full vs 19 ms incremental; 80M rows: 307 ms vs 19 ms).
Full numbers in the Verification section below.

Design highlights:

  1. Correctness algebra: S(v2) = S(v1) ⊎ Δ for append-only data plus the
    homomorphism partial(A ⊎ B) ≡ merge(partial(A), partial(B)). Every
    precondition guards one of the two properties; any violation falls back
    to a full recompute silently, so results are always correct.
  2. FE authorizes via a new optional thrift field
    (TQueryCacheParam.allow_incremental) only for non-finalize aggregations
    directly above the olap scan on an append-only index: DUP_KEYS, or
    merge-on-write UNIQUE_KEYS. Merge-on-read UNIQUE and AGG tables always
    fall back.
  3. BE re-validates per tablet at decision time (fallback reasons are visible
    in the profile): cloud mode, version order, the compaction threshold
    (query_cache_max_incremental_merge_count, default 8, 0 disables), keys
    type, delta capturability on the version graph, delete predicates in the
    delta, and for merge-on-write tables a delete-bitmap window check that
    rejects loads rewriting pre-existing keys. A rare backfill therefore costs
    exactly one full recompute, which re-bases the entry, and the next
    pure-append load is incremental again.
  4. A fragment-level QueryCacheRuntime makes one idempotent decision per
    instance (HIT / INCREMENTAL / MISS), pins the entry and pre-captures the
    delta read source. This also fixes three pre-existing defects: the
    scan/cache-source double-lookup race (could write back an empty poisoned
    entry), row-binlog scans polluting the cache, and build_cache_key
    failures aborting the query instead of degrading to uncached execution.
  5. Entropy control: every merge appends the delta blocks to the entry, so
    after query_cache_max_incremental_merge_count merges the next query
    recomputes in full and compacts the entry; oversized entries keep the
    delta-scan benefit but skip the write back.

Verification:

  • FE UT: QueryCacheNormalizerTest 13/13 (8 assertions on the incremental
    authorization matrix: switch, plan shapes, DUP / MoW / MoR / AGG / nested
    aggregation).

  • BE UT: 34/34 across the decision layer, the incremental scenarios (MoW
    pure-append / history-rewrite / irrelevant bitmap entries, version gap,
    capture error via debug point, delete predicates) and the operator layer.
    llvm-cov: zero uncovered changed lines; query_cache.cpp at 100% line
    coverage.

  • Regression: query_cache_incremental passes on a real 1FE+1BE cluster;
    every step is checked against a cache-off baseline. BE metrics confirm the
    incremental path actually fires (stale_hit_total +40 across the suite,
    fallbacks exactly at the three designed spots).

  • Benchmark (80M-row DUP table, 200k-row hourly appends, group by a
    non-distribution column, 5 rounds each):

    scenario latency (median)
    no cache, full scan ~446 ms
    exact hit ~17 ms
    stale + incremental merge (this PR) ~19 ms
    stale + full recompute (switch off) ~307 ms

    A stale query becomes as cheap as an exact hit, and its cost no longer
    grows with the base data size (8M rows: 48ms full vs 19ms incremental;
    80M rows: 307ms vs 19ms).

Release note

Add experimental incremental merge for the query cache: a stale entry can be
reused by scanning only the delta rowsets and merging them with the cached
partial aggregation state. Controlled by the session variable
enable_query_cache_incremental (default off) and the BE config
query_cache_max_incremental_merge_count (default 8).

Check List (For Author)

Check List (For Reviewer who merge this PR)

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

…e entries

Hourly batch loads keep bumping the version of the hot partition, so its
query cache entries never hit again and every "last N days" aggregation
recomputes the whole partition each hour. This change lets BE reuse a
stale entry instead of discarding it: scan only the delta rowsets in
(cached_version, current_version], emit their partial aggregation state
side by side with the cached partial blocks (the upstream merge
aggregation combines both), and write the merged entry back under the
new version.

Correctness rests on two properties: append-only snapshots decompose as
S(v2) = S(v1) + delta, and partial aggregation states merge
homomorphically. Every precondition guards one of them, and any
violation falls back to a full recompute silently:

- FE only sets TQueryCacheParam.allow_incremental (new optional field 8)
  when the experimental session switch enable_query_cache_incremental is
  on, the cache point is a non-finalize aggregation directly above the
  olap scan node, and the selected index is append-only: DUP_KEYS, or
  merge-on-write UNIQUE_KEYS.
- BE re-validates per tablet at decision time: local storage mode,
  version order, the compaction threshold
  (query_cache_max_incremental_merge_count, BE config, default 8), keys
  type, delta capturability, no delete predicates in the delta, and for
  merge-on-write tables a delete-bitmap window check that rejects any
  load that rewrote pre-existing keys, so an occasional backfill costs
  exactly one full recompute and re-bases the entry.

A new fragment-level QueryCacheRuntime makes one idempotent decision
(HIT / INCREMENTAL / MISS) per instance, pins the entry and pre-captures
the delta read source. Centralizing the decision also fixes three
pre-existing defects: the scan/cache-source double-lookup race that
could write back an empty poisoned entry, row-binlog scans polluting
the cache, and build_cache_key failures aborting the whole query
instead of degrading to uncached execution.

Observability: profile fields HitCacheStale, IncrementalDeltaVersions
and IncrementalFallbackReason, plus three BE metrics
(query_cache_stale_hit_total, query_cache_incremental_fallback_total,
query_cache_write_back_total).

Benchmark (80M-row duplicate-key table, 200k-row hourly appends,
group by a non-distribution column): a stale query drops from ~307ms
(full recompute) to ~19ms with incremental merge, on par with an exact
hit, and the cost no longer grows with the base data size.
@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?

@morningman morningman self-assigned this Jul 12, 2026
924060929
924060929 previously approved these changes Jul 13, 2026

@924060929 924060929 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.

LGTM for FE part

@github-actions github-actions Bot added the approved Indicates a PR has been approved by one committer. label Jul 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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

@github-actions

Copy link
Copy Markdown
Contributor

PR approved by anyone and no changes requested.

@morrySnow

Copy link
Copy Markdown
Contributor

run buildall

@morrySnow

Copy link
Copy Markdown
Contributor

/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.

I found three issues, all in the test/coverage layer for the query-cache incremental merge change. The BE/FE/storage implementation paths I reviewed did not reveal a substantiated data-correctness issue, but the current tests do not yet prove the actual stale incremental SQL path and one BE fixture masks setup failures.

Critical checkpoint conclusions:

  • Goal/test proof: incomplete; the SQL regression does not assert that Mode::INCREMENTAL is reached.
  • Scope/focus: the implementation is focused on query-cache incremental merge.
  • Concurrency/lifecycle: shared cache decision, pinned cache handles, and pipeline local-state setup were reviewed; no reportable issue found.
  • FE-BE/thrift compatibility: the optional field is appended and old/new mixed behavior falls back safely.
  • Storage/version correctness: delta rowset capture, MOW delete bitmap checks, compaction fallback, and cloud fallback were reviewed; no reportable issue found.
  • Testing standards: issues found in the regression and BE fixture, reported inline.

Validation: static review only; I did not run builds or tests in this review-only runner.

// (including those where incremental merge falls back to a full recompute,
// e.g. cloud mode) while exercising the incremental path on local storage.
suite("query_cache_incremental") {
def tableName = "test_query_cache_incremental"

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.

This new regression does not follow the local regression-test standards: ordinary single test tables should use hardcoded names instead of def tableName, determined result checks should be generated through qt/order_qt style expected output rather than only ad hoc assertEquals, and the tables should be dropped before use but left in place after the suite for debugging. Please align the suite with those standards while keeping any extra cache-on/cache-off behavioral checks you need.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Aligned in 26484d7: table names are hardcoded now, five order_qt_* snapshots backed by a generated .out (via -forceGenOut against a real cluster) anchor the deterministic results at each phase, and the final DROP TABLE statements are removed so the tables stay in place for debugging. The cache-on/off double-run comparison is kept on top as the behavioral check.

// Compare the cached query result against the uncached one, twice: the
// first cached run may fill or incrementally merge the entry, the second
// one should serve it.
def checkConsistency = { String sqlText ->

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.

This helper only proves that the cache-enabled result matches the uncached result. The suite even documents that it passes when incremental merge falls back to a full recompute, so a regression where the real SQL path never takes Mode::INCREMENTAL would still pass. Please add an assertion that at least one post-insert local-storage query actually records a stale incremental hit, for example via HitCacheStale / IncrementalDeltaVersions in the profile or a monotonic increase in query_cache_stale_hit_total, and assert the intended fallback phase separately.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in 26484d7. On local storage the suite now proves through BE metrics that the incremental path really fires: the first append round on both the duplicate-key table and the merge-on-write table must strictly increase query_cache_stale_hit_total. These rounds are chosen because the hot partition holds only two data rowsets at that point, so neither the merge-count threshold nor a background compaction can interfere. The two designed fallback phases (a delete predicate in the delta, and a MoW backfill that rewrites history) must each increase query_cache_incremental_fallback_total, so each intended fallback phase is asserted separately. Both counters only move when enable_query_cache_incremental is on (default off, and this suite is the only one that sets it), and the assertions are one-sided deltas summed across all backends, so concurrent suites cannot break them. Cloud mode keeps the plain consistency checks since it always falls back by design. Verified on a 1 FE + 1 BE cluster: the counters do move at all four asserted points.

_engine = engine.get();
ExecEnv::GetInstance()->set_storage_engine(std::move(engine));
_data_dir = std::make_unique<DataDir>(*_engine, _absolute_dir);
static_cast<void>(_data_dir->init());

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 do not discard these setup Status values. If _data_dir->init() fails, the fixture still proceeds to register tablets and the later incremental/fallback assertions can run against an invalid test environment. The same pattern appears below for tablet_meta->add_rs_meta(rs_meta) and tablet->init(). Check each status with a form valid for the enclosing function, for example a fatal assertion in SetUp() and either a non-fatal check plus early failure return, or a refactor to a void/fatal helper, inside create_tablet(), before using the constructed tablet state.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 26484d7: SetUp() now uses fatal assertions and prints the failed Status; create_tablet() checks add_rs_meta() and tablet->init() with a non-fatal check plus an early return nullptr before the tablet is registered into the tablet map, and the two call sites that dereference the result assert it is not null. The two discarded booleans in init_rs_meta() (JsonToProtoMessage, init_from_pb) are asserted as well. QueryCache* UTs pass 34/34 with the change.

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 90.48% (19/21) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

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

------ Round 1 ----------------------------------
============================================
q1	17621	4150	4246	4150
q2	2035	328	209	209
q3	10770	1469	851	851
q4	4809	475	341	341
q5	8337	871	569	569
q6	315	170	141	141
q7	822	836	631	631
q8	10575	1647	1703	1647
q9	5822	4415	4382	4382
q10	6818	1783	1542	1542
q11	504	354	312	312
q12	720	553	441	441
q13	18122	3861	2789	2789
q14	275	265	242	242
q15	q16	790	788	717	717
q17	996	972	991	972
q18	6664	5779	5604	5604
q19	1170	1263	1012	1012
q20	732	636	526	526
q21	5600	2678	2455	2455
q22	437	364	301	301
Total cold run time: 103934 ms
Total hot run time: 29834 ms

----- Round 2, with runtime_filter_mode=off -----
============================================
q1	4414	4335	4369	4335
q2	295	318	221	221
q3	4583	5019	4406	4406
q4	2168	2137	1447	1447
q5	4526	4383	4675	4383
q6	251	195	139	139
q7	2101	1826	1629	1629
q8	2488	2235	2184	2184
q9	8027	7909	7961	7909
q10	4749	4967	4349	4349
q11	630	433	403	403
q12	755	756	569	569
q13	3253	3640	2972	2972
q14	313	318	282	282
q15	q16	739	729	644	644
q17	1379	1366	1356	1356
q18	7985	7349	7021	7021
q19	1110	1072	1091	1072
q20	2220	2229	1951	1951
q21	5343	4678	4585	4585
q22	519	469	423	423
Total cold run time: 57848 ms
Total hot run time: 52280 ms

@hello-stephen

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

query5	4336	625	499	499
query6	492	218	204	204
query7	4921	596	347	347
query8	332	194	175	175
query9	8757	4093	4061	4061
query10	480	345	284	284
query11	5984	2371	2165	2165
query12	163	102	104	102
query13	1256	621	443	443
query14	6310	5301	4984	4984
query14_1	4305	4370	4298	4298
query15	221	218	182	182
query16	1068	487	475	475
query17	1142	711	600	600
query18	2710	480	359	359
query19	212	197	155	155
query20	112	112	107	107
query21	228	152	135	135
query22	13614	13623	13411	13411
query23	17426	16577	16137	16137
query23_1	16358	16256	16261	16256
query24	7469	1783	1322	1322
query24_1	1315	1311	1272	1272
query25	562	491	410	410
query26	1356	347	219	219
query27	2532	603	385	385
query28	4435	2006	1964	1964
query29	1118	648	513	513
query30	342	261	224	224
query31	1113	1094	972	972
query32	116	65	65	65
query33	528	332	254	254
query34	1145	1145	657	657
query35	789	799	685	685
query36	1443	1401	1279	1279
query37	159	113	94	94
query38	1881	1712	1672	1672
query39	944	926	896	896
query39_1	878	890	875	875
query40	254	164	184	164
query41	64	63	67	63
query42	96	91	90	90
query43	332	331	273	273
query44	1381	797	769	769
query45	195	198	179	179
query46	1065	1225	783	783
query47	2433	2325	2206	2206
query48	355	429	290	290
query49	576	422	319	319
query50	1066	427	334	334
query51	10850	10805	10726	10726
query52	87	93	74	74
query53	250	280	200	200
query54	280	240	234	234
query55	76	71	66	66
query56	301	311	301	301
query57	1446	1413	1322	1322
query58	280	262	271	262
query59	1588	1676	1463	1463
query60	304	267	285	267
query61	159	150	156	150
query62	691	663	574	574
query63	242	207	203	203
query64	2808	1074	898	898
query65	4871	4794	4774	4774
query66	1773	505	386	386
query67	29471	29642	29286	29286
query68	3142	1527	1042	1042
query69	417	310	266	266
query70	1125	978	1021	978
query71	358	322	306	306
query72	3094	2888	2386	2386
query73	816	749	447	447
query74	5092	4962	4764	4764
query75	2629	2589	2261	2261
query76	2354	1186	775	775
query77	353	393	289	289
query78	12197	12322	11816	11816
query79	1415	1171	740	740
query80	1302	544	454	454
query81	523	332	282	282
query82	637	159	122	122
query83	370	318	290	290
query84	278	165	131	131
query85	965	603	531	531
query86	440	304	267	267
query87	1840	1824	1777	1777
query88	3678	2783	2780	2780
query89	460	401	362	362
query90	1972	202	196	196
query91	205	190	168	168
query92	67	62	55	55
query93	1646	1546	976	976
query94	743	368	322	322
query95	796	580	488	488
query96	1033	782	337	337
query97	2695	2682	2564	2564
query98	215	210	202	202
query99	1171	1159	1037	1037
Total cold run time: 266348 ms
Total hot run time: 180384 ms

@hello-stephen

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

query1	0.01	0.00	0.00
query2	0.15	0.09	0.08
query3	0.37	0.25	0.26
query4	1.61	0.26	0.30
query5	0.35	0.32	0.32
query6	1.15	0.68	0.67
query7	0.04	0.00	0.00
query8	0.10	0.08	0.07
query9	0.50	0.39	0.38
query10	0.59	0.59	0.58
query11	0.31	0.18	0.18
query12	0.32	0.20	0.19
query13	0.54	0.54	0.53
query14	0.94	0.93	0.94
query15	0.68	0.58	0.61
query16	0.39	0.41	0.40
query17	1.04	1.05	1.04
query18	0.31	0.30	0.30
query19	1.94	1.80	1.83
query20	0.02	0.01	0.01
query21	15.40	0.39	0.32
query22	4.72	0.14	0.14
query23	15.82	0.50	0.30
query24	2.44	0.61	0.44
query25	0.15	0.10	0.10
query26	0.76	0.27	0.21
query27	0.11	0.09	0.10
query28	3.42	0.88	0.51
query29	12.48	4.19	3.26
query30	0.41	0.26	0.26
query31	2.78	0.61	0.33
query32	3.26	0.59	0.47
query33	2.95	3.03	2.94
query34	15.83	4.14	3.36
query35	3.32	3.34	3.30
query36	0.65	0.53	0.53
query37	0.13	0.10	0.10
query38	0.08	0.07	0.08
query39	0.08	0.07	0.06
query40	0.21	0.19	0.18
query41	0.14	0.08	0.08
query42	0.09	0.06	0.05
query43	0.07	0.07	0.06
Total cold run time: 96.66 s
Total hot run time: 25.73 s

…tests

Prove the incremental path in the regression through BE metrics: the
first append round on the duplicate-key and the merge-on-write table
must increase query_cache_stale_hit_total (the hot partition holds two
data rowsets at that point, so neither the merge-count threshold nor a
background compaction can interfere), and the delete-predicate and MoW
backfill phases must each increase
query_cache_incremental_fallback_total. Both counters only move when
enable_query_cache_incremental is on (default off, this suite is the
only one that sets it), and the assertions are one-sided deltas summed
across all backends, so concurrent suites cannot break them. Cloud mode
keeps the plain consistency checks since it always falls back by
design.

Align the suite with the regression standards: hardcoded table names,
order_qt snapshots at each stable phase backed by a generated .out, and
tables left in place after the run for debugging.

Stop discarding setup statuses in the BE UT fixture: fatal assertions
in SetUp() that print the failed Status, non-fatal checks with an early
return in create_tablet() before the tablet is registered into the
tablet map, null checks at the dereferencing call sites, and assertions
on the two previously discarded booleans in init_rs_meta().
@github-actions github-actions Bot removed the approved Indicates a PR has been approved by one committer. label Jul 13, 2026
@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 85.63% (298/348) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 57.02% (23647/41474)
Line Coverage 40.71% (231505/568712)
Region Coverage 36.60% (182957/499837)
Branch Coverage 37.72% (81788/216857)

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants