blob: d7e042e0301a24c0e28bdb511b50fca810ad243d [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
#include "runtime/query_cache/query_cache.h"
#include <limits>
#include "cloud/config.h"
#include "common/logging.h"
#include "common/metrics/doris_metrics.h"
#include "cpp/sync_point.h"
#include "storage/olap_common.h"
#include "storage/rowset/rowset.h"
#include "storage/rowset/rowset_reader.h"
#include "storage/tablet/base_tablet.h"
#include "storage/tablet/tablet_meta.h"
namespace doris {
std::vector<int>* QueryCacheHandle::get_cache_slot_orders() {
DCHECK(_handle);
auto result_ptr = reinterpret_cast<LRUHandle*>(_handle)->value;
return &((QueryCache::CacheValue*)(result_ptr))->slot_orders;
}
CacheResult* QueryCacheHandle::get_cache_result() {
DCHECK(_handle);
auto result_ptr = reinterpret_cast<LRUHandle*>(_handle)->value;
return &((QueryCache::CacheValue*)(result_ptr))->result;
}
int64_t QueryCacheHandle::get_cache_version() {
DCHECK(_handle);
auto result_ptr = reinterpret_cast<LRUHandle*>(_handle)->value;
return ((QueryCache::CacheValue*)(result_ptr))->version;
}
int64_t QueryCacheHandle::get_cache_delta_count() {
DCHECK(_handle);
auto result_ptr = reinterpret_cast<LRUHandle*>(_handle)->value;
return ((QueryCache::CacheValue*)(result_ptr))->delta_count;
}
int64_t QueryCacheHandle::get_cache_total_bytes() {
DCHECK(_handle);
auto result_ptr = reinterpret_cast<LRUHandle*>(_handle)->value;
return ((QueryCache::CacheValue*)(result_ptr))->total_bytes;
}
int64_t QueryCacheHandle::get_cache_total_rows() {
DCHECK(_handle);
auto result_ptr = reinterpret_cast<LRUHandle*>(_handle)->value;
return ((QueryCache::CacheValue*)(result_ptr))->total_rows;
}
void QueryCache::insert(const CacheKey& key, int64_t version, CacheResult& res,
const std::vector<int>& slot_orders, int64_t cache_size,
int64_t delta_count) {
SCOPED_SWITCH_THREAD_MEM_TRACKER_LIMITER(ExecEnv::GetInstance()->query_cache_mem_tracker());
CacheResult cache_result;
for (auto& block_data : res) {
cache_result.emplace_back(Block::create_unique())->swap(block_data->clone_empty());
ScopedMutableBlock scoped_mutable_block(cache_result.back().get());
auto& mutable_block = scoped_mutable_block.mutable_block();
auto st = mutable_block.merge(*block_data);
DORIS_CHECK(st.ok());
}
auto cache_value_ptr = std::make_unique<QueryCache::CacheValue>(
version, std::move(cache_result), slot_orders, delta_count, cache_size);
QueryCacheHandle(this, LRUCachePolicy::insert(key, (void*)cache_value_ptr.release(), cache_size,
cache_size, CachePriority::NORMAL));
DorisMetrics::instance()->query_cache_write_back_total->increment(1);
}
bool QueryCache::lookup(const CacheKey& key, int64_t version, doris::QueryCacheHandle* handle) {
SCOPED_SWITCH_THREAD_MEM_TRACKER_LIMITER(ExecEnv::GetInstance()->query_cache_mem_tracker());
auto* lru_handle = LRUCachePolicy::lookup(key);
if (lru_handle) {
QueryCacheHandle tmp_handle(this, lru_handle);
if (tmp_handle.get_cache_version() == version) {
*handle = std::move(tmp_handle);
return true;
}
}
return false;
}
bool QueryCache::lookup_any_version(const CacheKey& key, QueryCacheHandle* handle) {
SCOPED_SWITCH_THREAD_MEM_TRACKER_LIMITER(ExecEnv::GetInstance()->query_cache_mem_tracker());
auto* lru_handle = LRUCachePolicy::lookup(key);
if (lru_handle) {
*handle = QueryCacheHandle(this, lru_handle);
return true;
}
return false;
}
QueryCacheInstanceDecision::~QueryCacheInstanceDecision() = default;
std::unique_ptr<TabletReadSource> QueryCacheInstanceDecision::take_delta_read_source(
int64_t tablet_id) {
std::lock_guard<std::mutex> lock(_take_lock);
auto it = _delta_read_sources.find(tablet_id);
if (it == _delta_read_sources.end()) {
return nullptr;
}
auto res = std::move(it->second);
_delta_read_sources.erase(it);
return res;
}
std::shared_ptr<QueryCacheInstanceDecision> QueryCacheRuntime::get_or_make_decision(
const std::vector<TScanRangeParams>& scan_ranges) {
std::string cache_key;
int64_t version = 0;
Status st = QueryCache::build_cache_key(scan_ranges, _param, &cache_key, &version);
if (!st.ok()) {
// No reliable cache key for this instance (e.g. FE could not align the
// instance to a single partition so tablets carry different versions).
// Degrade to an uncached scan instead of failing the query: both the
// scan operator and the cache source operator observe the shared MISS
// decision with key_valid=false, so nothing is looked up and nothing
// is written back. This is an expected plan shape, so log only once
// per fragment instead of twice per instance.
std::lock_guard<std::mutex> lock(_lock);
if (_invalid_decision == nullptr) {
LOG(WARNING) << "query cache degrades to uncached scan, node_id=" << _param.node_id
<< ", reason: " << st.to_string();
_invalid_decision = std::make_shared<QueryCacheInstanceDecision>();
}
return _invalid_decision;
}
{
std::lock_guard<std::mutex> lock(_lock);
auto it = _decisions.find(cache_key);
if (it != _decisions.end()) {
return it->second;
}
}
// Build the candidate outside the lock: a stale-entry decision walks
// tablet metadata under the header lock and, on merge-on-write tables,
// scans the delete bitmap, so its cost grows with the tablets per
// instance and the bitmap size. This runtime is fragment-wide, so a
// single lock-scoped build would serialize even unrelated instances'
// keys behind one slow decision. Racing callers of the same instance
// build duplicate candidates; the loser adopts the winner's decision
// below and its own candidate releases with the shared_ptr (the entry
// pin and any captured delta read sources go with it), which only costs
// a redundant metadata pass. The metrics are settled once, by the
// winner, after publication.
auto decision = std::make_shared<QueryCacheInstanceDecision>();
decision->key_valid = true;
decision->cache_key = cache_key;
decision->current_version = version;
_make_decision(scan_ranges, decision.get());
TEST_SYNC_POINT("QueryCacheRuntime::get_or_make_decision.before_publish");
std::lock_guard<std::mutex> lock(_lock);
auto [it, inserted] = _decisions.emplace(std::move(cache_key), decision);
if (inserted) {
if (decision->mode == QueryCacheInstanceDecision::Mode::INCREMENTAL) {
DorisMetrics::instance()->query_cache_stale_hit_total->increment(1);
} else if (decision->mode == QueryCacheInstanceDecision::Mode::MISS &&
!decision->incremental_fallback_reason.empty()) {
// Only count real fallbacks: an empty reason means incremental
// merge was not enabled for this query in the first place.
DorisMetrics::instance()->query_cache_incremental_fallback_total->increment(1);
}
}
return it->second;
}
void QueryCacheRuntime::_make_decision(const std::vector<TScanRangeParams>& scan_ranges,
QueryCacheInstanceDecision* decision) {
decision->mode = QueryCacheInstanceDecision::Mode::MISS;
if (_binlog_scan) {
// A row-binlog scan reads a different data stream under the same plan
// digest: it must neither serve cached blocks nor write its output
// back (that would poison the cache for normal queries).
decision->key_valid = false;
return;
}
if (_param.force_refresh_query_cache) {
return;
}
QueryCacheHandle handle;
if (!_cache->lookup_any_version(decision->cache_key, &handle)) {
return;
}
// Pin the entry: as long as this decision object lives, the entry cannot be
// evicted, so every operator consuming this decision sees the same blocks.
decision->handle = std::move(handle);
if (decision->handle.get_cache_version() == decision->current_version) {
decision->mode = QueryCacheInstanceDecision::Mode::HIT;
decision->cached_delta_count = decision->handle.get_cache_delta_count();
return;
}
if (_try_prepare_incremental(scan_ranges, decision)) {
decision->mode = QueryCacheInstanceDecision::Mode::INCREMENTAL;
return;
}
// Stale entry not reusable: drop the pin and fall back to a full scan.
// The stale-hit/fallback metrics are settled by the caller once the
// winning candidate is published, so a losing racer cannot double-count.
decision->_delta_read_sources.clear();
decision->handle = QueryCacheHandle();
decision->mode = QueryCacheInstanceDecision::Mode::MISS;
}
bool QueryCacheRuntime::_try_prepare_incremental(const std::vector<TScanRangeParams>& scan_ranges,
QueryCacheInstanceDecision* decision) {
if (!(_param.__isset.allow_incremental && _param.allow_incremental)) {
// Not a fallback: incremental merge is simply not enabled for this
// query, so leave incremental_fallback_reason empty.
return false;
}
// Cloud tablets capture rowsets through a different (partly asynchronous)
// path; incremental merge only supports local storage for now.
if (config::is_cloud_mode()) {
decision->incremental_fallback_reason = "cloud mode";
return false;
}
int64_t cached_version = decision->handle.get_cache_version();
if (cached_version >= decision->current_version) {
// The entry is newer than what this replica is asked to read (e.g. the
// entry was filled from another replica with a newer visible version).
// A full scan of the requested version is the only safe answer.
decision->incremental_fallback_reason = "cached entry is newer";
return false;
}
int64_t cached_delta_count = decision->handle.get_cache_delta_count();
if (cached_delta_count >= config::query_cache_max_incremental_merge_count) {
// Force a full recompute to compact the entry: every incremental merge
// appends the delta blocks to the entry, so both the entry and the
// upstream merge get more fragmented as deltas accumulate.
decision->incremental_fallback_reason = "delta count reached compaction threshold";
return false;
}
for (const auto& scan_range : scan_ranges) {
int64_t tablet_id = scan_range.scan_range.palo_scan_range.tablet_id;
if (!_capture_tablet_delta(tablet_id, cached_version, decision)) {
return false;
}
}
// If the cached entry alone already exceeds the entry limits, the merged
// entry (which can only be larger) could never be written back. Still scan
// only the delta, but tell the cache source upfront so it does not clone
// blocks for a write back that would be discarded anyway. Such an entry
// stays stale until compaction merges its delta away (then the capture
// above fails and a full recompute takes over).
if (decision->handle.get_cache_total_bytes() > _param.entry_max_bytes ||
decision->handle.get_cache_total_rows() > _param.entry_max_rows) {
decision->write_back_feasible = false;
}
decision->cached_version = cached_version;
decision->cached_delta_count = cached_delta_count;
return true;
}
bool QueryCacheRuntime::_capture_tablet_delta(int64_t tablet_id, int64_t cached_version,
QueryCacheInstanceDecision* decision) {
auto tablet_res = ExecEnv::get_tablet(tablet_id);
if (!tablet_res) {
decision->incremental_fallback_reason = "tablet not found";
return false;
}
BaseTabletSPtr tablet = std::move(tablet_res.value());
// Defensive re-check of what FE promised with allow_incremental:
// "cached snapshot + delta rowsets == new snapshot" holds unconditionally
// for append-only DUP_KEYS data, and for merge-on-write UNIQUE data as
// long as the delta did not rewrite any row that predates the cached
// version (verified below once the delta rowsets are known). It never
// holds for merge-on-read UNIQUE data (duplicates are resolved by merging
// across rowsets at read time, so a delta-only scan cannot stand alone)
// nor for AGG tables (rows merge in the storage layer likewise).
const bool merge_on_write = tablet->keys_type() == KeysType::UNIQUE_KEYS &&
tablet->enable_unique_key_merge_on_write();
if (tablet->keys_type() != KeysType::DUP_KEYS && !merge_on_write) {
decision->incremental_fallback_reason = "keys type not append-only";
return false;
}
// quiet: on a version-graph miss (the delta merged away by compaction, an
// expected per-query situation) this option makes the capture API neither
// log nor error; it returns whatever PREFIX of the path it walked before
// the miss (empty when the window start itself is gone), so emptiness AND
// window coverage are both checked below as the failure signals.
auto source_res = tablet->capture_read_source({cached_version + 1, decision->current_version},
{.quiet = true});
if (!source_res) {
// Non-pathfinding failures (e.g. a rowset object missing for a found
// path) still surface as errors even under quiet.
decision->incremental_fallback_reason = "delta versions not capturable";
return false;
}
auto read_source = std::make_unique<TabletReadSource>(std::move(source_res.value()));
if (read_source->rs_splits.empty()) {
// A non-empty version window always lies on a consistent path with at
// least one rowset; an empty capture therefore means the delta
// versions are gone, never "no new data". Treating it as INCREMENTAL
// would silently drop the delta rows from the result.
decision->incremental_fallback_reason = "delta versions not capturable";
return false;
}
// A quiet capture broken mid-window (e.g. a replica whose local view ends
// short of current_version) yields a partial prefix, and treating it as
// INCREMENTAL would silently drop the tail rows and poison the entry on
// write-back. The path is contiguous by construction, so checking both
// endpoints pins the whole (cached_version, current_version] window.
if (read_source->rs_splits.front().rs_reader->rowset()->start_version() != cached_version + 1 ||
read_source->rs_splits.back().rs_reader->rowset()->end_version() !=
decision->current_version) {
decision->incremental_fallback_reason = "delta versions not capturable";
return false;
}
read_source->fill_delete_predicates();
if (!read_source->delete_predicates.empty()) {
// A delete in the delta logically removes rows that are already folded
// into the cached partial result; that cannot be undone by merging, so
// fall back to a full recompute.
decision->incremental_fallback_reason = "delta contains delete predicates";
return false;
}
// Pin the cached side for as long as the classification below reads the
// delete bitmap. The markers it looks for sit on the rowsets the cached
// snapshot was built from, and the capture above pins only the delta
// side, so nothing else keeps that evidence alive: once a compaction
// spanning both sides retires such a rowset, an overwritten row has no
// counterpart in the output and its marker is therefore not relocated
// (BaseTablet::calc_compaction_output_rowset_delete_bitmap skips a source
// row whose id does not convert), leaving the marker only on the retired
// input, which the stale sweep then hands to the unused-rowset GC.
// Classification would see no rewrite and merge the cached rows with
// their replacements: a wrong entry, stored at the current version.
// Holding the rowsets stops that, in start_delete_unused_rowset(): it
// collects a retired rowset only once nothing else references it, and
// only what it collects gets remove_rowset_delete_bitmap(), which drops
// every version of that rowset's bitmap. The other remover, the key
// ranges that the stale sweep queues onto the pre-window rowsets, is
// held off by the delta capture instead: that queue is released only
// once the swept path's own rowsets are unreferenced.
std::vector<RowsetSharedPtr> cached_side_pin;
if (merge_on_write) {
{
std::shared_lock rdlock(tablet->get_header_lock());
auto pin_res = tablet->capture_consistent_rowsets_unlocked({0, cached_version},
{.quiet = true});
if (pin_res) {
cached_side_pin = std::move(pin_res.value().rowsets);
}
}
// quiet yields the prefix it walked, so cover the whole cached range
// before trusting the classification: an unpinned tail is evidence we
// cannot vouch for.
if (cached_side_pin.empty() || cached_side_pin.front()->start_version() != 0 ||
cached_side_pin.back()->end_version() != cached_version) {
decision->incremental_fallback_reason = "cached versions not pinnable";
return false;
}
// The pin is only worth anything while the classification runs, so
// let a test observe it exactly there rather than infer it.
TEST_SYNC_POINT_CALLBACK("QueryCacheRuntime::_capture_tablet_delta.cached_side_pinned",
&cached_side_pin);
}
if (merge_on_write &&
_delta_rewrites_history(*tablet, *read_source, cached_version, decision->current_version)) {
// A load in the delta window marked rows of the cached snapshot as
// deleted through the delete bitmap (an upsert, a partial update or a
// delete sign hit a pre-existing key). Those rows are already folded
// into the cached partial result and cannot be subtracted, so only a
// full recompute is safe. The full run re-bases the entry at the new
// version, so an occasional backfill costs exactly one full scan and
// the next pure-append hour is incremental again.
decision->incremental_fallback_reason = "delta rewrites history rows";
return false;
}
decision->_delta_read_sources[tablet_id] = std::move(read_source);
return true;
}
bool QueryCacheRuntime::_delta_rewrites_history(BaseTablet& tablet,
const TabletReadSource& delta_source,
int64_t cached_version, int64_t current_version) {
RowsetIdUnorderedSet delta_rowsets;
for (const auto& split : delta_source.rs_splits) {
delta_rowsets.insert(split.rs_reader->rowset()->rowset_id());
}
// The tablet delete bitmap is shared and it does change at versions that
// are already visible, so what keeps this scan safe is not immutability
// but the fallback it feeds. Loads add their markers before the version
// becomes visible. Compaction relocates existing markers onto its output
// rowset keeping their original version (BaseTablet::calc_compaction_
// output_rowset_delete_bitmap); markers that matter here sit on a rowset
// at or below the cached version, and an output that swallowed such a
// rowset starts no later than it does, so the relocated marker lands
// outside the delta and is read as a rewrite: conservative, never a miss.
// (An output whose inputs all sit inside the window does belong to the
// delta, and markers on it are delta-internal, which is what we want.) The one
// rewrite that moves markers out of the window, the aggregation that
// re-stamps a stale range onto its end version (BaseTablet::agg_delete_
// bitmap_for_stale_rowsets, run by delete_expired_stale_rowset), only
// fires once the merged versions are swept, and the capture this scan
// pairs with anchors on the window endpoints, so a window whose path is
// gone is rejected before we get here. Capture and scan are not atomic,
// so a sweep landing exactly in between (with the aggregated keys also
// already recycled) can still hide a rewrite: a known narrow race the
// fallback does not cover, shared with the baseline exact-hit path.
// Pending entries use DeleteBitmap::TEMP_VERSION_COMMON (= 0) and stay
// below the window as well.
//
// The map is ordered by (rowset, segment, version), and most entries of
// a group lie OUTSIDE the delta window (dedup history below it, pending
// entries at 0, concurrent newer loads above it), so on a long-lived
// merge-on-write tablet the map is much larger than the window. Hop from
// group to group with lower_bound/upper_bound instead of visiting every
// entry: the shared-lock hold, which excludes bitmap writers such as
// load publish, is then bounded by the in-window entries plus one
// logarithmic hop per group instead of the full map size.
// Compare versions in the uint64 key domain (both window bounds are
// non-negative), so a hypothetical out-of-int64-range stored version can
// only take the above-window hop, whose target is strictly past the
// whole group: forward progress holds for arbitrary map contents, like
// the plain walk this replaces.
const auto window_begin = static_cast<uint64_t>(cached_version) + 1;
const auto window_end = static_cast<uint64_t>(current_version);
const DeleteBitmap& delete_bitmap = tablet.tablet_meta()->delete_bitmap();
std::shared_lock rdlock(delete_bitmap.lock);
const auto& bitmap_map = delete_bitmap.delete_bitmap;
auto it = bitmap_map.begin();
while (it != bitmap_map.end()) {
const auto& [rowset_id, segment_id, version_key] = it->first;
if (version_key < window_begin) {
it = bitmap_map.lower_bound({rowset_id, segment_id, window_begin});
continue;
}
if (version_key > window_end) {
it = bitmap_map.upper_bound(
{rowset_id, segment_id, std::numeric_limits<uint64_t>::max()});
continue;
}
if (!it->second.isEmpty() && !delta_rowsets.contains(rowset_id)) {
return true;
}
++it;
}
return false;
}
} // namespace doris