// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.provision.autoscale;

import com.yahoo.config.provision.ClusterResources;
import com.yahoo.config.provision.ClusterSpec;
import com.yahoo.vespa.hosted.provision.Node;
import com.yahoo.vespa.hosted.provision.NodeRepository;
import com.yahoo.vespa.hosted.provision.applications.Cluster;

import java.time.Duration;
import java.util.List;
import java.util.Optional;

/**
 * The autoscaler makes decisions about the flavor and node count that should be allocated to a cluster
 * based on observed behavior.
 *
 * @author bratseth
 */
public class Autoscaler {

    /** What cost difference factor is worth a reallocation? */
    private static final double costDifferenceWorthReallocation = 0.1;
    /** What difference factor for a resource is worth a reallocation? */
    private static final double resourceDifferenceWorthReallocation = 0.1;

    private final MetricsDb metricsDb;
    private final NodeRepository nodeRepository;
    private final AllocationOptimizer allocationOptimizer;

    public Autoscaler(MetricsDb metricsDb, NodeRepository nodeRepository) {
        this.metricsDb = metricsDb;
        this.nodeRepository = nodeRepository;
        this.allocationOptimizer = new AllocationOptimizer(nodeRepository);
    }

    /**
     * Suggest a scaling of a cluster. This returns a better allocation (if found)
     * without taking min and max limits into account.
     *
     * @param clusterNodes the list of all the active nodes in a cluster
     * @return scaling advice for this cluster
     */
    public Advice suggest(Cluster cluster, List<Node> clusterNodes) {
        return autoscale(cluster, clusterNodes, Limits.empty(), cluster.exclusive());
    }

    /**
     * Autoscale a cluster by load. This returns a better allocation (if found) inside the min and max limits.
     *
     * @param clusterNodes the list of all the active nodes in a cluster
     * @return scaling advice for this cluster
     */
    public Advice autoscale(Cluster cluster, List<Node> clusterNodes) {
        if (cluster.minResources().equals(cluster.maxResources())) return Advice.none(); // Shortcut
        return autoscale(cluster, clusterNodes, Limits.of(cluster), cluster.exclusive());
    }

    private Advice autoscale(Cluster cluster, List<Node> clusterNodes, Limits limits, boolean exclusive) {
        if (unstable(clusterNodes, nodeRepository)) return Advice.none();

        AllocatableClusterResources currentAllocation = new AllocatableClusterResources(clusterNodes, nodeRepository);

        ClusterTimeseries clusterTimeseries = new ClusterTimeseries(cluster, clusterNodes, metricsDb, nodeRepository);

        Optional<Double> cpuLoad    = clusterTimeseries.averageLoad(Resource.cpu);
        Optional<Double> memoryLoad = clusterTimeseries.averageLoad(Resource.memory);
        Optional<Double> diskLoad   = clusterTimeseries.averageLoad(Resource.disk);
        if (cpuLoad.isEmpty() || memoryLoad.isEmpty() || diskLoad.isEmpty()) return Advice.none();
        var target = ResourceTarget.idealLoad(cpuLoad.get(), memoryLoad.get(), diskLoad.get(), currentAllocation);

        Optional<AllocatableClusterResources> bestAllocation =
                allocationOptimizer.findBestAllocation(target, currentAllocation, limits, exclusive);
        if (bestAllocation.isEmpty()) return Advice.dontScale();
        if (similar(bestAllocation.get(), currentAllocation)) return Advice.dontScale();
        return Advice.scaleTo(bestAllocation.get().toAdvertisedClusterResources());
    }

    /** Returns true if both total real resources and total cost are similar */
    private boolean similar(AllocatableClusterResources a, AllocatableClusterResources b) {
        return similar(a.cost(), b.cost(), costDifferenceWorthReallocation) &&
               similar(a.realResources().vcpu() * a.nodes(),
                       b.realResources().vcpu() * b.nodes(), resourceDifferenceWorthReallocation) &&
               similar(a.realResources().memoryGb() * a.nodes(),
                       b.realResources().memoryGb() * b.nodes(), resourceDifferenceWorthReallocation) &&
               similar(a.realResources().diskGb() * a.nodes(),
                       b.realResources().diskGb() * b.nodes(), resourceDifferenceWorthReallocation);
    }

    private boolean similar(double r1, double r2, double threshold) {
        return Math.abs(r1 - r2) / (( r1 + r2) / 2) < threshold;
    }

    /** The duration of the window we need to consider to make a scaling decision. See also minimumMeasurementsPerNode */
    static Duration scalingWindow(ClusterSpec.Type clusterType) {
        if (clusterType.isContent()) return Duration.ofHours(12);
        return Duration.ofHours(1);
    }

    static Duration maxScalingWindow() {
        return Duration.ofHours(12);
    }

    /** Measurements are currently taken once a minute. See also scalingWindow */
    static int minimumMeasurementsPerNode(ClusterSpec.Type clusterType) {
        if (clusterType.isContent()) return 60;
        return 20;
    }

    public static boolean unstable(List<Node> nodes, NodeRepository nodeRepository) {
        // The cluster is processing recent changes
        if (nodes.stream().anyMatch(node -> node.status().wantToRetire() ||
                                            node.allocation().get().membership().retired() ||
                                            node.allocation().get().isRemovable()))
            return true;

        // A deployment is ongoing
        if (nodeRepository.getNodes(nodes.get(0).allocation().get().owner(), Node.State.reserved).size() > 0)
            return true;

        return false;
    }
    
    public static class Advice {

        private final boolean present;
        private final Optional<ClusterResources> target;

        private Advice(Optional<ClusterResources> target, boolean present) {
            this.target = target;
            this.present = present;
        }

        public Optional<ClusterResources> target() { return target; }
        public boolean isEmpty() { return ! present; }
        public boolean isPresent() { return present; }

        public static Advice none() { return new Advice(Optional.empty(), false); }
        public static Advice dontScale() { return new Advice(Optional.empty(), true); }
        public static Advice scaleTo(ClusterResources target) { return new Advice(Optional.of(target), true); }

    }

}

@@ -5761,20 +5761,16 @@
 ;%0A    %7D%0A
-    
 %0A    pub
@@ -6063,124 +6063,447 @@
     
-public Optional%3CClusterResources%3E target() %7B return target; %7D%0A        public boolean isEmpty() %7B return ! present; %7D
+/**%0A         * Returns the autoscaling target that should be set by this advice.%0A         * This is empty if the advice is to keep the current allocation.%0A         */%0A        public Optional%3CClusterResources%3E target() %7B return target; %7D%0A%0A        /** True if this does not provide any advice */%0A        public boolean isEmpty() %7B return ! present; %7D%0A%0A        /** True if this provides advice (which may be to keep the current allocation) */
 %0A   
@@ -6556,37 +6556,38 @@
 nt; %7D%0A%0A        p
-ublic
+rivate
  static Advice n
@@ -6641,37 +6641,38 @@
 se); %7D%0A        p
-ublic
+rivate
  static Advice d
@@ -6734,29 +6734,30 @@
  %7D%0A        p
-ublic
+rivate
  static Advi

Add javadoc