Skip to content

Commit 28e3839

Browse files
committed
[FLINK-39549][flink-autoscaler] Compute OBSERVED_TPR for non-source vertices behind a feature flag
1 parent 27f66d9 commit 28e3839

8 files changed

Lines changed: 235 additions & 6 deletions

File tree

docs/layouts/shortcodes/generated/auto_scaler_configuration.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@
128128
<td>Integer</td>
129129
<td>Minimum nr of observations used when estimating / switching to observed true processing rate.</td>
130130
</tr>
131+
<tr>
132+
<td><h5>job.autoscaler.observed-true-processing-rate.non-source.enabled</h5></td>
133+
<td style="word-wrap: break-word;">false</td>
134+
<td>Boolean</td>
135+
<td>Whether to also compute the observed (backpressure-derived) true processing rate for non-source vertices. When enabled, OBSERVED_TPR is populated for any vertex whose engagement (busy + backpressured time) crosses the configured threshold, mirroring the source-side behavior gated on lag. Default off for backward compatibility.</td>
136+
</tr>
137+
<tr>
138+
<td><h5>job.autoscaler.observed-true-processing-rate.non-source.engagement-threshold</h5></td>
139+
<td style="word-wrap: break-word;">0.95</td>
140+
<td>Double</td>
141+
<td>Engagement threshold (between 0 and 1) for triggering observed true processing rate computation on non-source vertices. Computed as (busyTimeMsPerSecond + backPressuredTimeMsPerSecond) / 1000. The vertex is considered fully engaged (and therefore not input-starved) when this value crosses the threshold, which is the non-source analogue of the source 'has lag' condition.</td>
142+
</tr>
131143
<tr>
132144
<td><h5>job.autoscaler.observed-true-processing-rate.switch-threshold</h5></td>
133145
<td style="word-wrap: break-word;">0.15</td>

flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingMetricCollector.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,7 @@ protected Map<JobVertexID, Map<String, FlinkMetric>> queryFilteredMetricNames(
439439
@SneakyThrows
440440
private Map<JobVertexID, Map<String, FlinkMetric>> queryFilteredMetricNames(
441441
Context ctx, JobTopology topology, Stream<JobVertexID> vertexStream) {
442+
var conf = ctx.getConfiguration();
442443
try (var restClient = ctx.getRestClusterClient()) {
443444
return vertexStream
444445
.filter(v -> !topology.getFinishedVertices().contains(v))
@@ -447,7 +448,11 @@ private Map<JobVertexID, Map<String, FlinkMetric>> queryFilteredMetricNames(
447448
v -> v,
448449
v ->
449450
getFilteredVertexMetricNames(
450-
restClient, ctx.getJobID(), v, topology)));
451+
restClient,
452+
ctx.getJobID(),
453+
v,
454+
topology,
455+
conf)));
451456
}
452457
}
453458

@@ -456,7 +461,8 @@ Map<String, FlinkMetric> getFilteredVertexMetricNames(
456461
RestClusterClient<?> restClient,
457462
JobID jobID,
458463
JobVertexID jobVertexID,
459-
JobTopology topology) {
464+
JobTopology topology,
465+
Configuration conf) {
460466

461467
var allMetricNames = queryAggregatedMetricNames(restClient, jobID, jobVertexID);
462468
var filteredMetrics = new HashMap<String, FlinkMetric>();
@@ -484,6 +490,17 @@ Map<String, FlinkMetric> getFilteredVertexMetricNames(
484490
.findAny(allMetricNames)
485491
.ifPresent(
486492
m -> filteredMetrics.put(m, FlinkMetric.SOURCE_TASK_NUM_RECORDS_OUT));
493+
} else if (conf != null
494+
&& conf.get(AutoScalerOptions.OBSERVED_TRUE_PROCESSING_RATE_NON_SOURCE_ENABLED)) {
495+
// Non-source observed TPR computation requires backpressure and a per-vertex input
496+
// rate. These are best-effort: if either is unavailable for the deployed Flink
497+
// version, we silently skip observed-TPR for this vertex (no exception).
498+
FlinkMetric.BACKPRESSURE_TIME_PER_SEC
499+
.findAny(allMetricNames)
500+
.ifPresent(m -> filteredMetrics.put(m, FlinkMetric.BACKPRESSURE_TIME_PER_SEC));
501+
FlinkMetric.NUM_RECORDS_IN_PER_SEC
502+
.findAny(allMetricNames)
503+
.ifPresent(m -> filteredMetrics.put(m, FlinkMetric.NUM_RECORDS_IN_PER_SEC));
487504
}
488505

489506
for (FlinkMetric flinkMetric : requiredMetrics) {

flink-autoscaler/src/main/java/org/apache/flink/autoscaler/config/AutoScalerOptions.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,35 @@ private static ConfigOptions.OptionBuilder autoScalerConfig(String key) {
243243
.withDescription(
244244
"Minimum nr of observations used when estimating / switching to observed true processing rate.");
245245

246+
public static final ConfigOption<Boolean> OBSERVED_TRUE_PROCESSING_RATE_NON_SOURCE_ENABLED =
247+
autoScalerConfig("observed-true-processing-rate.non-source.enabled")
248+
.booleanType()
249+
.defaultValue(false)
250+
.withFallbackKeys(
251+
oldOperatorConfigKey(
252+
"observed-true-processing-rate.non-source.enabled"))
253+
.withDescription(
254+
"Whether to also compute the observed (backpressure-derived) true processing rate "
255+
+ "for non-source vertices. When enabled, OBSERVED_TPR is populated for any vertex "
256+
+ "whose engagement (busy + backpressured time) crosses the configured threshold, "
257+
+ "mirroring the source-side behavior gated on lag. Default off for backward compatibility.");
258+
259+
public static final ConfigOption<Double>
260+
OBSERVED_TRUE_PROCESSING_RATE_NON_SOURCE_ENGAGEMENT_THRESHOLD =
261+
autoScalerConfig(
262+
"observed-true-processing-rate.non-source.engagement-threshold")
263+
.doubleType()
264+
.defaultValue(0.95)
265+
.withFallbackKeys(
266+
oldOperatorConfigKey(
267+
"observed-true-processing-rate.non-source.engagement-threshold"))
268+
.withDescription(
269+
"Engagement threshold (between 0 and 1) for triggering observed true processing rate "
270+
+ "computation on non-source vertices. Computed as (busyTimeMsPerSecond + "
271+
+ "backPressuredTimeMsPerSecond) / 1000. The vertex is considered fully engaged "
272+
+ "(and therefore not input-starved) when this value crosses the threshold, "
273+
+ "which is the non-source analogue of the source 'has lag' condition.");
274+
246275
public static final ConfigOption<Boolean> SCALING_EFFECTIVENESS_DETECTION_ENABLED =
247276
autoScalerConfig("scaling.effectiveness.detection.enabled")
248277
.booleanType()

flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/FlinkMetric.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
*/
3333
public enum FlinkMetric {
3434
BUSY_TIME_PER_SEC(s -> s.equals("busyTimeMsPerSecond")),
35+
NUM_RECORDS_IN_PER_SEC(s -> s.equals("numRecordsInPerSecond")),
3536
SOURCE_TASK_NUM_RECORDS_IN_PER_SEC(
3637
s -> s.startsWith("Source__") && s.endsWith(".numRecordsInPerSecond")),
3738
SOURCE_TASK_NUM_RECORDS_OUT(s -> s.startsWith("Source__") && s.endsWith(".numRecordsOut")),

flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/ScalingMetric.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ public enum ScalingMetric {
3333
/** Processing rate at full capacity (records/sec). */
3434
TRUE_PROCESSING_RATE(true),
3535

36-
/** Observed true processing rate for sources. */
36+
/**
37+
* Observed (backpressure-derived) true processing rate. Always populated for source vertices
38+
* when the source is "catching up" (lag-based gate). Optionally populated for non-source
39+
* vertices when {@code job.autoscaler.observed-true-processing-rate.non-source.enabled} is set
40+
* — gated on the vertex being fully engaged (busy + backpressured time crosses the configured
41+
* engagement threshold), which is the per-vertex analogue of the source "has lag" condition.
42+
*/
3743
OBSERVED_TPR(true),
3844

3945
/** Target processing rate of operators as derived from source inputs (records/sec). */

flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/ScalingMetrics.java

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,12 @@ public static void computeDataRateMetrics(
9898
.orElseGet(observedTprAvg);
9999
scalingMetrics.put(ScalingMetric.OBSERVED_TPR, observedTprOpt);
100100
}
101+
} else if (conf.get(AutoScalerOptions.OBSERVED_TRUE_PROCESSING_RATE_NON_SOURCE_ENABLED)) {
102+
var nonSourceObservedTpr =
103+
getNonSourceObservedTpr(flinkMetrics, conf).orElseGet(observedTprAvg);
104+
if (!Double.isNaN(nonSourceObservedTpr)) {
105+
scalingMetrics.put(ScalingMetric.OBSERVED_TPR, nonSourceObservedTpr);
106+
}
101107
}
102108
}
103109

@@ -131,6 +137,58 @@ private static Optional<Double> getObservedTpr(
131137
return Double.isNaN(observedTpr) ? Optional.empty() : Optional.of(observedTpr);
132138
}
133139

140+
/**
141+
* Compute the observed (backpressure-derived) true processing rate for a non-source vertex.
142+
*
143+
* <p>Triggered only when the vertex is "fully engaged" — i.e. spending nearly all its time
144+
* either busy or backpressured. This is the per-vertex analogue of the source-side "has lag"
145+
* gate in {@link #getObservedTpr}: a fully engaged vertex is not input-starved by upstream, so
146+
* its observed input rate represents its true capacity (modulo backpressure).
147+
*
148+
* <p>Returns {@link Optional#empty()} when the required metrics are unavailable, the vertex is
149+
* not engaged enough, or the formula degenerates (e.g. {@code backpressure >= 1000ms/s}). In
150+
* those cases the caller falls back to the historical observed TPR average (if any).
151+
*/
152+
private static Optional<Double> getNonSourceObservedTpr(
153+
Map<FlinkMetric, AggregatedMetric> flinkMetrics, Configuration conf) {
154+
155+
var bpMetric = flinkMetrics.get(FlinkMetric.BACKPRESSURE_TIME_PER_SEC);
156+
var rateMetric = flinkMetrics.get(FlinkMetric.NUM_RECORDS_IN_PER_SEC);
157+
var busyMetric = flinkMetrics.get(FlinkMetric.BUSY_TIME_PER_SEC);
158+
if (bpMetric == null || rateMetric == null || busyMetric == null) {
159+
return Optional.empty();
160+
}
161+
162+
double busyMsPerSec = busyMetric.getAvg();
163+
double bpMsPerSec = bpMetric.getAvg();
164+
if (!Double.isFinite(busyMsPerSec) || !Double.isFinite(bpMsPerSec)) {
165+
return Optional.empty();
166+
}
167+
// Engagement = fraction of time the vertex is either doing work or blocked downstream.
168+
// High engagement means the vertex is not waiting for input -> rate reflects capacity.
169+
double engagement = (Math.max(0, busyMsPerSec) + Math.max(0, bpMsPerSec)) / 1000.;
170+
double engagementThreshold =
171+
conf.get(
172+
AutoScalerOptions
173+
.OBSERVED_TRUE_PROCESSING_RATE_NON_SOURCE_ENGAGEMENT_THRESHOLD);
174+
if (engagement < engagementThreshold) {
175+
return Optional.empty();
176+
}
177+
178+
double numRecordsInPerSecond = rateMetric.getSum();
179+
if (Double.isNaN(numRecordsInPerSecond)) {
180+
return Optional.empty();
181+
}
182+
// Mirror source-side semantics: zero input rate while engaged signals no work to do,
183+
// allow scale down.
184+
if (numRecordsInPerSecond == 0) {
185+
return Optional.of(Double.POSITIVE_INFINITY);
186+
}
187+
188+
double observedTpr = computeObservedTprWithBackpressure(numRecordsInPerSecond, bpMsPerSec);
189+
return Double.isNaN(observedTpr) ? Optional.empty() : Optional.of(observedTpr);
190+
}
191+
134192
public static double computeObservedTprWithBackpressure(
135193
double numRecordsInPerSecond, double backpressureMsPerSeconds) {
136194
if (backpressureMsPerSeconds >= 1000) {

flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingMetricCollectorTest.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,11 @@ public void testQueryNamesOnTopologyChange() {
392392
new RestApiMetricsCollector<JobID, JobAutoScalerContext<JobID>>() {
393393
@Override
394394
protected Map<String, FlinkMetric> getFilteredVertexMetricNames(
395-
RestClusterClient<?> rc, JobID id, JobVertexID jvi, JobTopology t) {
395+
RestClusterClient<?> rc,
396+
JobID id,
397+
JobVertexID jvi,
398+
JobTopology t,
399+
Configuration conf) {
396400
metricNameQueryCounter.compute(jvi, (j, c) -> c + 1);
397401
return Map.of();
398402
}
@@ -517,7 +521,8 @@ private void testRequiredMetrics(
517521
metricList.addAll(requiredMetrics);
518522
metricList.remove(m);
519523
try {
520-
testCollector.getFilteredVertexMetricNames(null, new JobID(), vertex, topology);
524+
testCollector.getFilteredVertexMetricNames(
525+
null, new JobID(), vertex, topology, new Configuration());
521526
fail(m);
522527
} catch (Exception e) {
523528
assertTrue(e.getMessage().startsWith("Could not find required metric "));
@@ -551,6 +556,6 @@ public void testThrowsMetricNotFoundException() {
551556
MetricNotFoundException.class,
552557
() ->
553558
metricCollector.getFilteredVertexMetricNames(
554-
null, new JobID(), source, topology));
559+
null, new JobID(), source, topology, new Configuration()));
555560
}
556561
}

flink-autoscaler/src/test/java/org/apache/flink/autoscaler/metrics/ScalingMetricsTest.java

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,107 @@ private static AggregatedMetric aggSum(double sum) {
291291
return new AggregatedMetric("", Double.NaN, Double.NaN, Double.NaN, sum, Double.NaN);
292292
}
293293

294+
private static AggregatedMetric aggAvg(double avg) {
295+
return new AggregatedMetric("", Double.NaN, Double.NaN, avg, Double.NaN, Double.NaN);
296+
}
297+
298+
private static double computeNonSourceObservedTpr(
299+
double busyAvg,
300+
double bpAvg,
301+
double inputRatePerSecSum,
302+
Configuration conf,
303+
double prevTpr) {
304+
var source = new JobVertexID();
305+
var sink = new JobVertexID();
306+
var topology =
307+
new JobTopology(
308+
new VertexInfo(
309+
source, Collections.emptyMap(), 1, 1, new IOMetrics(0, 0, 0)),
310+
new VertexInfo(
311+
sink, Map.of(source, REBALANCE), 1, 1, new IOMetrics(0, 0, 0)));
312+
313+
Map<ScalingMetric, Double> scalingMetrics = new HashMap<>();
314+
var flinkMetrics = new HashMap<FlinkMetric, AggregatedMetric>();
315+
flinkMetrics.put(FlinkMetric.BUSY_TIME_PER_SEC, aggAvg(busyAvg));
316+
if (!Double.isNaN(bpAvg)) {
317+
flinkMetrics.put(FlinkMetric.BACKPRESSURE_TIME_PER_SEC, aggAvg(bpAvg));
318+
}
319+
if (!Double.isNaN(inputRatePerSecSum)) {
320+
flinkMetrics.put(FlinkMetric.NUM_RECORDS_IN_PER_SEC, aggSum(inputRatePerSecSum));
321+
}
322+
ScalingMetrics.computeDataRateMetrics(
323+
sink, flinkMetrics, scalingMetrics, topology, conf, () -> prevTpr);
324+
return scalingMetrics.getOrDefault(ScalingMetric.OBSERVED_TPR, Double.NaN);
325+
}
326+
327+
@Test
328+
public void testNonSourceObservedTprDisabledByDefault() {
329+
// Even with all required metrics present and the vertex fully engaged,
330+
// OBSERVED_TPR must NOT be populated for non-sources unless the flag is on.
331+
var conf = new Configuration();
332+
// Flag intentionally NOT set -> default false.
333+
double tpr = computeNonSourceObservedTpr(900., 100., 1000., conf, Double.NaN);
334+
assertTrue(Double.isNaN(tpr));
335+
}
336+
337+
@Test
338+
public void testNonSourceObservedTprEnabled() {
339+
var conf = new Configuration();
340+
conf.set(AutoScalerOptions.OBSERVED_TRUE_PROCESSING_RATE_NON_SOURCE_ENABLED, true);
341+
342+
// Fully engaged (busy 900 + bp 100 = 1000ms/s) and 200ms/s backpressure.
343+
// Wait — set bp=200 so it's clearly above zero and the formula is exercised.
344+
// engagement = (busy + bp)/1000 = (700+200)/1000 = 0.9 -> below default 0.95 -> NaN
345+
assertTrue(Double.isNaN(computeNonSourceObservedTpr(700., 200., 1000., conf, Double.NaN)));
346+
347+
// engagement = (800+200)/1000 = 1.0 >= 0.95 -> compute
348+
// observedTpr = rate / (1 - bp/1000) = 1000 / 0.8 = 1250
349+
assertEquals(1000. / 0.8, computeNonSourceObservedTpr(800., 200., 1000., conf, Double.NaN));
350+
351+
// Zero input rate while engaged -> POSITIVE_INFINITY (mirrors source behavior).
352+
assertEquals(
353+
Double.POSITIVE_INFINITY,
354+
computeNonSourceObservedTpr(950., 50., 0., conf, Double.NaN));
355+
356+
// Backpressure >= 1000ms/s -> formula degenerates -> fallback supplier (null here)
357+
// is returned only if non-NaN, otherwise OBSERVED_TPR is not set.
358+
assertTrue(Double.isNaN(computeNonSourceObservedTpr(0., 1000., 1000., conf, Double.NaN)));
359+
360+
// Below engagement threshold -> fallback to historical observedTprAvg (PREV_TPR).
361+
assertEquals(PREV_TPR, computeNonSourceObservedTpr(100., 100., 1000., conf, PREV_TPR));
362+
}
363+
364+
@Test
365+
public void testNonSourceObservedTprCustomEngagementThreshold() {
366+
var conf = new Configuration();
367+
conf.set(AutoScalerOptions.OBSERVED_TRUE_PROCESSING_RATE_NON_SOURCE_ENABLED, true);
368+
conf.set(
369+
AutoScalerOptions.OBSERVED_TRUE_PROCESSING_RATE_NON_SOURCE_ENGAGEMENT_THRESHOLD,
370+
0.5);
371+
372+
// engagement = 0.6 >= 0.5 -> compute
373+
assertEquals(
374+
500. / (1 - 0.1), computeNonSourceObservedTpr(500., 100., 500., conf, Double.NaN));
375+
376+
// engagement = 0.4 < 0.5 -> NaN
377+
assertTrue(Double.isNaN(computeNonSourceObservedTpr(300., 100., 500., conf, Double.NaN)));
378+
}
379+
380+
@Test
381+
public void testNonSourceObservedTprMissingMetricsAreSilentlySkipped() {
382+
var conf = new Configuration();
383+
conf.set(AutoScalerOptions.OBSERVED_TRUE_PROCESSING_RATE_NON_SOURCE_ENABLED, true);
384+
385+
// No backpressure metric available (e.g. older Flink / metric not requested) -> NaN
386+
assertTrue(
387+
Double.isNaN(
388+
computeNonSourceObservedTpr(900., Double.NaN, 1000., conf, Double.NaN)));
389+
// No input rate metric available -> NaN
390+
assertTrue(
391+
Double.isNaN(
392+
computeNonSourceObservedTpr(900., 100., Double.NaN, conf, Double.NaN)));
393+
}
394+
294395
private static AggregatedMetric aggMax(double max) {
295396
return new AggregatedMetric("", Double.NaN, max, Double.NaN, Double.NaN, Double.NaN);
296397
}

0 commit comments

Comments
 (0)