github-actions[bot] commented on code in PR #65683:
URL: https://github.com/apache/doris/pull/65683#discussion_r3594467411
##########
be/src/cloud/cloud_warm_up_manager.cpp:
##########
@@ -815,7 +815,7 @@ Status CloudWarmUpManager::_do_warm_up_rowset(RowsetMeta&
rs_meta, int64_t table
Status st = Status::OK();
std::shared_ptr<PBackendService_Stub> brpc_stub =
ExecEnv::GetInstance()->brpc_internal_client_cache()->get_new_client_no_cache(
- brpc_addr);
+ brpc_addr, "", "", "", replica.host);
Review Comment:
[P0] Use the nested replica hostname at both call sites
This scope has no `replica` variable; the address is `info.replica.host`.
The analogous new call in `_recycle_cache()` at line 978 also uses
`replica.host` even though that loop element is `JobReplicaInfo` and the
surrounding code uses `replica.replica.host`. Both expressions fail the Cloud
BE compilation.
##########
be/src/exec/operator/exchange_sink_buffer.h:
##########
@@ -310,13 +310,13 @@ class ExchangeSinkBuffer : public HasTaskExecutionCtx {
#ifndef BE_TEST
inline void _ended(RpcInstance& ins);
- inline void _failed(InstanceLoId id, const std::string& err);
+ inline void _failed(InstanceLoId id, Status err);
inline void _set_receiver_eof(RpcInstance& ins);
inline void _turn_off_channel(RpcInstance& ins,
std::unique_lock<std::mutex>& with_lock);
#else
virtual void _ended(RpcInstance& ins);
- virtual void _failed(InstanceLoId id, const std::string& err);
+ virtual void _failed(InstanceLoId id, Status err);
Review Comment:
[P1] Migrate the BE test override with this signature
`MockSinkBuffer` in `be/test/exec/exchange/exchange_sink_test.h` still
declares `_failed(InstanceLoId, const std::string&) override`. After this
change it no longer overrides the base method, so the exchange-sink test target
fails to compile. Please update the mock to accept `Status` and assert the
newly preserved error code.
##########
be/src/util/thrift_rpc_helper.cpp:
##########
@@ -127,6 +129,79 @@ Status
ThriftRpcHelper::rpc(std::function<TNetworkAddress()> address_provider,
return Status::OK();
}
+template <typename T>
Review Comment:
[P0] Remove the duplicate template definition
This is a second definition of the exact
`rpc(std::function<TNetworkAddress()>, ..., int)` template already defined at
lines 68-130. C++ diagnoses the translation unit as a redefinition before the
explicit instantiations are reached, so the BE target cannot compile. Please
merge the intended changes into the existing body and keep only one definition.
##########
be/src/runtime/exec_env.h:
##########
@@ -140,6 +140,16 @@ class DeleteBitmapAggCache;
// set to true when BE is shutting down
inline bool k_doris_exit = false;
+// set to true once FE Master has pulled a heartbeat carrying is_shutdown=true.
+// Used by Phase A of graceful shutdown to know FE has been informed.
+inline bool k_shutdown_fe_known = false;
Review Comment:
[P1] Make the shutdown flags atomic
These flags are written by heartbeat Thrift workers but polled by the main
shutdown thread and read by FragmentMgr/RPC worker threads without a shared
lock. Concurrent access to these plain C++ `bool`s is a data race, so
acknowledgement and graceful-mode propagation have undefined behavior. Use
atomics with an explicit visibility contract (or one common synchronization
primitive) for both flags.
##########
be/src/service/doris_main.cpp:
##########
@@ -744,6 +751,10 @@ int main(int argc, char** argv) {
service.reset();
LOG(INFO) << "Backend Service stopped";
exec_env->destroy();
+ doris::ThreadLocalHandle::del_thread_local_if_count_is_zero();
Review Comment:
[P1] Let the scoped thread context release itself
`main()` already owns this handle through `SCOPED_INIT_THREAD_CONTEXT()`.
This explicit call deletes the only TLS context, and then the scope destructor
calls the same release routine again on `return 0`, reaching the fatal
no-pthread-context path. This breaks every clean `enable_graceful_exit_check`
shutdown; remove the manual deletion or end the RAII scope exactly once.
##########
be/src/agent/heartbeat_server.cpp:
##########
@@ -90,6 +90,12 @@ void HeartbeatServer::heartbeat(THeartbeatResult&
heartbeat_result,
heartbeat_result.backend_info.__set_be_node_role(config::be_node_role);
// If be is gracefully stop, then k_doris_exist is set to true
heartbeat_result.backend_info.__set_is_shutdown(doris::k_doris_exit);
+ if (doris::k_doris_exit) {
+ // FE Master has pulled at least one heartbeat carrying
+ // is_shutdown=true. Unblock Phase A in graceful_shutdown.
+ doris::k_shutdown_fe_known = true;
Review Comment:
[P1] Wait for a real FE acknowledgement
This flag is set while the BE is still constructing the heartbeat response.
FE cannot apply `is_shutdown` or write `OP_HEARTBEAT` until the RPC returns and
the heartbeat batch is collected, so a lost response makes BE proceed even
though no FE learned the state; a fixed sleep does not prove follower replay
either. Ack from a subsequent FE request only after the master has applied and
durably logged this generation.
##########
be/src/runtime/exec_env.cpp:
##########
@@ -170,20 +170,53 @@ std::map<TNetworkAddress, FrontendInfo>
ExecEnv::get_running_frontends() {
return res;
}
+void ExecEnv::wait_for_all_fe_known() {
+ // Phase A of graceful shutdown:
+ // Heartbeat callback (HeartbeatServer::heartbeat) sets
+ // k_shutdown_fe_known=true the next time Master pulls a heartbeat
+ // (which carries is_shutdown=true). Block here up to 30s for that to
+ // happen, then sleep an extra 10s so the resulting OP_HEARTBEAT EditLog
+ // can be replicated by BDBJE to all FE Followers before we start
+ // tearing down running tasks. The 30s deadline is a safety net for the
+ // case where Master is itself in drain / election and heartbeats are
+ // delayed.
+ constexpr int32_t kWaitLimitSeconds = 30;
+ constexpr int32_t kBufferSeconds = 10;
+ int32_t passed = 0;
+ while (!k_shutdown_fe_known && passed < kWaitLimitSeconds) {
+ sleep(1);
+ ++passed;
+ }
+ if (k_shutdown_fe_known) {
+ LOG(INFO) << "FE Master has acknowledged shutdown after " << passed
+ << "s, sleeping additional " << kBufferSeconds
+ << "s to let OP_HEARTBEAT EditLog replicate to all
Followers.";
+ } else {
+ LOG(WARNING) << "FE Master did not acknowledge shutdown within " <<
kWaitLimitSeconds
+ << "s, proceeding anyway.";
+ }
+ sleep(kBufferSeconds);
+}
+
void ExecEnv::wait_for_all_tasks_done() {
- // For graceful shutdown, need to wait for all running queries to stop
+ LOG(INFO) << "begin to wait for all tasks done before shutdown.
k_shutdown_fe_known: "
+ << k_shutdown_fe_known << " k_in_graceful_shutdown: " <<
k_in_graceful_shutdown;
+ // For graceful shutdown, need to wait for all running queries and load
channels to stop
int32_t wait_seconds_passed = 0;
while (true) {
int num_queries = _fragment_mgr->running_query_num();
- if (num_queries < 1) {
+ size_t num_load_channels =
_load_channel_mgr->get_active_load_channel_num();
Review Comment:
[P1] Include V2 receiver streams in the shutdown drain
This predicate counts only legacy `LoadChannelMgr` channels. The default
non-cloud `OlapTableSinkV2` receiver is registered in
`LoadStreamMgr::_load_streams_map` and can have active delta writers on a
receiver-only BE while both counters here are zero. The BE can therefore tear
down storage during an active write. Add synchronized drain accounting for
`LoadStreamMgr` (and audit `NewLoadStreamMgr`) before allowing shutdown.
##########
be/src/runtime/fragment_mgr.cpp:
##########
@@ -911,12 +911,27 @@ void FragmentMgr::_collect_invalid_queries(
itr->second.info.process_uuid == 0) {
continue;
} else {
+ if (doris::k_in_graceful_shutdown) {
Review Comment:
[P1] Still cancel after a confirmed coordinator epoch change
A nonzero FE process-UUID mismatch proves that the process owning this query
state is gone; its fragments cannot be adopted by the replacement FE. Skipping
this cleanup for the full rolling-restart flag strands BE work and resources
until query timeout. Limit any exemption to a bounded ambiguous liveness
window, and always cancel on a confirmed nonzero UUID change.
##########
fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java:
##########
@@ -1634,6 +1651,14 @@ public TLoadTxnCommitResult
loadTxnPreCommit(TLoadTxnCommitRequest request) thro
return result;
}
+ if (status.getStatusCode() != TStatusCode.OK) {
Review Comment:
[P1] Populate the precommit hint before the first return
The identical non-OK check immediately above already returns, so this branch
is unreachable and `loadTxnPreCommit` remains the one changed transaction RPC
that cannot redirect a NOT_MASTER retry. Set `masterAddress` in the first error
branch before returning and add a follower/precommit test.
##########
fe/fe-core/src/main/java/org/apache/doris/catalog/Env.java:
##########
@@ -1733,7 +1734,17 @@ private void transferToMaster() {
// set this after replay thread stopped. to avoid replay thread
modify them.
isReady.set(false);
- canRead.set(false);
+ // Do NOT set canRead to false here.
Review Comment:
[P1] Preserve the metadata-staleness bound during promotion
Once the replayer is joined, its `setCanRead()` loop is no longer available
to enforce `meta_delay_toleration_second`. If BDB open/fencing/replay stalls
beyond that bound, this change continues serving reads from the frozen catalog
indefinitely. Keep promotion reads only while the same staleness invariant
holds, with a timer/recheck that disables `canRead` when the configured bound
expires.
##########
fe/fe-core/src/main/java/org/apache/doris/qe/VariableMgr.java:
##########
@@ -456,6 +461,29 @@ private static void setGlobalVarAndWriteEditLog(VarContext
ctx, String name, Str
}
}
+ private static void applyGlobalVariableSideEffect(String name, String
value) throws DdlException {
+ if (!SessionVariable.ENABLE_GRACEFUL_SHUTDOWN.equals(name) ||
Env.isCheckpointThread()) {
+ return;
+ }
+ String heartbeatInterval = parseBooleanValue(name, value)
+ ? FAST_HEARTBEAT_INTERVAL_SECONDS :
DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
Review Comment:
[P1] Restore the configured heartbeat interval, not a literal
Disabling this global mode always writes `10`, even when
`heartbeat_interval_second` was configured or dynamically set to another value.
For example, a deployment using 30 seconds becomes 3 while enabled and silently
becomes 10 afterward. Model the fast value as an override over the current base
configuration, or save/restore each FE's prior value.
##########
fe/fe-core/src/main/java/org/apache/doris/common/DNSCache.java:
##########
@@ -60,6 +60,27 @@ public String get(String hostname) {
return cache.computeIfAbsent(hostname, this::resolveHostname);
}
+ /**
+ * Remove the cached entry for a hostname so the next get() will trigger a
fresh DNS resolution.
+ * Used to proactively invalidate stale entries when a backend is known to
have restarted.
+ *
+ * @param hostname The hostname whose cached entry should be evicted.
+ * @return The previously cached IP (may be null if not cached).
+ */
+ public String remove(String hostname) {
+ String old = cache.remove(hostname);
Review Comment:
[P1] Make DNS removal generation-safe
A refresh can resolve old IP A, this method can remove the entry during
restart cleanup, and that older refresh then observes `null` and publishes A
again. `ConcurrentHashMap` only makes individual operations safe; it does not
order invalidation against refresh publication. Track a per-host generation or
serialize refresh/remove per key, with a latch-based race test.
##########
fe/fe-core/src/main/java/org/apache/doris/system/Backend.java:
##########
@@ -968,9 +995,153 @@ public boolean handleHbResponse(BackendHbResponse
hbResponse, boolean isReplay)
lastMissingHeartbeatTime = System.currentTimeMillis();
}
+ // If we detected a BE restart (new epoch, or dead->alive transition),
proactively
+ // invalidate FE-side caches (DNSCache + gRPC channels + Thrift pool)
so the first
+ // query after restart does not hit a stale channel.
+ //
+ // NOTE: This must run on BOTH master (isReplay=false) and follower FEs
+ // (isReplay=true, replaying OP_HEARTBEAT editlog). Each FE process
holds its own
+ // in-memory caches, and follower FEs also serve client queries on
port 9030. If we
+ // skipped follower replay, follower FEs would keep stale gRPC
channels / Thrift
+ // sockets pointing to the previous BE IP and fail the first query
after BE restart
+ if (restartDetected) {
+ try {
+ String reason = String.format(
+ "heartbeat: restart detected (preHbStartTime=%d (%s), "
+ + "postHbStartTime=%d (%s), preHbIsAlive=%s,
postHbIsAlive=%s, "
+ + "isAvailable=%s, isShutDown=%s,
isQueryDisabled=%s)",
+ preHbStartTime,
TimeUtils.longToTimeString(preHbStartTime),
+ newStartTime, TimeUtils.longToTimeString(newStartTime),
+ preHbIsAlive, isAlive.get(),
SimpleScheduler.isAvailable(this),
+ isShutDown(), isQueryDisabled());
+ invalidateLocalConnections(host, reason);
+ } catch (Throwable t) {
+ LOG.warn("invalidateLocalConnections failed (heartbeat path),
backendId={}, "
+ + "host={}, err={}", id, host, t.getMessage(), t);
+ }
+ }
+
return isChanged;
}
+ private boolean shouldDelayAliveByGracefulHeartbeat(BackendHbResponse
hbResponse, boolean isReplay) {
+ if (isReplay || hbResponse.isShutDown() ||
!isGracefulShutdownEnabled()) {
+ resetGracefulAliveHeartbeat();
+ return false;
+ }
+ if (isAlive.get() && !isShutDown()) {
+ resetGracefulAliveHeartbeat();
+ return false;
+ }
+
+ long beStartTime = hbResponse.getBeStartTime();
+ if (gracefulAliveHeartbeatStartTime != beStartTime) {
+ gracefulAliveHeartbeatStartTime = beStartTime;
+ gracefulAliveHeartbeatOkCount = 0;
+ }
+ gracefulAliveHeartbeatOkCount++;
+
+ if (gracefulAliveHeartbeatOkCount <
GRACEFUL_ALIVE_HEARTBEAT_CONFIRM_COUNT) {
+ LOG.info("{} delays marking backend alive during graceful
shutdown, "
+ + "okHeartbeatCount={}/{}, beStartTime={} ({}),
isReplay={}",
+ this.toString(), gracefulAliveHeartbeatOkCount,
+ GRACEFUL_ALIVE_HEARTBEAT_CONFIRM_COUNT, beStartTime,
+ TimeUtils.longToTimeString(beStartTime), isReplay);
+ return true;
+ }
+
+ LOG.info("{} confirms backend alive during graceful shutdown after {}
consecutive OK heartbeats, "
+ + "beStartTime={} ({})",
+ this.toString(), gracefulAliveHeartbeatOkCount, beStartTime,
+ TimeUtils.longToTimeString(beStartTime));
+ resetGracefulAliveHeartbeat();
+ return false;
+ }
+
+ private boolean isGracefulShutdownEnabled() {
+ try {
+ return
VariableMgr.getDefaultSessionVariable().enableGracefulShutdown;
+ } catch (Throwable t) {
+ return false;
+ }
+ }
+
+ private void resetGracefulAliveHeartbeat() {
+ gracefulAliveHeartbeatOkCount = 0;
+ gracefulAliveHeartbeatStartTime = -1;
+ }
+
+ /**
+ * Public entry to invalidate FE-side connection caches (DNSCache + gRPC
channels +
+ * Thrift pool) targeting this backend. Safe to call from any thread; all
three
+ * underlying operations are idempotent.
+ *
+ * <p>Called from two replay paths:
+ * <ul>
+ * <li>{@link #handleHbResponse} when a heartbeat indicates a BE epoch
change
+ * (covers {@code OP_HEARTBEAT} editlog replay on follower FEs);</li>
+ * <li>{@code SystemInfoService.updateBackendState()} which replays
+ * {@code OP_BACKEND_STATE_CHANGE} editlog entries and bypasses the
heartbeat
+ * handler entirely.</li>
+ * </ul>
+ * Without this unified entry, follower FEs that learn about a BE restart
via
+ * state-change editlog instead of heartbeat would never clear their
in-memory
+ * connection caches and the next query would hit a stale gRPC channel /
Thrift
+ * socket pointing to the previous BE IP.
+ *
+ * @param prevHost the host string seen BEFORE the update, used to
invalidate the
+ * DNSCache entry for the old hostname in case the host
field has
+ * been mutated by setters. Pass current host if it did
not change.
+ * @param reason short tag included in the log line for diagnostics
+ * (e.g. {@code "OP_BACKEND_STATE_CHANGE replay"}).
+ */
+ public void invalidateLocalConnections(String prevHost, String reason) {
+ LOG.info("BE restart detected on FE. reason={}, backendId={},
currentHost={}, "
+ + "prevHost={}, bePort={}, brpcPort={}, isAlive={},
lastStartTime={} ({}). "
+ + "Will proactively invalidate DNSCache, gRPC channels
and Thrift pool "
+ + "to avoid stale connections on next query.",
+ reason, id, host, prevHost, bePort, brpcPort, isAlive.get(),
+ lastStartTime, TimeUtils.longToTimeString(lastStartTime));
+
+ Env env = Env.getCurrentEnv();
+ DNSCache dnsCache = env == null ? null : env.getDnsCache();
+ if (dnsCache != null) {
+ if (prevHost != null && !prevHost.isEmpty()) {
+ String prevIp = dnsCache.get(prevHost);
+ dnsCache.remove(prevHost);
+ LOG.info("BE restart cleanup: invalidated FE DNSCache.
backendId={}, host={}, "
+ + "previousCachedIp={}. Next getProxy() will
force re-resolution.",
+ id, prevHost, prevIp);
+ }
+ if (host != null && !host.isEmpty() && !host.equals(prevHost)) {
+ String prevIp = dnsCache.get(host);
+ dnsCache.remove(host);
+ LOG.info("BE restart cleanup: invalidated FE DNSCache for
current host. "
+ + "backendId={}, host={},
previousCachedIp={}.",
+ id, host, prevIp);
+ }
+ } else {
+ LOG.info("BE restart cleanup: DNSCache not available, skipped.
backendId={}", id);
+ }
+
+ if (brpcPort > 0) {
+ TNetworkAddress brpcAddr = new TNetworkAddress(host, brpcPort);
+ LOG.info("BE restart cleanup: dropping gRPC channels on all
shards. backendId={}, "
+ + "brpcAddr={}", id, brpcAddr);
+ BackendServiceProxy.removeProxyForAll(brpcAddr);
+ }
+
+ if (bePort > 0) {
+ TNetworkAddress bePoolAddr = new TNetworkAddress(host, bePort);
+ ClientPool.backendPool.clearPool(bePoolAddr);
Review Comment:
[P1] Prevent borrowed clients from re-entering after cleanup
`clearPool(key)` removes idle objects only. A client borrowed before this
restart cleanup can finish and return afterward, becoming an idle stale socket
that the next request borrows. Add a per-address generation (or close-on-return
marker) so clients from an invalidated generation are destroyed, and test
borrow/clear/return ordering.
##########
fe/fe-core/src/main/java/org/apache/doris/system/Backend.java:
##########
@@ -870,7 +878,25 @@ public String toString() {
*/
public boolean handleHbResponse(BackendHbResponse hbResponse, boolean
isReplay) {
boolean isChanged = false;
+ // Snapshot state BEFORE any mutation, so we can detect a BE process
restart
+ // (new beStartTime/epoch) and run proactive cleanup at the end of
this method.
+ // Trigger cleanup when:
+ // 1. beStartTime changed (process restarted; covers the "alive
whole time" case too), OR
+ // 2. BE was dead and is now coming back alive (may or may not have
a new epoch, but
+ // stale FE-side channels are still expected).
+ final long preHbStartTime = this.lastStartTime;
+ final boolean preHbIsAlive = this.isAlive.get();
+ final long newStartTime = hbResponse.getStatus() == HbStatus.OK ?
hbResponse.getBeStartTime() : 0L;
+ // Require a prior valid epoch (preHbStartTime > 0) so the very first
heartbeat of a
+ // freshly-added BE does not trigger a bogus "restart detected"
cleanup/log.
+ final boolean restartDetected = hbResponse.getStatus() == HbStatus.OK
+ && preHbStartTime > 0
Review Comment:
[P1] Do not gate dead-to-alive cleanup on a nonzero old epoch
Older-version oplogs can legitimately leave an existing backend with epoch
0. In that state this outer gate also suppresses the explicit dead-to-alive
cleanup, so its first OK heartbeat can retain stale DNS/gRPC/Thrift clients.
Treat dead-to-alive as a cleanup trigger regardless of epoch; distinguish a
truly new backend by lifecycle state rather than a compatibility value.
##########
be/src/util/thrift_rpc_helper.cpp:
##########
@@ -143,4 +218,110 @@ template Status
ThriftRpcHelper::rpc<TPaloBrokerServiceClient>(
const std::string& ip, const int32_t port,
std::function<void(ClientConnection<TPaloBrokerServiceClient>&)>
callback, int timeout_ms);
+Status ThriftRpcHelper::rpc_fe_with_master_refresh(
+ std::function<TNetworkAddress()> address_provider,
+ std::function<void(ClientConnection<FrontendServiceClient>&)> callback,
+ std::function<TStatus()> status_extractor,
+ std::function<const TNetworkAddress*()> master_addr_extractor, int
timeout_ms) {
+ // First attempt: drives the existing one-shot transport-level retry inside
+ // rpc<FrontendServiceClient>.
+ Status st = rpc<FrontendServiceClient>(address_provider, callback,
timeout_ms);
+
+ // Outside of a graceful rolling restart (operator-controlled
+ // `SET GLOBAL enable_graceful_shutdown=true`), keep strictly one-shot
+ // behavior: return the first-attempt result as-is, without probing
+ // followers or refreshing the cached master. The caller will see the
+ // raw TStatus (including NOT_MASTER) and decide what to do. This is
+ // equivalent to the legacy behavior before rpc_fe_with_master_refresh
+ // was introduced.
+ if (!doris::k_in_graceful_shutdown) {
+ return st;
+ }
+
+ bool transport_failed = !st.ok();
+ bool not_master = false;
+ TNetworkAddress new_master;
+ bool have_new_master = false;
+
+ if (st.ok()) {
+ TStatus resp_status = status_extractor();
+ if (resp_status.status_code == TStatusCode::NOT_MASTER) {
+ not_master = true;
+ const TNetworkAddress* addr = master_addr_extractor();
+ if (addr != nullptr && !addr->hostname.empty() && addr->port != 0)
{
+ new_master = *addr;
+ have_new_master = true;
+ }
+ }
+ }
+
+ if (!transport_failed && !not_master) {
+ return st;
+ }
+
+ TNetworkAddress old_master = address_provider();
+ if (have_new_master) {
+ // Refresh cached master immediately from the response. No extra RPC.
+ // Note: unprotected write, same pattern as HeartbeatServer; the field
+ // is also written by heartbeat thread with epoch checks, which will
+ // self-correct any stale value we might race-write.
+ _s_exec_env->cluster_info()->master_fe_addr = new_master;
Review Comment:
[P1] Do not publish an unversioned hint into heartbeat-owned state
This unsynchronized assignment races on a string-bearing `TNetworkAddress`
with heartbeat writers/readers. It can also wedge routing: heartbeat may
install master C at epoch E, then a delayed response overwrites the address
with stale B without changing `_fe_epoch`; subsequent C/E heartbeats reject the
repair because the epoch is not greater. Keep the hint local to this retry or
route all master updates through a synchronized, epoch-aware owner.
##########
fe/fe-core/src/main/java/org/apache/doris/mysql/AcceptListener.java:
##########
@@ -58,9 +58,7 @@ public void handleEvent(AcceptingChannel<StreamConnection>
channel) {
return;
}
connection.setOption(Options.KEEP_ALIVE, true);
- if (LOG.isDebugEnabled()) {
- LOG.debug("Connection established. remote={}",
connection.getPeerAddress());
- }
+ LOG.info("MySQL connection established. remote={}",
connection.getPeerAddress());
Review Comment:
[P2] Avoid per-connection INFO logging on the accept path
Graceful shutdown deliberately closes idle pooled connections and induces a
reconnect wave on surviving FEs. Promoting every accepted socket from debug to
INFO adds synchronous formatting/log I/O proportional to that storm. Keep this
at DEBUG, rate-limit it, or expose an aggregate reconnect metric.
##########
fe/fe-core/src/main/java/org/apache/doris/rpc/BackendServiceProxy.java:
##########
@@ -113,7 +131,84 @@ public void removeProxy(TNetworkAddress address) {
}
if (serviceClientExtIp != null) {
- serviceClientExtIp.client.shutdown();
+ LOG.info("remove gRPC proxy on shard, address={}, cachedRealIp={},
channelState={}",
+ address, serviceClientExtIp.realIp,
serviceClientExtIp.client.getConnectivityState());
+ scheduleDeferredShutdown(address, serviceClientExtIp.client,
"removeProxy");
+ } else {
+ LOG.info("remove gRPC proxy on shard, address={}, no cached
channel", address);
+ }
+ }
+
+ // Remove the cached gRPC channel for this address on ALL proxy shards.
+ // Used when a BE restart is detected, to make sure no stale channel
survives on any shard.
+ public static void removeProxyForAll(TNetworkAddress address) {
+ int shardCount = Holder.proxies.length;
Review Comment:
[P1] Order channel publication with restart invalidation
A concurrent `getProxy()` can resolve old IP A before cleanup, wait while
this loop removes the address from its shard, then acquire that shard lock and
insert a new A-backed client after eviction has passed. Cleanup returns with a
freshly stale channel. Capture an address/invalidation generation with DNS
resolution and revalidate it under the shard lock before publishing the client,
with a resolve-old / evict / insert race test.
##########
be/src/service/internal_service.cpp:
##########
@@ -355,6 +355,12 @@ void
PInternalService::exec_plan_fragment(google::protobuf::RpcController* contr
timeval tv {};
gettimeofday(&tv, nullptr);
response->set_received_time(tv.tv_sec * 1000LL + tv.tv_usec / 1000);
+ if (k_doris_exit) {
Review Comment:
[P1] Stop admitting fragments before taking the drain snapshot
This branch only logs and still queues the fragment. A request accepted
after `wait_for_all_tasks_done()` observes zero—or already waiting in
`_light_work_pool` before FragmentMgr registration—is absent from every drain
counter and can be terminated by the default `_exit(0)` path. Atomically reject
new prepare/execute admissions and account already-admitted work from queue
entry through registration/completion, while leaving required
completion/control RPCs available.
##########
fe/fe-core/src/main/java/org/apache/doris/qe/Coordinator.java:
##########
@@ -1369,6 +1389,12 @@ public Status shouldCancel(List<Backend>
currentBackends) {
// Check zero since during upgrading, older version oplog will
not persistent be start time
// so newer version follower will get zero epoch when
replaying oplog or snapshot
if (pipelineExecContext.beProcessEpoch != be.getProcessEpoch()
&& be.getProcessEpoch() != 0) {
+ if (clusterGraceful) {
Review Comment:
[P1] Do not suppress a confirmed BE epoch change
A nonzero process-epoch change means the old BE process and its submitted
fragments are gone; graceful draining cannot make that work resume. Returning
OK on every QueryCancelWorker pass leaves downstream fragments waiting and
retains resources until query timeout. Restrict the graceful exemption to a
transient `alive=false` state and keep cancellation for confirmed epoch changes.
##########
fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java:
##########
@@ -2312,6 +2315,24 @@ public boolean isEnableHboNonStrictMatchingMode() {
"服务端 prepared statement 最大个数", "the maximum prepared
statements server holds."})
public int maxPreparedStmtCount = 100000;
+ @VarAttrDef.VarAttr(name = ENABLE_GRACEFUL_SHUTDOWN, flag =
VarAttrDef.GLOBAL,
+ setter = "setEnableGracefulShutdown", needForward = true,
+ description = {
+ "集群级别开关:当 FE 或 BE 准备做滚动重启 / 优雅退出时,由运维 SET GLOBAL 打开。"
+ + "打开后:(1) FE QueryCancelWorker / Coordinator.shouldCancel
不再因 BE alive=false "
+ + "或 process epoch 变化 cancel in-flight query;(2) FE master
通过心跳把该标记下发"
+ + "给所有 BE,BE 端 FragmentMgr::cancel_worker 也跳过 'Coordinator
restarted' / "
+ + "'Source frontend is not running' cancel。timeout / 用户主动
KILL / pipeline task "
+ + "leak 等其他 cancel 不受影响。滚动结束后必须 SET GLOBAL ... = false
复位。",
+ "Cluster-level switch operators flip on via SET GLOBAL before
performing a rolling restart "
+ + "or graceful shutdown. When true: (1) FE
QueryCancelWorker / Coordinator.shouldCancel "
+ + "skip cancelling in-flight queries on BEs whose
alive=false or process_epoch changed; "
+ + "(2) FE master propagates the flag to all BEs via
heartbeat so that BE "
+ + "FragmentMgr::cancel_worker also skips 'Coordinator
restarted' / 'Source frontend is "
+ + "not running' cancel. Timeout / explicit KILL /
pipeline-task-leak cancels are not "
+ + "affected. Operators MUST flip it back to false after
rolling restart finishes."})
+ public boolean enableGracefulShutdown = false;
Review Comment:
[P1] Publish the cluster flag to runtime readers
SET/replay writes this field while holding `VariableMgr.wlock`, but
heartbeat and cancellation workers read the mutable default `SessionVariable`
directly without acquiring the matching read lock. Because the field is not
`volatile`/atomic, enable or disable has no Java happens-before edge to those
workers. Provide a locked or volatile/atomic cluster-mode accessor and route
all runtime reads through it.
##########
fe/fe-core/src/main/java/org/apache/doris/qe/QeService.java:
##########
@@ -66,7 +66,11 @@ public void start() throws Exception {
LOG.info("QE service start.");
}
- public void stop() {
+ /**
+ * Stop accepting new MySQL and Arrow Flight SQL connections while leaving
+ * existing connections to be drained by the graceful shutdown flow.
+ */
+ public void stopAcceptNewConnections() {
try {
if (flightStarter != null) {
flightStarter.stop();
Review Comment:
[P1] Do not close active Flight calls in the admission phase
`flightStarter.stop()` reaches `FlightServer.close()`. In [Arrow Java
19](https://github.com/apache/arrow-java/blob/v19.0.0/flight/flight-core/src/main/java/org/apache/arrow/flight/FlightServer.java#L145-L163),
`close()` waits only three seconds after shutdown and then calls
`shutdownNow()`, so a Flight query longer than three seconds is cancelled in
phase B despite the comment and the longer phase-C deadline. Separate new-call
rejection from server close, or defer `close()` until phase C completes.
##########
fe/fe-core/src/main/java/org/apache/doris/DorisFE.java:
##########
@@ -722,20 +735,95 @@ public static boolean isServerReady() {
return serverReady.get();
}
+ /**
+ * Returns true once graceful shutdown has entered phase B. New data-plane
+ * MySQL commands (COM_QUERY / COM_STMT_EXECUTE / ...) should be silently
+ * dropped so JDBC drivers fail over to another FE.
+ */
+ public static boolean shouldRejectNewQueries() {
+ return rejectNewQueries.get();
+ }
+
+ /**
+ * Graceful shutdown driven by SIGTERM (typically from K8s). Implements the
+ * A0 → A → B → C → D state machine described in the operator design doc.
+ *
+ * <pre>
+ * A0 serverReady=false → /api/health returns 503 (already done by
caller)
+ * A "drain": sleep graceful_shutdown_drain_seconds while still serving
+ * new queries, to let K8s LB/DNS converge.
+ * B atomic flip: rejectNewQueries=true, then close MySQL/ArrowFlight
+ * listeners, then close idle MySQL connections (FIN to client).
+ * C wait for in-flight Coordinators to finish (≤
grace_shutdown_wait_seconds),
+ * re-running closeIdleMysqlConnections() once per second.
+ * D return; JVM exits after shutdown hook completes.
+ * </pre>
+ */
private static void gracefulShutdown() {
- // wait for all queries to finish
try {
- long now = System.currentTimeMillis();
- List<Coordinator> allCoordinators =
QeProcessorImpl.INSTANCE.getAllCoordinators();
- while (!allCoordinators.isEmpty() && System.currentTimeMillis() -
now < 300 * 1000L) {
+ // === Phase A: drain — absorb K8s LB/DNS convergence gap ===
+ int drainSeconds = Math.max(0,
Config.graceful_shutdown_drain_seconds);
+ LOG.info("graceful shutdown phase A: drain for {}s (health=503 but
still serving)", drainSeconds);
+ for (int i = 0; i < drainSeconds; i++) {
+ Thread.sleep(1000);
+ }
+
+ // === Phase B: atomic flip + close listeners + close idle MySQL
connections ===
+ // Step 1: flip the reject flag FIRST so any thread that wins the
listener
+ // accept race still gets rejected at the dispatch layer.
+ rejectNewQueries.set(true);
+ LOG.info("graceful shutdown phase B step1: rejectNewQueries=true");
+
+ // Step 2: close MySQL / Arrow-Flight listeners. HTTP listener
stays open
+ // until after phase C so /api/health remains reachable for the
probe.
+ if (qeService != null) {
+ LOG.info("graceful shutdown phase B step2: close MySQL /
ArrowFlight listeners");
Review Comment:
[P1] Gate HTTP and Thrift admission in phase B
This closes MySQL/Flight only. The HTTP server and FE Thrift server remain
live throughout phase C, and the reject flag is not checked by HTTP
SQL/stream-load or `loadTxnBegin`/forward endpoints. New work can enter after
an empty coordinator snapshot and be killed when the hook exits. Add one
cross-protocol admission barrier for start/begin operations while still
allowing health and completion/commit traffic for already-admitted work.
##########
fe/fe-core/src/main/java/org/apache/doris/qe/VariableMgr.java:
##########
@@ -540,6 +568,7 @@ public static void
replayGlobalVariableV2(GlobalVarPersistInfo info, boolean fro
try {
setValue(varContext.getObj(), new
SessionVariableField(varContext.getField()),
root.get(varName).toString());
+ applyGlobalVariableSideEffect((String) varName,
root.get(varName).toString());
Review Comment:
[P1] Restore the side effect when loading an image
The checkpoint stores this SessionVariable field through
`SessionVariable.write/readFields`, but the `GlobalVarPersistInfo` replayed
here contains only `GlobalVariable` names. After journals are pruned, an FE can
load `enable_graceful_shutdown=true` while leaving the heartbeat interval at
its configured/default value. Add a serving-image load hook that restores both
the flag and daemon override, and cover an image round trip.
##########
fe/fe-common/src/main/java/org/apache/doris/common/Config.java:
##########
@@ -2110,7 +2124,7 @@ public class Config extends ConfigBase {
* Heartbeat interval in seconds.
* Default is 10, which means every 10 seconds, the master will send a
heartbeat to all backends.
*/
- @ConfField(mutable = false, masterOnly = false)
+ @ConfField(mutable = true, masterOnly = false)
Review Comment:
[P1] Validate the mutable heartbeat interval
Making this field mutable also allows live values of 0 or -1 because no
validator is attached. Zero makes `Daemon.run()` loop heartbeats continuously;
a negative interval makes `Thread.sleep()` throw `IllegalArgumentException`
outside its catch and terminates the master heartbeat thread. Reject
nonpositive and unreasonable values before mutating the config or daemon.
##########
fe/fe-core/src/main/java/org/apache/doris/system/SystemInfoService.java:
##########
@@ -872,6 +881,19 @@ public void updateBackendState(Backend be) {
memoryBe.setDisks(be.getDisks());
memoryBe.setCpuCores(be.getCputCores());
memoryBe.setPipelineExecutorSize(be.getPipelineExecutorSize());
+
+ boolean restartDetected =
Review Comment:
[P1] Invalidate the complete pre-update addresses
Only the old hostname is saved before mutating host, BE port, and BRPC port.
`invalidateLocalConnections` then builds gRPC/Thrift keys from the new fields,
so entries keyed by `(preHost, preBrpcPort)` and `(preHost, preBePort)` survive
indefinitely. Snapshot and evict both complete old addresses as well as the
current ones.
##########
be/src/util/dns_cache.cpp:
##########
@@ -97,6 +108,7 @@ Status DNSCache::_update(const std::string& hostname) {
cache[hostname] = real_ip;
LOG(INFO) << "update hostname " << hostname << "'s ip to " << real_ip;
}
+ dirty.erase(hostname); // here myabe need lock all update function.
Review Comment:
[P1] Do not clear a newer DNS invalidation
A lookup can resolve stale A, `invalidate(host)` can run, and this older
update then publishes A and unconditionally erases the dirty marker. The next
`get()` returns A without re-resolution, so the new invalidation contract is
lost. Capture a per-host generation before resolution and publish/clear only if
no newer invalidation occurred.
##########
fe/fe-core/src/main/java/org/apache/doris/rpc/BackendServiceProxy.java:
##########
@@ -65,6 +68,21 @@ public class BackendServiceProxy {
Config.grpc_threadmgr_threads_nums,
"grpc_thread_pool", true);
+ // Single-threaded scheduler used to *deferred-shutdown* gRPC channels
that were
+ // invalidated by removeProxy / removeProxyForAll on BE restart. We MUST
NOT call
+ // BackendServiceClient.shutdown() inline because it triggers
channel.shutdownNow()
+ // (after a 5s grace) and instantly kills any in-flight RPC on the old
channel with
+ // "UNAVAILABLE: Channel shutdownNow invoked". Instead we drop the client
from the
+ // serviceMap (so new RPCs go to a freshly-created channel pointing at the
new BE IP)
+ // and asynchronously close the old channel after a grace period long
enough for
+ // in-flight RPCs to finish or fail naturally.
+ private static final ScheduledExecutorService deferredShutdownExecutor =
Review Comment:
[P2] Bound the deferred channel-shutdown backlog
This single worker executes `BackendServiceClient.shutdown()`, which can
block for two five-second waits. One backend may enqueue clients from all 48
proxy shards, so the last channel can remain retained roughly eight minutes
beyond the advertised 120-second delay; rolling multiple backends extends the
queue without bound. Use a nonblocking/two-stage close or bounded concurrent
teardown with per-client deduplication.
##########
be/src/util/brpc_client_cache.h:
##########
@@ -166,66 +218,116 @@ class BrpcClientCache {
}
std::shared_ptr<T> get_client(const std::string& host, int port) {
- std::string realhost = host;
- auto dns_cache = ExecEnv::GetInstance()->dns_cache();
- if (dns_cache == nullptr) {
- LOG(WARNING) << "DNS cache is not initialized, skipping hostname
resolve";
- } else if (!is_valid_ip(host)) {
- Status status = dns_cache->get(host, &realhost);
- if (!status.ok()) {
- LOG(WARNING) << "failed to get ip from host:" <<
status.to_string();
- return nullptr;
- }
- }
-
- // Use original host:port as key (like Java's TNetworkAddress address)
- // This allows us to detect IP changes when DNS resolution changes
- std::string host_port = fmt::format("{}:{}", host, port);
+ // The handshake-and-retry path is only enabled while the cluster-level
+ // graceful shutdown flag is on (operator did
+ // `SET GLOBAL enable_graceful_shutdown=true` before rolling restart).
+ // Outside of rolling restart, get_client stays one-shot.
+ return get_client(host, port, doris::k_in_graceful_shutdown);
+ }
- std::shared_ptr<T> stub_ptr;
- bool need_remove = false;
-
- auto check_entry = [&](const auto& v) {
- const StubEntry<T>& entry = v.second;
- // Check if cached IP matches current resolved IP
- if (entry.real_ip != realhost) {
- // IP changed (DNS resolution changed)
- LOG(WARNING) << "Cached ip changed for " << host << ", before
ip: " << entry.real_ip
- << ", current ip: " << realhost;
- need_remove = true;
- } else if
(!static_cast<FailureDetectChannel*>(entry.stub->channel())
- ->channel_status()
- ->ok()) {
- // Client is not in normal state, need to recreate
- // At this point we cannot judge the progress of reconnecting
the underlying channel.
- // In the worst case, it may take two minutes. But we can't
stand the connection refused
- // for two minutes, so rebuild the channel directly.
- need_remove = true;
- } else {
- // Cache hit: IP matches and client is healthy
- stub_ptr = entry.stub;
+ // Explicit-override entry point. Pass `enable_handshake=true` to force the
+ // handshake-and-retry path (paid only on cache miss / rebuild — warm cache
+ // hits stay free). Pass `false` to disable. Max attempts is 5.
+ std::shared_ptr<T> get_client(const std::string& host, int port, bool
enable_handshake) {
+ const int max_attempts = enable_handshake ? 5 : 1;
+ std::string host_port;
+ for (int attempt = 1; attempt <= max_attempts; ++attempt) {
+ // Progressive backoff before retries so Kubernetes DNS/endpoints
+ // have more time to converge during graceful restart.
+ if (enable_handshake && attempt > 1) {
+ bthread_usleep(1000000 * (attempt - 1));
}
- };
-
- if (LIKELY(_stub_map.if_contains(host_port, check_entry))) {
- if (stub_ptr != nullptr) {
- return stub_ptr;
+ std::string realhost = host;
+ auto dns_cache = ExecEnv::GetInstance()->dns_cache();
+ if (dns_cache == nullptr) {
+ LOG(WARNING) << "DNS cache is not initialized, skipping
hostname resolve";
+ } else if (!is_valid_ip(host)) {
+ Status status = dns_cache->get(host, &realhost);
+ if (!status.ok()) {
+ LOG(WARNING) << "failed to get ip from host: " <<
status.to_string()
+ << ", attempt=" << attempt << "/" <<
max_attempts;
+ if (enable_handshake && attempt < max_attempts) {
+ continue;
+ }
+ return nullptr;
+ }
}
- // IP changed or client unhealthy, need to remove old entry
- if (need_remove) {
+ // Keep the original hostname as the cache key so a DNS answer
+ // change can replace the cached channel for the same logical peer.
+ host_port = fmt::format("{}:{}", host, port);
+
+ std::shared_ptr<T> stub_ptr;
+ bool need_remove = false;
+ auto check_entry = [&](const auto& v) {
+ const StubEntry<T>& entry = v.second;
+ if (entry.real_ip != realhost) {
+ LOG(WARNING) << "Cached ip changed for " << host
+ << ", before ip: " << entry.real_ip
+ << ", current ip: " << realhost;
+ need_remove = true;
+ } else if
(!static_cast<FailureDetectChannel*>(entry.stub->channel())
+ ->channel_status()
+ ->ok()) {
+ need_remove = true;
+ } else {
+ stub_ptr = entry.stub;
+ }
+ };
+ if (LIKELY(_stub_map.if_contains(host_port, check_entry))) {
+ if (stub_ptr != nullptr) {
+ // When enable_handshake is on (during graceful shutdown),
+ // verify the cached channel is still reachable before
+ // returning it. Otherwise a cached stub pointing to an
+ // expired Pod IP will fail on the first real RPC.
+ if (enable_handshake && !available(stub_ptr, host_port)) {
Review Comment:
[P2] Keep healthy warm-cache hits free of RPCs
While the cluster-wide flag is enabled, every healthy cache hit performs a
synchronous `hand_shake` before returning, even though the overload contract
says this cost is paid only on miss/rebuild. Query and load setup fan out
through this cache, so a rolling restart adds a network round trip to every
acquisition. Probe only rebuilt/faulted generations or use a bounded
asynchronous health signal.
##########
fe/fe-core/src/main/java/org/apache/doris/DorisFE.java:
##########
@@ -722,20 +735,95 @@ public static boolean isServerReady() {
return serverReady.get();
}
+ /**
+ * Returns true once graceful shutdown has entered phase B. New data-plane
+ * MySQL commands (COM_QUERY / COM_STMT_EXECUTE / ...) should be silently
+ * dropped so JDBC drivers fail over to another FE.
+ */
+ public static boolean shouldRejectNewQueries() {
+ return rejectNewQueries.get();
+ }
+
+ /**
+ * Graceful shutdown driven by SIGTERM (typically from K8s). Implements the
+ * A0 → A → B → C → D state machine described in the operator design doc.
+ *
+ * <pre>
+ * A0 serverReady=false → /api/health returns 503 (already done by
caller)
+ * A "drain": sleep graceful_shutdown_drain_seconds while still serving
+ * new queries, to let K8s LB/DNS converge.
+ * B atomic flip: rejectNewQueries=true, then close MySQL/ArrowFlight
+ * listeners, then close idle MySQL connections (FIN to client).
+ * C wait for in-flight Coordinators to finish (≤
grace_shutdown_wait_seconds),
+ * re-running closeIdleMysqlConnections() once per second.
+ * D return; JVM exits after shutdown hook completes.
+ * </pre>
+ */
private static void gracefulShutdown() {
- // wait for all queries to finish
try {
- long now = System.currentTimeMillis();
- List<Coordinator> allCoordinators =
QeProcessorImpl.INSTANCE.getAllCoordinators();
- while (!allCoordinators.isEmpty() && System.currentTimeMillis() -
now < 300 * 1000L) {
+ // === Phase A: drain — absorb K8s LB/DNS convergence gap ===
+ int drainSeconds = Math.max(0,
Config.graceful_shutdown_drain_seconds);
+ LOG.info("graceful shutdown phase A: drain for {}s (health=503 but
still serving)", drainSeconds);
+ for (int i = 0; i < drainSeconds; i++) {
+ Thread.sleep(1000);
+ }
+
+ // === Phase B: atomic flip + close listeners + close idle MySQL
connections ===
+ // Step 1: flip the reject flag FIRST so any thread that wins the
listener
+ // accept race still gets rejected at the dispatch layer.
+ rejectNewQueries.set(true);
+ LOG.info("graceful shutdown phase B step1: rejectNewQueries=true");
+
+ // Step 2: close MySQL / Arrow-Flight listeners. HTTP listener
stays open
+ // until after phase C so /api/health remains reachable for the
probe.
+ if (qeService != null) {
+ LOG.info("graceful shutdown phase B step2: close MySQL /
ArrowFlight listeners");
+ qeService.stopAcceptNewConnections();
+ }
+
+ // Step 3: send FIN to currently-idle (COM_SLEEP) MySQL
connections so
+ // pooled JDBC clients notice and reconnect to another FE.
+ LOG.info("graceful shutdown phase B step3: close idle mysql
connections");
+ closeIdleMysqlConnections();
+
+ // === Phase C: wait for in-flight Coordinators to drain ===
+ long deadline = System.currentTimeMillis() + Math.max(0,
Config.grace_shutdown_wait_seconds) * 1000L;
+ LOG.info("graceful shutdown phase C: wait up to {}s for in-flight
queries to finish",
+ Config.grace_shutdown_wait_seconds);
+ while (System.currentTimeMillis() < deadline) {
+ List<Coordinator> allCoordinators =
QeProcessorImpl.INSTANCE.getAllCoordinators();
Review Comment:
[P1] Drain active requests outside the coordinator map
`getAllCoordinators()` is not a quiescence barrier. A request can pass the
phase-B gate, remain in parsing/planning before `StmtExecutor.registerQuery()`,
or execute wholly in FE without ever entering this map; this empty snapshot
then ends phase C and exits the JVM underneath it. After closing every
admission gate, wait on request-lifecycle accounting that spans admission
through pre-registration/FE-local work and coordinator completion.
--
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]