This is an automated email from the ASF dual-hosted git repository.
maytasm 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 148a728bb7b feat: Configurable per-threshold weighted laning (#19665)
148a728bb7b is described below
commit 148a728bb7b12648dbd64b6c5e593b18b5a92c2a
Author: mshahid6 <[email protected]>
AuthorDate: Wed Jul 15 14:32:18 2026 -0700
feat: Configurable per-threshold weighted laning (#19665)
* Weighted laning: configurable per-threshold weights
* updates
---------
Co-authored-by: Maryam Shahid <[email protected]>
---
docs/configuration/index.md | 6 +-
.../org/apache/druid/server/QueryScheduler.java | 6 ++
.../scheduling/WeightedQueryLaningStrategy.java | 86 +++++++++++++++++--
.../WeightedQueryLaningStrategyTest.java | 98 ++++++++++++++++++++++
4 files changed, 187 insertions(+), 9 deletions(-)
diff --git a/docs/configuration/index.md b/docs/configuration/index.md
index f9a90712536..ab864cb21ce 100644
--- a/docs/configuration/index.md
+++ b/docs/configuration/index.md
@@ -1742,7 +1742,7 @@ This laning strategy is best suited for cases where one
or more external applica
###### Weighted laning strategy
-This laning strategy assigns a cost to each query based on how many of a
configurable set of thresholds it breaches, then routes the query to the most
restrictive lane whose `minCost` the query meets. It is a more granular
alternative to the 'High/Low' strategy: rather than a binary split, a query
that breaches one threshold can be assigned to a different lane than one that
breaches several. The thresholds are the same ones used by the [threshold
prioritization strategy](#threshold-prior [...]
+This laning strategy assigns a cost to each query based on how many of a
configurable set of thresholds it breaches, then routes the query to the most
restrictive lane whose `minCost` the query meets. It is a more granular
alternative to the 'High/Low' strategy: rather than a binary split, a query
that breaches one threshold can be assigned to a different lane than one that
breaches several. The thresholds are the same ones used by the [threshold
prioritization strategy](#threshold-prior [...]
If a lane is specified in the [query
context](../querying/query-context-reference.md) `lane` parameter, this will
override the computed lane.
@@ -1754,6 +1754,10 @@ This strategy can be enabled by setting
`druid.query.scheduler.laning.strategy=w
|`druid.query.scheduler.laning.durationThreshold`|ISO 8601 duration (must not
contain month or year components). A query is charged 1 if its total interval
duration exceeds this.|null (not evaluated)|
|`druid.query.scheduler.laning.segmentCountThreshold`|A query is charged 1 if
the number of segments it involves exceeds this. Must be greater than 0.|null
(not evaluated)|
|`druid.query.scheduler.laning.segmentRangeThreshold`|ISO 8601 duration (must
not contain month or year components). A query is charged 1 if the summed time
range of its distinct segments exceeds this.|null (not evaluated)|
+|`druid.query.scheduler.laning.periodWeight`|Optional weight added to a
query's cost when it breaches `periodThreshold`. Must be an integer >= 1, and
`periodThreshold` must be set.|1|
+|`druid.query.scheduler.laning.durationWeight`|Optional weight added to a
query's cost when it breaches `durationThreshold`. Must be an integer >= 1, and
`durationThreshold` must be set.|1|
+|`druid.query.scheduler.laning.segmentCountWeight`|Optional weight added to a
query's cost when it breaches `segmentCountThreshold`. Must be an integer >= 1,
and `segmentCountThreshold` must be set.|1|
+|`druid.query.scheduler.laning.segmentRangeWeight`|Optional weight added to a
query's cost when it breaches `segmentRangeThreshold`. Must be an integer >= 1,
and `segmentRangeThreshold` must be set.|1|
|`druid.query.scheduler.laning.lanes`|A map of lane name to its configuration.
At least one lane must be defined. Each lane's configuration has two fields:
`minCost`, the minimum query cost required to be assigned to the lane (a query
is assigned to the lane with the highest `minCost` it meets; must be greater
than 0 and unique across lanes so lane selection is deterministic), and
`maxPercent`, the maximum percent of the smaller number of
`druid.server.http.numThreads` or `druid.query.sc [...]
At least one of `periodThreshold`, `durationThreshold`,
`segmentCountThreshold`, or `segmentRangeThreshold` must be set. For example,
the following configuration routes queries breaching 1 or 2 thresholds to a
`low` lane capped at 30% capacity, and queries breaching 3 or 4 thresholds to a
`very-low` lane capped at 10%:
diff --git a/server/src/main/java/org/apache/druid/server/QueryScheduler.java
b/server/src/main/java/org/apache/druid/server/QueryScheduler.java
index ad43d514890..6ec8186c52b 100644
--- a/server/src/main/java/org/apache/druid/server/QueryScheduler.java
+++ b/server/src/main/java/org/apache/druid/server/QueryScheduler.java
@@ -179,6 +179,12 @@ public class QueryScheduler implements QueryWatcher
.setDimension("lane", lane.orElse(DEFAULT))
.setDimension("dataSource", query.getDataSource().getTableNames())
.setDimension("type", query.getType());
+ // "id" (the query id) lets query/priority be attributed to an individual
query rather than only aggregated at
+ // datasource/type granularity. High cardinality, so downstream pipelines
must not forward it to low-cardinality
+ // stores (e.g. Atlas). Guarded because some (typically internal) queries
carry no id and setDimension rejects null.
+ if (query.getId() != null) {
+ builderUsr.setDimension("id", query.getId());
+ }
emitter.emit(builderUsr.setMetric("query/priority",
priority.orElse(Integer.valueOf(0))));
return lane.map(query::withLane).orElse(query);
}
diff --git
a/server/src/main/java/org/apache/druid/server/scheduling/WeightedQueryLaningStrategy.java
b/server/src/main/java/org/apache/druid/server/scheduling/WeightedQueryLaningStrategy.java
index b3f075981c7..6ee61cadb96 100644
---
a/server/src/main/java/org/apache/druid/server/scheduling/WeightedQueryLaningStrategy.java
+++
b/server/src/main/java/org/apache/druid/server/scheduling/WeightedQueryLaningStrategy.java
@@ -21,6 +21,7 @@ package org.apache.druid.server.scheduling;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import it.unimi.dsi.fastutil.objects.Object2IntArrayMap;
import it.unimi.dsi.fastutil.objects.Object2IntMap;
@@ -91,13 +92,32 @@ public class WeightedQueryLaningStrategy implements
QueryLaningStrategy
@JsonProperty
private final Map<String, LaneConfig> lanes;
+ // Optional per-threshold weight: how much a breach of that threshold adds
to the query's cost. Null means the
+ // default weight of 1 (the original flat scoring). Lets a breach on one
dimension count for more than another.
+ @JsonProperty("periodWeight")
+ @Nullable
+ private final Integer periodWeight;
+ @JsonProperty("durationWeight")
+ @Nullable
+ private final Integer durationWeight;
+ @JsonProperty("segmentCountWeight")
+ @Nullable
+ private final Integer segmentCountWeight;
+ @JsonProperty("segmentRangeWeight")
+ @Nullable
+ private final Integer segmentRangeWeight;
+
@JsonCreator
public WeightedQueryLaningStrategy(
@JsonProperty("periodThreshold") @Nullable String periodThresholdString,
@JsonProperty("durationThreshold") @Nullable String
durationThresholdString,
@JsonProperty("segmentCountThreshold") @Nullable Integer
segmentCountThreshold,
@JsonProperty("segmentRangeThreshold") @Nullable String
segmentRangeThresholdString,
- @JsonProperty("lanes") Map<String, LaneConfig> lanes
+ @JsonProperty("lanes") Map<String, LaneConfig> lanes,
+ @JsonProperty("periodWeight") @Nullable Integer periodWeight,
+ @JsonProperty("durationWeight") @Nullable Integer durationWeight,
+ @JsonProperty("segmentCountWeight") @Nullable Integer segmentCountWeight,
+ @JsonProperty("segmentRangeWeight") @Nullable Integer segmentRangeWeight
)
{
final Period parsedPeriod;
@@ -183,6 +203,12 @@ public class WeightedQueryLaningStrategy implements
QueryLaningStrategy
+ "produce non-deterministic results). Found duplicate minCost values
in lanes: [%s]",
lanes
);
+ // Each weight, if set, must be >= 1 and must correspond to a threshold
that is actually configured
+ // (otherwise the weight is silently inert, which is almost certainly a
misconfiguration).
+ validateWeight("periodWeight", periodWeight, parsedPeriod != null,
"periodThreshold");
+ validateWeight("durationWeight", durationWeight, parsedDuration != null,
"durationThreshold");
+ validateWeight("segmentCountWeight", segmentCountWeight,
segmentCountThreshold != null, "segmentCountThreshold");
+ validateWeight("segmentRangeWeight", segmentRangeWeight,
parsedSegmentRange != null, "segmentRangeThreshold");
this.segmentCountThreshold = segmentCountThreshold;
this.periodThresholdString = periodThresholdString;
@@ -192,6 +218,47 @@ public class WeightedQueryLaningStrategy implements
QueryLaningStrategy
this.durationThreshold = parsedDuration;
this.segmentRangeThreshold = parsedSegmentRange;
this.lanes = lanes;
+ this.periodWeight = periodWeight;
+ this.durationWeight = durationWeight;
+ this.segmentCountWeight = segmentCountWeight;
+ this.segmentRangeWeight = segmentRangeWeight;
+ }
+
+ /**
+ * Convenience constructor without per-threshold weights (every breach adds
1). Retained so existing callers
+ * and tests need not pass the optional weight parameters.
+ */
+ @VisibleForTesting
+ public WeightedQueryLaningStrategy(
+ @Nullable String periodThresholdString,
+ @Nullable String durationThresholdString,
+ @Nullable Integer segmentCountThreshold,
+ @Nullable String segmentRangeThresholdString,
+ Map<String, LaneConfig> lanes
+ )
+ {
+ this(periodThresholdString, durationThresholdString,
segmentCountThreshold, segmentRangeThresholdString, lanes,
+ null, null, null, null);
+ }
+
+ private static void validateWeight(String name, @Nullable Integer weight,
boolean thresholdSet, String thresholdName)
+ {
+ if (weight == null) {
+ return;
+ }
+ Preconditions.checkArgument(weight >= 1, "%s must be >= 1, got [%s]",
name, weight);
+ Preconditions.checkArgument(
+ thresholdSet,
+ "%s is set but %s is not configured, so the weight would have no
effect", name, thresholdName
+ );
+ }
+
+ /**
+ * Weight (cost contribution) for a breached threshold; defaults to 1 when
not configured.
+ */
+ private static int weightOrDefault(@Nullable Integer weight)
+ {
+ return weight == null ? 1 : weight;
}
@Override
@@ -212,7 +279,7 @@ public class WeightedQueryLaningStrategy implements
QueryLaningStrategy
return Optional.of(existingLane);
}
- int cost = computeCost(query.getQuery(), segments);
+ long cost = computeCost(query.getQuery(), segments);
if (cost == 0) {
return Optional.empty();
}
@@ -230,9 +297,12 @@ public class WeightedQueryLaningStrategy implements
QueryLaningStrategy
return Optional.ofNullable(highestLane);
}
- private <T> int computeCost(Query<T> query, Set<SegmentServerSelector>
segments)
+ // Accumulated as a long: with per-threshold weights up to
Integer.MAX_VALUE, summing all four in an int could
+ // overflow into a negative cost, silently bypassing every lane. Callers
compare this against int minCost values,
+ // so overflow of the long itself is not a concern in practice.
+ private <T> long computeCost(Query<T> query, Set<SegmentServerSelector>
segments)
{
- int cost = 0;
+ long cost = 0;
if (periodThreshold != null) {
final DateTime now = DateTimes.nowUtc();
@@ -242,16 +312,16 @@ public class WeightedQueryLaningStrategy implements
QueryLaningStrategy
// query with hundreds of intervals.
final List<Interval> intervals = query.getIntervals();
if (!intervals.isEmpty() &&
intervals.get(0).getStart().isBefore(cutoff)) {
- cost++;
+ cost += weightOrDefault(periodWeight);
}
}
if (durationThreshold != null &&
query.getDuration().isLongerThan(durationThreshold)) {
- cost++;
+ cost += weightOrDefault(durationWeight);
}
if (segmentCountThreshold != null && segments.size() >
segmentCountThreshold) {
- cost++;
+ cost += weightOrDefault(segmentCountWeight);
}
if (segmentRangeThreshold != null) {
@@ -262,7 +332,7 @@ public class WeightedQueryLaningStrategy implements
QueryLaningStrategy
.mapToLong(AbstractInterval::toDurationMillis)
.sum();
if (segmentRangeMs > segmentRangeThreshold.getMillis()) {
- cost++;
+ cost += weightOrDefault(segmentRangeWeight);
}
}
diff --git
a/server/src/test/java/org/apache/druid/server/scheduling/WeightedQueryLaningStrategyTest.java
b/server/src/test/java/org/apache/druid/server/scheduling/WeightedQueryLaningStrategyTest.java
index 65eeb50d4da..522d5ad18b1 100644
---
a/server/src/test/java/org/apache/druid/server/scheduling/WeightedQueryLaningStrategyTest.java
+++
b/server/src/test/java/org/apache/druid/server/scheduling/WeightedQueryLaningStrategyTest.java
@@ -376,6 +376,104 @@ public class WeightedQueryLaningStrategyTest
);
}
+ @Test
+ public void testComputeLane_segmentCountWeight_bumpsCost()
+ {
+ // A single segmentCount breach normally scores 1 -> "low"; weighting it 3
makes cost=3 -> "very-low".
+ WeightedQueryLaningStrategy strategy =
+ new WeightedQueryLaningStrategy(null, null, 1, null, TWO_LANES, null,
null, 3, null);
+ TimeseriesQuery query = queryBuilder.build();
+ Optional<String> lane = strategy.computeLane(QueryPlus.wrap(query),
makeSegments(5));
+ Assert.assertTrue(lane.isPresent());
+ Assert.assertEquals("very-low", lane.get());
+ }
+
+ @Test
+ public void testComputeLane_defaultWeightUnchanged()
+ {
+ // Same single segmentCount breach without weights stays "low" (weight
defaults to 1).
+ WeightedQueryLaningStrategy strategy = new
WeightedQueryLaningStrategy(null, null, 1, null, TWO_LANES);
+ Optional<String> lane =
strategy.computeLane(QueryPlus.wrap(queryBuilder.build()), makeSegments(5));
+ Assert.assertTrue(lane.isPresent());
+ Assert.assertEquals("low", lane.get());
+ }
+
+ @Test
+ public void testComputeLane_weightsSumAcrossThresholds()
+ {
+ // Breach durationThreshold (weight 2) + segmentCountThreshold (weight 2)
=> cost 4 => "very-low".
+ WeightedQueryLaningStrategy strategy =
+ new WeightedQueryLaningStrategy(null, "PT1S", 1, null, TWO_LANES,
null, 2, 2, null);
+ Optional<String> lane =
strategy.computeLane(QueryPlus.wrap(queryBuilder.build()), makeSegments(5));
+ Assert.assertTrue(lane.isPresent());
+ Assert.assertEquals("very-low", lane.get());
+ }
+
+ @Test
+ public void testComputeLane_largeWeightsDoNotOverflowToNegativeCost()
+ {
+ // durationThreshold and segmentCountThreshold both breached, each
weighted at Integer.MAX_VALUE. A naive
+ // int accumulation would wrap around to a negative cost and bypass every
lane (Optional.empty()); the fix
+ // accumulates as a long, so the query still lands in the most restrictive
lane.
+ WeightedQueryLaningStrategy strategy = new WeightedQueryLaningStrategy(
+ null,
+ "PT1S",
+ 1,
+ null,
+ TWO_LANES,
+ null,
+ Integer.MAX_VALUE,
+ Integer.MAX_VALUE,
+ null
+ );
+ Optional<String> lane =
strategy.computeLane(QueryPlus.wrap(queryBuilder.build()), makeSegments(5));
+ Assert.assertTrue(lane.isPresent());
+ Assert.assertEquals("very-low", lane.get());
+ }
+
+ @Test
+ public void testSerde_withWeights() throws Exception
+ {
+ ObjectMapper mapper = TestHelper.makeJsonMapper();
+ String json = "{\n"
+ + " \"strategy\": \"weighted\",\n"
+ + " \"segmentCountThreshold\": 1,\n"
+ + " \"segmentCountWeight\": 3,\n"
+ + " \"lanes\": {\n"
+ + " \"low\": { \"minCost\": 1, \"maxPercent\": 30 },\n"
+ + " \"very-low\": { \"minCost\": 3, \"maxPercent\": 10
}\n"
+ + " }\n"
+ + "}";
+ QueryLaningStrategy deserialized = mapper.readValue(json,
QueryLaningStrategy.class);
+ Assert.assertTrue(deserialized instanceof WeightedQueryLaningStrategy);
+ // weight 3 applied to the single segmentCount breach -> cost 3 ->
"very-low"
+ Optional<String> lane =
deserialized.computeLane(QueryPlus.wrap(queryBuilder.build()), makeSegments(5));
+ Assert.assertEquals("very-low", lane.orElse(null));
+ }
+
+ @Test
+ public void testValidation_weightBelowOne()
+ {
+ Assert.assertThrows(
+ IllegalArgumentException.class,
+ () -> new WeightedQueryLaningStrategy(null, null, 1, null, TWO_LANES,
null, null, 0, null)
+ );
+ Assert.assertThrows(
+ IllegalArgumentException.class,
+ () -> new WeightedQueryLaningStrategy(null, null, 1, null, TWO_LANES,
null, null, -1, null)
+ );
+ }
+
+ @Test
+ public void testValidation_weightForUnsetThreshold()
+ {
+ // periodWeight set but no periodThreshold configured -> reject (weight
would be inert).
+ Assert.assertThrows(
+ IllegalArgumentException.class,
+ () -> new WeightedQueryLaningStrategy(null, null, 1, null, TWO_LANES,
5, null, null, null)
+ );
+ }
+
private static Set<SegmentServerSelector> makeSegments(int count)
{
Set<SegmentServerSelector> segments = new HashSet<>();
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]