// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.applications; import com.yahoo.config.provision.ClusterInfo; import com.yahoo.config.provision.IntRange; import com.yahoo.config.provision.Capacity; import com.yahoo.config.provision.ClusterResources; import com.yahoo.config.provision.ClusterSpec; import com.yahoo.vespa.hosted.provision.autoscale.Autoscaler; import com.yahoo.vespa.hosted.provision.autoscale.Autoscaling; import com.yahoo.vespa.hosted.provision.autoscale.ClusterModel; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.Optional; /** * The node repo's view of a cluster in an application deployment. * * This is immutable, and must be locked with the application lock on read-modify-write. * * @author bratseth */ public class Cluster { public static final int maxScalingEvents = 15; private final ClusterSpec.Id id; private final boolean exclusive; private final ClusterResources min, max; private final IntRange groupSize; private final boolean required; private final List suggestions; private final Autoscaling target; private final ClusterInfo clusterInfo; private final BcpGroupInfo bcpGroupInfo; /** The maxScalingEvents last scaling events of this, sorted by increasing time (newest last) */ private final List scalingEvents; public Cluster(ClusterSpec.Id id, boolean exclusive, ClusterResources minResources, ClusterResources maxResources, IntRange groupSize, boolean required, List suggestions, Autoscaling target, ClusterInfo clusterInfo, BcpGroupInfo bcpGroupInfo, List scalingEvents) { this.id = Objects.requireNonNull(id); this.exclusive = exclusive; this.min = Objects.requireNonNull(minResources); this.max = Objects.requireNonNull(maxResources); this.groupSize = Objects.requireNonNull(groupSize); this.required = required; this.suggestions = Objects.requireNonNull(suggestions); Objects.requireNonNull(target); if (target.resources().isPresent() && ! target.resources().get().isWithin(minResources, maxResources)) this.target = target.withResources(Optional.empty()); // Delete illegal target else this.target = target; this.clusterInfo = clusterInfo; this.bcpGroupInfo = Objects.requireNonNull(bcpGroupInfo); this.scalingEvents = List.copyOf(scalingEvents); } public ClusterSpec.Id id() { return id; } /** Returns whether the nodes allocated to this cluster must be on host exclusively dedicated to this application */ public boolean exclusive() { return exclusive; } /** Returns the configured minimal resources in this cluster */ public ClusterResources minResources() { return min; } /** Returns the configured maximal resources in this cluster */ public ClusterResources maxResources() { return max; } /** Returns the configured group size range in this cluster */ public IntRange groupSize() { return groupSize; } /** * Returns whether the resources of this cluster are required to be within the specified min and max. * Otherwise, they may be adjusted by capacity policies. */ public boolean required() { return required; } /** * Returns the computed resources (between min and max, inclusive) this cluster should * have allocated at the moment (whether or not it actually has it), * or empty if the system currently has no target. */ public Autoscaling target() { return target; } /** * The list of suggested resources, which may or may not be within the min and max limits, * or empty if there is currently no recorded suggestion. * List is sorted by preference */ public List suggestions() { return suggestions; } /** Returns true if there is a current suggestion and we should actually make this suggestion to users. */ public boolean shouldSuggestResources(ClusterResources currentResources) { if (suggestions.isEmpty()) return false; return suggestions.stream().noneMatch(suggestion -> suggestion.resources().isEmpty() || suggestion.resources().get().isWithin(min, max) || ! Autoscaler.worthRescaling(currentResources, suggestion.resources().get()) ); } public ClusterInfo clusterInfo() { return clusterInfo; } /** Returns info about the BCP group of clusters this belongs to. */ public BcpGroupInfo bcpGroupInfo() { return bcpGroupInfo; } /** Returns the recent scaling events in this cluster */ public List scalingEvents() { return scalingEvents; } public Optional lastScalingEvent() { if (scalingEvents.isEmpty()) return Optional.empty(); return Optional.of(scalingEvents.get(scalingEvents.size() - 1)); } /** Returns whether the last scaling event in this has yet to complete. */ public boolean scalingInProgress() { return lastScalingEvent().isPresent() && lastScalingEvent().get().completion().isEmpty(); } public Cluster withConfiguration(boolean exclusive, Capacity capacity) { return new Cluster(id, exclusive, capacity.minResources(), capacity.maxResources(), capacity.groupSize(), capacity.isRequired(), suggestions, target, capacity.clusterInfo(), bcpGroupInfo, scalingEvents); } public Cluster withSuggestions(List suggestions) { return new Cluster(id, exclusive, min, max, groupSize, required, suggestions, target, clusterInfo, bcpGroupInfo, scalingEvents); } public Cluster withTarget(Autoscaling target) { return new Cluster(id, exclusive, min, max, groupSize, required, suggestions, target, clusterInfo, bcpGroupInfo, scalingEvents); } public Cluster with(BcpGroupInfo bcpGroupInfo) { return new Cluster(id, exclusive, min, max, groupSize, required, suggestions, target, clusterInfo, bcpGroupInfo, scalingEvents); } /** Add or update (based on "at" time) a scaling event */ public Cluster with(ScalingEvent scalingEvent) { List scalingEvents = new ArrayList<>(this.scalingEvents); int existingIndex = eventIndexAt(scalingEvent.at()); if (existingIndex >= 0) scalingEvents.set(existingIndex, scalingEvent); else scalingEvents.add(scalingEvent); prune(scalingEvents); return new Cluster(id, exclusive, min, max, groupSize, required, suggestions, target, clusterInfo, bcpGroupInfo, scalingEvents); } @Override public int hashCode() { return id.hashCode(); } @Override public boolean equals(Object other) { if (other == this) return true; if ( ! (other instanceof Cluster)) return false; return ((Cluster)other).id().equals(this.id); } @Override public String toString() { return id.toString(); } private void prune(List scalingEvents) { while (scalingEvents.size() > maxScalingEvents) scalingEvents.remove(0); } private int eventIndexAt(Instant at) { for (int i = 0; i < scalingEvents.size(); i++) { if (scalingEvents.get(i).at().equals(at)) return i; } return -1; } public static Cluster create(ClusterSpec.Id id, boolean exclusive, Capacity requested) { return new Cluster(id, exclusive, requested.minResources(), requested.maxResources(), requested.groupSize(), requested.isRequired(), List.of(), Autoscaling.empty(), requested.clusterInfo(), BcpGroupInfo.empty(), List.of()); } /** The predicted time it will take to rescale this cluster. */ public Duration scalingDuration(ClusterSpec clusterSpec) { int completedEventCount = 0; Duration totalDuration = Duration.ZERO; for (ScalingEvent event : scalingEvents()) { if (event.duration().isEmpty()) continue; // Assume we have missed timely recording completion if it is longer than 4 days, so ignore if ( ! event.duration().get().minus(Duration.ofDays(4)).isNegative()) continue; completedEventCount++; totalDuration = totalDuration.plus(event.duration().get()); } if (completedEventCount == 0) return ClusterModel.minScalingDuration(clusterSpec); return minimum(ClusterModel.minScalingDuration(clusterSpec), totalDuration.dividedBy(completedEventCount)); } /** The predicted time this cluster will stay in each resource configuration (including the scaling duration). */ public Duration allocationDuration(ClusterSpec clusterSpec) { if (scalingEvents.size() < 2) return Duration.ofHours(12); // Default long totalDurationMs = 0; for (int i = 1; i < scalingEvents().size(); i++) totalDurationMs += scalingEvents().get(i).at().toEpochMilli() - scalingEvents().get(i - 1).at().toEpochMilli(); return Duration.ofMillis(totalDurationMs / (scalingEvents.size() - 1)); } private static Duration minimum(Duration smallestAllowed, Duration duration) { if (duration.minus(smallestAllowed).isNegative()) return smallestAllowed; return duration; } }