This is an automated email from the ASF dual-hosted git repository.
gavinchou pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new ed735be66ef [fix](cloud) Clean stale routes when replaying replica
updates and before saving checkpoint images (#67636)
ed735be66ef is described below
commit ed735be66efbef4c94abc9a2498321a22237a07d
Author: deardeng <[email protected]>
AuthorDate: Wed Oct 14 15:16:44 2026 +0800
[fix](cloud) Clean stale routes when replaying replica updates and before
saving checkpoint images (#67636)
Problem Summary:
A CloudReplica keeps one route entry per compute group. When a compute
group is dropped, the master sweeps the stale entries out of its own
heap in CloudTabletRebalancer, but that sweep writes no edit log. A
follower only replays OP_UPDATE_CLOUD_REPLICA, which adds or overwrites
the route of the compute group named in the log and never removes
anything, so on a follower the route maps only ever grow. With a fixed
set of replicas and repeated compute group create/drop rounds, the
follower's route entries grow linearly with the number of rounds while
the master stays flat.
The generated checkpoint image has a related but separate gap. The
checkpoint Env does loadImage -> replayJournal -> post-processing ->
saveImage. gsonPostProcess() cleans what loadImage deserializes, but
nothing cleans what the replay window then adds, and
postProcessCloudMetadata() only copies tablet stats. So every image is
written with the stale routes accumulated since the previous image.
An earlier version of this PR fixed both by calling
CloudReplica.removeInvalidRoutes() once per replayed
UpdateCloudReplicaInfo journal entry, plus once before saving a
checkpoint image. That works, but on a large catalog (tens of millions
of tablets, many compute groups) it means every single route-update
journal entry re-scans that replica's whole route map, which adds up
across a replay batch and slows down replay itself -- the thing route
cleanup should never be on the critical path for.
This revision keeps the checkpoint-time sweep but replaces the
per-journal-entry replay cleanup with a coalesced sweep over the active
catalog, run at most once per replay batch, and adds an equivalent sweep
to the master's rebalancer round (previously that sweep was piggybacked
on the rebalancer's per-compute-group replica loop and had no way to run
when the cluster's live compute-group set had just gone empty). It also
fixes a correctness gap in the original per-journal-entry design: a
journal written before a backend was dropped can be replayed after the
drop, and the original code published that route unconditionally before
the next cleanup pass could catch it, which could momentarily discard an
already-valid secondary route for the same compute group.
Three independent sweep triggers, all reusing
CloudInternalCatalog.removeInvalidCloudReplicaRoutes(SystemInfoService)
-- an instance method that walks this catalog's own
db/table/partition/index(ALL, including shadow indexes)/tablet/replica
tree and calls CloudReplica.removeInvalidRoutes(systemInfo) per replica:
1. CloudEnv.replayJournal() calls it once per replay batch, gated on
CloudSystemInfoService.replayBackendRemovalVersion having changed since
the last sweep (a counter bumped only by replayDropBackend()). Additions
alone never strand a route, so they don't trigger a sweep. The very
first replay after startup always sweeps once regardless of the counter.
A failed sweep is retried on the next batch rather than being silently
dropped. The checkpoint thread's own replayJournal() call skips this
path entirely (Checkpoint.doCheckpoint() sweeps explicitly after replay
finishes, see 3 below).
2. CloudTabletRebalancer.completeRouteInfo() sweeps once per round when
the live backend set has shrunk since the last sweep, independent of the
per-compute-group replica loop (the old code piggybacked the sweep on
that loop by pinning it to one arbitrary compute group, which meant a
topology with no live compute groups could never trigger a sweep at
all). It runs for two consecutive rounds after a drop is detected, not
one: a query thread can pick a backend in hashReplicaToBe() just before
the drop and publish the route in getBackendIdImpl() after this round's
pass has already visited that replica, so the extra round catches routes
published while the first one was still in flight. This sweep can now
also be confined to a configurable daily time window
(cloud_tablet_rebalancer_stale_route_clean_start_time /
cloud_tablet_rebalancer_stale_route_clean_end_time, HH:mm, both default
to "00:00" which is unrestricted); outside the window the
pending-sweep-round counter is left untouched rather than drained, so a
drop detected outside the window still gets its two rounds once the
window opens.
3. Checkpoint.doCheckpoint() still sweeps the checkpoint Env's own
catalog against its own backend set after replay finishes and before
saveImage(), now via an explicit SystemInfoService parameter
(env.getClusterInfo()) threaded down through the whole call chain
instead of being resolved internally via
Env.getCurrentSystemInfo()/Env.getCurrentEnv(). This removes the
implicit dependency on the checkpoint thread's identity to resolve the
right Env, and keeps the catalog being swept and the backend set
deciding staleness from ever being able to come from two different Envs.
Late-journal protection:
CloudReplica.replayUpdateClusterToPrimaryBe(clusterId, beId, systemInfo)
replaces the old unconditional updateClusterToPrimaryBe() +
removeInvalidRoutes() pair in
CloudInternalCatalog.unprotectUpdateCloudReplica(). Before publishing a
replayed primary-route update, it checks whether the target backend is
still registered; if not, the update is dropped instead of being
published and then possibly torn down by the next sweep, which
previously had a window where it could also clear an already-valid
secondary route for the same compute group before the primary write was
reconciled.
FrontendServiceImpl.getTabletReplicaInfos()'s lazy peer-discovery path
now collects secondary backends via CloudReplica.getAllSecondaryBes()
(which scans secondaryClusterToBackends directly) instead of only
looking up a secondary for compute groups that still have a primary key
present. A secondary-only route (primary key already swept, secondary
still valid) was previously invisible to this path.
The journal protocol is unchanged: no new opcode, no new field, no
delete event, and no change to the meaning of existing logs.
Existing cleanup semantics are reused as-is:
enable_cloud_replica_stale_route_clean still gates every sweep call
site, staleness is still decided by whether the backend is registered in
the relevant Env (not by isAlive() or heartbeat), a dead primary whose
compute group still has a live secondary is still kept for the failover
and peer cache paths, and removal is still the conditional remove(key,
expectedValue).
Boundaries, deliberately not addressed here:
- Cleanup remains a synchronous full-catalog scan at each trigger point;
budgeted/incremental scheduling is deferred to a follow-up PR and should
be based on measured replay latency at production tablet-count scale,
not assumed here.
- The rebalancer's sweep only runs on the master; on a Follower, only
the replay-batch sweep and image-load's gsonPostProcess() reclaim
routes. That asymmetry is unchanged from before this PR.
- CatalogRecycleBin is intentionally out of scope: this PR only sweeps
the active catalog. A recycled table or partition's routes are reclaimed
through the normal recycle-bin lifecycle (erase, or restored and picked
up by a later active-catalog sweep after RECOVER), not by this sweep.
- A backend id that still exists but now belongs to a different compute
group is not detected, as before.
### Test
- run-fe-ut.sh across CloudEnvRouteCleanupTest, CloudReplicaTest,
CloudTabletRebalancerTest, CloudInternalCatalogTest, CheckpointTest,
FrontendServiceImplCloudTest, CatalogRecycleBinTest and RecoverTest: 106
tests, 0 failures, 0 errors. FE build with checkstyle passes with 0
violations (fe-common and fe-core).
- CloudEnvRouteCleanupTest (new): coalescing multiple backend-removal
journals into one sweep per batch, sweeping once on startup and once
immediately after re-enabling the config, retrying a sweep that threw,
and skipping the sweep entirely on the checkpoint thread.
- CloudInternalCatalogTest: replaying a route update no longer scans or
removes other routes itself (that responsibility moved to the batch
sweep); a late journal to an already-dropped backend does not clear an
existing valid secondary route.
- CheckpointTest: stale route dropped while a live route is kept; a dead
primary with a live secondary is kept; shadow-index and
temporary-partition replicas are swept; a recycled table/partition's
replicas are not swept and CatalogRecycleBin is never touched; cleanup
disabled is a no-op; a new case asserts the sweep judges backend
liveness against the checkpoint Env's own backend set, not the serving
Env's, by giving the two a different opinion on the same backend id and
checking which one wins.
- CloudTabletRebalancerTest: the sweep now runs independent of the
per-compute-group loop, including when the live compute-group set is
empty; the two-round-after-a-drop behavior; the new HH:mm time window,
including the midnight-wrapping case and that pending sweep rounds are
preserved (not drained) while outside the window.
- FrontendServiceImplCloudTest (new case): a secondary-only route (no
primary key present for that compute group) is still returned by the
lazy peer-discovery path.
- Not covered by tests: no multi-FE cluster regression, and no
large-scale (tens-of-millions-of-tablets) performance run reproducing
the original replay-latency numbers.
### Release note
Coalesce stale CloudReplica route cleanup to run once per replay batch
(and once per rebalancer round, now with an optional daily time window)
instead of on every route-update journal entry, and stop a late journal
from clearing an already-valid secondary route.
---
.../main/java/org/apache/doris/common/Config.java | 21 +-
.../org/apache/doris/cloud/catalog/CloudEnv.java | 27 ++
.../apache/doris/cloud/catalog/CloudReplica.java | 58 ++++-
.../doris/cloud/catalog/CloudTabletRebalancer.java | 72 ++++--
.../cloud/datasource/CloudInternalCatalog.java | 92 ++++++-
.../doris/cloud/system/CloudSystemInfoService.java | 8 +
.../java/org/apache/doris/master/Checkpoint.java | 19 ++
.../apache/doris/service/FrontendServiceImpl.java | 7 +-
.../cloud/catalog/CloudEnvRouteCleanupTest.java | 145 +++++++++++
.../doris/cloud/catalog/CloudReplicaTest.java | 6 +-
.../cloud/catalog/CloudTabletRebalancerTest.java | 119 ++++++++-
.../cloud/datasource/CloudInternalCatalogTest.java | 184 ++++++++++++++
.../org/apache/doris/master/CheckpointTest.java | 276 +++++++++++++++++++++
.../service/FrontendServiceImplCloudTest.java | 35 +++
14 files changed, 1019 insertions(+), 50 deletions(-)
diff --git a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
index 487efc4114d..076723aa8ac 100644
--- a/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
+++ b/fe/fe-common/src/main/java/org/apache/doris/common/Config.java
@@ -3571,11 +3571,26 @@ public class Config extends ConfigBase {
@ConfField(mutable = true, masterOnly = false,
description = "Whether to drop the primary/secondary route entries
of a CloudReplica whose backend no "
- + "longer exists, when loading the image and in the tablet
rebalancer round. Those entries are "
- + "already ignored at query time (the replica is
rehashed), so they only waste FE memory and "
- + "image size. Set to false to keep the legacy leaking
behavior. Default is true.")
+ + "longer exists, when loading the image, in the tablet
rebalancer round, and once per replayed "
+ + "batch of backend-removal journals (on Followers too,
since each FE applies this locally). "
+ + "Those entries are already ignored at query time (the
replica is rehashed), so they only "
+ + "waste FE memory and image size. Set to false to keep
the legacy leaking behavior. "
+ + "Default is true.")
public static boolean enable_cloud_replica_stale_route_clean = true;
+ @ConfField(mutable = true, masterOnly = true, description = "Start time of
the window in which the cloud "
+ + "tablet rebalancer is allowed to sweep stale CloudReplica
routes, in HH:mm format. Used with "
+ + "`cloud_tablet_rebalancer_stale_route_clean_end_time` to decide
the window. Only gates the "
+ + "rebalancer's own sweep -- image loading and replayed
backend-removal batches always clean "
+ + "regardless of this window. If set to the same value as the end
time (the default), the "
+ + "rebalancer sweeps any time it would otherwise sweep, i.e. the
window is unrestricted.")
+ public static String cloud_tablet_rebalancer_stale_route_clean_start_time
= "00:00";
+
+ @ConfField(mutable = true, masterOnly = true, description = "End time of
the window in which the cloud "
+ + "tablet rebalancer is allowed to sweep stale CloudReplica
routes, in HH:mm format. See "
+ + "`cloud_tablet_rebalancer_stale_route_clean_start_time`.")
+ public static String cloud_tablet_rebalancer_stale_route_clean_end_time =
"00:00";
+
@ConfField(mutable = false, masterOnly = true,
description = "Whether to use rendezvous hashing for colocate
bucket placement in cloud mode. If false, "
+ "use the legacy modulo placement. Restart-only.")
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudEnv.java
b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudEnv.java
index 85103b73487..681283576a6 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudEnv.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudEnv.java
@@ -94,6 +94,33 @@ public class CloudEnv extends Env {
private String clusterSnapshotFile;
+ private boolean replayRoutesCleaned;
+ private long cleanedBackendRemovalVersion;
+
+ @Override
+ public synchronized boolean replayJournal(long toJournalId) {
+ boolean hasLog = super.replayJournal(toJournalId);
+ // Checkpoint sweeps after postProcessCloudMetadata(), immediately
before saving its image.
+ if (Env.isCheckpointThread()) {
+ return hasLog;
+ }
+ if (!Config.enable_cloud_replica_stale_route_clean) {
+ replayRoutesCleaned = false;
+ return hasLog;
+ }
+ CloudSystemInfoService systemInfo = (CloudSystemInfoService)
getClusterInfo();
+ long removalVersion = systemInfo.getReplayBackendRemovalVersion();
+ if (!replayRoutesCleaned || removalVersion !=
cleanedBackendRemovalVersion) {
+ // ponytail: one synchronous scan per affected batch; budgeted
cleanup belongs in a follow-up.
+ // Pass this CloudEnv's own catalog and backend set together so
the sweep can never judge one
+ // Env's replicas against another Env's backends, regardless of
which thread calls replayJournal.
+ ((CloudInternalCatalog)
getInternalCatalog()).removeInvalidCloudReplicaRoutes(systemInfo);
+ cleanedBackendRemovalVersion = removalVersion;
+ replayRoutesCleaned = true;
+ }
+ return hasLog;
+ }
+
public CloudEnv(boolean isCheckpointCatalog) {
super(isCheckpointCatalog);
this.cleanCopyJobScheduler = new CleanCopyJobScheduler();
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudReplica.java
b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudReplica.java
index 3d6628985c8..a80df69a579 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudReplica.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudReplica.java
@@ -416,8 +416,11 @@ public class CloudReplica extends Replica implements
GsonPostProcessable {
if (!Config.enable_cloud_replica_stale_route_clean) {
return true;
}
- if (Env.getCurrentSystemInfo().getBackend(beId) == null) {
- removeInvalidRoutes();
+ // Query-path self-check, not part of the sweep/replay chain: this
always runs on whatever thread
+ // is serving the request, never the checkpoint thread, so
Env.getCurrentSystemInfo() is correct here.
+ SystemInfoService systemInfo = Env.getCurrentSystemInfo();
+ if (systemInfo.getBackend(beId) == null) {
+ removeInvalidRoutes(systemInfo);
return false;
}
return true;
@@ -646,13 +649,17 @@ public class CloudReplica extends Replica implements
GsonPostProcessable {
* compute groups that currently exist -- so entries of dropped compute
groups pile up forever, both
* in FE heap and in the image (the `bes`/`be` field).
*
+ * @param systemInfo the backend set to judge staleness against. Callers
must pass the SystemInfoService
+ * of the same Env whose catalog this replica belongs
to (the serving Env during normal
+ * replay/rebalancing, or the checkpoint's private Env
during image generation) -- never
+ * resolve it internally via
Env.getCurrentSystemInfo(), since that depends on which
+ * thread happens to be calling and would silently
judge the wrong Env's replicas.
* @return how many entries were dropped
*/
- public int removeInvalidRoutes() {
+ public int removeInvalidRoutes(SystemInfoService systemInfo) {
if (!Config.enable_cloud_replica_stale_route_clean ||
FeConstants.runningUnitTest) {
return 0;
}
- SystemInfoService systemInfo = Env.getCurrentSystemInfo();
// Remove conditionally rather than by predicate: route writers run
concurrently, so comparing map
// sizes before and after would mix their insertions into the count
(and could even report a
// negative one), and a key whose value was just rewritten to a live
backend must not be dropped.
@@ -664,18 +671,24 @@ public class CloudReplica extends Replica implements
GsonPostProcessable {
if
(systemInfo.getBackendByIdWithBoxedId(entry.getValue().key()) == null
&& secondaryClusterToBackends.remove(entry.getKey(),
entry.getValue())) {
removed++;
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("removed stale secondary route, clusterId
{}, beId {}, replica info {}",
+ entry.getKey(), entry.getValue().key(), this);
+ }
}
}
}
- // Keep a dead primary whose compute group still has a live secondary.
With
- // enable_immediate_be_assign=false that is the normal failover state,
and the lazy fetch path in
- // FrontendServiceImpl.getTabletReplicaInfos() enumerates secondaries
through the primary key set,
- // so dropping the key would hide a live secondary from the peer cache
candidates.
+ // Preserve the primary marker for the normal failover state with a
live secondary.
+ // Peer discovery enumerates secondaries independently: publication
can race with this check.
for (Map.Entry<String, Long> entry :
primaryClusterToBackend.entrySet()) {
if (systemInfo.getBackendByIdWithBoxedId(entry.getValue()) == null
&& !secondaryClusterToBackends.containsKey(entry.getKey())
&& primaryClusterToBackend.remove(entry.getKey(),
entry.getValue())) {
removed++;
+ if (LOG.isDebugEnabled()) {
+ LOG.debug("removed stale primary route, clusterId {}, beId
{}, replica info {}",
+ entry.getKey(), entry.getValue(), this);
+ }
}
}
return removed;
@@ -683,7 +696,6 @@ public class CloudReplica extends Replica implements
GsonPostProcessable {
/**
* Returns the set of compute group IDs that have primary backends for
this replica.
- * Used by lazy fetch path to also collect secondary backends per compute
group.
*/
public Set<String> getPrimaryComputeGroupIds() {
return primaryClusterToBackend.keySet();
@@ -708,6 +720,32 @@ public class CloudReplica extends Replica implements
GsonPostProcessable {
}
}
+ public List<Backend> getAllSecondaryBes() {
+ List<Backend> result = new ArrayList<>();
+ SystemInfoService systemInfo = Env.getCurrentSystemInfo();
+ for (Pair<Long, Long> route : secondaryClusterToBackends.values()) {
+ Backend backend =
systemInfo.getBackendByIdWithBoxedId(route.first);
+ if (backend != null) {
+ result.add(backend);
+ }
+ }
+ return result;
+ }
+
+ /**
+ * @param systemInfo the backend set to judge staleness against -- see
{@link #removeInvalidRoutes} for why
+ * this must come from the caller's Env rather than
Env.getCurrentSystemInfo().
+ */
+ public void replayUpdateClusterToPrimaryBe(String clusterId, long beId,
SystemInfoService systemInfo) {
+ // A journal queued before backend deletion can arrive after the
deletion was replayed.
+ // Do not publish that route, or clear a live secondary while
rejecting it.
+ if (Config.enable_cloud_replica_stale_route_clean &&
!FeConstants.runningUnitTest
+ && systemInfo.getBackend(beId) == null) {
+ return;
+ }
+ updateClusterToPrimaryBe(clusterId, beId);
+ }
+
public List<Backend> getAllPrimaryBes() {
List<Backend> result = new ArrayList<Backend>();
if (isColocated()) {
@@ -758,7 +796,7 @@ public class CloudReplica extends Replica implements
GsonPostProcessable {
// The backends module is loaded before db/recycleBin
(PersistMetaModules.MODULE_NAMES), and the
// checkpoint thread resolves Env.getCurrentEnv() to its own Env, so
the backend set read here is
// the one belonging to the image being loaded.
- removeInvalidRoutes();
+ removeInvalidRoutes(Env.getCurrentSystemInfo());
ConcurrentHashMap<String, Long> sharedRoutes = new
ConcurrentHashMap<>(primaryClusterToBackend.size());
primaryClusterToBackend.forEach((clusterId, backendId) ->
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudTabletRebalancer.java
b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudTabletRebalancer.java
index 9e92587da5e..ba26adbcc50 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudTabletRebalancer.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/cloud/catalog/CloudTabletRebalancer.java
@@ -28,6 +28,7 @@ import org.apache.doris.catalog.Table;
import org.apache.doris.catalog.Tablet;
import org.apache.doris.catalog.TabletMeta;
import org.apache.doris.catalog.TabletSlidingWindowAccessStats;
+import org.apache.doris.cloud.datasource.CloudInternalCatalog;
import org.apache.doris.cloud.persist.UpdateCloudReplicaInfo;
import org.apache.doris.cloud.proto.Cloud;
import org.apache.doris.cloud.qe.ComputeGroupException;
@@ -40,6 +41,7 @@ import org.apache.doris.common.ThreadPoolManager;
import org.apache.doris.common.UserException;
import org.apache.doris.common.util.DebugPointUtil;
import org.apache.doris.common.util.MasterDaemon;
+import org.apache.doris.common.util.TimeUtils;
import org.apache.doris.metric.MetricRepo;
import org.apache.doris.rpc.RpcException;
import org.apache.doris.service.FrontendOptions;
@@ -60,6 +62,9 @@ import lombok.Getter;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
@@ -86,6 +91,7 @@ public class CloudTabletRebalancer extends MasterDaemon {
private static final int MAX_GLOBAL_TABLET_SET_INITIAL_CAPACITY = 1 << 16;
private static final Function<Long, Set<Long>>
DEFAULT_GLOBAL_TABLET_SET_FACTORY =
ignored -> ConcurrentHashMap.newKeySet();
+ private static final DateTimeFormatter STALE_ROUTE_CLEAN_TIME_FORMAT =
DateTimeFormatter.ofPattern("HH:mm");
private final CloudTabletRebalancerMetrics rebalancerMetrics;
private long currentRoundTabletScanCount;
@@ -1029,24 +1035,62 @@ public class CloudTabletRebalancer extends MasterDaemon
{
// baseline. Advancing only after a sweep would leave the baseline at
the pre-addition set, and
// dropping that same backend later would compare equal to it and go
unnoticed.
lastSweptBackends = currentBes;
- if (pendingSweepRounds > 0) {
+ // Outside the configured window, leave pendingSweepRounds untouched
rather than draining it: a
+ // backend that goes away outside the window must still get its two
rounds once the window
+ // opens, not lose them to rounds that never actually swept.
+ if (pendingSweepRounds > 0 && isStaleRouteCleanTimeAllowed()) {
pendingSweepRounds--;
return true;
}
return false;
}
+ /**
+ * Whether the configured cleanup window
(cloud_tablet_rebalancer_stale_route_clean_start_time to
+ * ..._end_time) contains the current time. Equal start/end -- including
the "00:00"/"00:00" default --
+ * means unrestricted, matching the pre-existing behavior of sweeping
whenever staleRouteSweepNeeded()
+ * says a sweep is due. An unparseable configuration also falls back to
unrestricted rather than
+ * silently disabling cleanup.
+ */
+ @VisibleForTesting
+ boolean isStaleRouteCleanTimeAllowed() {
+ LocalTime start =
parseCleanTime(Config.cloud_tablet_rebalancer_stale_route_clean_start_time);
+ LocalTime end =
parseCleanTime(Config.cloud_tablet_rebalancer_stale_route_clean_end_time);
+ if (start == null || end == null || start.equals(end)) {
+ return true;
+ }
+ return isWithinWindow(LocalTime.now(TimeUtils.getDorisZoneId()),
start, end);
+ }
+
+ @VisibleForTesting
+ static boolean isWithinWindow(LocalTime now, LocalTime start, LocalTime
end) {
+ if (start.isBefore(end)) {
+ return !now.isBefore(start) && !now.isAfter(end);
+ }
+ // across midnight, e.g. 23:00 - 06:00
+ return !now.isBefore(start) || !now.isAfter(end);
+ }
+
+ private static LocalTime parseCleanTime(String time) {
+ try {
+ return LocalTime.parse(time, STALE_ROUTE_CLEAN_TIME_FORMAT);
+ } catch (DateTimeParseException e) {
+ LOG.warn("invalid cloud_tablet_rebalancer_stale_route_clean time:
{}", time);
+ return null;
+ }
+ }
+
private boolean completeRouteInfo() {
List<UpdateCloudReplicaInfo> updateReplicaInfos = new
ArrayList<UpdateCloudReplicaInfo>();
long[] assignedErrNum = {0L};
- long[] staleRouteNum = {0L};
boolean sweepStaleRoutes = staleRouteSweepNeeded(allBes);
- // loopCloudReplica() has the compute group loop innermost, so it
hands us every replica once per
- // live compute group, while removeInvalidRoutes() scans the whole
route map and does not care
- // which group we are on. Pin the sweep to one arbitrary group id so a
sweeping round still makes a
- // single pass per replica. If clusterToBes is empty the callback
never runs at all, so the serving
- // catalog keeps the entries until it reloads the image -- there is
nothing to route in that state.
- String sweepTicket = sweepStaleRoutes ?
clusterToBes.keySet().stream().findFirst().orElse(null) : null;
+ // Cleanup is independent of compute groups and includes shadow
indices of active tables.
+ // Take the catalog and backend set from the same Env so the sweep can
never mix the two.
+ Env currentEnv = Env.getCurrentEnv();
+ long staleRouteNum = sweepStaleRoutes
+ ? ((CloudInternalCatalog) currentEnv.getInternalCatalog())
+
.removeInvalidCloudReplicaRoutes(currentEnv.getClusterInfo())
+ : 0;
long needRehashDeadTime = System.currentTimeMillis() -
Config.rehash_tablet_after_be_dead_seconds * 1000L;
loopCloudReplica((Database db, Table table, Partition partition,
MaterializedIndex index, String cluster) -> {
boolean assigned = false;
@@ -1058,16 +1102,6 @@ public class CloudTabletRebalancer extends MasterDaemon {
for (Tablet tablet : tablets) {
for (Replica r : tablet.getReplicas()) {
CloudReplica replica = (CloudReplica) r;
- // Drop routes of compute groups that no longer exist;
gsonPostProcess() only converges
- // the catalog on image load, so without this the leader
keeps them until it restarts.
- // No edit log op is written for the removal: the entries
are already unroutable, and
- // the image is written by the master-only checkpoint,
whose Env cleans the catalog it
- // loads, so no leader/follower difference ever reaches
persisted state. Note this
- // daemon is master-only, so a follower keeps its own
stale entries in heap until it
- // restarts or is promoted and runs a round here.
- if (cluster.equals(sweepTicket)) {
- staleRouteNum[0] += replica.removeInvalidRoutes();
- }
// clean secondary map
replica.checkAndClearSecondaryClusterToBe(cluster,
needRehashDeadTime);
// colocate table no need to update primary backends
@@ -1137,7 +1171,7 @@ public class CloudTabletRebalancer extends MasterDaemon {
});
LOG.info("collect to editlog route {} infos, error num {}, swept stale
routes {}, entries dropped {}",
- updateReplicaInfos.size(), assignedErrNum[0],
sweepStaleRoutes, staleRouteNum[0]);
+ updateReplicaInfos.size(), assignedErrNum[0],
sweepStaleRoutes, staleRouteNum);
if (updateReplicaInfos.isEmpty()) {
return true;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cloud/datasource/CloudInternalCatalog.java
b/fe/fe-core/src/main/java/org/apache/doris/cloud/datasource/CloudInternalCatalog.java
index 95228b01c14..574c0df7d88 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/cloud/datasource/CloudInternalCatalog.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/cloud/datasource/CloudInternalCatalog.java
@@ -62,6 +62,7 @@ import org.apache.doris.cloud.system.CloudSystemInfoService;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.Config;
import org.apache.doris.common.DdlException;
+import org.apache.doris.common.FeConstants;
import org.apache.doris.common.MetaNotFoundException;
import org.apache.doris.common.util.ColumnsUtil;
import org.apache.doris.common.util.PropertyAnalyzer;
@@ -74,6 +75,7 @@ import org.apache.doris.proto.Types;
import org.apache.doris.qe.ConnectContext;
import org.apache.doris.rpc.RpcException;
import org.apache.doris.service.FrontendOptions;
+import org.apache.doris.system.SystemInfoService;
import org.apache.doris.thrift.TCompressionType;
import org.apache.doris.thrift.TInvertedIndexFileStorageFormat;
import org.apache.doris.thrift.TSortType;
@@ -1395,6 +1397,83 @@ public class CloudInternalCatalog extends
InternalCatalog {
}
}
+ // The three private helpers below are each independently callable (one
per catalog level), so this
+ // guard is checked in every one of them rather than once at the top -- a
config flip mid-sweep then
+ // takes effect at the next db/table/partition boundary instead of only on
the next whole-catalog call.
+ private static boolean routeCleanupDisabled() {
+ return Config.isNotCloudMode() ||
!Config.enable_cloud_replica_stale_route_clean
+ || FeConstants.runningUnitTest;
+ }
+
+ /**
+ * @param systemInfo the backend set to judge staleness against. Must come
from the same Env this
+ * catalog belongs to (the serving Env during
replay/rebalancing, or the checkpoint's
+ * private Env while generating an image) -- never
resolved internally via
+ * Env.getCurrentSystemInfo(), so a caller cannot
accidentally sweep this catalog's
+ * replicas against a different Env's backend set.
+ */
+ public long removeInvalidCloudReplicaRoutes(SystemInfoService systemInfo) {
+ if (routeCleanupDisabled()) {
+ return 0;
+ }
+ long start = System.currentTimeMillis();
+ long removed = 0;
+ for (Long dbId : getDbIds()) {
+ Database db = getDbNullable(dbId);
+ if (db == null) {
+ continue; // The database can be dropped concurrently on the
serving Env.
+ }
+ removed += removeInvalidCloudReplicaRoutes(db, systemInfo);
+ }
+ LOG.info("swept stale cloud routes, entries dropped {}, cost {} ms",
+ removed, System.currentTimeMillis() - start);
+ return removed;
+ }
+
+ private static long removeInvalidCloudReplicaRoutes(Database db,
SystemInfoService systemInfo) {
+ if (routeCleanupDisabled()) {
+ return 0;
+ }
+ long removed = 0;
+ for (Table table : db.getTables()) {
+ removed += removeInvalidCloudReplicaRoutes(table, systemInfo);
+ }
+ return removed;
+ }
+
+ private static long removeInvalidCloudReplicaRoutes(Table table,
SystemInfoService systemInfo) {
+ if (routeCleanupDisabled() || !table.isManagedTable()) {
+ return 0;
+ }
+ long removed = 0;
+ table.readLock();
+ try {
+ for (Partition partition : ((OlapTable) table).getAllPartitions())
{
+ removed += removeInvalidCloudReplicaRoutes(partition,
systemInfo);
+ }
+ } finally {
+ table.readUnlock();
+ }
+ return removed;
+ }
+
+ private static long removeInvalidCloudReplicaRoutes(Partition partition,
SystemInfoService systemInfo) {
+ if (routeCleanupDisabled()) {
+ return 0;
+ }
+ long removed = 0;
+ for (MaterializedIndex index :
partition.getMaterializedIndices(IndexExtState.ALL, true)) {
+ for (Tablet tablet : index.getTablets()) {
+ for (Replica replica : tablet.getReplicas()) {
+ if (replica instanceof CloudReplica) {
+ removed += ((CloudReplica)
replica).removeInvalidRoutes(systemInfo);
+ }
+ }
+ }
+ }
+ return removed;
+ }
+
private void unprotectUpdateCloudReplica(OlapTable olapTable,
UpdateCloudReplicaInfo info) {
Partition partition = olapTable.getPartition(info.getPartitionId());
if (partition == null) {
@@ -1414,15 +1493,15 @@ public class CloudInternalCatalog extends
InternalCatalog {
Replica replica = tablet.getReplicaById(info.getReplicaId());
Preconditions.checkNotNull(replica, info);
+ CloudSystemInfoService systemInfo = (CloudSystemInfoService)
Env.getCurrentSystemInfo();
String clusterId = info.getClusterId();
- String realClusterId = ((CloudSystemInfoService)
Env.getCurrentSystemInfo())
- .getCloudClusterIdByName(clusterId);
+ String realClusterId =
systemInfo.getCloudClusterIdByName(clusterId);
LOG.debug("cluster Id {}, real cluster Id {}", clusterId,
realClusterId);
if (!Strings.isNullOrEmpty(realClusterId)) {
clusterId = realClusterId;
}
- ((CloudReplica) replica).updateClusterToPrimaryBe(clusterId,
info.getBeId());
+ ((CloudReplica)
replica).replayUpdateClusterToPrimaryBe(clusterId, info.getBeId(), systemInfo);
LOG.debug("update single cloud replica cluster {} replica {}
be {}", info.getClusterId(),
replica.getId(), info.getBeId());
@@ -1438,9 +1517,9 @@ public class CloudInternalCatalog extends InternalCatalog
{
}
Preconditions.checkNotNull(replica, info);
+ CloudSystemInfoService systemInfo =
(CloudSystemInfoService) Env.getCurrentSystemInfo();
String clusterId = info.getClusterId();
- String realClusterId = ((CloudSystemInfoService)
Env.getCurrentSystemInfo())
- .getCloudClusterIdByName(clusterId);
+ String realClusterId =
systemInfo.getCloudClusterIdByName(clusterId);
LOG.debug("cluster Id {}, real cluster Id {}", clusterId,
realClusterId);
if (!Strings.isNullOrEmpty(realClusterId)) {
clusterId = realClusterId;
@@ -1448,7 +1527,8 @@ public class CloudInternalCatalog extends InternalCatalog
{
LOG.debug("update cloud replica cluster {} replica {} be
{}", info.getClusterId(),
replica.getId(), info.getBeIds().get(i));
- ((CloudReplica)
replica).updateClusterToPrimaryBe(clusterId, info.getBeIds().get(i));
+ ((CloudReplica)
replica).replayUpdateClusterToPrimaryBe(clusterId, info.getBeIds().get(i),
+ systemInfo);
}
}
} catch (Exception e) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/cloud/system/CloudSystemInfoService.java
b/fe/fe-core/src/main/java/org/apache/doris/cloud/system/CloudSystemInfoService.java
index b0f334d354d..3d368a73fc1 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/cloud/system/CloudSystemInfoService.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/cloud/system/CloudSystemInfoService.java
@@ -108,6 +108,13 @@ public class CloudSystemInfoService extends
SystemInfoService {
private InstanceInfoPB.Status instanceStatus;
+ // Written and consumed by the journal replay thread; additions never
strand existing routes.
+ private long replayBackendRemovalVersion;
+
+ public long getReplayBackendRemovalVersion() {
+ return replayBackendRemovalVersion;
+ }
+
public long getCloudColocateHrwBeId(GroupId groupId, String clusterId,
List<Long> availableBeIds, long idx) {
return getCloudColocateHrwBeIdInternal(groupId, clusterId,
availableBeIds, idx, -1);
}
@@ -973,6 +980,7 @@ public class CloudSystemInfoService extends
SystemInfoService {
List<Backend> toDel = new ArrayList<>();
toDel.add(backend);
updateCloudClusterMap(new ArrayList<>(), toDel);
+ replayBackendRemovalVersion++;
}
@Override
diff --git a/fe/fe-core/src/main/java/org/apache/doris/master/Checkpoint.java
b/fe/fe-core/src/main/java/org/apache/doris/master/Checkpoint.java
index 41d86851582..245c643f4c0 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/master/Checkpoint.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/master/Checkpoint.java
@@ -27,6 +27,7 @@ import org.apache.doris.catalog.Replica;
import org.apache.doris.catalog.Table;
import org.apache.doris.catalog.Tablet;
import org.apache.doris.cloud.catalog.CloudReplica;
+import org.apache.doris.cloud.datasource.CloudInternalCatalog;
import org.apache.doris.common.CheckpointException;
import org.apache.doris.common.Config;
import org.apache.doris.common.FeConstants;
@@ -44,6 +45,7 @@ import org.apache.doris.persist.Storage;
import org.apache.doris.qe.VariableMgr;
import org.apache.doris.system.Frontend;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -159,6 +161,13 @@ public class Checkpoint extends MasterDaemon {
}
env.postProcessAfterMetadataReplayed(false);
postProcessCloudMetadata();
+ try {
+ removeInvalidCloudReplicaRoutes(env);
+ } catch (Exception e) {
+ // Best effort: a failed sweep only leaves stale routes in
this image, and loading it
+ // runs gsonPostProcess() which cleans them again. Never fail
the checkpoint over it.
+ LOG.warn("failed to sweep stale cloud replica routes before
saving image", e);
+ }
latestImageFilePath = env.saveImage();
replayedJournalId = env.getReplayedJournalId();
@@ -409,6 +418,16 @@ public class Checkpoint extends MasterDaemon {
return lock;
}
+ @VisibleForTesting
+ static long removeInvalidCloudReplicaRoutes(Env env) {
+ if (Config.isNotCloudMode()) {
+ return 0;
+ }
+ // Sweep env's own catalog against env's own backend set -- this env
is the checkpoint's private
+ // Env, so its replicas must never be judged against the serving
cluster's live backends.
+ return ((CloudInternalCatalog)
env.getInternalCatalog()).removeInvalidCloudReplicaRoutes(env.getClusterInfo());
+ }
+
private void postProcessCloudMetadata() {
if (Config.isNotCloudMode()) {
return;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
index 5929665b25f..de1dd61bb23 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/service/FrontendServiceImpl.java
@@ -3886,12 +3886,7 @@ public class FrontendServiceImpl implements
FrontendService.Iface {
// This allows the BE to discover peer candidates
across warehouses.
backends = cloudReplica.getAllPrimaryBes();
// Also collect secondary backends (may have cached
data during primary failover)
- for (String computeGroupId :
cloudReplica.getPrimaryComputeGroupIds()) {
- Backend secondaryBe =
cloudReplica.getSecondaryBackend(computeGroupId);
- if (secondaryBe != null) {
- backends.add(secondaryBe);
- }
- }
+ backends.addAll(cloudReplica.getAllSecondaryBes());
} else {
// On the cloud, the PrimaryBackend of a tablet
// indicates the BE where the tablet is stably located,
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudEnvRouteCleanupTest.java
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudEnvRouteCleanupTest.java
new file mode 100644
index 00000000000..4bb3f170452
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudEnvRouteCleanupTest.java
@@ -0,0 +1,145 @@
+// 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.
+
+package org.apache.doris.cloud.catalog;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.cloud.datasource.CloudInternalCatalog;
+import org.apache.doris.cloud.system.CloudSystemInfoService;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.Pair;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.ha.FrontendNodeType;
+import org.apache.doris.journal.JournalCursor;
+import org.apache.doris.journal.JournalEntity;
+import org.apache.doris.persist.EditLog;
+import org.apache.doris.persist.OperationType;
+import org.apache.doris.resource.Tag;
+import org.apache.doris.system.Backend;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class CloudEnvRouteCleanupTest {
+ private boolean savedClean;
+ private CloudEnv env;
+ private CloudSystemInfoService systemInfo;
+ private CloudInternalCatalog catalog;
+ private MockedStatic<Env> envStatic;
+
+ @BeforeEach
+ public void setUp() {
+ savedClean = Config.enable_cloud_replica_stale_route_clean;
+ Config.enable_cloud_replica_stale_route_clean = true;
+ env = Mockito.mock(CloudEnv.class, Mockito.CALLS_REAL_METHODS);
+ systemInfo = new CloudSystemInfoService();
+ catalog = Mockito.mock(CloudInternalCatalog.class);
+ Mockito.doReturn(systemInfo).when(env).getClusterInfo();
+ Mockito.doReturn(catalog).when(env).getInternalCatalog();
+ Deencapsulation.setField(env, "replayedJournalId", new AtomicLong());
+ Deencapsulation.setField(env, "feType", FrontendNodeType.MASTER);
+ envStatic = Mockito.mockStatic(Env.class);
+ envStatic.when(Env::getCurrentEnv).thenReturn(env);
+ envStatic.when(Env::getCurrentSystemInfo).thenReturn(systemInfo);
+ }
+
+ @AfterEach
+ public void tearDown() {
+ envStatic.close();
+ Config.enable_cloud_replica_stale_route_clean = savedClean;
+ }
+
+ @Test
+ public void testCoalescesDropsAtBatchEndEvenWhenFinalTopologyIsUnchanged()
{
+ // Startup and idle batches: exactly one sweep, even without route
update journals.
+ Assertions.assertFalse(env.replayJournal(0));
+ Assertions.assertFalse(env.replayJournal(0));
+ Mockito.verify(catalog,
Mockito.times(1)).removeInvalidCloudReplicaRoutes(systemInfo);
+ Mockito.clearInvocations(catalog);
+
+ Backend first = backend(101L);
+ Backend second = backend(102L);
+ EditLog editLog = Mockito.mock(EditLog.class);
+ JournalCursor cursor = Mockito.mock(JournalCursor.class);
+ Deencapsulation.setField(env, "editLog", editLog);
+ Mockito.when(editLog.read(1L, 4L)).thenReturn(cursor);
+ Mockito.when(cursor.next()).thenReturn(
+ journal(1L, OperationType.OP_ADD_BACKEND, first),
+ journal(2L, OperationType.OP_ADD_BACKEND, second),
+ journal(3L, OperationType.OP_DROP_BACKEND, first),
+ journal(4L, OperationType.OP_DROP_BACKEND, second), null);
+
Mockito.when(catalog.removeInvalidCloudReplicaRoutes(systemInfo)).thenAnswer(invocation
-> {
+ Assertions.assertNull(systemInfo.getBackend(101L));
+ Assertions.assertNull(systemInfo.getBackend(102L));
+ Assertions.assertEquals(4L, env.getReplayedJournalId());
+ return 0L;
+ });
+
+ Assertions.assertTrue(env.replayJournal(4));
+ Assertions.assertEquals(2L,
systemInfo.getReplayBackendRemovalVersion());
+ Assertions.assertFalse(env.replayJournal(4));
+ Mockito.verify(catalog,
Mockito.times(1)).removeInvalidCloudReplicaRoutes(systemInfo);
+
+ // Backend additions alone do not trigger cleanup.
+ systemInfo.replayAddBackend(backend(103L));
+ Assertions.assertFalse(env.replayJournal(4));
+ Mockito.verify(catalog,
Mockito.times(1)).removeInvalidCloudReplicaRoutes(systemInfo);
+ }
+
+ @Test
+ public void testRetriesFailedSweepAndSweepsAfterReenable() {
+ Mockito.when(catalog.removeInvalidCloudReplicaRoutes(systemInfo))
+ .thenThrow(new IllegalStateException("test sweep
failure")).thenReturn(0L);
+ Assertions.assertThrows(IllegalStateException.class, () ->
env.replayJournal(0));
+ Assertions.assertFalse(env.replayJournal(0));
+ Assertions.assertFalse(env.replayJournal(0));
+ Mockito.verify(catalog,
Mockito.times(2)).removeInvalidCloudReplicaRoutes(systemInfo);
+ Config.enable_cloud_replica_stale_route_clean = false;
+ Assertions.assertFalse(env.replayJournal(0));
+ Config.enable_cloud_replica_stale_route_clean = true;
+ Assertions.assertFalse(env.replayJournal(0));
+ Mockito.verify(catalog,
Mockito.times(3)).removeInvalidCloudReplicaRoutes(systemInfo);
+ }
+
+ @Test
+ public void testCheckpointDefersSweepUntilAfterMetadataPostProcessing() {
+ envStatic.when(Env::isCheckpointThread).thenReturn(true);
+ Assertions.assertFalse(env.replayJournal(0));
+ Mockito.verifyNoInteractions(catalog);
+ }
+
+ private static Backend backend(long id) {
+ Backend backend = new Backend(id, "127.0.0.1", (int) (9050 + id));
+ backend.setTagMap(Map.of(Tag.TYPE_LOCATION, "default",
+ Tag.CLOUD_CLUSTER_ID, "cg", Tag.CLOUD_CLUSTER_NAME, "cg"));
+ return backend;
+ }
+
+ private static Pair<Long, JournalEntity> journal(long id, short opCode,
Backend backend) {
+ JournalEntity entity = new JournalEntity();
+ entity.setOpCode(opCode);
+ entity.setData(backend);
+ return Pair.of(id, entity);
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudReplicaTest.java
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudReplicaTest.java
index cc2c05c3951..ddbfddcc5b1 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudReplicaTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudReplicaTest.java
@@ -467,7 +467,7 @@ public class CloudReplicaTest {
replica.updateClusterToSecondaryBe(CLUSTER_ID_2, 2001L);
// one primary entry plus one secondary entry, both pointing at
the dropped backend
- Assertions.assertEquals(2, replica.removeInvalidRoutes());
+ Assertions.assertEquals(2,
replica.removeInvalidRoutes(mockInfoService));
Assertions.assertEquals(1,
replica.getPrimaryComputeGroupIds().size());
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(CLUSTER_ID_1));
@@ -494,7 +494,7 @@ public class CloudReplicaTest {
replica.updateClusterToPrimaryBe(CLUSTER_ID_2, 2001L);
replica.updateClusterToSecondaryBe(CLUSTER_ID_2, 2001L);
- Assertions.assertEquals(0, replica.removeInvalidRoutes());
+ Assertions.assertEquals(0,
replica.removeInvalidRoutes(mockInfoService));
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(CLUSTER_ID_2));
Backend revivedBe = createBackend(2001L, true, false);
@@ -523,7 +523,7 @@ public class CloudReplicaTest {
replica.updateClusterToPrimaryBe(CLUSTER_ID_1, 2001L);
replica.updateClusterToSecondaryBe(CLUSTER_ID_1, 1001L);
- Assertions.assertEquals(0, replica.removeInvalidRoutes());
+ Assertions.assertEquals(0,
replica.removeInvalidRoutes(mockInfoService));
// FrontendServiceImpl's lazy fetch reaches the secondary through
the primary key set
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(CLUSTER_ID_1));
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudTabletRebalancerTest.java
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudTabletRebalancerTest.java
index f2b630947f1..d58ae18f624 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudTabletRebalancerTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/cloud/catalog/CloudTabletRebalancerTest.java
@@ -27,10 +27,11 @@ import org.apache.doris.catalog.Replica;
import org.apache.doris.catalog.Tablet;
import org.apache.doris.catalog.TabletInvertedIndex;
import org.apache.doris.catalog.TabletMeta;
+import org.apache.doris.cloud.datasource.CloudInternalCatalog;
import org.apache.doris.cloud.persist.UpdateCloudReplicaInfo;
import org.apache.doris.cloud.system.CloudSystemInfoService;
import org.apache.doris.common.Config;
-import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.common.util.TimeUtils;
import org.apache.doris.metric.MetricRepo;
import org.apache.doris.system.Backend;
import org.apache.doris.system.SystemInfoService;
@@ -46,6 +47,8 @@ import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
import java.util.AbstractList;
import java.util.AbstractMap;
import java.util.ArrayList;
@@ -365,6 +368,7 @@ public class CloudTabletRebalancerTest {
Long beId = 60_001L;
String clusterId = "cluster-a";
setField(rebalancer, "clusterToBes",
Collections.singletonMap(clusterId, List.of(beId)));
+ setField(rebalancer, "allBes", Set.of(beId));
try (MockedStatic<Env> ignored = mockRouteEnvironment(
dbId, tableId, partitionId, indexId, tabletId, clusterId,
beId, 3)) {
@@ -387,6 +391,7 @@ public class CloudTabletRebalancerTest {
Long beId = 60_001L;
String clusterId = "cluster-a";
setField(rebalancer, "clusterToBes",
Collections.singletonMap(clusterId, List.of(beId)));
+ setField(rebalancer, "allBes", Set.of(beId));
try (MockedStatic<Env> ignored = mockRouteEnvironment(
dbId, tableId, partitionId, indexId, tabletId, clusterId,
beId, 3, true)) {
@@ -829,8 +834,9 @@ public class CloudTabletRebalancerTest {
Env env = Mockito.mock(Env.class);
TabletInvertedIndex invertedIndex =
Mockito.mock(TabletInvertedIndex.class);
TabletMeta tabletMeta = Mockito.mock(TabletMeta.class);
- InternalCatalog catalog = Mockito.mock(InternalCatalog.class);
+ CloudInternalCatalog catalog = Mockito.spy(new CloudInternalCatalog());
Mockito.when(env.getTabletInvertedIndex()).thenReturn(invertedIndex);
+ Mockito.when(env.getInternalCatalog()).thenReturn(catalog);
Mockito.when(invertedIndex.getTabletMeta(tabletId)).thenReturn(tabletMeta);
Mockito.when(tabletMeta.getTableId()).thenReturn(tableId);
Mockito.when(tabletMeta.getPartitionId()).thenReturn(partitionId);
@@ -876,7 +882,7 @@ public class CloudTabletRebalancerTest {
Env env = Mockito.mock(Env.class);
TabletInvertedIndex invertedIndex =
Mockito.mock(TabletInvertedIndex.class);
TabletMeta tabletMeta = Mockito.mock(TabletMeta.class);
- InternalCatalog catalog = Mockito.mock(InternalCatalog.class);
+ CloudInternalCatalog catalog = Mockito.spy(new CloudInternalCatalog());
ColocateTableIndex colocateTableIndex =
Mockito.mock(ColocateTableIndex.class);
Database database = Mockito.mock(Database.class);
OlapTable table = Mockito.mock(OlapTable.class);
@@ -888,6 +894,8 @@ public class CloudTabletRebalancerTest {
Long tabletId = tablet.getId();
Mockito.when(env.getTabletInvertedIndex()).thenReturn(invertedIndex);
+ Mockito.when(env.getInternalCatalog()).thenReturn(catalog);
+ Mockito.when(env.getClusterInfo()).thenReturn(systemInfoService);
Mockito.when(invertedIndex.getTabletMeta(tabletId)).thenReturn(tabletMeta);
Mockito.when(tabletMeta.getTableId()).thenReturn(tableId);
Mockito.when(tabletMeta.getPartitionId()).thenReturn(partitionId);
@@ -1251,6 +1259,15 @@ public class CloudTabletRebalancerTest {
// a backend that disappeared triggers a sweep
Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(new
HashSet<>(Arrays.asList(1L))));
+ // Cleanup must also run after the last compute group disappears.
+ TestRebalancer lastGroupRebalancer = new TestRebalancer();
+
Assertions.assertTrue(lastGroupRebalancer.staleRouteSweepNeeded(new
HashSet<>(Arrays.asList(1L))));
+
Assertions.assertTrue(lastGroupRebalancer.staleRouteSweepNeeded(new
HashSet<>(Arrays.asList(1L))));
+
Assertions.assertTrue(lastGroupRebalancer.staleRouteSweepNeeded(new
HashSet<>()));
+
Assertions.assertTrue(lastGroupRebalancer.staleRouteSweepNeeded(new
HashSet<>()));
+
Assertions.assertFalse(lastGroupRebalancer.staleRouteSweepNeeded(new
HashSet<>()));
+
Assertions.assertFalse(lastGroupRebalancer.staleRouteSweepNeeded(new
HashSet<>(Arrays.asList(2L))));
+
// turning the switch off drops the baseline so turning it back on
sweeps again
Config.enable_cloud_replica_stale_route_clean = false;
Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(bes));
@@ -1260,4 +1277,100 @@ public class CloudTabletRebalancerTest {
Config.enable_cloud_replica_stale_route_clean = saved;
}
}
+
+ @Test
+ public void testIsWithinWindow() {
+ // normal (non-wrapping) window
+ Assertions.assertTrue(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(12, 0), LocalTime.of(10, 0), LocalTime.of(14,
0)));
+ Assertions.assertFalse(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(9, 59), LocalTime.of(10, 0), LocalTime.of(14,
0)));
+ Assertions.assertFalse(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(14, 1), LocalTime.of(10, 0), LocalTime.of(14,
0)));
+ // boundaries are inclusive
+ Assertions.assertTrue(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(10, 0), LocalTime.of(10, 0), LocalTime.of(14,
0)));
+ Assertions.assertTrue(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(14, 0), LocalTime.of(10, 0), LocalTime.of(14,
0)));
+
+ // window across midnight, e.g. 23:00 - 06:00
+ Assertions.assertTrue(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(23, 30), LocalTime.of(23, 0), LocalTime.of(6,
0)));
+ Assertions.assertTrue(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(1, 0), LocalTime.of(23, 0), LocalTime.of(6, 0)));
+ Assertions.assertTrue(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(23, 0), LocalTime.of(23, 0), LocalTime.of(6, 0)));
+ Assertions.assertTrue(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(6, 0), LocalTime.of(23, 0), LocalTime.of(6, 0)));
+ Assertions.assertFalse(CloudTabletRebalancer.isWithinWindow(
+ LocalTime.of(12, 0), LocalTime.of(23, 0), LocalTime.of(6, 0)));
+ }
+
+ @Test
+ public void testIsStaleRouteCleanTimeAllowedDefaultsToUnrestricted() {
+ String savedStart =
Config.cloud_tablet_rebalancer_stale_route_clean_start_time;
+ String savedEnd =
Config.cloud_tablet_rebalancer_stale_route_clean_end_time;
+ try {
+ TestRebalancer rebalancer = new TestRebalancer();
+
+ // the actual default
+ Config.cloud_tablet_rebalancer_stale_route_clean_start_time =
"00:00";
+ Config.cloud_tablet_rebalancer_stale_route_clean_end_time =
"00:00";
+ Assertions.assertTrue(rebalancer.isStaleRouteCleanTimeAllowed());
+
+ // any equal start/end means unrestricted, not just the default
value
+ Config.cloud_tablet_rebalancer_stale_route_clean_start_time =
"09:30";
+ Config.cloud_tablet_rebalancer_stale_route_clean_end_time =
"09:30";
+ Assertions.assertTrue(rebalancer.isStaleRouteCleanTimeAllowed());
+
+ // an unparseable configuration falls back to unrestricted rather
than disabling cleanup
+ Config.cloud_tablet_rebalancer_stale_route_clean_start_time =
"not-a-time";
+ Config.cloud_tablet_rebalancer_stale_route_clean_end_time =
"18:00";
+ Assertions.assertTrue(rebalancer.isStaleRouteCleanTimeAllowed());
+ } finally {
+ Config.cloud_tablet_rebalancer_stale_route_clean_start_time =
savedStart;
+ Config.cloud_tablet_rebalancer_stale_route_clean_end_time =
savedEnd;
+ }
+ }
+
+ @Test
+ public void testStaleRouteSweepGateRespectsCleanWindow() {
+ boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+ String savedStart =
Config.cloud_tablet_rebalancer_stale_route_clean_start_time;
+ String savedEnd =
Config.cloud_tablet_rebalancer_stale_route_clean_end_time;
+ try {
+ Config.enable_cloud_replica_stale_route_clean = true;
+ Config.cloud_tablet_rebalancer_stale_route_clean_start_time =
"00:00";
+ Config.cloud_tablet_rebalancer_stale_route_clean_end_time =
"00:00";
+ TestRebalancer rebalancer = new TestRebalancer();
+ Set<Long> bes = new HashSet<>(Arrays.asList(1L, 2L));
+ // establish a clean baseline with no rounds pending
+ Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(bes));
+ Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(bes));
+ Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(bes));
+
+ // close the window (a 1-2 hour offset from "now" is never in it),
then a backend disappears
+ DateTimeFormatter fmt = DateTimeFormatter.ofPattern("HH:mm");
+ LocalTime now = LocalTime.now(TimeUtils.getDorisZoneId());
+ Config.cloud_tablet_rebalancer_stale_route_clean_start_time =
now.plusHours(1).format(fmt);
+ Config.cloud_tablet_rebalancer_stale_route_clean_end_time =
now.plusHours(2).format(fmt);
+ Set<Long> shrunk = new HashSet<>(Arrays.asList(1L));
+ // the drop is detected (baseline advances) but the window blocks
the sweep -- and the two
+ // rounds it owes must not be drained by rounds that never
actually ran
+ Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(shrunk));
+ Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(shrunk));
+ Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(shrunk));
+
+ // window opens: the two rounds owed from the earlier drop are
still there, untouched
+ Config.cloud_tablet_rebalancer_stale_route_clean_start_time =
"00:00";
+ Config.cloud_tablet_rebalancer_stale_route_clean_end_time =
"00:00";
+ Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(shrunk));
+ Assertions.assertTrue(rebalancer.staleRouteSweepNeeded(shrunk));
+ Assertions.assertFalse(rebalancer.staleRouteSweepNeeded(shrunk));
+ } finally {
+ Config.enable_cloud_replica_stale_route_clean = savedClean;
+ Config.cloud_tablet_rebalancer_stale_route_clean_start_time =
savedStart;
+ Config.cloud_tablet_rebalancer_stale_route_clean_end_time =
savedEnd;
+ }
+ }
}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/cloud/datasource/CloudInternalCatalogTest.java
b/fe/fe-core/src/test/java/org/apache/doris/cloud/datasource/CloudInternalCatalogTest.java
index 6f124c7fad9..cabc592f4d3 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/cloud/datasource/CloudInternalCatalogTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/cloud/datasource/CloudInternalCatalogTest.java
@@ -18,22 +18,35 @@
package org.apache.doris.cloud.datasource;
import org.apache.doris.analysis.DataSortInfo;
+import org.apache.doris.catalog.ColocateTableIndex;
+import org.apache.doris.catalog.Database;
+import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.KeysType;
+import org.apache.doris.catalog.MaterializedIndex;
import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.Partition;
import org.apache.doris.catalog.Replica;
import org.apache.doris.catalog.Tablet;
+import org.apache.doris.cloud.catalog.CloudReplica;
+import org.apache.doris.cloud.catalog.CloudTablet;
+import org.apache.doris.cloud.persist.UpdateCloudReplicaInfo;
import org.apache.doris.cloud.proto.Cloud;
import org.apache.doris.cloud.rpc.MetaServiceProxy;
+import org.apache.doris.cloud.system.CloudSystemInfoService;
import org.apache.doris.common.Config;
import org.apache.doris.common.DdlException;
+import org.apache.doris.common.FeConstants;
import org.apache.doris.proto.OlapFile;
+import org.apache.doris.system.Backend;
import org.apache.doris.thrift.TCompressionType;
import org.apache.doris.thrift.TInvertedIndexFileStorageFormat;
import org.apache.doris.thrift.TSortType;
import org.apache.doris.thrift.TStorageFormat;
import org.apache.doris.thrift.TTabletType;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
@@ -44,6 +57,45 @@ import java.util.Collections;
import java.util.List;
public class CloudInternalCatalogTest {
+ private static final long DB_ID = 10001L;
+ private static final long TABLE_ID = 20001L;
+ private static final long PARTITION_ID = 30001L;
+ private static final long INDEX_ID = 40001L;
+ private static final long LIVE_BE_ID = 50001L;
+ private static final long DEAD_BE_ID = 50002L;
+ private static final String STALE_CLUSTER_ID = "stale_cluster";
+ private static final String LIVE_CLUSTER_ID = "live_cluster";
+
+ private MockedStatic<Env> envMockedStatic;
+ private CloudSystemInfoService systemInfoService;
+ private CloudInternalCatalog catalog;
+ private MaterializedIndex materializedIndex;
+
+ @BeforeEach
+ public void setUpReplayCatalog() {
+ systemInfoService = Mockito.mock(CloudSystemInfoService.class);
+ ColocateTableIndex colocateTableIndex =
Mockito.mock(ColocateTableIndex.class);
+ envMockedStatic = Mockito.mockStatic(Env.class);
+
envMockedStatic.when(Env::getCurrentSystemInfo).thenReturn(systemInfoService);
+
envMockedStatic.when(Env::getCurrentColocateIndex).thenReturn(colocateTableIndex);
+
Mockito.when(colocateTableIndex.isColocateTableNoLock(TABLE_ID)).thenReturn(false);
+
+ catalog = Mockito.spy(new CloudInternalCatalog());
+ Database database = Mockito.mock(Database.class);
+ OlapTable table = Mockito.mock(OlapTable.class);
+ Partition partition = Mockito.mock(Partition.class);
+ materializedIndex = Mockito.mock(MaterializedIndex.class);
+ Mockito.doReturn(database).when(catalog).getDbNullable(DB_ID);
+ Mockito.when(database.getTableNullable(TABLE_ID)).thenReturn(table);
+ Mockito.when(table.getPartition(PARTITION_ID)).thenReturn(partition);
+
Mockito.when(partition.getIndex(INDEX_ID)).thenReturn(materializedIndex);
+ }
+
+ @AfterEach
+ public void tearDownReplayCatalog() {
+ envMockedStatic.close();
+ }
+
@Test
public void testCreateTabletMetaUsesCurrentSchemaVersionAndFormat() throws
Exception {
Tablet tablet = Mockito.mock(Tablet.class);
@@ -114,6 +166,138 @@ public class CloudInternalCatalogTest {
}
}
+ @Test
+ public void testReplaySingleReplicaDoesNotScanOtherRoutes() throws
Exception {
+ boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+ boolean savedUnitTest = FeConstants.runningUnitTest;
+ try {
+ Config.enable_cloud_replica_stale_route_clean = true;
+ FeConstants.runningUnitTest = false;
+ setBackend(LIVE_BE_ID, Mockito.mock(Backend.class));
+ setBackend(DEAD_BE_ID, null);
+ CloudReplica replica = addReplayReplica(60001L, 70001L);
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+
+ catalog.replayUpdateCloudReplica(new UpdateCloudReplicaInfo(DB_ID,
TABLE_ID, PARTITION_ID, INDEX_ID,
+ 60001L, 70001L, LIVE_CLUSTER_ID, LIVE_BE_ID));
+
+
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ Mockito.verify(replica,
Mockito.never()).removeInvalidRoutes(Mockito.any());
+ Assertions.assertEquals(LIVE_BE_ID,
replica.getClusterPrimaryBackendId(LIVE_CLUSTER_ID));
+ } finally {
+ Config.enable_cloud_replica_stale_route_clean = savedClean;
+ FeConstants.runningUnitTest = savedUnitTest;
+ }
+ }
+
+ @Test
+ public void
testReplayBatchDoesNotScanOtherRoutesWithAndWithoutReplicaIds() throws
Exception {
+ boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+ boolean savedUnitTest = FeConstants.runningUnitTest;
+ try {
+ Config.enable_cloud_replica_stale_route_clean = true;
+ FeConstants.runningUnitTest = false;
+ setBackend(LIVE_BE_ID, Mockito.mock(Backend.class));
+ setBackend(DEAD_BE_ID, null);
+ List<CloudReplica> replicas = Arrays.asList(
+ addReplayReplica(60001L, 70001L), addReplayReplica(60002L,
70002L));
+ replicas.forEach(replica ->
replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID));
+ List<Long> tabletIds = Arrays.asList(60001L, 60002L);
+ List<Long> beIds = Arrays.asList(LIVE_BE_ID, LIVE_BE_ID);
+
+ UpdateCloudReplicaInfo explicitReplicaIds = new
UpdateCloudReplicaInfo(
+ DB_ID, TABLE_ID, PARTITION_ID, INDEX_ID, LIVE_CLUSTER_ID,
beIds, tabletIds);
+ explicitReplicaIds.setReplicaIds(Arrays.asList(70001L, 70002L));
+ catalog.replayUpdateCloudReplica(explicitReplicaIds);
+
+ for (CloudReplica replica : replicas) {
+
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ Assertions.assertEquals(LIVE_BE_ID,
replica.getClusterPrimaryBackendId(LIVE_CLUSTER_ID));
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+ }
+
+ catalog.replayUpdateCloudReplica(new UpdateCloudReplicaInfo(
+ DB_ID, TABLE_ID, PARTITION_ID, INDEX_ID, LIVE_CLUSTER_ID,
beIds, tabletIds));
+
+ for (CloudReplica replica : replicas) {
+
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ Mockito.verify(replica,
Mockito.never()).removeInvalidRoutes(Mockito.any());
+ Assertions.assertEquals(LIVE_BE_ID,
replica.getClusterPrimaryBackendId(LIVE_CLUSTER_ID));
+ }
+ } finally {
+ Config.enable_cloud_replica_stale_route_clean = savedClean;
+ FeConstants.runningUnitTest = savedUnitTest;
+ }
+ }
+
+ @Test
+ public void testReplayKeepsStaleRoutesWhenCleanupDisabled() throws
Exception {
+ boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+ boolean savedUnitTest = FeConstants.runningUnitTest;
+ try {
+ Config.enable_cloud_replica_stale_route_clean = false;
+ FeConstants.runningUnitTest = false;
+ setBackend(LIVE_BE_ID, Mockito.mock(Backend.class));
+ setBackend(DEAD_BE_ID, null);
+ CloudReplica replica = addReplayReplica(60001L, 70001L);
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+
+ catalog.replayUpdateCloudReplica(new UpdateCloudReplicaInfo(DB_ID,
TABLE_ID, PARTITION_ID, INDEX_ID,
+ 60001L, 70001L, LIVE_CLUSTER_ID, LIVE_BE_ID));
+
+
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ Assertions.assertEquals(LIVE_BE_ID,
replica.getClusterPrimaryBackendId(LIVE_CLUSTER_ID));
+ } finally {
+ Config.enable_cloud_replica_stale_route_clean = savedClean;
+ FeConstants.runningUnitTest = savedUnitTest;
+ }
+ }
+
+ @Test
+ public void testReplayRemovesDeletedBackendRouteAndKeepsLiveSecondary()
throws Exception {
+ boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+ boolean savedUnitTest = FeConstants.runningUnitTest;
+ try {
+ Config.enable_cloud_replica_stale_route_clean = true;
+ FeConstants.runningUnitTest = false;
+ Backend liveBackend = Mockito.mock(Backend.class);
+ setBackend(LIVE_BE_ID, liveBackend);
+ setBackend(DEAD_BE_ID, null);
+ CloudReplica replica = addReplayReplica(60001L, 70001L);
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+ replica.updateClusterToSecondaryBe(STALE_CLUSTER_ID, LIVE_BE_ID);
+
+ catalog.replayUpdateCloudReplica(new UpdateCloudReplicaInfo(DB_ID,
TABLE_ID, PARTITION_ID, INDEX_ID,
+ 60001L, 70001L, LIVE_CLUSTER_ID, DEAD_BE_ID));
+
+
Assertions.assertFalse(replica.getPrimaryComputeGroupIds().contains(LIVE_CLUSTER_ID));
+
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ Assertions.assertSame(liveBackend,
replica.getSecondaryBackend(STALE_CLUSTER_ID));
+
+ // A late journal must not clear the valid fallback for this same
compute group.
+ catalog.replayUpdateCloudReplica(new UpdateCloudReplicaInfo(DB_ID,
TABLE_ID, PARTITION_ID, INDEX_ID,
+ 60001L, 70001L, STALE_CLUSTER_ID, DEAD_BE_ID));
+ Assertions.assertSame(liveBackend,
replica.getSecondaryBackend(STALE_CLUSTER_ID));
+ } finally {
+ Config.enable_cloud_replica_stale_route_clean = savedClean;
+ FeConstants.runningUnitTest = savedUnitTest;
+ }
+ }
+
+ private CloudReplica addReplayReplica(long tabletId, long replicaId) {
+ CloudReplica replica = Mockito.spy(new CloudReplica(replicaId, -1L,
Replica.ReplicaState.NORMAL, 1L, 0,
+ DB_ID, TABLE_ID, PARTITION_ID, INDEX_ID, 0L));
+ CloudTablet tablet = new CloudTablet(tabletId);
+ tablet.addReplica(replica, true);
+ Mockito.when(materializedIndex.getTablet(tabletId)).thenReturn(tablet);
+ return replica;
+ }
+
+ private void setBackend(long backendId, Backend backend) {
+
Mockito.when(systemInfoService.getBackendByIdWithBoxedId(backendId)).thenReturn(backend);
+
Mockito.when(systemInfoService.getBackend(backendId)).thenReturn(backend);
+ }
+
private OlapFile.TabletMetaCloudPB createTabletMeta(Tablet tablet) throws
Exception {
return new CloudInternalCatalog().createTabletMetaBuilder(
1L, 2L, 3L, tablet, TTabletType.TABLET_TYPE_DISK, 23,
KeysType.DUP_KEYS, (short) 1,
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/master/CheckpointTest.java
b/fe/fe-core/src/test/java/org/apache/doris/master/CheckpointTest.java
new file mode 100644
index 00000000000..1ddd31e07cd
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/master/CheckpointTest.java
@@ -0,0 +1,276 @@
+// 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.
+
+package org.apache.doris.master;
+
+import org.apache.doris.catalog.CatalogRecycleBin;
+import org.apache.doris.catalog.ColocateTableIndex;
+import org.apache.doris.catalog.Database;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.MaterializedIndex;
+import org.apache.doris.catalog.MaterializedIndex.IndexExtState;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.Partition;
+import org.apache.doris.catalog.Replica;
+import org.apache.doris.catalog.Table;
+import org.apache.doris.cloud.catalog.CloudReplica;
+import org.apache.doris.cloud.catalog.CloudTablet;
+import org.apache.doris.cloud.datasource.CloudInternalCatalog;
+import org.apache.doris.cloud.system.CloudSystemInfoService;
+import org.apache.doris.common.Config;
+import org.apache.doris.common.FeConstants;
+import org.apache.doris.system.Backend;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+public class CheckpointTest {
+ private static final long DB_ID = 10001L;
+ private static final long TABLE_ID = 20001L;
+ private static final long PARTITION_ID = 30001L;
+ private static final long INDEX_ID = 40001L;
+ private static final long LIVE_BE_ID = 50001L;
+ private static final long DEAD_BE_ID = 50002L;
+ private static final String STALE_CLUSTER_ID = "stale_cluster";
+ private static final String LIVE_CLUSTER_ID = "live_cluster";
+
+ private MockedStatic<Env> envMockedStatic;
+ private Env env;
+ private Database database;
+ private OlapTable table;
+ private Partition partition;
+ private MaterializedIndex materializedIndex;
+ private Backend liveBackend;
+ private CatalogRecycleBin recycleBin;
+
+ @BeforeEach
+ public void setUp() {
+ CloudSystemInfoService systemInfoService =
Mockito.mock(CloudSystemInfoService.class);
+ ColocateTableIndex colocateTableIndex =
Mockito.mock(ColocateTableIndex.class);
+ envMockedStatic = Mockito.mockStatic(Env.class);
+
envMockedStatic.when(Env::getCurrentSystemInfo).thenReturn(systemInfoService);
+
envMockedStatic.when(Env::getCurrentColocateIndex).thenReturn(colocateTableIndex);
+
Mockito.when(colocateTableIndex.isColocateTableNoLock(TABLE_ID)).thenReturn(false);
+ liveBackend = Mockito.mock(Backend.class);
+
Mockito.when(systemInfoService.getBackendByIdWithBoxedId(LIVE_BE_ID)).thenReturn(liveBackend);
+
Mockito.when(systemInfoService.getBackendByIdWithBoxedId(DEAD_BE_ID)).thenReturn(null);
+
Mockito.when(systemInfoService.getBackend(LIVE_BE_ID)).thenReturn(liveBackend);
+
+ env = Mockito.mock(Env.class);
+ CloudInternalCatalog catalog = Mockito.spy(new CloudInternalCatalog());
+ database = Mockito.mock(Database.class);
+ table = Mockito.mock(OlapTable.class);
+ partition = Mockito.mock(Partition.class);
+ materializedIndex = Mockito.mock(MaterializedIndex.class);
+ envMockedStatic.when(Env::getCurrentEnv).thenReturn(env);
+ recycleBin = new CatalogRecycleBin();
+ envMockedStatic.when(Env::getCurrentRecycleBin).thenReturn(recycleBin);
+ Mockito.when(env.getInternalCatalog()).thenReturn(catalog);
+ Mockito.when(env.getClusterInfo()).thenReturn(systemInfoService);
+
Mockito.when(catalog.getDbIds()).thenReturn(Collections.singletonList(DB_ID));
+ Mockito.when(catalog.getDbNullable(DB_ID)).thenReturn(database);
+
Mockito.when(database.getTables()).thenReturn(Collections.singletonList(table));
+ Mockito.when(table.isManagedTable()).thenReturn(true);
+
Mockito.when(table.getAllPartitions()).thenReturn(Collections.singletonList(partition));
+ Mockito.when(partition.getMaterializedIndices(IndexExtState.ALL, true))
+ .thenReturn(Collections.singletonList(materializedIndex));
+ }
+
+ @AfterEach
+ public void tearDown() {
+ envMockedStatic.close();
+ }
+
+ @Test
+ public void testDoesNotSweepRecycledTableAndPartition() {
+ withRouteCleanup(() -> {
+ CloudReplica tableReplica = addReplica(materializedIndex, 60001L);
+ tableReplica.updateClusterToPrimaryBe(STALE_CLUSTER_ID,
DEAD_BE_ID);
+
Mockito.when(database.getTables()).thenReturn(Collections.emptyList());
+ Mockito.when(table.getId()).thenReturn(TABLE_ID);
+ Mockito.when(table.getName()).thenReturn("recycled_table");
+ Assertions.assertTrue(recycleBin.recycleTable(DB_ID, table, true,
false, 1L));
+ Partition recycledPartition = Mockito.mock(Partition.class);
+ Mockito.when(recycledPartition.getId()).thenReturn(PARTITION_ID +
1);
+
Mockito.when(recycledPartition.getName()).thenReturn("recycled_partition");
+ MaterializedIndex recycledIndex =
Mockito.mock(MaterializedIndex.class);
+
Mockito.when(recycledPartition.getMaterializedIndices(IndexExtState.ALL, true))
+ .thenReturn(Collections.singletonList(recycledIndex));
+ CloudReplica partitionReplica = addReplica(recycledIndex, 60002L);
+ partitionReplica.updateClusterToPrimaryBe(STALE_CLUSTER_ID,
DEAD_BE_ID);
+ Assertions.assertTrue(recycleBin.recyclePartition(DB_ID, TABLE_ID,
"table", recycledPartition,
+ null, null, null, null, false, true));
+
+ Assertions.assertEquals(0L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+
Assertions.assertTrue(tableReplica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+
Assertions.assertTrue(partitionReplica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ envMockedStatic.verify(Env::getCurrentRecycleBin, Mockito.never());
+ });
+ }
+
+ @Test
+ public void testRemovesStaleRouteAndKeepsLiveRoute() {
+ withRouteCleanup(() -> {
+ CloudReplica replica = addReplica(materializedIndex, 60001L);
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+ replica.updateClusterToPrimaryBe(LIVE_CLUSTER_ID, LIVE_BE_ID);
+
+ Assertions.assertEquals(1L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+
Assertions.assertFalse(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ Assertions.assertEquals(LIVE_BE_ID,
replica.getClusterPrimaryBackendId(LIVE_CLUSTER_ID));
+ Assertions.assertEquals(0L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+ });
+ }
+
+ @Test
+ public void testKeepsDeadPrimaryWithLiveSecondary() {
+ withRouteCleanup(() -> {
+ CloudReplica replica = addReplica(materializedIndex, 60001L);
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+ replica.updateClusterToSecondaryBe(STALE_CLUSTER_ID, LIVE_BE_ID);
+
+ Assertions.assertEquals(0L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+ Assertions.assertEquals(DEAD_BE_ID,
replica.getClusterPrimaryBackendId(STALE_CLUSTER_ID));
+ Assertions.assertSame(liveBackend,
replica.getSecondaryBackend(STALE_CLUSTER_ID));
+ });
+ }
+
+ @Test
+ public void testNonCloudModeIsNoOp() {
+ withRouteCleanup(() -> {
+ Config.deploy_mode = "local";
+ Config.cloud_unique_id = "";
+ CloudReplica replica = addReplica(materializedIndex, 60001L);
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+
+ Assertions.assertEquals(0L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ Mockito.verify(env, Mockito.never()).getInternalCatalog();
+ });
+ }
+
+ @Test
+ public void testSkipsNonManagedTable() {
+ withRouteCleanup(() -> {
+ Table nonManagedTable = Mockito.mock(Table.class);
+ Mockito.when(nonManagedTable.isManagedTable()).thenReturn(false);
+
Mockito.when(database.getTables()).thenReturn(Arrays.asList(nonManagedTable,
table));
+ CloudReplica replica = addReplica(materializedIndex, 60001L);
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+
+ Assertions.assertEquals(1L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+
Assertions.assertFalse(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ });
+ }
+
+ @Test
+ public void testSweepsShadowIndex() {
+ withRouteCleanup(() -> {
+ MaterializedIndex shadowIndex =
Mockito.mock(MaterializedIndex.class);
+
Mockito.when(shadowIndex.getState()).thenReturn(MaterializedIndex.IndexState.SHADOW);
+
Mockito.when(partition.getMaterializedIndices(IndexExtState.VISIBLE, true))
+ .thenReturn(Collections.singletonList(materializedIndex));
+ Mockito.when(partition.getMaterializedIndices(IndexExtState.ALL,
true))
+ .thenReturn(Arrays.asList(materializedIndex, shadowIndex));
+ CloudReplica visibleReplica = addReplica(materializedIndex,
60001L);
+ CloudReplica shadowReplica = addReplica(shadowIndex, 60002L);
+ visibleReplica.updateClusterToPrimaryBe(STALE_CLUSTER_ID,
DEAD_BE_ID);
+ shadowReplica.updateClusterToPrimaryBe(STALE_CLUSTER_ID,
DEAD_BE_ID);
+
+ Assertions.assertEquals(2L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+
Assertions.assertFalse(visibleReplica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+
Assertions.assertFalse(shadowReplica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ });
+ }
+
+ @Test
+ public void testSweepsAgainstCheckpointEnvBackendsNotServingEnvBackends() {
+ withRouteCleanup(() -> {
+ // The serving Env (reached via the static
Env.getCurrentSystemInfo()) still thinks DEAD_BE_ID
+ // is alive -- only the checkpoint's own private Env
(env.getClusterInfo()) has it removed. If
+ // the sweep ever regresses to resolving backends ambiently
instead of from `env`, this route
+ // would wrongly survive.
+ CloudSystemInfoService servingSystemInfo =
Mockito.mock(CloudSystemInfoService.class);
+ Backend stillAliveOnServingEnv = Mockito.mock(Backend.class);
+
Mockito.when(servingSystemInfo.getBackendByIdWithBoxedId(DEAD_BE_ID)).thenReturn(stillAliveOnServingEnv);
+
envMockedStatic.when(Env::getCurrentSystemInfo).thenReturn(servingSystemInfo);
+
+ CloudReplica replica = addReplica(materializedIndex, 60001L);
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+
+ Assertions.assertEquals(1L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+
Assertions.assertFalse(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ });
+ }
+
+ @Test
+ public void testSkipsNonCloudReplica() {
+ withRouteCleanup(() -> {
+ CloudTablet tablet = new CloudTablet(60001L);
+ tablet.addReplica(Mockito.mock(Replica.class), true);
+
Mockito.when(materializedIndex.getTablets()).thenReturn(Collections.singletonList(tablet));
+
+ Assertions.assertEquals(0L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+ });
+ }
+
+ @Test
+ public void testCleanupDisabledIsNoOp() {
+ withRouteCleanup(() -> {
+ Config.enable_cloud_replica_stale_route_clean = false;
+ CloudReplica replica = addReplica(materializedIndex, 60001L);
+ replica.updateClusterToPrimaryBe(STALE_CLUSTER_ID, DEAD_BE_ID);
+
+ Assertions.assertEquals(0L,
Checkpoint.removeInvalidCloudReplicaRoutes(env));
+
Assertions.assertTrue(replica.getPrimaryComputeGroupIds().contains(STALE_CLUSTER_ID));
+ });
+ }
+
+ private CloudReplica addReplica(MaterializedIndex index, long tabletId) {
+ CloudReplica replica = new CloudReplica(tabletId + 10000L, -1L,
Replica.ReplicaState.NORMAL, 1L, 0,
+ DB_ID, TABLE_ID, PARTITION_ID, INDEX_ID, 0L);
+ CloudTablet tablet = new CloudTablet(tabletId);
+ tablet.addReplica(replica, true);
+
Mockito.when(index.getTablets()).thenReturn(Collections.singletonList(tablet));
+ return replica;
+ }
+
+ private void withRouteCleanup(Runnable test) {
+ boolean savedClean = Config.enable_cloud_replica_stale_route_clean;
+ boolean savedUnitTest = FeConstants.runningUnitTest;
+ String savedDeployMode = Config.deploy_mode;
+ String savedCloudUniqueId = Config.cloud_unique_id;
+ try {
+ Config.enable_cloud_replica_stale_route_clean = true;
+ FeConstants.runningUnitTest = false;
+ Config.deploy_mode = "cloud";
+ test.run();
+ } finally {
+ Config.enable_cloud_replica_stale_route_clean = savedClean;
+ FeConstants.runningUnitTest = savedUnitTest;
+ Config.deploy_mode = savedDeployMode;
+ Config.cloud_unique_id = savedCloudUniqueId;
+ }
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplCloudTest.java
b/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplCloudTest.java
index ef0cd3e7be4..eefcfc29795 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplCloudTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/service/FrontendServiceImplCloudTest.java
@@ -17,10 +17,16 @@
package org.apache.doris.service;
+import org.apache.doris.catalog.ColocateTableIndex;
import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.Replica;
+import org.apache.doris.catalog.TabletInvertedIndex;
import org.apache.doris.cloud.CacheHotspotManager;
import org.apache.doris.cloud.catalog.CloudEnv;
+import org.apache.doris.cloud.catalog.CloudReplica;
import org.apache.doris.common.Config;
+import org.apache.doris.system.Backend;
+import org.apache.doris.system.SystemInfoService;
import org.apache.doris.thrift.TGetTabletReplicaInfosRequest;
import org.apache.doris.thrift.TGetTabletReplicaInfosResult;
import org.apache.doris.thrift.TStatusCode;
@@ -34,6 +40,35 @@ import java.util.Collections;
public class FrontendServiceImplCloudTest {
+ @Test
+ public void testSecondaryOnlyRouteIsDiscovered() {
+ String saved = Config.cloud_unique_id;
+ Config.cloud_unique_id = "secondary-peer-test";
+ try (MockedStatic<Env> envMock = Mockito.mockStatic(Env.class)) {
+
envMock.when(Env::getCurrentEnv).thenReturn(Mockito.mock(CloudEnv.class));
+
envMock.when(Env::getCurrentColocateIndex).thenReturn(Mockito.mock(ColocateTableIndex.class));
+ SystemInfoService systemInfo =
Mockito.mock(SystemInfoService.class);
+ envMock.when(Env::getCurrentSystemInfo).thenReturn(systemInfo);
+ Backend backend = new Backend(12L, "127.0.0.1", 9050);
+ Mockito.when(systemInfo.getBackend(12L)).thenReturn(backend);
+
Mockito.when(systemInfo.getBackendByIdWithBoxedId(12L)).thenReturn(backend);
+ CloudReplica replica = new CloudReplica(1L, -1L,
Replica.ReplicaState.NORMAL, 1L, 0,
+ 2L, 3L, 4L, 5L, 0L);
+ // Cleanup and secondary publication can leave this valid
secondary without a primary key.
+ replica.updateClusterToSecondaryBe("cg", 12L);
+ TabletInvertedIndex index =
Mockito.mock(TabletInvertedIndex.class);
+ envMock.when(Env::getCurrentInvertedIndex).thenReturn(index);
+
Mockito.when(index.getReplicasByTabletId(789L)).thenReturn(Collections.singletonList(replica));
+ TGetTabletReplicaInfosRequest request = new
TGetTabletReplicaInfosRequest();
+ request.setTabletIds(Collections.singletonList(789L));
+ TGetTabletReplicaInfosResult result = new
FrontendServiceImpl(Mockito.mock(ExecuteEnv.class))
+ .getTabletReplicaInfos(request);
+ Assertions.assertEquals(1,
result.getTabletReplicaInfos().get(789L).size());
+ } finally {
+ Config.cloud_unique_id = saved;
+ }
+ }
+
// Regression test for FrontendServiceImpl.getTabletReplicaInfos NPE:
// When a warm-up job has been removed from
// CacheHotspotManager.cloudWarmUpJobs (past
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]