This is an automated email from the ASF dual-hosted git repository.

tuglu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/master by this push:
     new 681cbdee15d feat: historical tier aliasing support in coordinator 
dynamic config (#19204)
681cbdee15d is described below

commit 681cbdee15d279acf976a4e851da3e7a03bbba81
Author: jtuglu1 <[email protected]>
AuthorDate: Tue Apr 14 13:32:05 2026 -0700

    feat: historical tier aliasing support in coordinator dynamic config 
(#19204)
    
    Introduces historical tier aliases in the Coordinator dynamic config. This 
allows usage of a virtual "alias" tier in retention rules, e.g. hot to map to N 
other historical tiers. The alias tier is virtual (it itself doesn't store 
segments), but instead acts as a single identifier for a group of replica 
tiers. This reduces friction for operators (to not need to duplicate their 
retention rules N times) as well as enabling use-cases like:
    
    1. Rolling deployments that use historical tiering for query/data isolation 
across versions (e.g. ensure new version of historical on hot_new and old 
version of historical on hot_old load the same data).
    2. Historical tiers to be mirrored across different regions/AZs for 
fault-tolerance.
---
 docs/api-reference/dynamic-configuration-api.md    |   5 +-
 docs/configuration/index.md                        |   1 +
 .../coordinator/CoordinatorDynamicConfig.java      |  52 ++++++++-
 .../coordinator/loading/SegmentLoadingConfig.java  |  20 +++-
 .../loading/StrategicSegmentAssigner.java          |  36 +++++-
 .../server/coordinator/rules/LoadRuleTest.java     | 127 +++++++++++++++++++++
 .../server/http/CoordinatorDynamicConfigTest.java  |  53 +++++++++
 .../coordinator-dynamic-config-completions.ts      |   5 +
 .../coordinator-dynamic-config.tsx                 |  13 +++
 9 files changed, 299 insertions(+), 13 deletions(-)

diff --git a/docs/api-reference/dynamic-configuration-api.md 
b/docs/api-reference/dynamic-configuration-api.md
index 95166aa3c54..259e50c8800 100644
--- a/docs/api-reference/dynamic-configuration-api.md
+++ b/docs/api-reference/dynamic-configuration-api.md
@@ -107,7 +107,10 @@ Host: http://ROUTER_IP:ROUTER_PORT
     "smartSegmentLoading": true,
     "debugDimensions": null,
     "turboLoadingNodes": [],
-    "cloneServers": {}
+    "cloneServers": {},
+    "historicalTierAliases": {
+        "hot": ["hot_1", "hot_2"]
+    }
 
 }
 ```
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
index 172d8062a5e..e309126863a 100644
--- a/docs/configuration/index.md
+++ b/docs/configuration/index.md
@@ -835,6 +835,7 @@ The following table shows the dynamic configuration 
properties for the Coordinat
 |`replicateAfterLoadTimeout`|Boolean flag for whether or not additional 
replication is needed for segments that have failed to load due to the expiry 
of `druid.coordinator.load.timeout`. If this is set to true, the Coordinator 
will attempt to replicate the failed segment on a different historical server. 
This helps improve the segment availability if there are a few slow Historicals 
in the cluster. However, the slow Historical may still load the segment later 
and the Coordinator may issu [...]
 |`turboLoadingNodes`| Experimental. List of Historical servers to place in 
turbo loading mode. These servers use a larger thread-pool to load segments 
faster but at the cost of query performance. For servers specified in 
`turboLoadingNodes`, `druid.coordinator.loadqueuepeon.http.batchSize` is 
ignored and the coordinator uses the value of the respective 
`numLoadingThreads` instead.<br/>Please use this config with caution. All 
servers should eventually be removed from this list once the se [...]
 |`cloneServers`| Experimental. Map from target Historical server to source 
Historical server which should be cloned by the target. The target Historical 
does not participate in regular segment assignment or balancing. Instead, the 
Coordinator mirrors any segment assignment made to the source Historical onto 
the target Historical, so that the target becomes an exact copy of the source. 
Segments on the target Historical do not count towards replica counts either. 
If the source disappears,  [...]
+|`historicalTierAliases`|Map from a virtual tier name to the set of real 
Historical tier names it expands to. When a load/drop rule references a virtual 
alias tier, the Coordinator replaces it with its real tiers — each receiving 
the full replica count independently. The alias key itself is never loaded to 
directly. For example, `{"hot": ["hot_1", "hot_2"]}` causes a rule of `{"hot": 
2}` to load 2 replicas on each of `hot_1` and `hot_2`; `hot` receives no direct 
assignment. An alias valu [...]
 
 ##### Smart segment loading
 
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/CoordinatorDynamicConfig.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/CoordinatorDynamicConfig.java
index f34c3b8ae39..66652961e36 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/CoordinatorDynamicConfig.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/CoordinatorDynamicConfig.java
@@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonIgnore;
 import com.fasterxml.jackson.annotation.JsonProperty;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
 import org.apache.druid.common.config.Configs;
 import org.apache.druid.common.config.JacksonConfigManager;
 import org.apache.druid.error.InvalidInput;
@@ -76,6 +77,13 @@ public class CoordinatorDynamicConfig
   private final Set<String> turboLoadingNodes;
   private final Map<String, String> cloneServers;
 
+  /**
+   * Map from alias tier name to the set of actual tier names it represents.
+   * For example, "hot" -> {"hot_1", "hot_2"} allows rules targeting "hot" to 
apply
+   * to servers in either tier.
+   */
+  private final Map<String, Set<String>> historicalTierAliases;
+
   /**
    * Stale pending segments belonging to the data sources in this list are not 
killed by {@code
    * KillStalePendingSegments}. In other words, segments in these data sources 
are "protected".
@@ -126,7 +134,8 @@ public class CoordinatorDynamicConfig
       @JsonProperty("smartSegmentLoading") @Nullable Boolean 
smartSegmentLoading,
       @JsonProperty("debugDimensions") @Nullable Map<String, String> 
debugDimensions,
       @JsonProperty("turboLoadingNodes") @Nullable Set<String> 
turboLoadingNodes,
-      @JsonProperty("cloneServers") @Nullable Map<String, String> cloneServers
+      @JsonProperty("cloneServers") @Nullable Map<String, String> cloneServers,
+      @JsonProperty("historicalTierAliases") @Nullable Map<String, 
Set<String>> historicalTierAliases
   )
   {
     this.markSegmentAsUnusedDelayMillis =
@@ -172,6 +181,17 @@ public class CoordinatorDynamicConfig
     this.validDebugDimensions = validateDebugDimensions(debugDimensions);
     this.turboLoadingNodes = Configs.valueOrDefault(turboLoadingNodes, 
Set.of());
     this.cloneServers = Configs.valueOrDefault(cloneServers, Map.of());
+
+    this.historicalTierAliases = Configs.valueOrDefault(historicalTierAliases, 
Map.of());
+    final Set<String> aliasKeys = this.historicalTierAliases.keySet();
+    for (Set<String> mappedTiers : this.historicalTierAliases.values()) {
+      if (!Sets.intersection(mappedTiers, aliasKeys).isEmpty()) {
+        throw InvalidInput.exception(
+            "historicalTierAliases [%s] is invalid. A virtual tier alias 
cannot be a physical tier.",
+            this.historicalTierAliases
+        );
+      }
+    }
   }
 
   private Map<Dimension, String> validateDebugDimensions(Map<String, String> 
debugDimensions)
@@ -338,6 +358,12 @@ public class CoordinatorDynamicConfig
     return cloneServers;
   }
 
+  @JsonProperty
+  public Map<String, Set<String>> getHistoricalTierAliases()
+  {
+    return historicalTierAliases;
+  }
+
   /**
    * List of servers to put in turbo-loading mode. These servers will use a 
larger thread pool to load
    * segments. This causes decreases the average time taken to load segments. 
However, this also means less resources
@@ -371,6 +397,7 @@ public class CoordinatorDynamicConfig
            ", replicateAfterLoadTimeout=" + replicateAfterLoadTimeout +
            ", turboLoadingNodes=" + turboLoadingNodes +
            ", cloneServers=" + cloneServers +
+           ", historicalTierAliases=" + historicalTierAliases +
            '}';
   }
 
@@ -407,7 +434,8 @@ public class CoordinatorDynamicConfig
            && Objects.equals(decommissioningNodes, that.decommissioningNodes)
            && Objects.equals(turboLoadingNodes, that.turboLoadingNodes)
            && Objects.equals(debugDimensions, that.debugDimensions)
-           && Objects.equals(cloneServers, that.cloneServers);
+           && Objects.equals(cloneServers, that.cloneServers)
+           && Objects.equals(historicalTierAliases, 
that.historicalTierAliases);
   }
 
   @Override
@@ -431,7 +459,8 @@ public class CoordinatorDynamicConfig
         pauseCoordination,
         debugDimensions,
         turboLoadingNodes,
-        cloneServers
+        cloneServers,
+        historicalTierAliases
     );
   }
 
@@ -488,6 +517,7 @@ public class CoordinatorDynamicConfig
     private Boolean smartSegmentLoading;
     private Set<String> turboLoadingNodes;
     private Map<String, String> cloneServers;
+    private Map<String, Set<String>> historicalTierAliases;
 
     public Builder()
     {
@@ -512,7 +542,8 @@ public class CoordinatorDynamicConfig
         @JsonProperty("smartSegmentLoading") @Nullable Boolean 
smartSegmentLoading,
         @JsonProperty("debugDimensions") @Nullable Map<String, String> 
debugDimensions,
         @JsonProperty("turboLoadingNodes") @Nullable Set<String> 
turboLoadingNodes,
-        @JsonProperty("cloneServers") @Nullable Map<String, String> 
cloneServers
+        @JsonProperty("cloneServers") @Nullable Map<String, String> 
cloneServers,
+        @JsonProperty("historicalTierAliases") @Nullable Map<String, 
Set<String>> historicalTierAliases
     )
     {
       this.markSegmentAsUnusedDelayMillis = markSegmentAsUnusedDelayMillis;
@@ -533,6 +564,7 @@ public class CoordinatorDynamicConfig
       this.debugDimensions = debugDimensions;
       this.turboLoadingNodes = turboLoadingNodes;
       this.cloneServers = cloneServers;
+      this.historicalTierAliases = historicalTierAliases;
     }
 
     public Builder withMarkSegmentAsUnusedDelayMillis(long leadingTimeMillis)
@@ -631,6 +663,12 @@ public class CoordinatorDynamicConfig
       return this;
     }
 
+    public Builder withHistoricalTierAliases(Map<String, Set<String>> 
historicalTierAliases)
+    {
+      this.historicalTierAliases = historicalTierAliases;
+      return this;
+    }
+
     /**
      * Builds a CoordinatoryDynamicConfig using either the configured values, 
or
      * the default value if not configured.
@@ -658,7 +696,8 @@ public class CoordinatorDynamicConfig
           valueOrDefault(smartSegmentLoading, Defaults.SMART_SEGMENT_LOADING),
           debugDimensions,
           turboLoadingNodes,
-          cloneServers
+          cloneServers,
+          historicalTierAliases
       );
     }
 
@@ -690,7 +729,8 @@ public class CoordinatorDynamicConfig
           valueOrDefault(smartSegmentLoading, 
defaults.isSmartSegmentLoading()),
           valueOrDefault(debugDimensions, defaults.getDebugDimensions()),
           valueOrDefault(turboLoadingNodes, defaults.getTurboLoadingNodes()),
-          valueOrDefault(cloneServers, defaults.getCloneServers())
+          valueOrDefault(cloneServers, defaults.getCloneServers()),
+          valueOrDefault(historicalTierAliases, 
defaults.getHistoricalTierAliases())
       );
     }
   }
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/SegmentLoadingConfig.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/SegmentLoadingConfig.java
index 26fcd220c25..d89c009f472 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/SegmentLoadingConfig.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/SegmentLoadingConfig.java
@@ -22,6 +22,9 @@ package org.apache.druid.server.coordinator.loading;
 import org.apache.druid.java.util.common.logger.Logger;
 import org.apache.druid.server.coordinator.CoordinatorDynamicConfig;
 
+import java.util.Map;
+import java.util.Set;
+
 /**
  * Contains recomputed configs from {@link CoordinatorDynamicConfig} based on
  * whether {@link CoordinatorDynamicConfig#isSmartSegmentLoading} is enabled 
or not.
@@ -38,6 +41,8 @@ public class SegmentLoadingConfig
 
   private final boolean useRoundRobinSegmentAssignment;
 
+  private final Map<String, Set<String>> historicalTierAliases;
+
   /**
    * Creates a new SegmentLoadingConfig with recomputed coordinator config 
values
    * based on whether {@link CoordinatorDynamicConfig#isSmartSegmentLoading()}
@@ -61,7 +66,8 @@ public class SegmentLoadingConfig
           replicationThrottleLimit,
           60,
           true,
-          numBalancerThreads
+          numBalancerThreads,
+          dynamicConfig.getHistoricalTierAliases()
       );
     } else {
       // Use the configured values
@@ -70,7 +76,8 @@ public class SegmentLoadingConfig
           dynamicConfig.getReplicationThrottleLimit(),
           dynamicConfig.getReplicantLifetime(),
           dynamicConfig.isUseRoundRobinSegmentAssignment(),
-          dynamicConfig.getBalancerComputeThreads()
+          dynamicConfig.getBalancerComputeThreads(),
+          dynamicConfig.getHistoricalTierAliases()
       );
     }
   }
@@ -80,7 +87,8 @@ public class SegmentLoadingConfig
       int replicationThrottleLimit,
       int maxLifetimeInLoadQueue,
       boolean useRoundRobinSegmentAssignment,
-      int balancerComputeThreads
+      int balancerComputeThreads,
+      Map<String, Set<String>> historicalTierAliases
   )
   {
     this.maxSegmentsInLoadQueue = maxSegmentsInLoadQueue;
@@ -88,6 +96,7 @@ public class SegmentLoadingConfig
     this.maxLifetimeInLoadQueue = maxLifetimeInLoadQueue;
     this.useRoundRobinSegmentAssignment = useRoundRobinSegmentAssignment;
     this.balancerComputeThreads = balancerComputeThreads;
+    this.historicalTierAliases = historicalTierAliases;
   }
 
   public int getMaxSegmentsInLoadQueue()
@@ -114,4 +123,9 @@ public class SegmentLoadingConfig
   {
     return balancerComputeThreads;
   }
+
+  public Map<String, Set<String>> getHistoricalTierAliases()
+  {
+    return historicalTierAliases;
+  }
 }
diff --git 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
index 2b7bbdb49e7..d4fbfd82e31 100644
--- 
a/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
+++ 
b/server/src/main/java/org/apache/druid/server/coordinator/loading/StrategicSegmentAssigner.java
@@ -64,6 +64,7 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
   private final BalancerStrategy strategy;
 
   private final boolean useRoundRobinAssignment;
+  private final Map<String, Set<String>> historicalTierAliases;
 
   private final Map<String, Set<String>> datasourceToInvalidLoadTiers = new 
HashMap<>();
   private final Map<String, Integer> tierToHistoricalCount = new HashMap<>();
@@ -87,6 +88,7 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
     this.replicationThrottler = createReplicationThrottler(cluster, 
loadingConfig);
     this.useRoundRobinAssignment = 
loadingConfig.isUseRoundRobinSegmentAssignment();
     this.serverSelector = useRoundRobinAssignment ? new 
RoundRobinServerSelector(cluster) : null;
+    this.historicalTierAliases = loadingConfig.getHistoricalTierAliases();
 
     cluster.getManagedHistoricals().forEach(
         (tier, historicals) -> tierToHistoricalCount.put(tier, 
historicals.size())
@@ -198,17 +200,45 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
     }
   }
 
+  /**
+   * Resolves alias tiers in the given tier-to-replica-count map. For each tier
+   * that is a key in {@link #historicalTierAliases}, the entry is replaced by
+   * one entry per alias value — each receiving the same replica count. The 
alias
+   * key itself is treated as a virtual tier and is not kept in the result. 
Tiers
+   * not present in {@link #historicalTierAliases} are passed through 
unchanged.
+   * Explicit counts already present in the map are not overwritten by alias 
expansion.
+   */
+  private Map<String, Integer> expandWithAliases(Map<String, Integer> 
tierToReplicaCount)
+  {
+    if (historicalTierAliases.isEmpty()) {
+      return tierToReplicaCount;
+    }
+
+    final Map<String, Integer> expanded = new HashMap<>();
+    tierToReplicaCount.forEach((tier, replicaCount) -> {
+      final Set<String> aliases = historicalTierAliases.get(tier);
+      if (aliases != null) {
+        // tier is a virtual alias key — replace it with its real tiers
+        aliases.forEach(alias -> expanded.putIfAbsent(alias, replicaCount));
+      } else {
+        expanded.put(tier, replicaCount);
+      }
+    });
+    return expanded;
+  }
+
   @Override
   public void replicateSegment(DataSegment segment, Map<String, Integer> 
tierToReplicaCount)
   {
+    final Map<String, Integer> effectiveTierToReplicaCount = 
expandWithAliases(tierToReplicaCount);
     final Set<String> allTiersInCluster = 
Sets.newHashSet(cluster.getTierNames());
 
-    if (tierToReplicaCount.isEmpty()) {
+    if (effectiveTierToReplicaCount.isEmpty()) {
       // Track the counts for a segment even if it requires 0 replicas on all 
tiers
       replicaCountMap.computeIfAbsent(segment.getId(), 
DruidServer.DEFAULT_TIER);
     } else {
       // Identify empty tiers and determine total required replicas
-      tierToReplicaCount.forEach((tier, requiredReplicas) -> {
+      effectiveTierToReplicaCount.forEach((tier, requiredReplicas) -> {
         reportTierCapacityStats(segment, requiredReplicas, tier);
 
         SegmentReplicaCount replicaCount = 
replicaCountMap.computeIfAbsent(segment.getId(), tier);
@@ -237,7 +267,7 @@ public class StrategicSegmentAssigner implements 
SegmentActionHandler
       dropsQueued += updateReplicasInTier(
           segment,
           tier,
-          tierToReplicaCount.getOrDefault(tier, 0),
+          effectiveTierToReplicaCount.getOrDefault(tier, 0),
           replicaSurplus - dropsQueued
       );
     }
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/LoadRuleTest.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/LoadRuleTest.java
index 5cd5dd9d0f9..329f24d420e 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/rules/LoadRuleTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/rules/LoadRuleTest.java
@@ -58,6 +58,7 @@ import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
 
 /**
@@ -523,10 +524,136 @@ public class LoadRuleTest
     );
   }
 
+  /**
+   * Verifies that a load rule targeting a virtual alias tier is applied only 
to
+   * the real tiers in the alias set — the alias key itself receives no 
assignment.
+   */
+  @Test
+  public void testHistoricalTierAliasesAppliesOnlyToAliasTiers()
+  {
+    // T1 is the virtual alias key and has no servers; T2 and T3 are the real 
tiers
+    final ServerHolder hot1Server = createServer(Tier.T2);
+    final ServerHolder hot2Server = createServer(Tier.T3);
+    DruidCluster cluster = DruidCluster
+        .builder()
+        .addTier(Tier.T2, hot1Server)
+        .addTier(Tier.T3, hot2Server)
+        .build();
+
+    final DataSegment segment = createDataSegment(TestDataSource.WIKI);
+    LoadRule rule = loadForever(ImmutableMap.of(Tier.T1, 1));
+    CoordinatorRunStats stats = runRuleAndGetStats(
+        rule,
+        segment,
+        makeCoordinatorRuntimeParams(
+            cluster,
+            ImmutableMap.of(Tier.T1, Set.of(Tier.T2, Tier.T3)),
+            segment
+        )
+    );
+
+    Assert.assertEquals(0L, stats.getSegmentStat(Stats.Segments.ASSIGNED, 
Tier.T1, TestDataSource.WIKI));
+    Assert.assertEquals(1L, stats.getSegmentStat(Stats.Segments.ASSIGNED, 
Tier.T2, TestDataSource.WIKI));
+    Assert.assertEquals(1L, stats.getSegmentStat(Stats.Segments.ASSIGNED, 
Tier.T3, TestDataSource.WIKI));
+  }
+
+  /**
+   * Verifies that an alias key tier with no servers does not fire an 
invalid-tier
+   * alert, but an alias value tier with no servers does.
+   */
+  @Test
+  public void testHistoricalTierAliasesInvalidTierAlerts()
+  {
+    // Only T2 and T3 have servers; T1 ("hot") is a pure alias key with no 
servers
+    final ServerHolder t2Server = createServer(Tier.T2);
+    DruidCluster cluster = DruidCluster
+        .builder()
+        .addTier(Tier.T2, t2Server)
+        .build();
+
+    final DataSegment segment = createDataSegment(TestDataSource.WIKI);
+    LoadRule rule = loadForever(ImmutableMap.of(Tier.T1, 1));
+
+    // T1 is an alias key -> no invalid-tier alert for T1.
+    // T3 is an alias value with no servers -> invalid-tier alert fires for T3.
+    DruidCoordinatorRuntimeParams params = makeCoordinatorRuntimeParams(
+        cluster,
+        ImmutableMap.of(Tier.T1, Set.of(Tier.T2, Tier.T3)),
+        segment
+    );
+    rule.run(segment, params.getSegmentAssigner());
+    Map<String, Set<String>> invalidTiers = 
params.getSegmentAssigner().getDatasourceToInvalidLoadTiers();
+
+    Assert.assertFalse(
+        "Alias key tier should not trigger an invalid-tier alert",
+        invalidTiers.getOrDefault(TestDataSource.WIKI, 
Collections.emptySet()).contains(Tier.T1)
+    );
+    Assert.assertTrue(
+        "Alias value tier with no servers should trigger an invalid-tier 
alert",
+        invalidTiers.getOrDefault(TestDataSource.WIKI, 
Collections.emptySet()).contains(Tier.T3)
+    );
+  }
+
+  /**
+   * Verifies that an explicit replica count for an alias value tier in the 
rule
+   * is not overwritten by the alias expansion, and the virtual alias key tier
+   * itself receives no assignment.
+   */
+  @Test
+  public void testHistoricalTierAliasesDoesNotOverwriteExplicitCount()
+  {
+    final ServerHolder t2Server1 = createServer(Tier.T2);
+    final ServerHolder t2Server2 = createServer(Tier.T2);
+    DruidCluster cluster = DruidCluster
+        .builder()
+        .addTier(Tier.T2, t2Server1, t2Server2)
+        .build();
+
+    final DataSegment segment = createDataSegment(TestDataSource.WIKI);
+    // Rule explicitly sets T2 to 2; T1 is a virtual alias for T2 with count 1.
+    // T2's explicit count of 2 must win over the alias-derived count of 1.
+    LoadRule rule = loadForever(ImmutableMap.of(Tier.T1, 1, Tier.T2, 2));
+    CoordinatorRunStats stats = runRuleAndGetStats(
+        rule,
+        segment,
+        makeCoordinatorRuntimeParams(
+            cluster,
+            ImmutableMap.of(Tier.T1, Set.of(Tier.T2)),
+            segment
+        )
+    );
+
+    Assert.assertEquals(0L, stats.getSegmentStat(Stats.Segments.ASSIGNED, 
Tier.T1, TestDataSource.WIKI));
+    Assert.assertEquals(2L, stats.getSegmentStat(Stats.Segments.ASSIGNED, 
Tier.T2, TestDataSource.WIKI));
+  }
+
+  private DruidCoordinatorRuntimeParams makeCoordinatorRuntimeParams(
+      DruidCluster druidCluster,
+      Map<String, Set<String>> historicalTierAliases,
+      DataSegment... usedSegments
+  )
+  {
+    return DruidCoordinatorRuntimeParams
+        .builder()
+        .withDruidCluster(druidCluster)
+        .withBalancerStrategy(balancerStrategy)
+        .withUsedSegments(usedSegments)
+        .withDynamicConfigs(
+            CoordinatorDynamicConfig.builder()
+                                    .withSmartSegmentLoading(false)
+                                    
.withUseRoundRobinSegmentAssignment(useRoundRobinAssignment)
+                                    
.withHistoricalTierAliases(historicalTierAliases)
+                                    .build()
+        )
+        .withSegmentAssignerUsing(loadQueueManager)
+        .build();
+  }
+
   private static class Tier
   {
     static final String T1 = "tier1";
     static final String T2 = "tier2";
+    static final String T3 = "tier3";
   }
 
   @Test
diff --git 
a/server/src/test/java/org/apache/druid/server/http/CoordinatorDynamicConfigTest.java
 
b/server/src/test/java/org/apache/druid/server/http/CoordinatorDynamicConfigTest.java
index 22f3ccbdd4d..f56344ec12e 100644
--- 
a/server/src/test/java/org/apache/druid/server/http/CoordinatorDynamicConfigTest.java
+++ 
b/server/src/test/java/org/apache/druid/server/http/CoordinatorDynamicConfigTest.java
@@ -23,6 +23,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import nl.jqno.equalsverifier.EqualsVerifier;
+import org.apache.druid.error.DruidException;
 import org.apache.druid.segment.TestHelper;
 import org.apache.druid.server.coordinator.CoordinatorDynamicConfig;
 import org.apache.druid.utils.JvmUtils;
@@ -279,6 +280,7 @@ public class CoordinatorDynamicConfigTest
         false,
         null,
         ImmutableSet.of("host1"),
+        null,
         null
     );
     
Assert.assertTrue(config.getSpecificDataSourcesToKillUnusedSegmentsIn().isEmpty());
@@ -305,6 +307,7 @@ public class CoordinatorDynamicConfigTest
         false,
         null,
         ImmutableSet.of("host1"),
+        null,
         null
     );
     Assert.assertEquals(ImmutableSet.of("test1"), 
config.getSpecificDataSourcesToKillUnusedSegmentsIn());
@@ -645,6 +648,56 @@ public class CoordinatorDynamicConfigTest
     return Math.max(1, JvmUtils.getRuntimeInfo().getAvailableProcessors() / 2);
   }
 
+  @Test
+  public void testHistoricalTierAliases() throws Exception
+  {
+    // Basic set and get via builder
+    Map<String, Set<String>> aliases = Map.of(
+        "hot", Set.of("hot_1", "hot_2"),
+        "cold", Set.of("cold_1")
+    );
+    CoordinatorDynamicConfig config = CoordinatorDynamicConfig.builder()
+                                                              
.withHistoricalTierAliases(aliases)
+                                                              .build();
+    Assert.assertEquals(aliases, config.getHistoricalTierAliases());
+
+    // build(defaults) propagates aliases when not overridden
+    CoordinatorDynamicConfig updated = 
CoordinatorDynamicConfig.builder().build(config);
+    Assert.assertEquals(aliases, updated.getHistoricalTierAliases());
+
+    // Serde roundtrip with duplicate values in the JSON array — duplicates 
must be deduplicated
+    String jsonWithDupes = "{"
+                           + "\"historicalTierAliases\": {"
+                           + "  \"hot\": [\"hot_1\", \"hot_2\", \"hot_1\"]"
+                           + "}"
+                           + "}";
+    CoordinatorDynamicConfig deserialized = mapper.readValue(
+        mapper.writeValueAsString(mapper.readValue(jsonWithDupes, 
CoordinatorDynamicConfig.class)),
+        CoordinatorDynamicConfig.class
+    );
+    Assert.assertEquals(Set.of("hot_1", "hot_2"), 
deserialized.getHistoricalTierAliases().get("hot"));
+
+    // Absent field defaults to empty map
+    CoordinatorDynamicConfig defaultConfig = 
CoordinatorDynamicConfig.builder().build();
+    Assert.assertEquals(Map.of(), defaultConfig.getHistoricalTierAliases());
+  }
+
+  @Test
+  public void testHistoricalTierAliasesNoCycle()
+  {
+    Map<String, Set<String>> aliases = Map.of(
+        "hot", Set.of("hot", "hot_2"),
+        "cold", Set.of("cold_1"),
+        "another", Set.of("hot")
+    );
+
+    DruidException exception = Assert.assertThrows(
+        DruidException.class,
+        () -> 
CoordinatorDynamicConfig.builder().withHistoricalTierAliases(aliases).build()
+    );
+    Assert.assertTrue("Throws correct virtual tier alias message", 
exception.getMessage().contains("A virtual tier alias cannot be a physical 
tier."));
+  }
+
   @Test
   public void testEquals()
   {
diff --git 
a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-completions.ts
 
b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-completions.ts
index 7a5cc2b7ee8..2f0ca83ae0e 100644
--- 
a/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-completions.ts
+++ 
b/web-console/src/dialogs/coordinator-dynamic-config-dialog/coordinator-dynamic-config-completions.ts
@@ -93,6 +93,11 @@ export const COORDINATOR_DYNAMIC_CONFIG_COMPLETIONS: 
JsonCompletionRule[] = [
         value: 'turboLoadingNodes',
         documentation: 'List of Historical servers to place in turbo loading 
mode (experimental)',
       },
+      {
+        value: 'historicalTierAliases',
+        documentation:
+          'Map of virtual alias tier names to sets of physical historical 
tiers they represent',
+      },
     ],
   },
   // Properties only available when smartSegmentLoading is false
diff --git 
a/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
 
b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
index 3794ecb6037..4b7a84090ae 100644
--- 
a/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
+++ 
b/web-console/src/druid-models/coordinator-dynamic-config/coordinator-dynamic-config.tsx
@@ -39,6 +39,7 @@ export interface CoordinatorDynamicConfig {
   useRoundRobinSegmentAssignment?: boolean;
   smartSegmentLoading?: boolean;
   turboLoadingNodes?: string[];
+  historicalTierAliases?: Record<string, string[]>;
 
   // Undocumented
   debugDimensions?: any;
@@ -265,4 +266,16 @@ export const COORDINATOR_DYNAMIC_CONFIG_FIELDS: 
Field<CoordinatorDynamicConfig>[
       </>
     ),
   },
+  {
+    name: 'historicalTierAliases',
+    type: 'json',
+    placeholder: '{"hot": ["hot_v1", "hot_v2"]}',
+    info: (
+      <>
+        A map of virtual alias tier names to the sets of physical historical 
tiers they represent.
+        Retention rules can reference an alias tier instead of listing each 
physical tier
+        separately. An alias tier cannot itself be a physical tier name.
+      </>
+    ),
+  },
 ];


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to