diff --git a/docs/layouts/shortcodes/generated/auto_scaler_configuration.html b/docs/layouts/shortcodes/generated/auto_scaler_configuration.html index db32d37939..021625eaa1 100644 --- a/docs/layouts/shortcodes/generated/auto_scaler_configuration.html +++ b/docs/layouts/shortcodes/generated/auto_scaler_configuration.html @@ -20,6 +20,12 @@ Duration The target duration for fully processing any backlog after a scaling operation. Set to 0 to disable backlog based scaling. + +
job.autoscaler.dynamic-source.topology-correction.enabled
+ false + Boolean + Enables topology correction from the optional DynamicKafkaSource.activeSplitCount gauge. Missing or incomplete data falls back to legacy behavior. Valid data caps ordinary source scaling; persistently fewer active splits than parallelism bypasses the scale-down delay, while a persistent empty-reader hole may request one controlled same-parallelism recovery. The Kubernetes realizer supports LAST_STATE jobs only. This is disabled by default. Enable it only when the source/runtime restore semantics redistribute active splits after recovery for the configured source mode. +
job.autoscaler.enabled
false diff --git a/flink-autoscaler-plugin-jdbc/src/test/java/org/apache/flink/autoscaler/jdbc/state/JdbcAutoScalerStateStoreTest.java b/flink-autoscaler-plugin-jdbc/src/test/java/org/apache/flink/autoscaler/jdbc/state/JdbcAutoScalerStateStoreTest.java index 31dbd07be4..742778156e 100644 --- a/flink-autoscaler-plugin-jdbc/src/test/java/org/apache/flink/autoscaler/jdbc/state/JdbcAutoScalerStateStoreTest.java +++ b/flink-autoscaler-plugin-jdbc/src/test/java/org/apache/flink/autoscaler/jdbc/state/JdbcAutoScalerStateStoreTest.java @@ -22,6 +22,7 @@ import org.apache.flink.autoscaler.jdbc.testutils.databases.derby.DerbyTestBase; import org.apache.flink.autoscaler.state.AbstractAutoScalerStateStoreTest; import org.apache.flink.autoscaler.state.AutoScalerStateStore; +import org.apache.flink.runtime.jobgraph.JobVertexID; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -99,4 +100,46 @@ void testDiscardInvalidHistory() throws Exception { ctx.getJobKey().toString(), StateType.SCALING_HISTORY)) .isEmpty(); } + + @Test + void testOldDelayedScaleDownStateStillDeserializes() throws Exception { + jdbcStateStore.putSerializedState( + ctx.getJobKey().toString(), + StateType.DELAYED_SCALE_DOWN, + "{\"delayedVertices\":{}}"); + + var delayedScaleDown = stateStore.getDelayedScaleDown(ctx); + assertThat(delayedScaleDown.getDelayedVertices()).isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceVertices()).isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isNull(); + assertThat(delayedScaleDown.isSourceAssignmentRebalanceTriggered()).isFalse(); + } + + @Test + void testSourceAssignmentRecoveryLatchRoundTrips() throws Exception { + var vertex = new JobVertexID(); + jdbcStateStore.putSerializedState( + ctx.getJobKey().toString(), + StateType.DELAYED_SCALE_DOWN, + "{\"delayedVertices\":{}," + + "\"sourceAssignmentRebalanceVertices\":[\"" + + vertex.toHexString() + + "\"]," + + "\"sourceAssignmentRebalanceRequestId\":123," + + "\"sourceAssignmentRebalanceTriggered\":true}"); + + var delayedScaleDown = stateStore.getDelayedScaleDown(ctx); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceVertices()) + .containsExactly(vertex.toHexString()); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isEqualTo(123L); + assertThat(delayedScaleDown.isSourceAssignmentRebalanceTriggered()).isTrue(); + + stateStore.storeDelayedScaleDown(ctx, delayedScaleDown); + stateStore.flush(ctx); + var roundTripped = createPhysicalAutoScalerStateStore().getDelayedScaleDown(ctx); + assertThat(roundTripped.getSourceAssignmentRebalanceVertices()) + .containsExactly(vertex.toHexString()); + assertThat(roundTripped.getSourceAssignmentRebalanceRequestId()).isEqualTo(123L); + assertThat(roundTripped.isSourceAssignmentRebalanceTriggered()).isTrue(); + } } diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/DelayedScaleDown.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/DelayedScaleDown.java index 489bbc9f9e..c677be7308 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/DelayedScaleDown.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/DelayedScaleDown.java @@ -27,11 +27,15 @@ import lombok.Getter; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import java.time.Instant; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedList; import java.util.Map; +import java.util.Objects; +import java.util.Set; import static org.apache.flink.util.Preconditions.checkState; @@ -125,12 +129,28 @@ public RecommendedParallelism getMaxRecommendedParallelism(Instant windowStartTi @Getter private final Map delayedVertices; + /** + * Dynamic-source vertices covered by one same-parallelism recovery request. + * + *

The request stays latched until those vertices report a persistent non-hole topology. + * Keeping this tiny bit of durable state prevents a persistent assignment hole from restarting + * the job on every autoscaler pass. + */ + @Getter private final Set sourceAssignmentRebalanceVertices; + + /** Stable restart nonce for the currently latched source-assignment recovery. */ + @Nullable @Getter private Long sourceAssignmentRebalanceRequestId; + + /** Whether the current recovery request has been accepted by the realizer. */ + @Getter private boolean sourceAssignmentRebalanceTriggered; + // Have any scale down request been updated? It doesn't need to be stored, it is only used to // determine whether DelayedScaleDown needs to be stored. @JsonIgnore @Getter private boolean updated = false; public DelayedScaleDown() { this.delayedVertices = new HashMap<>(); + this.sourceAssignmentRebalanceVertices = new HashSet<>(); } /** Trigger a scale down, and return the corresponding {@link VertexDelayedScaleDownInfo}. */ @@ -162,12 +182,84 @@ void clearVertex(JobVertexID vertex) { } } + /** + * Create or reuse one stable recovery request for the current assignment hole. + * + *

A pending job-level restart can cover new holes before the realizer accepts it. Once + * accepted, a newly observed uncovered hole needs a fresh nonce so the realizer performs one + * more recovery. + */ + long getOrCreateSourceAssignmentRebalanceRequestId( + Set vertices, long candidateRequestId) { + if (sourceAssignmentRebalanceRequestId == null) { + sourceAssignmentRebalanceRequestId = Math.max(1L, candidateRequestId); + sourceAssignmentRebalanceVertices.clear(); + vertices.forEach(vertex -> sourceAssignmentRebalanceVertices.add(vertex.toHexString())); + sourceAssignmentRebalanceTriggered = false; + updated = true; + } else if (!sourceAssignmentRebalanceTriggered) { + boolean changed = false; + for (JobVertexID vertex : vertices) { + changed |= sourceAssignmentRebalanceVertices.add(vertex.toHexString()); + } + if (changed) { + // One pending job-level restart covers all holes observed before it is accepted. + updated = true; + } + } else if (vertices.stream() + .map(JobVertexID::toHexString) + .anyMatch(vertex -> !sourceAssignmentRebalanceVertices.contains(vertex))) { + sourceAssignmentRebalanceRequestId = + Math.max(sourceAssignmentRebalanceRequestId + 1, candidateRequestId); + sourceAssignmentRebalanceVertices.clear(); + vertices.forEach(vertex -> sourceAssignmentRebalanceVertices.add(vertex.toHexString())); + sourceAssignmentRebalanceTriggered = false; + updated = true; + } + return sourceAssignmentRebalanceRequestId; + } + + /** Adopt a pending restart nonce already present on the resource. */ + void replaceSourceAssignmentRebalanceRequestId(long requestId) { + if (!Objects.equals(sourceAssignmentRebalanceRequestId, requestId)) { + sourceAssignmentRebalanceRequestId = requestId; + updated = true; + } + } + + /** Mark the current recovery request as accepted so it is only reported once. */ + void markSourceAssignmentRebalanceTriggered() { + if (!sourceAssignmentRebalanceTriggered) { + sourceAssignmentRebalanceTriggered = true; + updated = true; + } + } + + /** Clear the recovery latch after a persistent non-hole topology is observed. */ + void clearSourceAssignmentRebalance() { + if (sourceAssignmentRebalanceRequestId == null + && sourceAssignmentRebalanceVertices.isEmpty() + && !sourceAssignmentRebalanceTriggered) { + return; + } + sourceAssignmentRebalanceVertices.clear(); + sourceAssignmentRebalanceRequestId = null; + sourceAssignmentRebalanceTriggered = false; + updated = true; + } + // Clear all delayed scale down when rescale happens. void clearAll() { - if (delayedVertices.isEmpty()) { + if (delayedVertices.isEmpty() + && sourceAssignmentRebalanceRequestId == null + && sourceAssignmentRebalanceVertices.isEmpty() + && !sourceAssignmentRebalanceTriggered) { return; } delayedVertices.clear(); + sourceAssignmentRebalanceVertices.clear(); + sourceAssignmentRebalanceRequestId = null; + sourceAssignmentRebalanceTriggered = false; updated = true; } } diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/JobAutoScalerImpl.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/JobAutoScalerImpl.java index d516a82a5a..607418bd04 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/JobAutoScalerImpl.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/JobAutoScalerImpl.java @@ -38,6 +38,7 @@ import java.util.concurrent.ConcurrentHashMap; import static org.apache.flink.autoscaler.config.AutoScalerOptions.AUTOSCALER_ENABLED; +import static org.apache.flink.autoscaler.config.AutoScalerOptions.SCALING_EVENT_INTERVAL; /** The default implementation of {@link JobAutoScaler}. */ public class JobAutoScalerImpl> @@ -181,7 +182,58 @@ private void runScalingLogic(Context ctx, AutoscalerFlinkMetrics autoscalerMetri if (!metricsEvaluator.evaluate(ctx)) { return; } - if (scalingExecutor.execute(ctx)) { + var parallelismChanged = scalingExecutor.execute(ctx); + var sourceAssignmentRebalanceTriggered = false; + if (!parallelismChanged) { + var delayedScaleDown = stateStore.getDelayedScaleDown(ctx); + var collectedMetrics = cycleState.getCollectedMetrics(); + var rebalanceRequest = + scalingExecutor.getSourceAssignmentRebalanceRequest( + ctx, + collectedMetrics.getJobTopology(), + collectedMetrics.getMetricHistory(), + delayedScaleDown, + cycleState.getNow()); + if (rebalanceRequest.isPresent()) { + // Persist the stable nonce before mutating desired state. A later pass can retry + // the same nonce if reconciliation fails after the realizer accepts it. + if (delayedScaleDown.isUpdated()) { + stateStore.storeDelayedScaleDown(ctx, delayedScaleDown); + stateStore.flush(ctx); + } + + var effectiveRequestId = + scalingRealizer.realizeSourceAssignmentRebalance( + ctx, rebalanceRequest.get().getRequestId()); + if (effectiveRequestId.isPresent()) { + if (effectiveRequestId.getAsLong() != rebalanceRequest.get().getRequestId()) { + delayedScaleDown.replaceSourceAssignmentRebalanceRequestId( + effectiveRequestId.getAsLong()); + } + sourceAssignmentRebalanceTriggered = + !delayedScaleDown.isSourceAssignmentRebalanceTriggered(); + delayedScaleDown.markSourceAssignmentRebalanceTriggered(); + if (sourceAssignmentRebalanceTriggered) { + eventHandler.handleEvent( + ctx, + AutoScalerEventHandler.Type.Normal, + ScalingExecutor.SOURCE_ASSIGNMENT_REBALANCE_REASON, + String.format( + "Covered dynamic source assignment holes in vertices %s " + + "with controlled same-parallelism recovery.", + rebalanceRequest.get().getVertices()), + ScalingExecutor.SOURCE_ASSIGNMENT_REBALANCE_REASON, + ctx.getConfiguration().get(SCALING_EVENT_INTERVAL)); + } + } + } + + if (delayedScaleDown.isUpdated()) { + stateStore.storeDelayedScaleDown(ctx, delayedScaleDown); + } + } + + if (parallelismChanged || sourceAssignmentRebalanceTriggered) { autoscalerMetrics.incrementScaling(); } else { autoscalerMetrics.incrementBalanced(); diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/JobVertexScaler.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/JobVertexScaler.java index 6797e1e864..5b7f7b97d5 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/JobVertexScaler.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/JobVertexScaler.java @@ -46,8 +46,10 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.OptionalInt; import java.util.SortedMap; +import static org.apache.flink.autoscaler.config.AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED; import static org.apache.flink.autoscaler.config.AutoScalerOptions.MAX_SCALE_DOWN_FACTOR; import static org.apache.flink.autoscaler.config.AutoScalerOptions.MAX_SCALE_UP_FACTOR; import static org.apache.flink.autoscaler.config.AutoScalerOptions.OBSERVED_SCALABILITY_ENABLED; @@ -57,6 +59,7 @@ import static org.apache.flink.autoscaler.config.AutoScalerOptions.UTILIZATION_TARGET; import static org.apache.flink.autoscaler.config.AutoScalerOptions.VERTEX_MAX_PARALLELISM; import static org.apache.flink.autoscaler.config.AutoScalerOptions.VERTEX_MIN_PARALLELISM; +import static org.apache.flink.autoscaler.metrics.ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT; import static org.apache.flink.autoscaler.metrics.ScalingMetric.EXPECTED_PROCESSING_RATE; import static org.apache.flink.autoscaler.metrics.ScalingMetric.MAX_PARALLELISM; import static org.apache.flink.autoscaler.metrics.ScalingMetric.NUM_SOURCE_PARTITIONS; @@ -183,6 +186,17 @@ public ParallelismChange computeScaleTargetParallelism( DelayedScaleDown delayedScaleDown) { var conf = context.getConfiguration(); var currentParallelism = (int) evaluatedMetrics.get(PARALLELISM).getCurrent(); + var activeSplitCount = getActiveSplitCount(conf, evaluatedMetrics); + if (activeSplitCount.isPresent() && activeSplitCount.getAsInt() < currentParallelism) { + return computeSourcePartitionCap( + vertex, + conf, + evaluatedMetrics, + currentParallelism, + activeSplitCount.getAsInt(), + delayedScaleDown); + } + double averageTrueProcessingRate = evaluatedMetrics.get(TRUE_PROCESSING_RATE).getAverage(); if (Double.isNaN(averageTrueProcessingRate)) { LOG.warn( @@ -228,16 +242,23 @@ public ParallelismChange computeScaleTargetParallelism( scaleFactor = maxScaleFactor; } + int parallelismUpperLimit = + Math.min( + Math.max(currentParallelism, conf.get(VERTEX_MAX_PARALLELISM)), + activeSplitCount.orElse(Integer.MAX_VALUE)); + int sourcePartitionsForAlignment = + activeSplitCount.orElse( + (int) evaluatedMetrics.get(NUM_SOURCE_PARTITIONS).getCurrent()); int newParallelism = scale( vertex, currentParallelism, inputShipStrategies, - (int) evaluatedMetrics.get(NUM_SOURCE_PARTITIONS).getCurrent(), + sourcePartitionsForAlignment, (int) evaluatedMetrics.get(MAX_PARALLELISM).getCurrent(), scaleFactor, Math.min(currentParallelism, conf.get(VERTEX_MIN_PARALLELISM)), - Math.max(currentParallelism, conf.get(VERTEX_MAX_PARALLELISM)), + parallelismUpperLimit, evaluatedMetrics, jobTopology, context); @@ -270,6 +291,78 @@ public ParallelismChange computeScaleTargetParallelism( delayedScaleDown); } + /** + * Apply a dynamic source's active split count as an explicit topology constraint. + * + *

This path is intentionally separate from {@link ScalingMetric#NUM_SOURCE_PARTITIONS}: the + * latter remains the legacy metric-name-derived value used by ordinary utilization scaling. A + * valid connector gauge also replaces that stale value for ordinary scaling alignment and + * bounds the scaling path, so stale legacy partition names cannot scale the source above its + * active split count or turn a bounded scale-up into a scale-down. The evaluator only exposes a + * positive {@code N < P} after it persists across the retained metrics window; once exposed, + * this path should not wait for the utilization scale-down interval. It also intentionally + * bypasses parallelism alignment because stale legacy partition names could raise the target + * above the active-split bound; the configured minimum parallelism is still honored. + */ + private ParallelismChange computeSourcePartitionCap( + JobVertexID vertex, + Configuration conf, + Map evaluatedMetrics, + int currentParallelism, + int activeSplitCount, + DelayedScaleDown delayedScaleDown) { + int minParallelism = Math.min(currentParallelism, conf.get(VERTEX_MIN_PARALLELISM)); + int targetParallelism = Math.max(minParallelism, activeSplitCount); + if (targetParallelism >= currentParallelism) { + LOG.warn( + "Source {} has {} active splits below current parallelism {}, but configured " + + "minimum parallelism {} prevents a topology correction.", + vertex, + activeSplitCount, + currentParallelism, + minParallelism); + return ParallelismChange.noChange(currentParallelism); + } + + double trueProcessingRate = evaluatedMetrics.get(TRUE_PROCESSING_RATE).getAverage(); + double expectedProcessingRate = + Double.isNaN(trueProcessingRate) + ? Double.NaN + : trueProcessingRate * targetParallelism / currentParallelism; + evaluatedMetrics.put( + EXPECTED_PROCESSING_RATE, EvaluatedScalingMetric.of(expectedProcessingRate)); + delayedScaleDown.clearVertex(vertex); + + LOG.info( + "Scaling source {} from {} to {} because it reports {} active splits.", + vertex, + currentParallelism, + targetParallelism, + activeSplitCount); + // Treat the topology correction as an executable change even when utilization is in range. + return ParallelismChange.build(targetParallelism, currentParallelism, true); + } + + private static OptionalInt getActiveSplitCount( + Configuration conf, Map evaluatedMetrics) { + if (!conf.get(DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED)) { + return OptionalInt.empty(); + } + + var activeSplitCountMetric = evaluatedMetrics.get(ACTIVE_SOURCE_SPLIT_COUNT); + if (activeSplitCountMetric == null + || !isPositiveWholeNumber(activeSplitCountMetric.getCurrent()) + || activeSplitCountMetric.getCurrent() > Integer.MAX_VALUE) { + return OptionalInt.empty(); + } + + return OptionalInt.of((int) activeSplitCountMetric.getCurrent()); + } + + private static boolean isPositiveWholeNumber(double value) { + return Double.isFinite(value) && value > 0 && value == Math.rint(value); + } + /** * Calculates the scaling coefficient based on historical scaling data. * diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingExecutor.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingExecutor.java index 8843cef877..9634d84084 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingExecutor.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingExecutor.java @@ -21,6 +21,7 @@ import org.apache.flink.autoscaler.alignment.ParallelismAlignmentMode; import org.apache.flink.autoscaler.config.AutoScalerOptions; import org.apache.flink.autoscaler.event.AutoScalerEventHandler; +import org.apache.flink.autoscaler.metrics.CollectedMetrics; import org.apache.flink.autoscaler.metrics.EvaluatedMetrics; import org.apache.flink.autoscaler.metrics.EvaluatedScalingMetric; import org.apache.flink.autoscaler.metrics.ScalingMetric; @@ -53,11 +54,13 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.SortedMap; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; +import static org.apache.flink.autoscaler.config.AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED; import static org.apache.flink.autoscaler.config.AutoScalerOptions.EXCLUDED_PERIODS; import static org.apache.flink.autoscaler.config.AutoScalerOptions.SCALING_ENABLED; import static org.apache.flink.autoscaler.config.AutoScalerOptions.SCALING_EVENT_INTERVAL; @@ -66,6 +69,8 @@ import static org.apache.flink.autoscaler.event.AutoScalerEventHandler.SCALING_SUMMARY_HEADER_SCALING_EXECUTION_ENABLED; import static org.apache.flink.autoscaler.metrics.ScalingHistoryUtils.addToScalingHistoryAndStore; import static org.apache.flink.autoscaler.metrics.ScalingHistoryUtils.copyScalingSummaries; +import static org.apache.flink.autoscaler.metrics.ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT; +import static org.apache.flink.autoscaler.metrics.ScalingMetric.MIN_ACTIVE_SOURCE_SPLIT_COUNT; /** Class responsible for executing scaling decisions. */ public class ScalingExecutor> { @@ -80,9 +85,29 @@ public class ScalingExecutor> { "Resource usage is above the allowed limit for scaling operations. Please adjust the resource quota manually."; public static final String SCALING_VETOED_REASON = "ScalingVetoed"; + public static final String SOURCE_ASSIGNMENT_REBALANCE_REASON = "SourceAssignmentRebalance"; private static final Logger LOG = LoggerFactory.getLogger(ScalingExecutor.class); + /** One job-level same-parallelism recovery request for observed dynamic-source holes. */ + static class SourceAssignmentRebalanceRequest { + private final Set vertices; + private final long requestId; + + SourceAssignmentRebalanceRequest(Set vertices, long requestId) { + this.vertices = vertices; + this.requestId = requestId; + } + + Set getVertices() { + return vertices; + } + + long getRequestId() { + return requestId; + } + } + private final JobVertexScaler jobVertexScaler; private final AutoScalerEventHandler autoScalerEventHandler; private final AutoScalerStateStore autoScalerStateStore; @@ -241,6 +266,139 @@ boolean scaleResource( return true; } + /** + * Detect assignment holes that cannot be repaired by changing parallelism. + * + *

When a source has at least as many active splits as readers ({@code N >= P}) but the + * minimum reader count is zero, a same-parallelism recovery lets the source redistribute the + * active splits. The condition must hold for every sample retained in the metrics window so a + * transient empty reader during reassignment does not restart the job. A small durable latch + * keeps one stable restart nonce for the continuous hole; missing optional metrics leave that + * latch untouched, and a persistent non-hole topology clears it. + */ + @VisibleForTesting + Optional getSourceAssignmentRebalanceRequest( + Context context, + JobTopology jobTopology, + SortedMap metricHistory, + DelayedScaleDown delayedScaleDown, + Instant now) { + var conf = context.getConfiguration(); + if (!conf.get(DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED)) { + delayedScaleDown.clearSourceAssignmentRebalance(); + return Optional.empty(); + } + if (metricHistory.isEmpty()) { + return Optional.empty(); + } + + var holes = new HashSet(); + var recoveredTrackedVertices = new HashSet(); + var trackedVertices = delayedScaleDown.getSourceAssignmentRebalanceVertices(); + var currentVertexIds = new HashSet(); + var latestVertexMetrics = metricHistory.get(metricHistory.lastKey()).getVertexMetrics(); + var excludedVertexIds = conf.get(AutoScalerOptions.VERTEX_EXCLUDE_IDS); + + for (var vertex : jobTopology.getVertexInfos().keySet()) { + var vertexId = vertex.toHexString(); + currentVertexIds.add(vertexId); + if (!jobTopology.isSource(vertex) || excludedVertexIds.contains(vertexId)) { + if (trackedVertices.contains(vertexId)) { + recoveredTrackedVertices.add(vertexId); + } + continue; + } + + int parallelism = jobTopology.get(vertex).getParallelism(); + var metrics = latestVertexMetrics.get(vertex); + if (parallelism <= 0 || !hasValidSourceAssignmentMetrics(metrics)) { + continue; + } + + if (hasSourceAssignmentHole(metrics, parallelism)) { + if (hasPersistentSourceAssignmentHole(vertex, parallelism, metricHistory)) { + holes.add(vertex); + } + } else if (trackedVertices.contains(vertexId) + && hasPersistentSourceAssignmentNonHole(vertex, parallelism, metricHistory)) { + // N < P is handled by the partition-cap path; N == 0 or min > 0 is non-hole. + // Require the same full-window stability as hole detection before re-arming. + recoveredTrackedVertices.add(vertexId); + } + } + + trackedVertices.stream() + .filter(vertexId -> !currentVertexIds.contains(vertexId)) + .forEach(recoveredTrackedVertices::add); + if (!trackedVertices.isEmpty() && recoveredTrackedVertices.containsAll(trackedVertices)) { + delayedScaleDown.clearSourceAssignmentRebalance(); + } + + if (holes.isEmpty() + || !conf.get(SCALING_ENABLED) + || CalendarUtils.inExcludedPeriods(conf, now)) { + return Optional.empty(); + } + + long requestId = + delayedScaleDown.getOrCreateSourceAssignmentRebalanceRequestId( + holes, now.toEpochMilli()); + return Optional.of(new SourceAssignmentRebalanceRequest(holes, requestId)); + } + + private static boolean hasPersistentSourceAssignmentHole( + JobVertexID vertex, + int parallelism, + SortedMap metricHistory) { + return metricHistory.values().stream() + .allMatch( + collectedMetrics -> { + var vertexMetrics = collectedMetrics.getVertexMetrics().get(vertex); + return hasValidSourceAssignmentMetrics(vertexMetrics) + && hasSourceAssignmentHole(vertexMetrics, parallelism); + }); + } + + private static boolean hasPersistentSourceAssignmentNonHole( + JobVertexID vertex, + int parallelism, + SortedMap metricHistory) { + return metricHistory.values().stream() + .allMatch( + collectedMetrics -> { + var vertexMetrics = collectedMetrics.getVertexMetrics().get(vertex); + return hasValidSourceAssignmentMetrics(vertexMetrics) + && !hasSourceAssignmentHole(vertexMetrics, parallelism); + }); + } + + private static boolean hasValidSourceAssignmentMetrics( + @Nullable Map metrics) { + if (metrics == null) { + return false; + } + + var activeSplitCount = metrics.get(ACTIVE_SOURCE_SPLIT_COUNT); + var minActiveSplitCount = metrics.get(MIN_ACTIVE_SOURCE_SPLIT_COUNT); + return isWholeNonNegativeValue(activeSplitCount) + && isWholeNonNegativeValue(minActiveSplitCount) + && minActiveSplitCount <= activeSplitCount; + } + + private static boolean hasSourceAssignmentHole( + Map metrics, int parallelism) { + return metrics.get(ACTIVE_SOURCE_SPLIT_COUNT) >= parallelism + && metrics.get(MIN_ACTIVE_SOURCE_SPLIT_COUNT) == 0; + } + + private static boolean isWholeNonNegativeValue(@Nullable Double value) { + return value != null + && Double.isFinite(value) + && value >= 0 + && value <= Integer.MAX_VALUE + && value == Math.rint(value); + } + private void updateRecommendedParallelism( Map> evaluatedMetrics, Map scalingSummaries) { diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingMetricCollector.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingMetricCollector.java index 9cdf4ace3b..5b6c13894d 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingMetricCollector.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingMetricCollector.java @@ -31,6 +31,7 @@ import org.apache.flink.autoscaler.state.AutoScalerStateStore; import org.apache.flink.autoscaler.topology.IOMetrics; import org.apache.flink.autoscaler.topology.JobTopology; +import org.apache.flink.autoscaler.topology.VertexInfo; import org.apache.flink.client.program.rest.RestClusterClient; import org.apache.flink.configuration.Configuration; import org.apache.flink.runtime.execution.ExecutionState; @@ -383,6 +384,13 @@ private CollectedMetrics convertToScalingMetrics( out.put(jobVertexID, vertexScalingMetrics); if (jobTopology.isSource(jobVertexID)) { + if (conf.get( + AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED)) { + addDynamicSourceTopologyMetrics( + jobTopology.get(jobVertexID), + vertexFlinkMetrics, + vertexScalingMetrics); + } ScalingMetrics.computeLagMetrics(vertexFlinkMetrics, vertexScalingMetrics); } @@ -423,6 +431,80 @@ private CollectedMetrics convertToScalingMetrics( return new CollectedMetrics(out, globalMetrics); } + /** + * Publish the optional dynamic-source topology signal without changing the legacy source + * partition count. + * + *

{@code activeSplitCount} is a lifetime per-subtask gauge. A positive {@code sum / avg} + * equals the number of reporting subtasks, so only publish it when every current subtask is + * represented. This avoids treating a temporarily incomplete optional metric as a real + * partition shrink. An all-zero aggregate cannot prove reporter completeness or produce a + * positive parallelism target, so it is retained only as a non-scaling topology observation. + */ + private void addDynamicSourceTopologyMetrics( + VertexInfo vertexInfo, + Map vertexFlinkMetrics, + Map vertexScalingMetrics) { + var metric = vertexFlinkMetrics.get(FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT); + if (metric == null + || metric.getSum() == null + || metric.getMin() == null + || metric.getAvg() == null) { + return; + } + + double sum = metric.getSum(); + double min = metric.getMin(); + double avg = metric.getAvg(); + if (sum == 0 && min == 0 && avg == 0) { + vertexScalingMetrics.put(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT, 0D); + vertexScalingMetrics.put(ScalingMetric.MIN_ACTIVE_SOURCE_SPLIT_COUNT, 0D); + return; + } + + if (!isValidWholeCount(sum) + || !isValidWholeCount(min) + || !Double.isFinite(avg) + || avg <= 0 + || sum == 0 + || min > sum + || sum > Integer.MAX_VALUE + || min > Integer.MAX_VALUE) { + LOG.debug( + "Ignoring invalid active split aggregate sum={}, min={}, avg={} for {}", + sum, + min, + avg, + vertexInfo.getId()); + return; + } + + double reportingSubtasks = sum / avg; + if (!approximatelyEqual(reportingSubtasks, vertexInfo.getParallelism())) { + LOG.debug( + "Ignoring incomplete active split aggregate sum={}, min={}, avg={} for {} " + + "at parallelism {}", + sum, + min, + avg, + vertexInfo.getId(), + vertexInfo.getParallelism()); + return; + } + + vertexScalingMetrics.put(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT, Math.rint(sum)); + vertexScalingMetrics.put(ScalingMetric.MIN_ACTIVE_SOURCE_SPLIT_COUNT, Math.rint(min)); + } + + private static boolean isValidWholeCount(double value) { + return Double.isFinite(value) && value >= 0 && approximatelyEqual(value, Math.rint(value)); + } + + private static boolean approximatelyEqual(double left, double right) { + return Math.abs(left - right) + <= 1e-6 * Math.max(1D, Math.max(Math.abs(left), Math.abs(right))); + } + private static Supplier observedTprAvg( JobVertexID jobVertexID, SortedMap metricHistory, @@ -478,6 +560,9 @@ protected Map> queryFilteredMetricNames( @SneakyThrows private Map> queryFilteredMetricNames( Context ctx, JobTopology topology, Stream vertexStream) { + boolean dynamicSourceTopologyCorrectionEnabled = + ctx.getConfiguration() + .get(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED); try (var restClient = ctx.getRestClusterClient()) { return vertexStream .filter(v -> !topology.getFinishedVertices().contains(v)) @@ -485,8 +570,18 @@ private Map> queryFilteredMetricNames( Collectors.toMap( v -> v, v -> - getFilteredVertexMetricNames( - restClient, ctx.getJobID(), v, topology))); + dynamicSourceTopologyCorrectionEnabled + ? getFilteredVertexMetricNames( + restClient, + ctx.getJobID(), + v, + topology, + true) + : getFilteredVertexMetricNames( + restClient, + ctx.getJobID(), + v, + topology))); } } @@ -496,6 +591,16 @@ Map getFilteredVertexMetricNames( JobID jobID, JobVertexID jobVertexID, JobTopology topology) { + return getFilteredVertexMetricNames(restClient, jobID, jobVertexID, topology, false); + } + + /** Query and filter metric names for a given job vertex. */ + Map getFilteredVertexMetricNames( + RestClusterClient restClient, + JobID jobID, + JobVertexID jobVertexID, + JobTopology topology, + boolean dynamicSourceTopologyCorrectionEnabled) { var allMetricNames = queryAggregatedMetricNames(restClient, jobID, jobVertexID); var filteredMetrics = new HashMap(); @@ -523,6 +628,14 @@ Map getFilteredVertexMetricNames( .findAny(allMetricNames) .ifPresent( m -> filteredMetrics.put(m, FlinkMetric.SOURCE_TASK_NUM_RECORDS_OUT)); + if (dynamicSourceTopologyCorrectionEnabled) { + FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT + .findAny(allMetricNames) + .ifPresent( + m -> + filteredMetrics.put( + m, FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT)); + } } else { FlinkMetric.NUM_RECORDS_IN_PER_SEC .findAny(allMetricNames) diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingMetricEvaluator.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingMetricEvaluator.java index 15a85b9cd3..95c61af5bb 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingMetricEvaluator.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/ScalingMetricEvaluator.java @@ -52,12 +52,14 @@ import static org.apache.flink.autoscaler.config.AutoScalerOptions.BACKLOG_PROCESSING_LAG_THRESHOLD; import static org.apache.flink.autoscaler.config.AutoScalerOptions.CUSTOM_EVALUATORS; +import static org.apache.flink.autoscaler.config.AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED; import static org.apache.flink.autoscaler.config.AutoScalerOptions.TARGET_UTILIZATION_BOUNDARY; import static org.apache.flink.autoscaler.config.AutoScalerOptions.UTILIZATION_MAX; import static org.apache.flink.autoscaler.config.AutoScalerOptions.UTILIZATION_MIN; import static org.apache.flink.autoscaler.config.AutoScalerOptions.UTILIZATION_TARGET; import static org.apache.flink.autoscaler.metrics.AutoscalerFlinkMetrics.initRecommendedParallelism; import static org.apache.flink.autoscaler.metrics.AutoscalerFlinkMetrics.resetRecommendedParallelism; +import static org.apache.flink.autoscaler.metrics.ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT; import static org.apache.flink.autoscaler.metrics.ScalingMetric.CATCH_UP_DATA_RATE; import static org.apache.flink.autoscaler.metrics.ScalingMetric.GC_PRESSURE; import static org.apache.flink.autoscaler.metrics.ScalingMetric.HEAP_MAX_USAGE_RATIO; @@ -285,6 +287,22 @@ private Map evaluateMetrics( NUM_SOURCE_PARTITIONS, EvaluatedScalingMetric.of(vertexInfo.getNumSourcePartitions())); + if (conf.get(DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED)) { + Optional.ofNullable(latestVertexMetrics.get(ACTIVE_SOURCE_SPLIT_COUNT)) + .filter( + value -> + value >= vertexInfo.getParallelism() + || hasPersistentActiveSplitDeficit( + vertex, + vertexInfo.getParallelism(), + metricsHistory)) + .ifPresent( + value -> + evaluatedMetrics.put( + ACTIVE_SOURCE_SPLIT_COUNT, + EvaluatedScalingMetric.of(value))); + } + computeProcessingRateThresholds(evaluatedMetrics, conf, processingBacklog, restartTime); Optional.ofNullable(customEvaluationSession) @@ -307,6 +325,33 @@ private Map evaluateMetrics( return evaluatedMetrics; } + /** + * Check whether every retained sample reports fewer active splits than readers. + * + *

The collector only adds complete, valid active split aggregates. Requiring the optional + * metric in every sample also keeps older jobs and incomplete windows on the legacy path. + */ + private static boolean hasPersistentActiveSplitDeficit( + JobVertexID vertex, + int parallelism, + SortedMap metricsHistory) { + return metricsHistory.values().stream() + .allMatch( + collectedMetrics -> { + var metrics = collectedMetrics.getVertexMetrics().get(vertex); + if (metrics == null) { + return false; + } + + var activeSplitCount = metrics.get(ACTIVE_SOURCE_SPLIT_COUNT); + return activeSplitCount != null + && Double.isFinite(activeSplitCount) + && activeSplitCount > 0 + && activeSplitCount == Math.rint(activeSplitCount) + && activeSplitCount < parallelism; + }); + } + /** * Compute the average busy time for the given vertex for the current metric window. Depending * on the {@link MetricAggregator} chosen we use two different mechanisms: diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/config/AutoScalerOptions.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/config/AutoScalerOptions.java index 8dc99c503b..2fc4efcaae 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/config/AutoScalerOptions.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/config/AutoScalerOptions.java @@ -143,6 +143,26 @@ private static ConfigOptions.OptionBuilder autoScalerConfig(String key) { + "Reducing the frequency of job restarts can improve job availability. " + "Scale down can be executed directly if it's less than or equal 0."); + public static final ConfigOption DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED = + autoScalerConfig("dynamic-source.topology-correction.enabled") + .booleanType() + .defaultValue(false) + .withFallbackKeys( + oldOperatorConfigKey("dynamic-source.topology-correction.enabled")) + .withDescription( + "Enables topology correction from the optional " + + "DynamicKafkaSource.activeSplitCount gauge. Missing or " + + "incomplete data falls back to legacy behavior. Valid data " + + "caps ordinary source scaling; persistently fewer active " + + "splits than parallelism bypasses the scale-down delay, " + + "while a " + + "persistent empty-reader hole may request one controlled " + + "same-parallelism recovery. The Kubernetes realizer supports " + + "LAST_STATE jobs only. This is disabled by default. Enable " + + "it only when the source/runtime restore semantics " + + "redistribute active splits after recovery for the " + + "configured source mode."); + public static final ConfigOption VERTEX_MIN_PARALLELISM = autoScalerConfig("vertex.min-parallelism") .intType() diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/FlinkMetric.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/FlinkMetric.java index e968716cc8..d3d69192b8 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/FlinkMetric.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/FlinkMetric.java @@ -38,6 +38,7 @@ public enum FlinkMetric { SOURCE_TASK_NUM_RECORDS_OUT_PER_SEC( s -> s.startsWith("Source__") && s.endsWith(".numRecordsOutPerSecond")), SOURCE_TASK_NUM_RECORDS_IN(s -> s.startsWith("Source__") && s.endsWith(".numRecordsIn")), + DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT(s -> s.endsWith(".DynamicKafkaSource.activeSplitCount")), PENDING_RECORDS(s -> s.endsWith(".pendingRecords")), BACKPRESSURE_TIME_PER_SEC(s -> s.equals("backPressuredTimeMsPerSecond")), NUM_RECORDS_IN_PER_SEC(s -> s.equals("numRecordsInPerSecond")), diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/ScalingMetric.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/ScalingMetric.java index 81eb39dcfe..7c8fc30b25 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/ScalingMetric.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/metrics/ScalingMetric.java @@ -57,6 +57,20 @@ public enum ScalingMetric { /** Source vertex partition count. */ NUM_SOURCE_PARTITIONS(false), + /** + * Active split count reported by a dynamic source's lifetime per-subtask gauge. This is kept + * separate from {@link #NUM_SOURCE_PARTITIONS}, whose legacy metric-name counting remains the + * partition/alignment input to ordinary utilization scaling; when available, this gauge + * supplies an additional upper bound. + */ + ACTIVE_SOURCE_SPLIT_COUNT(false), + + /** + * Minimum active split count across dynamic-source subtasks. Zero means at least one reader is + * empty while the aggregate active split count is positive. + */ + MIN_ACTIVE_SOURCE_SPLIT_COUNT(false), + /** Upper boundary of the target data rate range. */ SCALE_UP_RATE_THRESHOLD(false), diff --git a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/realizer/ScalingRealizer.java b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/realizer/ScalingRealizer.java index c7446819c9..09942fe255 100644 --- a/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/realizer/ScalingRealizer.java +++ b/flink-autoscaler/src/main/java/org/apache/flink/autoscaler/realizer/ScalingRealizer.java @@ -22,6 +22,7 @@ import org.apache.flink.autoscaler.tuning.ConfigChanges; import java.util.Map; +import java.util.OptionalLong; /** * The Scaling Realizer is responsible for applying scaling actions, i.e. actually rescaling the @@ -47,4 +48,16 @@ void realizeParallelismOverrides(Context context, Map parallelis * @throws Exception Error during realize config overrides. */ void realizeConfigOverrides(Context context, ConfigChanges configChanges) throws Exception; + + /** + * Request a controlled same-parallelism recovery for dynamic-source split reassignment. + * + *

The request id is stable for one continuous assignment hole. Implementations should apply + * it idempotently and return the effective id when the request is accepted. The default keeps + * existing realizers backward compatible. + */ + default OptionalLong realizeSourceAssignmentRebalance(Context context, long requestId) + throws Exception { + return OptionalLong.empty(); + } } diff --git a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/DelayedScaleDownTest.java b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/DelayedScaleDownTest.java index 8adc62bb94..173c701d35 100644 --- a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/DelayedScaleDownTest.java +++ b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/DelayedScaleDownTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import java.time.Instant; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -29,6 +30,27 @@ /** Test for {@link DelayedScaleDown}. */ public class DelayedScaleDownTest { + @Test + void testNewTriggeredSourceAssignmentHoleGetsFreshRequestId() { + var delayedScaleDown = new DelayedScaleDown(); + var firstSource = new JobVertexID(); + var secondSource = new JobVertexID(); + + long firstRequestId = + delayedScaleDown.getOrCreateSourceAssignmentRebalanceRequestId( + Set.of(firstSource), 123L); + delayedScaleDown.markSourceAssignmentRebalanceTriggered(); + + long secondRequestId = + delayedScaleDown.getOrCreateSourceAssignmentRebalanceRequestId( + Set.of(firstSource, secondSource), 123L); + + assertThat(secondRequestId).isGreaterThan(firstRequestId); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceVertices()) + .containsExactlyInAnyOrder(firstSource.toHexString(), secondSource.toHexString()); + assertThat(delayedScaleDown.isSourceAssignmentRebalanceTriggered()).isFalse(); + } + private final JobVertexID vertex = new JobVertexID(); @Test diff --git a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/JobAutoScalerImplTest.java b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/JobAutoScalerImplTest.java index 7eac783aa6..836e5dd8fc 100644 --- a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/JobAutoScalerImplTest.java +++ b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/JobAutoScalerImplTest.java @@ -24,6 +24,7 @@ import org.apache.flink.autoscaler.exceptions.NotReadyException; import org.apache.flink.autoscaler.metrics.AutoscalerFlinkMetrics; import org.apache.flink.autoscaler.metrics.CollectedMetrics; +import org.apache.flink.autoscaler.metrics.FlinkMetric; import org.apache.flink.autoscaler.metrics.ScalingMetric; import org.apache.flink.autoscaler.metrics.TestMetrics; import org.apache.flink.autoscaler.realizer.ScalingRealizer; @@ -41,6 +42,7 @@ import org.apache.flink.mock.Whitebox; import org.apache.flink.runtime.jobgraph.JobVertexID; import org.apache.flink.runtime.metrics.groups.GenericMetricGroup; +import org.apache.flink.runtime.rest.messages.job.metrics.AggregatedMetric; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -52,10 +54,12 @@ import java.time.Duration; import java.time.Instant; import java.time.ZoneId; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.OptionalLong; import java.util.TreeMap; import static java.util.Map.entry; @@ -138,6 +142,91 @@ void testMetricReporting() throws Exception { "Expected scaling metric LOAD was not reported. Reporting is broken"); } + @Test + void testSourceAssignmentHoleMustPersistAcrossMetricsWindowBeforeRecovery() throws Exception { + var conf = context.getConfiguration(); + conf.set(SCALING_ENABLED, true); + conf.set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + conf.set(AutoScalerOptions.STABILIZATION_INTERVAL, Duration.ZERO); + conf.set(AutoScalerOptions.UTILIZATION_MIN, 0.0); + conf.set(AutoScalerOptions.UTILIZATION_MAX, 1.0); + + JobVertexID source = new JobVertexID(); + var sourceInfo = new VertexInfo(source, Map.of(), 10, 1000); + var jobTopology = new JobTopology(sourceInfo); + var metricsCollector = + new TestingMetricsCollector>( + jobTopology, stateStore); + metricsCollector.setTestMetricWindowSize(Duration.ofSeconds(1)); + + var testMetrics = + TestMetrics.builder() + .numRecordsIn(0) + .numRecordsOut(0) + .numRecordsInPerSec(500.) + .maxBusyTimePerSec(600) + .pendingRecords(0L) + .build(); + sourceInfo.setIoMetrics(testMetrics.toIoMetrics()); + var sourceMetrics = new HashMap<>(testMetrics.toFlinkMetrics()); + sourceMetrics.put( + FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT, + new AggregatedMetric("", 1., 12., 1.2, 12., Double.NaN)); + metricsCollector.setCurrentMetrics(Map.of(source, sourceMetrics)); + + var recoveryRequests = new ArrayList(); + var recoveryRealizer = + new TestingScalingRealizer>() { + @Override + public OptionalLong realizeSourceAssignmentRebalance( + JobAutoScalerContext ctx, long requestId) { + recoveryRequests.add(requestId); + return OptionalLong.of(requestId); + } + }; + var autoscaler = + new JobAutoScalerImpl<>( + metricsCollector, + new ScalingMetricEvaluator<>(Collections.emptyList()), + new ScalingExecutor<>(eventCollector, stateStore), + eventCollector, + recoveryRealizer, + stateStore); + + var initialTime = Instant.now(); + autoscaler.setClock(Clock.fixed(initialTime, ZoneId.systemDefault())); + autoscaler.scale(context); + + // A single empty-reader sample is transient until it fills the existing metrics window. + sourceMetrics.put( + FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT, + new AggregatedMetric("", 0., 12., 1.2, 12., Double.NaN)); + autoscaler.setClock(Clock.fixed(initialTime.plusSeconds(1), ZoneId.systemDefault())); + autoscaler.scale(context); + assertThat(recoveryRequests).isEmpty(); + assertThat(stateStore.getDelayedScaleDown(context).getSourceAssignmentRebalanceRequestId()) + .isNull(); + + // Once only hole samples remain in the one-second window, recover at the same P. + autoscaler.setClock(Clock.fixed(initialTime.plusSeconds(2), ZoneId.systemDefault())); + autoscaler.scale(context); + assertThat(recoveryRequests).hasSize(1); + assertThat(stateStore.getDelayedScaleDown(context).getSourceAssignmentRebalanceRequestId()) + .isEqualTo(recoveryRequests.get(0)); + assertThat(stateStore.getParallelismOverrides(context)).isEmpty(); + assertThat(stateStore.getScalingHistory(context)).isEmpty(); + + autoscaler.setClock(Clock.fixed(initialTime.plusSeconds(3), ZoneId.systemDefault())); + autoscaler.scale(context); + assertThat(recoveryRequests).hasSize(2).containsOnly(recoveryRequests.get(0)); + assertThat(eventCollector.events) + .filteredOn( + event -> + event.getReason() + .equals(ScalingExecutor.SOURCE_ASSIGNMENT_REBALANCE_REASON)) + .hasSize(1); + } + @SuppressWarnings("unchecked") private static double getGaugeValue( MetricGroup metricGroup, String gaugeName, String... nestedMetricGroupNames) { diff --git a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/JobVertexScalerTest.java b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/JobVertexScalerTest.java index 68da35bdd8..f032a2c0fd 100644 --- a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/JobVertexScalerTest.java +++ b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/JobVertexScalerTest.java @@ -1268,6 +1268,126 @@ public void testSendingScalingLimitedEvents() { .isEqualTo(smallChangesForScaleFactorLimitedEvent.getMessageKey()); } + @Test + public void testDynamicSourcePartitionCapIsOptInAndSeparateFromLegacyScaling() { + conf.set(UTILIZATION_TARGET, 1.0); + conf.set(AutoScalerOptions.SCALE_DOWN_INTERVAL, Duration.ofHours(1)); + var metrics = evaluated(10, 100, 100); + metrics.put(ScalingMetric.NUM_SOURCE_PARTITIONS, EvaluatedScalingMetric.of(100)); + metrics.put(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT, EvaluatedScalingMetric.of(5)); + + // Default-off behavior remains the legacy utilization decision. + assertEquals( + ParallelismChange.noChange(10), + vertexScaler.computeScaleTargetParallelism( + context, + vertex, + NOT_ADJUST_INPUTS, + null, + metrics, + Collections.emptySortedMap(), + restartTime, + new DelayedScaleDown())); + + conf.set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + assertEquals( + ParallelismChange.build(5, 10, true), + vertexScaler.computeScaleTargetParallelism( + context, + vertex, + NOT_ADJUST_INPUTS, + null, + metrics, + Collections.emptySortedMap(), + restartTime, + new DelayedScaleDown())); + assertEquals(50, metrics.get(ScalingMetric.EXPECTED_PROCESSING_RATE).getCurrent()); + } + + @Test + public void testDynamicSourcePartitionCapRemainsUpperBoundForUtilizationScaling() { + conf.set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + conf.set(UTILIZATION_TARGET, 1.0); + var metrics = evaluated(5, 100, 50); + metrics.put(ScalingMetric.NUM_SOURCE_PARTITIONS, EvaluatedScalingMetric.of(100)); + metrics.put(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT, EvaluatedScalingMetric.of(5)); + + // The stale legacy partition count must not scale the source back above its active splits. + assertEquals( + ParallelismChange.noChange(5), + vertexScaler.computeScaleTargetParallelism( + context, + vertex, + NOT_ADJUST_INPUTS, + null, + metrics, + Collections.emptySortedMap(), + restartTime, + new DelayedScaleDown())); + } + + @Test + public void testDynamicSourceActiveSplitCountDrivesUtilizationAlignment() { + conf.set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + conf.set(UTILIZATION_TARGET, 1.0); + var metrics = evaluated(5, 60, 50); + metrics.put(ScalingMetric.NUM_SOURCE_PARTITIONS, EvaluatedScalingMetric.of(7)); + metrics.put(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT, EvaluatedScalingMetric.of(6)); + + // Stale legacy partition names must not turn the bounded 5 -> 6 scale-up into 5 -> 4. + assertEquals( + ParallelismChange.build(6, 5, true), + vertexScaler.computeScaleTargetParallelism( + context, + vertex, + NOT_ADJUST_INPUTS, + null, + metrics, + Collections.emptySortedMap(), + restartTime, + new DelayedScaleDown())); + } + + @Test + public void testDynamicSourcePartitionCapFallsBackWhenGaugeIsMissing() { + conf.set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + conf.set(UTILIZATION_TARGET, 1.0); + var metrics = evaluated(5, 100, 50); + metrics.put(ScalingMetric.NUM_SOURCE_PARTITIONS, EvaluatedScalingMetric.of(100)); + + // Older jobs do not expose the connector gauge and keep the legacy scaling behavior. + assertEquals( + ParallelismChange.build(10, 5, true), + vertexScaler.computeScaleTargetParallelism( + context, + vertex, + NOT_ADJUST_INPUTS, + null, + metrics, + Collections.emptySortedMap(), + restartTime, + new DelayedScaleDown())); + } + + @Test + public void testDynamicSourcePartitionCapIgnoresZeroGauge() { + conf.set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + conf.set(UTILIZATION_TARGET, 1.0); + var zero = evaluated(10, 100, 100); + zero.put(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT, EvaluatedScalingMetric.of(0)); + assertEquals( + ParallelismChange.noChange(10), + vertexScaler.computeScaleTargetParallelism( + context, + vertex, + NOT_ADJUST_INPUTS, + null, + zero, + Collections.emptySortedMap(), + restartTime, + new DelayedScaleDown())); + } + private Map evaluated( int parallelism, double targetDataRate, double trueProcessingRate) { var metrics = new HashMap(); diff --git a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/MetricsCollectionAndEvaluationTest.java b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/MetricsCollectionAndEvaluationTest.java index 64e8f1772a..4237a3aa1f 100644 --- a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/MetricsCollectionAndEvaluationTest.java +++ b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/MetricsCollectionAndEvaluationTest.java @@ -34,6 +34,7 @@ import org.apache.flink.runtime.instance.SlotSharingGroupId; import org.apache.flink.runtime.jobgraph.JobVertexID; import org.apache.flink.runtime.rest.messages.job.JobDetailsInfo; +import org.apache.flink.runtime.rest.messages.job.metrics.AggregatedMetric; import org.apache.flink.shaded.jackson2.com.fasterxml.jackson.core.JsonProcessingException; import org.apache.flink.shaded.jackson2.com.fasterxml.jackson.databind.ObjectMapper; @@ -293,6 +294,104 @@ public void testKafkaPulsarNumPartitions() throws Exception { assertEquals(5, collectedMetrics.getJobTopology().get(source2).getNumSourcePartitions()); } + @Test + public void testActiveSplitGaugeIsOptInAndDoesNotReplaceLegacyPartitionCount() + throws Exception { + setDefaultMetrics(metricsCollector); + metricsCollector.setMetricNames( + Map.of( + source1, + List.of( + "1.Source__Kafka_Source_(testTopic).kafkaCluster.removed.KafkaSourceReader.topic.testTopic.partition.0.currentOffset", + "1.Source__Kafka_Source_(testTopic).kafkaCluster.removed.KafkaSourceReader.topic.testTopic.partition.1.currentOffset"))); + + var sourceMetrics = + new HashMap<>( + TestMetrics.builder() + .numRecordsIn(0) + .numRecordsOut(0) + .numRecordsInPerSec(500.) + .maxBusyTimePerSec(1000) + .pendingRecords(0L) + .build() + .toFlinkMetrics()); + sourceMetrics.put( + FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT, + aggregatedActiveSplitCounts(1, 0, 2)); + metricsCollector.setCurrentMetrics(Map.of(source1, sourceMetrics)); + + var collectedMetrics = collect(); + assertEquals(2, collectedMetrics.getJobTopology().get(source1).getNumSourcePartitions()); + assertFalse( + latestVertexMetrics(collectedMetrics, source1) + .containsKey(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + + context.getConfiguration() + .set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + metricsCollector.setClock(Clock.offset(clock, Duration.ofSeconds(1))); + collectedMetrics = collect(); + assertEquals(2, collectedMetrics.getJobTopology().get(source1).getNumSourcePartitions()); + assertEquals( + 1.0, + latestVertexMetrics(collectedMetrics, source1) + .get(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + assertEquals( + 0.0, + latestVertexMetrics(collectedMetrics, source1) + .get(ScalingMetric.MIN_ACTIVE_SOURCE_SPLIT_COUNT)); + + sourceMetrics.remove(FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT); + metricsCollector.setClock(Clock.offset(clock, Duration.ofSeconds(2))); + collectedMetrics = collect(); + assertFalse( + latestVertexMetrics(collectedMetrics, source1) + .containsKey(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + + sourceMetrics.put( + FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT, + aggregatedActiveSplitCounts(1, 0, 1)); + metricsCollector.setClock(Clock.offset(clock, Duration.ofSeconds(3))); + collectedMetrics = collect(); + assertFalse( + latestVertexMetrics(collectedMetrics, source1) + .containsKey(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + + sourceMetrics.put( + FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT, + aggregatedActiveSplitCounts(0, 0, 2)); + metricsCollector.setClock(Clock.offset(clock, Duration.ofSeconds(4))); + collectedMetrics = collect(); + assertEquals( + 0.0, + latestVertexMetrics(collectedMetrics, source1) + .get(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + assertEquals( + 0.0, + latestVertexMetrics(collectedMetrics, source1) + .get(ScalingMetric.MIN_ACTIVE_SOURCE_SPLIT_COUNT)); + + sourceMetrics.put( + FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT, + aggregatedActiveSplitCounts(1.5, 0, 2)); + metricsCollector.setClock(Clock.offset(clock, Duration.ofSeconds(5))); + collectedMetrics = collect(); + assertFalse( + latestVertexMetrics(collectedMetrics, source1) + .containsKey(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + } + + private static Map latestVertexMetrics( + CollectedMetricHistory collectedMetrics, JobVertexID vertex) { + var metricHistory = collectedMetrics.getMetricHistory(); + return metricHistory.get(metricHistory.lastKey()).getVertexMetrics().get(vertex); + } + + private static AggregatedMetric aggregatedActiveSplitCounts( + double sum, double min, int reportingReaders) { + double avg = reportingReaders == 0 ? 0 : sum / reportingReaders; + return new AggregatedMetric("", min, sum, avg, sum, Double.NaN); + } + @Test public void testJobDetailsRestCompatibility() throws JsonProcessingException { String flink15Response = diff --git a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingExecutorTest.java b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingExecutorTest.java index 6213104c50..b526655de4 100644 --- a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingExecutorTest.java +++ b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingExecutorTest.java @@ -21,6 +21,7 @@ import org.apache.flink.autoscaler.config.AutoScalerOptions; import org.apache.flink.autoscaler.event.TestingEventCollector; import org.apache.flink.autoscaler.metrics.CollectedMetricHistory; +import org.apache.flink.autoscaler.metrics.CollectedMetrics; import org.apache.flink.autoscaler.metrics.EvaluatedMetrics; import org.apache.flink.autoscaler.metrics.EvaluatedScalingMetric; import org.apache.flink.autoscaler.metrics.ScalingMetric; @@ -53,6 +54,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.SortedMap; import java.util.TreeMap; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -223,6 +225,175 @@ public void testUtilizationBoundaries() { .hasSize(2); } + @Test + public void testDynamicSourceAssignmentHoleUsesOneStableRecoveryRequest() { + conf.set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + var source = new JobVertexID(); + var jobTopology = new JobTopology(new VertexInfo(source, Map.of(), 10, 1000, false, null)); + var delayedScaleDown = new DelayedScaleDown(); + var now = Instant.now(); + var persistentHoleHistory = + sourceAssignmentMetricHistory(source, new double[] {12, 0}, new double[] {12, 0}); + + // A transient latest hole must not trigger same-parallelism recovery. + assertThat( + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, + jobTopology, + sourceAssignmentMetricHistory( + source, new double[] {12, 1}, new double[] {12, 0}), + delayedScaleDown, + now)) + .isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isNull(); + + // N < P in an earlier sample is not a persistent same-parallelism hole. + assertThat( + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, + jobTopology, + sourceAssignmentMetricHistory( + source, new double[] {5, 0}, new double[] {12, 0}), + delayedScaleDown, + now)) + .isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isNull(); + + // Missing optional metrics anywhere in the window fail closed. + var incompleteHistory = + sourceAssignmentMetricHistory(source, new double[] {12, 0}, new double[] {12, 0}); + incompleteHistory + .get(Instant.EPOCH) + .getVertexMetrics() + .get(source) + .remove(ScalingMetric.MIN_ACTIVE_SOURCE_SPLIT_COUNT); + assertThat( + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, jobTopology, incompleteHistory, delayedScaleDown, now)) + .isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isNull(); + + var request = + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, jobTopology, persistentHoleHistory, delayedScaleDown, now); + assertThat(request).isPresent(); + assertThat(request.orElseThrow().getVertices()).containsExactly(source); + long requestId = request.orElseThrow().getRequestId(); + + delayedScaleDown.markSourceAssignmentRebalanceTriggered(); + var retry = + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, + jobTopology, + persistentHoleHistory, + delayedScaleDown, + now.plusSeconds(1)); + assertThat(retry).isPresent(); + assertThat(retry.orElseThrow().getRequestId()).isEqualTo(requestId); + + // Missing optional metrics do not clear the latch and cannot request a new restart. + var missingLatestHistory = sourceAssignmentMetricHistory(source, new double[] {12, 0}); + missingLatestHistory + .get(missingLatestHistory.lastKey()) + .getVertexMetrics() + .get(source) + .remove(ScalingMetric.MIN_ACTIVE_SOURCE_SPLIT_COUNT); + assertThat( + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, + jobTopology, + missingLatestHistory, + delayedScaleDown, + now.plusSeconds(2))) + .isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isEqualTo(requestId); + + // One transient non-hole sample must not clear a latch for the continuous hole. + assertThat( + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, + jobTopology, + sourceAssignmentMetricHistory( + source, new double[] {12, 0}, new double[] {0, 0}), + delayedScaleDown, + now.plusSeconds(3))) + .isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isEqualTo(requestId); + + // A full-window zero-split topology clears the latch and allows a later distinct hole. + assertThat( + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, + jobTopology, + sourceAssignmentMetricHistory( + source, new double[] {0, 0}, new double[] {0, 0}), + delayedScaleDown, + now.plusSeconds(4))) + .isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isNull(); + + var nextRequest = + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, + jobTopology, + persistentHoleHistory, + delayedScaleDown, + now.plusSeconds(5)); + assertThat(nextRequest).isPresent(); + assertThat(nextRequest.orElseThrow().getRequestId()).isNotEqualTo(requestId); + + // N < P is a partition-cap case, not a same-parallelism restart. + assertThat( + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, + jobTopology, + sourceAssignmentMetricHistory(source, new double[] {5, 0}), + new DelayedScaleDown(), + now.plusSeconds(6))) + .isEmpty(); + } + + @Test + public void testDynamicSourceAssignmentRecoveryIsDisabledByDefault() { + var source = new JobVertexID(); + var jobTopology = new JobTopology(new VertexInfo(source, Map.of(), 10, 1000, false, null)); + var delayedScaleDown = new DelayedScaleDown(); + delayedScaleDown.getOrCreateSourceAssignmentRebalanceRequestId( + List.of(source).stream().collect(Collectors.toSet()), 123L); + + assertThat( + scalingExecutor.getSourceAssignmentRebalanceRequest( + context, + jobTopology, + sourceAssignmentMetricHistory(source, new double[] {12, 0}), + delayedScaleDown, + Instant.now())) + .isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isNull(); + } + + @Test + public void testDynamicSourcePartitionCapProducesActiveSplitOverride() throws Exception { + conf.set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + var source = new JobVertexID(); + var jobTopology = new JobTopology(new VertexInfo(source, Map.of(), 10, 1000, false, null)); + var sourceMetrics = evaluated(10, 100, 100); + sourceMetrics.put(ScalingMetric.NUM_SOURCE_PARTITIONS, EvaluatedScalingMetric.of(100)); + sourceMetrics.put(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT, EvaluatedScalingMetric.of(5)); + var metrics = new EvaluatedMetrics(Map.of(source, sourceMetrics), dummyGlobalMetrics); + + assertTrue( + scalingExecutor.scaleResource( + context, + metrics, + new HashMap<>(), + scalingTracking, + Instant.now(), + jobTopology, + new DelayedScaleDown())); + assertThat(getScaledParallelism(stateStore, context)).containsEntry(source, 5); + } + @Test public void testNoScaleDownOnZeroLowerUtilizationBoundary() throws Exception { var conf = context.getConfiguration(); @@ -1053,6 +1224,20 @@ private Map evaluated( return evaluated(parallelism, target, trueProcessingRate, 0.); } + private static SortedMap sourceAssignmentMetricHistory( + JobVertexID source, double[]... observations) { + var history = new TreeMap(); + for (int i = 0; i < observations.length; i++) { + var metrics = new HashMap(); + metrics.put(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT, observations[i][0]); + metrics.put(ScalingMetric.MIN_ACTIVE_SOURCE_SPLIT_COUNT, observations[i][1]); + history.put( + Instant.EPOCH.plusSeconds(i), + new CollectedMetrics(Map.of(source, metrics), Map.of())); + } + return history; + } + protected static > Map getScaledParallelism( AutoScalerStateStore stateStore, Context context) diff --git a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingMetricCollectorTest.java b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingMetricCollectorTest.java index bb2150460e..faf22726fb 100644 --- a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingMetricCollectorTest.java +++ b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingMetricCollectorTest.java @@ -58,6 +58,7 @@ import static org.apache.flink.autoscaler.topology.ShipStrategy.HASH; import static org.apache.flink.autoscaler.topology.ShipStrategy.REBALANCE; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -484,6 +485,41 @@ protected Collection queryAggregatedMetricNames( testRequiredMetrics(metricList, getRequiredMetrics(), testCollector, sink, topology); } + @Test + public void testDynamicSourceMetricIsCollectedOnlyWhenEnabled() { + List metricList = + new ArrayList<>( + List.of( + "busyTimeMsPerSecond", + "backPressuredTimeMsPerSecond", + "Source__XXX.numRecordsInPerSecond", + "Source__XXX.numRecordsIn")); + String activeSplitMetric = "Source__DynamicKafkaSource.DynamicKafkaSource.activeSplitCount"; + metricList.add(activeSplitMetric); + RestApiMetricsCollector> testCollector = + new RestApiMetricsCollector<>( + new InMemoryAutoScalerStateStore>()) { + @Override + protected Collection queryAggregatedMetricNames( + RestClusterClient restClient, JobID jobID, JobVertexID jobVertexID) { + return metricList; + } + }; + + var source = new JobVertexID(); + var topology = new JobTopology(new VertexInfo(source, Map.of(), 1, 1)); + + assertFalse( + testCollector + .getFilteredVertexMetricNames(null, new JobID(), source, topology, false) + .containsKey(activeSplitMetric)); + assertEquals( + FlinkMetric.DYNAMIC_SOURCE_ACTIVE_SPLIT_COUNT, + testCollector + .getFilteredVertexMetricNames(null, new JobID(), source, topology, true) + .get(activeSplitMetric)); + } + @Test public void testQueryMetricNames() throws Exception { var testCollector = diff --git a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingMetricEvaluatorTest.java b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingMetricEvaluatorTest.java index e979ce4bf1..6455121023 100644 --- a/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingMetricEvaluatorTest.java +++ b/flink-autoscaler/src/test/java/org/apache/flink/autoscaler/ScalingMetricEvaluatorTest.java @@ -441,6 +441,62 @@ public void testUtilizationBoundaryComputation() { assertEquals(Tuple2.of(700., Double.POSITIVE_INFINITY), getThresholds(700, 0, conf, true)); } + @Test + public void testActiveSplitDeficitRequiresFullMetricsWindow() { + assertFalse( + evaluateActiveSplitCount(10, 10D, 5D) + .containsKey(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + assertFalse( + evaluateActiveSplitCount(10, null, 5D) + .containsKey(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + assertFalse( + evaluateActiveSplitCount(10, 5D, 0D) + .containsKey(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + + assertEquals( + EvaluatedScalingMetric.of(4), + evaluateActiveSplitCount(10, 5D, 4D).get(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + + // N >= P remains available immediately so it can cap later ordinary scale-ups. + assertEquals( + EvaluatedScalingMetric.of(10), + evaluateActiveSplitCount(10, 5D, 10D).get(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT)); + } + + private Map evaluateActiveSplitCount( + int parallelism, Double... activeSplitCounts) { + var source = new JobVertexID(); + var topology = + new JobTopology( + new VertexInfo(source, Collections.emptyMap(), parallelism, 100, null)); + var metricHistory = new TreeMap(); + + for (int i = 0; i < activeSplitCounts.length; i++) { + var metrics = new HashMap(); + metrics.put(ScalingMetric.NUM_RECORDS_IN, (double) i); + metrics.put(ScalingMetric.NUM_RECORDS_OUT, (double) i); + metrics.put(ScalingMetric.LAG, 0D); + metrics.put(ScalingMetric.LOAD, 0.5); + if (activeSplitCounts[i] != null) { + metrics.put(ScalingMetric.ACTIVE_SOURCE_SPLIT_COUNT, activeSplitCounts[i]); + } + metricHistory.put( + Instant.ofEpochSecond(i), + new CollectedMetrics(Map.of(source, metrics), Map.of())); + } + + var conf = new Configuration(); + conf.set(AutoScalerOptions.DYNAMIC_SOURCE_TOPOLOGY_CORRECTION_ENABLED, true); + return evaluator + .computeEvaluatedMetrics( + null, + conf, + new CollectedMetricHistory(topology, metricHistory, Instant.now()), + Duration.ZERO) + .getVertexMetrics() + .get(source); + } + @Test public void testUtilizationBoundaryComputationWithRestartTimesTracking() { diff --git a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/KubernetesScalingRealizer.java b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/KubernetesScalingRealizer.java index 3d93f62fea..38767ee35a 100644 --- a/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/KubernetesScalingRealizer.java +++ b/flink-kubernetes-operator/src/main/java/org/apache/flink/kubernetes/operator/autoscaler/KubernetesScalingRealizer.java @@ -27,6 +27,8 @@ import org.apache.flink.kubernetes.operator.api.FlinkDeployment; import org.apache.flink.kubernetes.operator.api.spec.Resource; import org.apache.flink.kubernetes.operator.api.spec.TaskManagerSpec; +import org.apache.flink.kubernetes.operator.api.spec.UpgradeMode; +import org.apache.flink.kubernetes.operator.config.FlinkConfigBuilder; import org.apache.flink.kubernetes.operator.utils.ResourceConfigUtils; import io.fabric8.kubernetes.api.model.Quantity; @@ -38,6 +40,8 @@ import javax.annotation.Nullable; import java.util.Map; +import java.util.Objects; +import java.util.OptionalLong; /** The Kubernetes implementation for applying parallelism overrides. */ public class KubernetesScalingRealizer @@ -164,6 +168,50 @@ private static Quantity scaleMemory( return new Quantity(String.valueOf(Math.round(limitBytes * ratio))); } + /** + * Trigger a stateful same-parallelism restart. When the deployed source/runtime and configured + * source mode support recovery-time active-split reassignment, DynamicKafkaSource recovery can + * then rebuild a balanced active assignment without retaining removed splits as live work. + * LAST_STATE alone does not establish that capability. + */ + @Override + public OptionalLong realizeSourceAssignmentRebalance( + KubernetesJobAutoScalerContext context, long requestId) { + var resource = context.getResource(); + var jobSpec = resource.getSpec().getJob(); + if (jobSpec == null || jobSpec.getUpgradeMode() != UpgradeMode.LAST_STATE) { + LOG.warn( + "Skipping source assignment rebalance for {} because it requires a " + + "LAST_STATE application job.", + context.getJobKey()); + return OptionalLong.empty(); + } + + var currentRestartNonce = resource.getSpec().getRestartNonce(); + var lastReconciledSpec = + resource.getStatus().getReconciliationStatus().deserializeLastReconciledSpec(); + var lastReconciledRestartNonce = + lastReconciledSpec == null ? null : lastReconciledSpec.getRestartNonce(); + if (currentRestartNonce != null + && !Objects.equals(currentRestartNonce, lastReconciledRestartNonce) + && currentRestartNonce != requestId) { + LOG.info( + "Using pending restart nonce {} to cover source assignment rebalance for {}.", + currentRestartNonce, + context.getJobKey()); + return OptionalLong.of(currentRestartNonce); + } + + if (!Objects.equals(currentRestartNonce, requestId)) { + resource.getSpec().setRestartNonce(requestId); + LOG.info( + "Requesting controlled source assignment rebalance for {} with restart nonce {}.", + context.getJobKey(), + requestId); + } + return OptionalLong.of(requestId); + } + @Nullable private static String getOverrideString( KubernetesJobAutoScalerContext context, Map newOverrides) { diff --git a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/KubernetesScalingRealizerTest.java b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/KubernetesScalingRealizerTest.java index 25e7ba1945..ea3d3d6638 100644 --- a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/KubernetesScalingRealizerTest.java +++ b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/KubernetesScalingRealizerTest.java @@ -24,6 +24,7 @@ import org.apache.flink.configuration.TaskManagerOptions; import org.apache.flink.kubernetes.operator.TestUtils; import org.apache.flink.kubernetes.operator.api.FlinkDeployment; +import org.apache.flink.kubernetes.operator.api.spec.UpgradeMode; import io.fabric8.kubernetes.api.model.Quantity; import io.fabric8.kubernetes.api.model.ResourceRequirements; @@ -307,6 +308,39 @@ public void testApplyMemoryOverridesWithLimitsOnlyResourceRequirements() { assertThat(tuned.getRequests()).isNullOrEmpty(); } + @Test + public void testSourceAssignmentRebalanceUsesStableRestartNonce() { + KubernetesJobAutoScalerContext ctx = + TestingKubernetesAutoscalerUtils.createContext("test", null); + FlinkDeployment resource = (FlinkDeployment) ctx.getResource(); + resource.getSpec().getJob().setUpgradeMode(UpgradeMode.LAST_STATE); + var realizer = new KubernetesScalingRealizer(); + + assertThat(realizer.realizeSourceAssignmentRebalance(ctx, 123L)).hasValue(123L); + assertThat(resource.getSpec().getRestartNonce()).isEqualTo(123L); + + resource.getStatus() + .getReconciliationStatus() + .serializeAndSetLastReconciledSpec(resource.getSpec(), resource); + assertThat(realizer.realizeSourceAssignmentRebalance(ctx, 123L)).hasValue(123L); + assertThat(resource.getSpec().getRestartNonce()).isEqualTo(123L); + + resource.getSpec().setRestartNonce(456L); + assertThat(realizer.realizeSourceAssignmentRebalance(ctx, 789L)).hasValue(456L); + assertThat(resource.getSpec().getRestartNonce()).isEqualTo(456L); + } + + @Test + public void testSourceAssignmentRebalanceRequiresLastState() { + KubernetesJobAutoScalerContext ctx = + TestingKubernetesAutoscalerUtils.createContext("test", null); + ctx.getResource().getSpec().getJob().setUpgradeMode(UpgradeMode.STATELESS); + + assertThat(new KubernetesScalingRealizer().realizeSourceAssignmentRebalance(ctx, 123L)) + .isEmpty(); + assertThat(ctx.getResource().getSpec().getRestartNonce()).isNull(); + } + private void assertOverridesDoNotChange( String currentOverrides, LinkedHashMap newOverrides) { diff --git a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/KubernetesAutoScalerStateStoreTest.java b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/KubernetesAutoScalerStateStoreTest.java index b704cfcf66..58d864a032 100644 --- a/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/KubernetesAutoScalerStateStoreTest.java +++ b/flink-kubernetes-operator/src/test/java/org/apache/flink/kubernetes/operator/autoscaler/state/KubernetesAutoScalerStateStoreTest.java @@ -17,6 +17,7 @@ package org.apache.flink.kubernetes.operator.autoscaler.state; +import org.apache.flink.autoscaler.DelayedScaleDown; import org.apache.flink.autoscaler.ScalingSummary; import org.apache.flink.autoscaler.metrics.CollectedMetrics; import org.apache.flink.autoscaler.metrics.EvaluatedScalingMetric; @@ -37,6 +38,7 @@ import java.util.HashMap; import java.util.Map; import java.util.Random; +import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; @@ -126,6 +128,47 @@ void testCompressionMigration() throws Exception { assertThat(stateStore.getCollectedMetrics(ctx)).isEqualTo(metricHistory); } + @Test + void testOldDelayedScaleDownStateStillDeserializes() throws Exception { + var delayedScaleDown = + KubernetesAutoScalerStateStore.YAML_MAPPER.readValue( + "delayedVertices: {}", DelayedScaleDown.class); + + assertThat(delayedScaleDown.getDelayedVertices()).isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceVertices()).isEmpty(); + assertThat(delayedScaleDown.getSourceAssignmentRebalanceRequestId()).isNull(); + assertThat(delayedScaleDown.isSourceAssignmentRebalanceTriggered()).isFalse(); + } + + @Test + void testSourceAssignmentRecoveryLatchRoundTrips() throws Exception { + var vertex = new JobVertexID(); + var serializedState = + KubernetesAutoScalerStateStore.YAML_MAPPER.writeValueAsString( + Map.of( + "delayedVertices", + Map.of(), + "sourceAssignmentRebalanceVertices", + Set.of(vertex.toHexString()), + "sourceAssignmentRebalanceRequestId", + 123L, + "sourceAssignmentRebalanceTriggered", + true)); + var delayedScaleDown = + KubernetesAutoScalerStateStore.YAML_MAPPER.readValue( + serializedState, DelayedScaleDown.class); + var roundTripped = + KubernetesAutoScalerStateStore.YAML_MAPPER.readValue( + KubernetesAutoScalerStateStore.YAML_MAPPER.writeValueAsString( + delayedScaleDown), + DelayedScaleDown.class); + + assertThat(roundTripped.getSourceAssignmentRebalanceVertices()) + .containsExactly(vertex.toHexString()); + assertThat(roundTripped.getSourceAssignmentRebalanceRequestId()).isEqualTo(123L); + assertThat(roundTripped.isSourceAssignmentRebalanceTriggered()).isTrue(); + } + @Test void testMetricsTrimming() throws Exception { var v1 = new JobVertexID();