asdf2014 commented on code in PR #65788:
URL: https://github.com/apache/doris/pull/65788#discussion_r3610800222


##########
be/src/runtime/query_cache/query_cache.cpp:
##########
@@ -282,8 +295,187 @@ bool QueryCacheRuntime::_try_prepare_incremental(const 
std::vector<TScanRangePar
     return true;
 }
 
-bool QueryCacheRuntime::_capture_tablet_delta(int64_t tablet_id, int64_t 
cached_version,
-                                              QueryCacheInstanceDecision* 
decision) {
+std::unordered_map<int64_t, std::string> 
QueryCacheRuntime::_presync_cloud_delta_tablets(
+        const std::vector<TScanRangeParams>& scan_ranges, int64_t 
current_version) {
+    // Index-aligned so the fork-joined tasks write disjoint slots without a
+    // lock; an empty slot means synced (or skipped as non-append-only), a
+    // non-empty slot is the fallback reason. Held behind a shared_ptr because
+    // the fan-out below is waited on with a fast-fail deadline: on a timeout
+    // this frame returns while the still-running tasks keep writing their
+    // slots, so the storage must outlive the frame -- the shared_ptr each task
+    // captured keeps it alive until the last one finishes. Only read on the
+    // completed (non-timeout) path, where every task is done.
+    auto per_range_reason = 
std::make_shared<std::vector<std::string>>(scan_ranges.size());
+    std::vector<std::function<Status()>> tasks;
+    tasks.reserve(scan_ranges.size());
+    for (size_t i = 0; i < scan_ranges.size(); ++i) {
+        int64_t tablet_id = 
scan_ranges[i].scan_range.palo_scan_range.tablet_id;
+        tasks.emplace_back([tablet_id, current_version, per_range_reason, i]() 
-> Status {
+            auto tablet_res = ExecEnv::get_tablet(tablet_id);
+            if (!tablet_res) {
+                // _capture_tablet_delta reports "tablet not found" from its 
own
+                // get_tablet; nothing to sync. Should that later get_tablet
+                // succeed instead (a transient failure here), it takes the
+                // cache-miss load, which itself syncs rowsets AND the delete
+                // bitmap up to the visible version -- that load-bearing
+                // invariant (plus the endpoint check downstream) is what keeps
+                // a tablet that skipped this pre-sync safe to capture.
+                return Status::OK();
+            }
+            BaseTabletSPtr tablet = std::move(tablet_res.value());
+            const bool append_only =
+                    tablet->keys_type() == KeysType::DUP_KEYS ||
+                    (tablet->keys_type() == KeysType::UNIQUE_KEYS &&
+                     tablet->enable_unique_key_merge_on_write());
+            if (!append_only) {
+                // Skip the RPC entirely: _capture_tablet_delta rejects this
+                // tablet at its keys-type check before it would consume a 
sync.
+                return Status::OK();
+            }
+            auto cloud_tablet = 
std::dynamic_pointer_cast<CloudTablet>(std::move(tablet));
+            if (cloud_tablet == nullptr) {
+                // is_cloud_mode() flipped on a live local deployment
+                // (cloud_unique_id is a mutable config): degrade to a full
+                // recompute rather than dereference the failed cast 
downstream.
+                (*per_range_reason)[i] = "tablet is not a cloud tablet";
+                return Status::OK();
+            }
+            SyncOptions options;
+            options.query_version = current_version;
+            options.merge_schema = true;
+            // The history-rewrite check reads the delete bitmap this sync 
merges,
+            // so pin the dependency explicitly rather than inherit the struct
+            // default (which a refactor could flip with no compile error): a
+            // merge-on-write delta cannot be classified on a bitmap the sync 
did
+            // not bring up to the queried version.
+            options.sync_delete_bitmap = true;

Review Comment:
   The classification never reads a weaker bitmap than the MOW read path 
itself. Two mechanisms make it version-exact at the point of the 
history-rewrite check:
   
   - The decision pre-sync runs `sync_rowsets` with `options.sync_delete_bitmap 
= true` (query_cache.cpp, in `_presync_cloud_delta_tablets`), so the local 
CloudTablet view is brought to `query_version` for BOTH its rowset list and its 
delete bitmap before the decision consumes it. `_capture_tablet_delta` then 
reads that same bitmap. There is no separate, weaker read: the classification 
inherits exactly the completeness of the MOW read path, no more and no less.
   - If the pre-sync could not vouch for the view (cast failure on a 
misconfigured deployment, an infrastructure sync failure, or the fast-fail 
budget expiring), `_capture_tablet_delta` sees a recorded reason and falls the 
whole decision back to a full recompute. So an incomplete-sync outcome is a 
fallback, not a silent misclassification.
   
   On `_max_version >= query_version` not proving bitmap completeness: agreed 
in the general case, which is exactly why the MOW decision is conservative 
rather than relying on that inequality. `_capture_tablet_delta` inspects the 
delete bitmap for any marker that rewrites a row predating the cached version 
and falls back when it finds one (covered by `mow_history_rewrite_falls_back`, 
and for the cloud sync-completeness angle by 
`mow_decision_sync_brings_bitmap_detects_rewrite`, which guards that the bitmap 
the decision sync carried, not the loader's, feeds the check).
   
   On full-compaction materialization omitting the bitmap: compaction reads 
through its output version and relocates later delete markers onto the 
compacted output, so a marker is not lost, it moves. A delta that spans a 
compaction boundary is not capturable as a clean [cached+1, query] range and 
takes the fallback; rowset pins protect the inputs from concurrent GC during 
the window. So this parallel sync path does not produce an append-only-looking 
rewrite either.
   
   (If a reviewer still wants belt-and-suspenders here, the cheapest hardening 
is to make `_capture_tablet_delta` fall back whenever the tablet carries any 
post-cached full-compaction output rather than reasoning about marker 
relocation. Happy to add it, but I believe the current path is correct.)
   



##########
be/src/runtime/query_cache/query_cache.cpp:
##########
@@ -282,8 +295,187 @@ bool QueryCacheRuntime::_try_prepare_incremental(const 
std::vector<TScanRangePar
     return true;
 }
 
-bool QueryCacheRuntime::_capture_tablet_delta(int64_t tablet_id, int64_t 
cached_version,
-                                              QueryCacheInstanceDecision* 
decision) {
+std::unordered_map<int64_t, std::string> 
QueryCacheRuntime::_presync_cloud_delta_tablets(
+        const std::vector<TScanRangeParams>& scan_ranges, int64_t 
current_version) {
+    // Index-aligned so the fork-joined tasks write disjoint slots without a
+    // lock; an empty slot means synced (or skipped as non-append-only), a
+    // non-empty slot is the fallback reason. Held behind a shared_ptr because
+    // the fan-out below is waited on with a fast-fail deadline: on a timeout
+    // this frame returns while the still-running tasks keep writing their
+    // slots, so the storage must outlive the frame -- the shared_ptr each task
+    // captured keeps it alive until the last one finishes. Only read on the
+    // completed (non-timeout) path, where every task is done.
+    auto per_range_reason = 
std::make_shared<std::vector<std::string>>(scan_ranges.size());
+    std::vector<std::function<Status()>> tasks;
+    tasks.reserve(scan_ranges.size());
+    for (size_t i = 0; i < scan_ranges.size(); ++i) {
+        int64_t tablet_id = 
scan_ranges[i].scan_range.palo_scan_range.tablet_id;
+        tasks.emplace_back([tablet_id, current_version, per_range_reason, i]() 
-> Status {
+            auto tablet_res = ExecEnv::get_tablet(tablet_id);
+            if (!tablet_res) {
+                // _capture_tablet_delta reports "tablet not found" from its 
own
+                // get_tablet; nothing to sync. Should that later get_tablet
+                // succeed instead (a transient failure here), it takes the
+                // cache-miss load, which itself syncs rowsets AND the delete
+                // bitmap up to the visible version -- that load-bearing
+                // invariant (plus the endpoint check downstream) is what keeps
+                // a tablet that skipped this pre-sync safe to capture.
+                return Status::OK();
+            }
+            BaseTabletSPtr tablet = std::move(tablet_res.value());
+            const bool append_only =
+                    tablet->keys_type() == KeysType::DUP_KEYS ||
+                    (tablet->keys_type() == KeysType::UNIQUE_KEYS &&
+                     tablet->enable_unique_key_merge_on_write());
+            if (!append_only) {
+                // Skip the RPC entirely: _capture_tablet_delta rejects this
+                // tablet at its keys-type check before it would consume a 
sync.
+                return Status::OK();
+            }
+            auto cloud_tablet = 
std::dynamic_pointer_cast<CloudTablet>(std::move(tablet));
+            if (cloud_tablet == nullptr) {
+                // is_cloud_mode() flipped on a live local deployment
+                // (cloud_unique_id is a mutable config): degrade to a full
+                // recompute rather than dereference the failed cast 
downstream.
+                (*per_range_reason)[i] = "tablet is not a cloud tablet";
+                return Status::OK();
+            }
+            SyncOptions options;
+            options.query_version = current_version;
+            options.merge_schema = true;
+            // The history-rewrite check reads the delete bitmap this sync 
merges,
+            // so pin the dependency explicitly rather than inherit the struct
+            // default (which a refactor could flip with no compile error): a
+            // merge-on-write delta cannot be classified on a bitmap the sync 
did
+            // not bring up to the queried version.
+            options.sync_delete_bitmap = true;
+            Status st = cloud_tablet->sync_rowsets(options);
+            if (!st.ok()) {
+                // Unlike the other fallback reasons (expected data shapes), a
+                // failed sync is an infrastructure error: log the status so 
the
+                // profile reason correlates to a cause. Throttled with
+                // LOG_EVERY_N because a meta-service brownout fails this for
+                // every stale tablet of every query at once; the underlying 
RPC
+                // errors are already logged per retry inside retry_rpc, so a
+                // sampled line here is enough to correlate without a storm, 
and
+                // the exact reason still reaches the user via the query 
profile.
+                LOG_EVERY_N(WARNING, 100)
+                        << "query cache incremental merge falls back, cloud 
rowset sync failed"
+                        << ", tablet_id=" << tablet_id << ", status=" << 
st.to_string();
+                (*per_range_reason)[i] = "cloud rowset sync failed";
+            }
+            return Status::OK();
+        });
+    }
+    // Fan the syncs out asynchronously and wait on the result with a fast-fail
+    // budget instead of blocking unconditionally. This decision runs in
+    // operator init on a bounded query-admission pool (the BE light_work_pool,
+    // contractually "must be light, not locked"), so a meta-service brownout
+    // that stalls these RPCs must not hold that thread for the full RPC retry
+    // budget (tens of seconds): sustained, it would exhaust the pool and 
reject
+    // query admission cluster-wide. If the fan-out does not finish within
+    // query_cache_decision_sync_timeout_ms the decision abandons the wait and
+    // falls the whole instance back to a full recompute; the still-running
+    // tasks are correctness-harmless and bounded: on a slow-but-healthy sync
+    // each merely advances the tablet view early (work the scan node's own
+    // async sync would otherwise do), and on a failing sync each is at worst 
one
+    // extra bounded sync attempt that changes no result; either way they stay
+    // cheap while pending (small per-task captures; the brpc I/O yields the
+    // pthread rather than pinning it) and self-limiting once the meta service
+    // recovers, and keep their slot storage alive through the shared_ptr they
+    // captured. A healthy sync is
+    // milliseconds, far under the budget, so this only trips under real
+    // meta-service degradation and leaves the steady-state incremental path
+    // unchanged.
+    //
+    // Parallelism reuses init_scanner_sync_rowsets_parallelism on purpose: 
this
+    // is the same per-tablet rowset-sync fan-out the scan node runs, and one
+    // shared knob keeps the two fan-outs' budgets aligned rather than letting 
a
+    // cache-only config silently drift apart from it. Every task swallows its
+    // own error into its slot and returns OK, which is load-bearing:
+    // bthread_fork_join stops DISPATCHING the tasks queued after the first one
+    // that returns non-OK (see cloud_meta_mgr.cpp), so a task that propagated
+    // its sync error would leave the tablets queued behind it un-synced;
+    // recording the failure into the slot keeps every tablet's sync attempted.
+    // The blocking time (bounded by the budget) is folded into
+    // query_cache_decision_sync_time_ms.
+    auto sync_fanout_start = std::chrono::steady_clock::now();
+    std::future<Status> fanout_done;
+    // Clamp the shared parallelism knob to >= 1: bthread_fork_join waits for a
+    // free slot before dispatching its first task (count 0 >= concurrency 0),
+    // so a misconfigured 0/negative would park the driver bthread forever with
+    // nothing to notify it. On the scan node that surfaces as a hung query; on
+    // this fast-fail path the caller's wait_for still expires and falls back, 
so
+    // the deadlock would instead leak the driver bthread and its task closures
+    // silently, once per incremental candidate. A local floor avoids that.
+    Status launch_st = cloud::bthread_fork_join(
+            std::move(tasks), std::max(1, 
config::init_scanner_sync_rowsets_parallelism),
+            &fanout_done);
+    bool timed_out = false;
+    if (!launch_st.ok()) {
+        // Could not even spawn the fan-out driver bthread: fall back rather 
than
+        // block, the same as a timeout (its future would never be fulfilled).
+        timed_out = true;
+    } else if (fanout_done.wait_for(std::chrono::milliseconds(

Review Comment:
   Replaced the raw detached bthreads with a dedicated engine-owned pool and 
single-owner slot accounting:
   
   - Ownership/lifetime: `_presync_cloud_delta_tablets` submits to 
`CloudStorageEngine::query_cache_delta_sync_pool()`, a `ThreadPool` the engine 
constructs up front and drains FIRST in `stop()` (before the compaction and 
delete-bitmap pools it depends on, and before `~CloudStorageEngine` destroys 
`_meta_mgr`/`_tablet_mgr`). A task that outlived a timed-out query therefore 
always runs against a live engine, and none can survive the engine. The raw 
pointer capture is safe for the same reason.
   - Bound: the pool has a fixed width and a bounded queue 
(`config::query_cache_delta_sync_thread` / 
`query_cache_delta_sync_max_pending_tasks`), so a brownout can neither spawn an 
unbounded concurrent fanout nor grow the backlog without bound; a submit past 
the queue cap fails fast and that tablet falls back.
   - Admission latency: the pool is sized fixed (min == max) and its workers 
are pre-started AND verified present at engine construction (a `CHECK` on the 
worker count, because `ThreadPool::init` swallows per-thread start failures, so 
a successful build alone does not prove the workers exist). So `submit_func` 
provably never creates a thread inside the operator-init loop (thread creation 
can take hundreds of ms per the ThreadPool docs); it is pure enqueue and never 
blocks, keeping the whole submit loop far under the deadline. The cost is a 
fixed set of mostly-idle workers on every cloud BE even when this opt-in 
feature is unused, which is the deliberate trade for predictable, non-blocking 
admission.
   - Single deadline: the fan-out plus wait is bounded by one absolute deadline 
anchored at the fan-out start, so submission cannot re-grant the full timeout 
to the wait; the total budget stays `query_cache_decision_sync_timeout_ms`.
   - Cancellation: a shared `abandoned` atomic is set when the caller's bounded 
wait expires; a not-yet-started task then skips its sync RPC instead of 
spending the full retry budget, so the bounded queue drains quickly under a 
brownout.
   - Non-positive timeout: it now launches nothing at all. The `<= 0` case 
returns up front with every scanned tablet falling back, before the engine is 
even touched, so there is no ownerless work.
   - No admission-thread loads: the capture loop consumes each tablet's 
recorded fallback reason BEFORE its own `get_tablet`, and a worker-side 
tablet-load failure publishes a reason instead of leaving the slot empty. So 
neither a refused submit nor a failed worker load can push the synchronous 
cache-miss meta-service load onto the admission thread outside the deadline; 
the admission path performs zero remote loads on every fallback route.
   - Slot accounting: each slot is claimed via an atomic exchange BEFORE any 
sync RPC, so exactly one of the task and the inline submit-failure path owns 
both the RPC and the latch count-down; the loser touches neither. A task that a 
later worker picks up after its slot was already claimed inline (the narrow 
enqueue-then-fail case where `do_submit` queued it then returned an error) 
loses the claim and bails before its RPC, so it cannot fire a meta-service call 
for an already-settled query, and the caller past the settled latch is the sole 
reader of the result.
   
   On the bthread memory-tracker attach: these tasks intentionally do not 
attach a query/ResourceContext. This is a detached engine-layer sync with no 
live query context to charge to (the query may already have timed out and 
returned). It matches the sibling FE warmup path 
`CloudBackendService::sync_load_for_tablets`, which submits the same 
`CloudTablet::sync_rowsets` to an engine-owned `ThreadPool` and likewise does 
not attach a tracker; the allocations fall to the worker thread's default 
accounting and remain process-tracked, not orphaned. I documented this on the 
call site. If the project would rather these attach to a background tracker, 
point me at the preferred one and I will wire it, but the current behavior is 
consistent with the sibling path.
   



##########
fe/fe-core/src/main/java/org/apache/doris/planner/normalize/QueryCacheNormalizer.java:
##########
@@ -179,6 +179,33 @@ private boolean computeAllowIncremental(CachePoint 
cachePoint, SessionVariable s
         if (!sessionVariable.getEnableQueryCacheIncremental()) {
             return false;
         }
+        // Freshness tolerance and prefer-cached-rowset (both cloud-only in
+        // effect) trade exactness for speed and locality: the scan may read a
+        // warmed-up layout that stops below the queried version (freshness
+        // halts at the warmed boundary) or reaches beyond it (neither walk
+        // clips an edge spanning it). An incremental merge would defeat both 
knobs,
+        // because the delta capture always targets the exact queried version
+        // (it must, to keep the merged entry correct): under freshness
+        // tolerance it would force the wait for un-warmed data the query
+        // explicitly chose to skip, and under prefer-cached-rowset it would
+        // ignore the layout preference the user set. So exclude incremental
+        // for such queries and let them take their cheap path. Correctness
+        // against their version-inexact reads does NOT rest on this per-query
+        // gate (an entry filled by such a read could still be reused by a
+        // different, knob-free query sharing the cache key): on cloud, the
+        // only mode where these reads occur, the BE suppresses their cache
+        // write-back, so no entry whose content mismatches its version stamp
+        // exists in the first place. These knobs are inert on local storage,
+        // so gating them in any mode only forgoes incremental for a query
+        // that opted into a cloud trade-off; the gate stays mode-agnostic on
+        // purpose, because a mode-conditioned gate cannot be exercised by the
+        // local FE unit-test harness (planning under a flipped cloud flag 
casts
+        // SystemInfoService to CloudSystemInfoService and fails), so it would
+        // ship an untestable branch for no correctness gain.
+        if (sessionVariable.getQueryFreshnessToleranceMs() > 0

Review Comment:
   FE now resolves the table type before the knob gate and reports it to BE:
   
   - `QueryCacheNormalizer.computeAllowIncremental` was reordered so the table 
type is known before the knob is consulted: freshness tolerance blocks all 
incremental, and prefer-cached-rowset blocks only non-MOW (cloud MOW ignores 
prefer, so it must not be blocked by it).
   - A new optional thrift field `TQueryCacheParam.is_merge_on_write` carries 
the selected index's MOW state (via `computeIsMergeOnWrite`), independent of 
`allow_incremental` because the write-back gate applies to MISS decisions too.
   - BE `cache_source_operator` keys write-back on `is_cloud_mode() && 
(freshness || (prefer && !is_merge_on_write))`, so cloud MOW under prefer keeps 
its version-exact write-back instead of being suppressed by a knob that does 
not apply to it.
   
   Local scans ignoring both cloud knobs: that is the pre-existing 
shared-nothing behavior and is unchanged; the gate above is cloud-only 
(`is_cloud_mode()`), so it does not touch the local path.
   



##########
be/src/runtime/query_cache/query_cache.cpp:
##########
@@ -259,9 +262,19 @@ bool QueryCacheRuntime::_try_prepare_incremental(const 
std::vector<TScanRangePar
         return false;
     }
 
+    // Cloud only: fan the per-tablet meta-service syncs out in parallel before
+    // the serial capture below, so an instance holding many stale tablets does
+    // not stall the shared prepare thread issuing those RPCs one at a time.
+    // Empty (and untouched) in shared-nothing mode, where no sync is needed.
+    std::unordered_map<int64_t, std::string> presync_reasons;
+    if (config::is_cloud_mode()) {

Review Comment:
   The follow-up adds the BE-side default the review asked for, through the 
optional thrift field:
   
   - old-FE to new-BE: `is_merge_on_write` is absent, so it reads its default 
`false`, which drives the conservative branch (write-back suppressed for what 
would be a MOW exact fill). No bypass.
   - new-FE to old-BE: the old BE ignores the appended optional field and keeps 
its prior conservative behavior.
   - same-version: aligned.
   
   So the mixed-version matrix defaults to suppression on the unknown side 
rather than to an unguarded fill. There is no storage-format or symbol change, 
and the field is optional field 9 appended to `TQueryCacheParam`, so it is 
skip-safe both directions.
   



##########
regression-test/suites/query_p0/cache/query_cache_incremental.groovy:
##########
@@ -72,20 +75,26 @@ suite("query_cache_incremental") {
         return total
     }
 
-    // Besides result consistency, prove on local storage that the stale entry
-    // was really merged incrementally (Mode::INCREMENTAL), not recomputed:
-    // only the incremental path increases query_cache_stale_hit_total, and no
+    // Besides result consistency, prove that the stale entry was really
+    // merged incrementally (Mode::INCREMENTAL), not recomputed: only the
+    // incremental path increases query_cache_stale_hit_total, and no
     // concurrent suite touches it because the session switch defaults to off.
-    // Cloud mode always falls back by design, so it only checks consistency.
     // Residual caveat (here and in checkIncrementalFallback): a
     // memory-pressure prune evicting the entry inside the tiny fill-to-assert
     // window surfaces as a plain MISS and would fail the assertion; accepted
-    // as rare, and a rerun re-establishes the counters from fresh deltas.
+    // as rare, and a rerun re-establishes the counters from fresh deltas. The
+    // one channel that is NOT left to chance is compaction: every table here
+    // sets disable_auto_compaction, so no background compaction can merge the
+    // delta away mid-window and turn the expected incremental hit into a
+    // fallback (this matters most on cloud, where compaction is driven
+    // externally rather than by the querying BE).
+    // On cloud two more channels surface the same way and fail only

Review Comment:
   - The negative cases now exist as deterministic BE unit tests: the 
incomplete-bitmap/history-rewrite case (`mow_history_rewrite_falls_back`, 
`mow_decision_sync_brings_bitmap_detects_rewrite`), the timeout/abandon case 
(`decision_sync_timeout_falls_back`), the engine-stopped and non-cloud-engine 
fallbacks, the pool-rejects-submit fallback 
(`presync_falls_back_when_pool_rejects_submit`), the rejection-plus-cache-miss 
composition proving the admission path performs zero meta loads 
(`pool_rejection_skips_admission_tablet_load`), and the worker-load-failure 
case proving exactly one worker-side load and no admission-thread retry 
(`presync_tablet_load_failure_falls_back`).
   - The routing-dependent metric oracle: the stale-hit assertion in the 
regression is gated to the shared-nothing path, because an aggregated BE-local 
counter cannot prove a specific cloud query used a stale entry when 
placement/routing is topology-dependent. On cloud the suite proves correctness 
by result consistency, and the cloud INCREMENTAL path is proven directly by the 
deterministic BE tests in `QueryCacheCloudIncrementalTest`.
   - Both clang-format checks are green in the follow-up (the changed-file 
violations the local dry run reproduced are fixed).
   



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