diff options
author | Jon Bratseth <bratseth@gmail.com> | 2023-01-22 15:13:07 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-01-22 15:13:07 +0100 |
commit | 45577c7d7d2c70637d63f89702c3229df312edc3 (patch) | |
tree | 77d7095ffeb6a631ee701546a8f0b18735bf47bd | |
parent | 85bb4e8d16ec06bba6c3b45ea7cb1f084f5e988d (diff) | |
parent | 1d5806f068309e18b70fee03b7a22e111c180607 (diff) |
Merge pull request #25667 from vespa-engine/bratseth/group-size
Support a group size constraint in content clusters
35 files changed, 633 insertions, 160 deletions
diff --git a/config-model/src/main/java/com/yahoo/config/model/provision/InMemoryProvisioner.java b/config-model/src/main/java/com/yahoo/config/model/provision/InMemoryProvisioner.java index f44849e185f..105a9669d1f 100644 --- a/config-model/src/main/java/com/yahoo/config/model/provision/InMemoryProvisioner.java +++ b/config-model/src/main/java/com/yahoo/config/model/provision/InMemoryProvisioner.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.config.model.provision; +import com.yahoo.collections.IntRange; import com.yahoo.collections.ListMap; import com.yahoo.collections.Pair; import com.yahoo.config.model.api.HostProvisioner; @@ -153,13 +154,18 @@ public class InMemoryProvisioner implements HostProvisioner { requested = requested.withLimits(requested.minResources().withNodes(1), requested.maxResources().withNodes(1)); } - if (useMaxResources) - return prepare(cluster, requested.maxResources(), requested.isRequired(), requested.canFail()); - else - return prepare(cluster, requested.minResources(), requested.isRequired(), requested.canFail()); + IntRange groupRange = IntRange.of(requested.minResources().groups(), requested.maxResources().groups()); + if (useMaxResources) { + int groups = groupRange.fit(requested.maxResources().nodes() / requested.groupSize().to().orElse(1)); + return prepare(cluster, requested.maxResources(),groups, requested.isRequired(), requested.canFail()); + } + else { + int groups = groupRange.fit(requested.minResources().nodes() / requested.groupSize().from().orElse(1)); + return prepare(cluster, requested.minResources(), groups, requested.isRequired(), requested.canFail()); + } } - public List<HostSpec> prepare(ClusterSpec cluster, ClusterResources requested, boolean required, boolean canFail) { + public List<HostSpec> prepare(ClusterSpec cluster, ClusterResources requested, int groups, boolean required, boolean canFail) { if (cluster.group().isPresent() && requested.groups() > 1) throw new IllegalArgumentException("Cannot both be specifying a group and ask for groups to be created"); @@ -169,7 +175,7 @@ public class InMemoryProvisioner implements HostProvisioner { if (alwaysReturnOneNode) nodes = 1; - int groups = Math.min(requested.groups(), nodes); + groups = Math.min(groups, nodes); List<HostSpec> allocation = new ArrayList<>(); if (groups == 1) { diff --git a/config-model/src/main/java/com/yahoo/vespa/model/builder/xml/dom/NodesSpecification.java b/config-model/src/main/java/com/yahoo/vespa/model/builder/xml/dom/NodesSpecification.java index aac968f9038..352e08af4a3 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/builder/xml/dom/NodesSpecification.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/builder/xml/dom/NodesSpecification.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.model.builder.xml.dom; +import com.yahoo.collections.IntRange; import com.yahoo.collections.Pair; import com.yahoo.component.Version; import com.yahoo.config.application.api.DeployLogger; @@ -34,6 +35,8 @@ public class NodesSpecification { private final ClusterResources min, max; + private final IntRange groupSize; + private final boolean dedicated; /** The Vespa version we want the nodes to run */ @@ -63,6 +66,7 @@ public class NodesSpecification { private NodesSpecification(ClusterResources min, ClusterResources max, + IntRange groupSize, boolean dedicated, Version version, boolean required, boolean canFail, boolean exclusive, Optional<DockerImage> dockerImageRepo, @@ -83,6 +87,7 @@ public class NodesSpecification { this.min = min; this.max = max; + this.groupSize = groupSize; this.dedicated = dedicated; this.version = version; this.required = required; @@ -99,10 +104,11 @@ public class NodesSpecification { Optional<CloudAccount> cloudAccount) { var resolvedElement = resolveElement(nodesElement); var combinedId = findCombinedId(nodesElement, resolvedElement); - var resources = toResources(resolvedElement); + var resourceConstraints = toResourceConstraints(resolvedElement); boolean hasCountAttribute = resolvedElement.stringAttribute("count") != null; - return new NodesSpecification(resources.getFirst(), - resources.getSecond(), + return new NodesSpecification(resourceConstraints.min, + resourceConstraints.max, + resourceConstraints.groupSize, dedicated, version, resolvedElement.booleanAttribute("required", false), @@ -114,14 +120,27 @@ public class NodesSpecification { hasCountAttribute); } - private static Pair<ClusterResources, ClusterResources> toResources(ModelElement nodesElement) { - Pair<Integer, Integer> nodes = toRange(nodesElement.stringAttribute("count"), 1, Integer::parseInt); - Pair<Integer, Integer> groups = toRange(nodesElement.stringAttribute("groups"), 1, Integer::parseInt); - var min = new ClusterResources(nodes.getFirst(), groups.getFirst(), nodeResources(nodesElement).getFirst()); - var max = new ClusterResources(nodes.getSecond(), groups.getSecond(), nodeResources(nodesElement).getSecond()); - return new Pair<>(min, max); + private static ResourceConstraints toResourceConstraints(ModelElement nodesElement) { + var nodes = rangeFrom(nodesElement, "count"); + var groups = rangeFrom(nodesElement, "groups"); + var groupSize = rangeFrom(nodesElement, "group-size"); + int defaultMaxGroups = groupSize.isEmpty() ? 1 : nodes.to().orElse(1); // Don't constrain the number of groups if group size is set + var min = new ClusterResources(nodes.from().orElse(1), groups.from().orElse(1), nodeResources(nodesElement).getFirst()); + var max = new ClusterResources(nodes.to().orElse(1), groups.to().orElse(defaultMaxGroups), nodeResources(nodesElement).getSecond()); + return new ResourceConstraints(min, max, groupSize); } + private static IntRange rangeFrom(ModelElement element, String name) { + try { + return IntRange.from(element.stringAttribute(name, "")); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Illegal " + name + " value", e); + } + } + + private record ResourceConstraints(ClusterResources min, ClusterResources max, IntRange groupSize) {} + /** Returns the ID of the cluster referencing this node specification, if any */ private static Optional<String> findCombinedId(ModelElement nodesElement, ModelElement resolvedElement) { if (resolvedElement != nodesElement) { @@ -143,18 +162,6 @@ public class NodesSpecification { } /** - * Returns a requirement for dedicated nodes taken from the <code>nodes</code> element - * contained in the given parent element, or empty if the parent element is null, or the nodes elements - * is not present. - */ - public static Optional<NodesSpecification> fromParent(ModelElement parentElement, ConfigModelContext context) { - if (parentElement == null) return Optional.empty(); - ModelElement nodesElement = parentElement.child("nodes"); - if (nodesElement == null) return Optional.empty(); - return Optional.of(from(nodesElement, context)); - } - - /** * Returns a requirement for non-dedicated or dedicated nodes taken from the <code>nodes</code> element * contained in the given parent element, or empty if the parent element is null, or the nodes elements * is not present. @@ -178,6 +185,7 @@ public class NodesSpecification { public static NodesSpecification nonDedicated(int count, ConfigModelContext context) { return new NodesSpecification(new ClusterResources(count, 1, NodeResources.unspecified()), new ClusterResources(count, 1, NodeResources.unspecified()), + IntRange.empty(), false, context.getDeployState().getWantedNodeVespaVersion(), false, @@ -193,6 +201,7 @@ public class NodesSpecification { public static NodesSpecification dedicated(int count, ConfigModelContext context) { return new NodesSpecification(new ClusterResources(count, 1, NodeResources.unspecified()), new ClusterResources(count, 1, NodeResources.unspecified()), + IntRange.empty(), true, context.getDeployState().getWantedNodeVespaVersion(), false, @@ -219,6 +228,7 @@ public class NodesSpecification { .toList(); return new NodesSpecification(new ClusterResources(count, 1, resources), new ClusterResources(count, 1, resources), + IntRange.empty(), true, context.getDeployState().getWantedNodeVespaVersion(), allContent.stream().anyMatch(content -> content.required), @@ -232,6 +242,7 @@ public class NodesSpecification { public ClusterResources minResources() { return min; } public ClusterResources maxResources() { return max; } + public IntRange groupSize() { return groupSize; } /** * Returns whether this requires dedicated nodes. @@ -275,7 +286,7 @@ public class NodesSpecification { .loadBalancerSettings(zoneEndpoint) .stateful(stateful) .build(); - return hostSystem.allocateHosts(cluster, Capacity.from(min, max, required, canFail, cloudAccount), logger); + return hostSystem.allocateHosts(cluster, Capacity.from(min, max, groupSize, required, canFail, cloudAccount), logger); } private static Pair<NodeResources, NodeResources> nodeResources(ModelElement nodesElement) { diff --git a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java index 4c7bad575d2..62a3d004ac0 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/container/xml/ContainerModelBuilder.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.model.container.xml; +import com.yahoo.collections.IntRange; import com.yahoo.component.ComponentSpecification; import com.yahoo.component.Version; import com.yahoo.component.chain.dependencies.Dependencies; @@ -926,6 +927,7 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { ClusterResources resources = new ClusterResources(nodeCount, 1, NodeResources.unspecified()); Capacity capacity = Capacity.from(resources, resources, + IntRange.empty(), false, !deployState.getProperties().isBootstrap(), context.getDeployState().getProperties().cloudAccount()); @@ -945,16 +947,21 @@ public class ContainerModelBuilder extends ConfigModelBuilder<ContainerModel> { } private List<ApplicationContainer> createNodesFromNodeCount(ApplicationContainerCluster cluster, Element containerElement, Element nodesElement, ConfigModelContext context) { - NodesSpecification nodesSpecification = NodesSpecification.from(new ModelElement(nodesElement), context); - ClusterSpec.Id clusterId = ClusterSpec.Id.from(cluster.name()); - ZoneEndpoint zoneEndpoint = zoneEndpoint(context, clusterId); - Map<HostResource, ClusterMembership> hosts = nodesSpecification.provision(cluster.getRoot().hostSystem(), - ClusterSpec.Type.container, - clusterId, - zoneEndpoint, - log, - getZooKeeper(containerElement) != null); - return createNodesFromHosts(hosts, cluster, context.getDeployState()); + try { + NodesSpecification nodesSpecification = NodesSpecification.from(new ModelElement(nodesElement), context); + ClusterSpec.Id clusterId = ClusterSpec.Id.from(cluster.name()); + ZoneEndpoint zoneEndpoint = zoneEndpoint(context, clusterId); + Map<HostResource, ClusterMembership> hosts = nodesSpecification.provision(cluster.getRoot().hostSystem(), + ClusterSpec.Type.container, + clusterId, + zoneEndpoint, + log, + getZooKeeper(containerElement) != null); + return createNodesFromHosts(hosts, cluster, context.getDeployState()); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("In " + cluster, e); + } } private List<ApplicationContainer> createNodesFromNodeType(ApplicationContainerCluster cluster, Element nodesElement, ConfigModelContext context) { diff --git a/config-model/src/main/java/com/yahoo/vespa/model/content/IndexedHierarchicDistributionValidator.java b/config-model/src/main/java/com/yahoo/vespa/model/content/IndexedHierarchicDistributionValidator.java index dbe813bfb2d..b4a83efd2aa 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/content/IndexedHierarchicDistributionValidator.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/content/IndexedHierarchicDistributionValidator.java @@ -30,17 +30,18 @@ public class IndexedHierarchicDistributionValidator { public void validate() { validateThatWeHaveOneGroupLevel(); validateThatLeafGroupsHasEqualNumberOfNodes(); - validateThatLeafGroupsCountIsAFactorOfRedundancy(clusterName, redundancy.effectiveFinalRedundancy(), rootGroup.getSubgroups().size()); + validateThatLeafGroupsCountIsAFactorOfRedundancy(redundancy.effectiveFinalRedundancy(), rootGroup.getSubgroups().size()); validateThatRedundancyPerGroupIsEqual(); - validateThatReadyCopiesIsCompatibleWithRedundancy(clusterName, redundancy.effectiveFinalRedundancy(), redundancy.effectiveReadyCopies(), rootGroup.getSubgroups().size()); + validateThatReadyCopiesIsCompatibleWithRedundancy(redundancy.effectiveFinalRedundancy(), redundancy.effectiveReadyCopies(), rootGroup.getSubgroups().size()); } private void validateThatWeHaveOneGroupLevel() { for (StorageGroup group : rootGroup.getSubgroups()) { if (group.getSubgroups().size() > 0) { - throw new IllegalArgumentException(getErrorMsgPrefix(clusterName) + "Expected all groups under root group '" + - rootGroup.getName() + "' to be leaf groups only containing nodes, but sub group '" + group.getName() + "' contains " + - group.getSubgroups().size() + " sub groups."); + throw new IllegalArgumentException("Expected all groups under root group '" + + rootGroup.getName() + "' to be leaf groups only containing nodes, but sub group '" + + group.getName() + "' contains " + + group.getSubgroups().size() + " sub groups"); } } } @@ -56,18 +57,19 @@ public class IndexedHierarchicDistributionValidator { } if (group.getNodes().size() != previousGroup.getNodes().size()) - throw new IllegalArgumentException(getErrorMsgPrefix(clusterName) + "Expected leaf groups to contain an equal number of nodes, but leaf group '" + - previousGroup.getName() + "' contains " + previousGroup.getNodes().size() + " node(s) while leaf group '" + - group.getName() + "' contains " + group.getNodes().size() + " node(s)."); + throw new IllegalArgumentException("Expected leaf groups to contain an equal number of nodes, but leaf group '" + + previousGroup.getName() + "' contains " + previousGroup.getNodes().size() + + " node(s) while leaf group '" + group.getName() + + "' contains " + group.getNodes().size() + " node(s)"); previousGroup = group; } } - static public void validateThatLeafGroupsCountIsAFactorOfRedundancy(String clusterName, int totalRedundancy, int subGroups) { + static public void validateThatLeafGroupsCountIsAFactorOfRedundancy(int totalRedundancy, int subGroups) { if (totalRedundancy % subGroups != 0) { - throw new IllegalArgumentException(getErrorMsgPrefix(clusterName) + "Expected number of leaf groups (" + + throw new IllegalArgumentException("Expected number of leaf groups (" + subGroups + ") to be a factor of redundancy (" + - totalRedundancy + "), but it is not."); + totalRedundancy + "), but it is not"); } } @@ -75,9 +77,10 @@ public class IndexedHierarchicDistributionValidator { int redundancyPerGroup = redundancy.effectiveFinalRedundancy() / rootGroup.getSubgroups().size(); String expPartitions = createDistributionPartitions(redundancyPerGroup, rootGroup.getSubgroups().size()); if (!rootGroup.getPartitions().get().equals(expPartitions)) { - throw new IllegalArgumentException(getErrorMsgPrefix(clusterName) + "Expected redundancy per leaf group to be " + + throw new IllegalArgumentException("Expected redundancy per leaf group to be " + redundancyPerGroup + ", but it is not according to distribution partitions '" + - rootGroup.getPartitions().get() + "'. Expected distribution partitions should be '" + expPartitions + "'."); + rootGroup.getPartitions().get() + + "'. Expected distribution partitions should be '" + expPartitions + "'"); } } @@ -91,20 +94,17 @@ public class IndexedHierarchicDistributionValidator { return sb.toString(); } - static public void validateThatReadyCopiesIsCompatibleWithRedundancy(String clusterName, int totalRedundancy, int totalReadyCopies, int groupCount) { + static public void validateThatReadyCopiesIsCompatibleWithRedundancy(int totalRedundancy, int totalReadyCopies, int groupCount) { if (totalRedundancy % groupCount != 0) { - throw new IllegalArgumentException(getErrorMsgPrefix(clusterName) + "Expected equal redundancy per group."); + throw new IllegalArgumentException("Expected equal redundancy per group"); } if (totalReadyCopies % groupCount != 0) { - throw new IllegalArgumentException(getErrorMsgPrefix(clusterName) + "Expected equal amount of ready copies per group, but " + + throw new IllegalArgumentException("Expected equal amount of ready copies per group, but " + totalReadyCopies + " ready copies is specified with " + groupCount + " groups"); } if (totalReadyCopies == 0) { - throw new IllegalArgumentException(getErrorMsgPrefix(clusterName) + "Warning. No ready copies configured. At least one is required."); + throw new IllegalArgumentException("No ready copies configured. At least 1 is required."); } } - static private String getErrorMsgPrefix(String clusterName) { - return "In indexed content cluster '" + clusterName + "' using hierarchic distribution: "; - } } diff --git a/config-model/src/main/java/com/yahoo/vespa/model/content/StorageGroup.java b/config-model/src/main/java/com/yahoo/vespa/model/content/StorageGroup.java index aef00be5ea9..36090aa7349 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/content/StorageGroup.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/content/StorageGroup.java @@ -204,30 +204,35 @@ public class StorageGroup { } public StorageGroup buildRootGroup(DeployState deployState, RedundancyBuilder redundancyBuilder, ContentCluster owner) { - if (owner.isHosted()) - validateRedundancyAndGroups(deployState.zone().environment()); + try { + if (owner.isHosted()) + validateRedundancyAndGroups(deployState.zone().environment()); - Optional<ModelElement> group = Optional.ofNullable(clusterElement.child("group")); - Optional<ModelElement> nodes = getNodes(clusterElement); + Optional<ModelElement> group = Optional.ofNullable(clusterElement.child("group")); + Optional<ModelElement> nodes = getNodes(clusterElement); - if (group.isPresent() && nodes.isPresent()) - throw new IllegalArgumentException("Both <group> and <nodes> is specified: Only one of these tags can be used in the same configuration"); - if (group.isPresent() && (group.get().integerAttribute("distribution-key") != null)) { - deployState.getDeployLogger().logApplicationPackage(Level.INFO, "'distribution-key' attribute on a content cluster's root group is ignored"); - } + if (group.isPresent() && nodes.isPresent()) + throw new IllegalArgumentException("Both <group> and <nodes> is specified: Only one of these tags can be used in the same configuration"); + if (group.isPresent() && (group.get().integerAttribute("distribution-key") != null)) { + deployState.getDeployLogger().logApplicationPackage(Level.INFO, "'distribution-key' attribute on a content cluster's root group is ignored"); + } - GroupBuilder groupBuilder = collectGroup(owner.isHosted(), group, nodes, null, null); - StorageGroup storageGroup = owner.isHosted() - ? groupBuilder.buildHosted(deployState, owner, Optional.empty()) - : groupBuilder.buildNonHosted(deployState, owner, Optional.empty()); + GroupBuilder groupBuilder = collectGroup(owner.isHosted(), group, nodes, null, null); + StorageGroup storageGroup = owner.isHosted() + ? groupBuilder.buildHosted(deployState, owner, Optional.empty()) + : groupBuilder.buildNonHosted(deployState, owner, Optional.empty()); - Redundancy redundancy = redundancyBuilder.build(owner.getName(), owner.isHosted(), storageGroup.subgroups.size(), - storageGroup.getNumberOfLeafGroups(), storageGroup.countNodes(false)); - owner.setRedundancy(redundancy); - if (storageGroup.partitions.isEmpty() && (redundancy.groups() > 1)) { - storageGroup.partitions = Optional.of(computePartitions(redundancy.finalRedundancy(), redundancy.groups())); + Redundancy redundancy = redundancyBuilder.build(owner.isHosted(), storageGroup.subgroups.size(), + storageGroup.getNumberOfLeafGroups(), storageGroup.countNodes(false)); + owner.setRedundancy(redundancy); + if (storageGroup.partitions.isEmpty() && (redundancy.groups() > 1)) { + storageGroup.partitions = Optional.of(computePartitions(redundancy.finalRedundancy(), redundancy.groups())); + } + return storageGroup; + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("In " + owner, e); } - return storageGroup; } private void validateRedundancyAndGroups(Environment environment) { @@ -242,11 +247,11 @@ public class StorageGroup { // Allow dev deployment of self-hosted app (w/o count attribute): absent count => 1 node if (!nodesSpec.hasCountAttribute() && environment == Environment.dev) return; - int minNodesPerGroup = (int)Math.ceil((double)nodesSpec.minResources().nodes() / nodesSpec.minResources().groups()); + int minNodesPerGroup = (int) Math.ceil((double) nodesSpec.minResources().nodes() / nodesSpec.minResources().groups()); if (minNodesPerGroup < redundancy) { - throw new IllegalArgumentException("Cluster '" + clusterElement.stringAttribute("id") + "' " + - "specifies redundancy " + redundancy + ", but it cannot be higher than " + + throw new IllegalArgumentException("This cluster specifies redundancy " + redundancy + + ", but this cannot be higher than " + "the minimum nodes per group, which is " + minNodesPerGroup); } } diff --git a/config-model/src/main/java/com/yahoo/vespa/model/content/cluster/RedundancyBuilder.java b/config-model/src/main/java/com/yahoo/vespa/model/content/cluster/RedundancyBuilder.java index 1948cc1bd71..853a90cd8fa 100644 --- a/config-model/src/main/java/com/yahoo/vespa/model/content/cluster/RedundancyBuilder.java +++ b/config-model/src/main/java/com/yahoo/vespa/model/content/cluster/RedundancyBuilder.java @@ -37,14 +37,15 @@ public class RedundancyBuilder { } } } - public Redundancy build(String clusterName, boolean isHosted, int subGroups, int leafGroups, int totalNodes) { + public Redundancy build(boolean isHosted, int subGroups, int leafGroups, int totalNodes) { if (isHosted) { return new Redundancy(initialRedundancy, finalRedundancy, readyCopies, leafGroups, totalNodes); } else { subGroups = Math.max(1, subGroups); - IndexedHierarchicDistributionValidator.validateThatLeafGroupsCountIsAFactorOfRedundancy(clusterName, finalRedundancy, subGroups); - IndexedHierarchicDistributionValidator.validateThatReadyCopiesIsCompatibleWithRedundancy(clusterName, finalRedundancy, readyCopies, subGroups); - return new Redundancy(initialRedundancy/subGroups, finalRedundancy/subGroups, readyCopies/subGroups, subGroups, totalNodes); + IndexedHierarchicDistributionValidator.validateThatLeafGroupsCountIsAFactorOfRedundancy(finalRedundancy, subGroups); + IndexedHierarchicDistributionValidator.validateThatReadyCopiesIsCompatibleWithRedundancy(finalRedundancy, readyCopies, subGroups); + return new Redundancy(initialRedundancy/subGroups, finalRedundancy/subGroups, + readyCopies/subGroups, subGroups, totalNodes); } } diff --git a/config-model/src/main/resources/schema/content.rnc b/config-model/src/main/resources/schema/content.rnc index 3d1873507ce..703001f0107 100644 --- a/config-model/src/main/resources/schema/content.rnc +++ b/config-model/src/main/resources/schema/content.rnc @@ -226,7 +226,8 @@ ContentNodes = element nodes { attribute required { xsd:boolean }? & attribute exclusive { xsd:boolean }? & attribute docker-image { xsd:string }? & - attribute groups { xsd:positiveInteger | xsd:string }? + attribute groups { xsd:positiveInteger | xsd:string }? & + attribute group-size { xsd:positiveInteger | xsd:string }? ) | ContentNode + diff --git a/config-model/src/test/java/com/yahoo/config/model/provision/ModelProvisioningTest.java b/config-model/src/test/java/com/yahoo/config/model/provision/ModelProvisioningTest.java index 635ed4c8659..6f8547c3701 100644 --- a/config-model/src/test/java/com/yahoo/config/model/provision/ModelProvisioningTest.java +++ b/config-model/src/test/java/com/yahoo/config/model/provision/ModelProvisioningTest.java @@ -501,14 +501,14 @@ public class ModelProvisioningTest { " <documents>" + " <document type='type1' mode='index'/>" + " </documents>" + - " <nodes count='27' groups='9'/>" + + " <nodes count='27' groups='9' group-size='[2, 3]'/>" + " </content>" + " <content version='1.0' id='baz'>" + " <redundancy>1</redundancy>" + " <documents>" + " <document type='type1' mode='index'/>" + " </documents>" + - " <nodes count='27' groups='27'/>" + + " <nodes count='27' groups='27' group-size='1'/>" + " </content>" + "</services>"; @@ -595,6 +595,126 @@ public class ModelProvisioningTest { } @Test + public void testUsingGroups() { + String services = + "<?xml version='1.0' encoding='utf-8' ?>\n" + + "<services>" + + " <admin version='4.0'/>" + + " <container version='1.0' id='foo'>" + + " <nodes count='10'/>" + + " </container>" + + " <content version='1.0' id='bar'>" + + " <redundancy>2</redundancy>" + + " <documents>" + + " <document type='type1' mode='index'/>" + + " </documents>" + + " <nodes count='30' groups='2'/>" + + " </content>" + + " <content version='1.0' id='baz'>" + + " <redundancy>1</redundancy>" + + " <documents>" + + " <document type='type1' mode='index'/>" + + " </documents>" + + " <nodes count='30' groups='30'/>" + + " </content>" + + "</services>"; + + int numberOfHosts = 73; + VespaModelTester tester = new VespaModelTester(); + tester.addHosts(numberOfHosts); + VespaModel model = tester.createModel(services, true); + assertEquals(numberOfHosts, model.getRoot().hostSystem().getHosts().size()); + + ContentCluster cluster = model.getContentClusters().get("bar"); + List<StorageGroup> subGroups = cluster.getRootGroup().getSubgroups(); + assertEquals( 0, cluster.getRootGroup().getNodes().size()); + assertEquals( 2, subGroups.size()); + assertEquals(15, subGroups.get(0).getNodes().size()); + + cluster = model.getContentClusters().get("baz"); + subGroups = cluster.getRootGroup().getSubgroups(); + assertEquals( 0, cluster.getRootGroup().getNodes().size()); + assertEquals(30, subGroups.size()); + assertEquals( 1, subGroups.get(0).getNodes().size()); + } + + // Same as the test above but setting groupSize only + @Test + public void testUsingGroupSizeNotGroups() { + String services = + "<?xml version='1.0' encoding='utf-8' ?>\n" + + "<services>" + + " <admin version='4.0'/>" + + " <container version='1.0' id='foo'>" + + " <nodes count='10'/>" + + " </container>" + + " <content version='1.0' id='bar'>" + + " <redundancy>2</redundancy>" + + " <documents>" + + " <document type='type1' mode='index'/>" + + " </documents>" + + " <nodes count='30' group-size='[15, 30]'/>" + + " </content>" + + " <content version='1.0' id='baz'>" + + " <redundancy>1</redundancy>" + + " <documents>" + + " <document type='type1' mode='index'/>" + + " </documents>" + + " <nodes count='30' group-size='1'/>" + + " </content>" + + "</services>"; + + int numberOfHosts = 73; + VespaModelTester tester = new VespaModelTester(); + tester.addHosts(numberOfHosts); + VespaModel model = tester.createModel(services, true); + assertEquals(numberOfHosts, model.getRoot().hostSystem().getHosts().size()); + + ContentCluster cluster = model.getContentClusters().get("bar"); + List<StorageGroup> subGroups = cluster.getRootGroup().getSubgroups(); + assertEquals( 0, cluster.getRootGroup().getNodes().size()); + assertEquals( 2, subGroups.size()); + assertEquals(15, subGroups.get(0).getNodes().size()); + + cluster = model.getContentClusters().get("baz"); + subGroups = cluster.getRootGroup().getSubgroups(); + assertEquals( 0, cluster.getRootGroup().getNodes().size()); + assertEquals(30, subGroups.size()); + assertEquals( 1, subGroups.get(0).getNodes().size()); + } + + @Test + public void testIllegalGroupSize() { + String services = + "<?xml version='1.0' encoding='utf-8' ?>\n" + + "<services>" + + " <admin version='4.0'/>" + + " <container version='1.0' id='foo'>" + + " <nodes count='2'/>" + + " </container>" + + " <content version='1.0' id='bar'>" + + " <redundancy>2</redundancy>" + + " <documents>" + + " <document type='type1' mode='index'/>" + + " </documents>" + + " <nodes count='5' group-size='[2, --]'/>" + + " </content>" + + "</services>"; + + int numberOfHosts = 10; + VespaModelTester tester = new VespaModelTester(); + tester.addHosts(numberOfHosts); + try { + tester.createModel(services, true); + fail("Expected exception"); + } + catch (IllegalArgumentException e) { + assertEquals("In content cluster 'bar': Illegal group-size value: " + + "Expected a number or range on the form [min, max], but got '[2, --]': '--' is not an integer", Exceptions.toMessageString(e)); + } + } + + @Test public void testSlobroksOnContainersIfNoContentClusters() { String services = "<?xml version='1.0' encoding='utf-8' ?>\n" + @@ -1104,7 +1224,8 @@ public class ModelProvisioningTest { fail("Expected exception"); } catch (IllegalArgumentException e) { - assertEquals("Cluster 'bar' specifies redundancy 2, but it cannot be higher than the minimum nodes per group, which is 1", Exceptions.toMessageString(e)); + assertEquals("In content cluster 'bar': This cluster specifies redundancy 2, " + + "but this cannot be higher than the minimum nodes per group, which is 1", Exceptions.toMessageString(e)); } } @@ -1449,7 +1570,7 @@ public class ModelProvisioningTest { tester.addHosts(new NodeResources(85, 200, 1000_000_000, 0.3), 20); tester.addHosts(new NodeResources( 0.5, 2, 10, 0.3), 3); VespaModel model = tester.createModel(services, true); - assertEquals(totalHosts + 3, model.getRoot().hostSystem().getHosts().size()); + assertEquals(4 + 6 + 1, model.getRoot().hostSystem().getHosts().size()); } @Test @@ -1700,7 +1821,9 @@ public class ModelProvisioningTest { VespaModel model = tester.createModel(new Zone(Environment.staging, RegionName.from("us-central-1")), services, true); fail("expected failure"); } catch (IllegalArgumentException e) { - assertTrue(e.getMessage().startsWith("Clusters in hosted environments must have a <nodes count='N'> tag")); + assertEquals("In content cluster 'bar': Clusters in hosted environments must have a <nodes count='N'> tag\n" + + "matching all zones, and having no <node> subtags,\nsee https://cloud.vespa.ai/en/reference/services", + Exceptions.toMessageString(e)); } } diff --git a/config-model/src/test/java/com/yahoo/vespa/model/application/validation/change/CloudAccountChangeValidatorTest.java b/config-model/src/test/java/com/yahoo/vespa/model/application/validation/change/CloudAccountChangeValidatorTest.java index 46d0fcb3123..91493bab480 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/application/validation/change/CloudAccountChangeValidatorTest.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/application/validation/change/CloudAccountChangeValidatorTest.java @@ -1,5 +1,6 @@ package com.yahoo.vespa.model.application.validation.change; +import com.yahoo.collections.IntRange; import com.yahoo.config.application.api.ValidationOverrides; import com.yahoo.config.model.api.Provisioned; import com.yahoo.config.model.deploy.DeployState; @@ -55,6 +56,7 @@ class CloudAccountChangeValidatorTest { NodeResources nodeResources = new NodeResources(4, 8, 100, 10); return Capacity.from(new ClusterResources(2, 1, nodeResources), new ClusterResources(2, 1, nodeResources), + IntRange.empty(), false, false, Optional.of(cloudAccount).filter(account -> !account.isUnspecified())); diff --git a/config-model/src/test/java/com/yahoo/vespa/model/content/IndexedHierarchicDistributionTest.java b/config-model/src/test/java/com/yahoo/vespa/model/content/IndexedHierarchicDistributionTest.java index 07c032a52a5..7fba5ba12e9 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/content/IndexedHierarchicDistributionTest.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/content/IndexedHierarchicDistributionTest.java @@ -6,6 +6,7 @@ import com.yahoo.vespa.model.content.cluster.ContentCluster; import com.yahoo.vespa.model.search.DispatchGroup; import com.yahoo.vespa.model.search.SearchInterface; import com.yahoo.vespa.model.search.SearchNode; +import com.yahoo.yolean.Exceptions; import org.junit.jupiter.api.Test; import java.util.ArrayList; @@ -176,7 +177,8 @@ public class IndexedHierarchicDistributionTest { getIllegalMultipleGroupsLevelCluster(); fail("Did not get expected Exception"); } catch (Exception e) { - assertTrue(e.getMessage().contains("sub group 'group0' contains 2 sub groups.")); + assertEquals("Expected all groups under root group 'null' to be leaf groups only containing nodes, but sub group 'group0' contains 2 sub groups", + Exceptions.toMessageString(e)); } } @@ -220,7 +222,8 @@ public class IndexedHierarchicDistributionTest { getTwoGroupsCluster(3, 3, "2|*"); fail("Did not get expected Exception"); } catch (Exception e) { - assertTrue(e.getMessage().contains("Expected number of leaf groups (2) to be a factor of redundancy (3)")); + assertEquals("In content cluster 'mycluster': Expected number of leaf groups (2) to be a factor of redundancy (3), but it is not", + Exceptions.toMessageString(e)); } } @@ -240,7 +243,7 @@ public class IndexedHierarchicDistributionTest { getTwoGroupsCluster(4, 3, "2|*"); fail("Did not get expected Exception"); } catch (Exception e) { - assertTrue(e.getMessage().contains("Expected equal amount of ready copies per group")); + assertEquals("In content cluster 'mycluster': Expected equal amount of ready copies per group, but 3 ready copies is specified with 2 groups", Exceptions.toMessageString(e)); } } diff --git a/config-model/src/test/java/com/yahoo/vespa/model/content/StorageClusterTest.java b/config-model/src/test/java/com/yahoo/vespa/model/content/StorageClusterTest.java index 9fb4eefba75..57ee15a1dc4 100644 --- a/config-model/src/test/java/com/yahoo/vespa/model/content/StorageClusterTest.java +++ b/config-model/src/test/java/com/yahoo/vespa/model/content/StorageClusterTest.java @@ -21,6 +21,7 @@ import static com.yahoo.config.model.test.TestUtil.joinLines; import com.yahoo.vespa.model.content.cluster.ContentCluster; import com.yahoo.vespa.model.content.storagecluster.StorageCluster; import com.yahoo.vespa.model.content.utils.ContentClusterUtils; +import com.yahoo.yolean.Exceptions; import org.junit.jupiter.api.Test; @@ -432,8 +433,8 @@ public class StorageClusterTest { ContentClusterUtils.createCluster(xml, root); fail("Did not fail when having both group and nodes"); } catch (RuntimeException e) { - assertEquals("Both <group> and <nodes> is specified: Only one of these tags can be used in the same configuration", - e.getMessage()); + assertEquals("In content cluster 'storage': Both <group> and <nodes> is specified: Only one of these tags can be used in the same configuration", + Exceptions.toMessageString(e)); } } @@ -507,7 +508,7 @@ public class StorageClusterTest { ContentClusterUtils.createCluster(xml, new MockRoot()); fail("Did not get exception with missing distribution element"); } catch (RuntimeException e) { - assertEquals("'distribution' attribute is required with multiple subgroups", e.getMessage()); + assertEquals("In content cluster 'storage': 'distribution' attribute is required with multiple subgroups", Exceptions.toMessageString(e)); } } } diff --git a/config-model/src/test/schema-test-files/services-hosted.xml b/config-model/src/test/schema-test-files/services-hosted.xml index caf56881475..2697cc871e2 100644 --- a/config-model/src/test/schema-test-files/services-hosted.xml +++ b/config-model/src/test/schema-test-files/services-hosted.xml @@ -31,7 +31,7 @@ <content id="ml" version="1.0"> <redundancy>2</redundancy> - <nodes count="[10,20]" flavor="large" groups="[1,3]" vespamalloc-debug-stacktrace="proton"> + <nodes count="[10,20]" flavor="large" groups="[1,3]" group-size="[1,2]" vespamalloc-debug-stacktrace="proton"> <resources vcpu="[3.0, 4]" memory="[32000.0Mb, 33Gb]" disk="[300 Gb, 1Tb]"/> </nodes> </content> diff --git a/config-provisioning/src/main/java/com/yahoo/config/provision/Capacity.java b/config-provisioning/src/main/java/com/yahoo/config/provision/Capacity.java index e63750e0e11..993ab686675 100644 --- a/config-provisioning/src/main/java/com/yahoo/config/provision/Capacity.java +++ b/config-provisioning/src/main/java/com/yahoo/config/provision/Capacity.java @@ -1,6 +1,8 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.config.provision; +import com.yahoo.collections.IntRange; + import java.util.Objects; import java.util.Optional; import java.util.stream.Stream; @@ -15,12 +17,19 @@ public final class Capacity { /** Resources should stay between these values, inclusive */ private final ClusterResources min, max; + private final IntRange groupSize; private final boolean required; private final boolean canFail; private final NodeType type; private final Optional<CloudAccount> cloudAccount; - private Capacity(ClusterResources min, ClusterResources max, boolean required, boolean canFail, NodeType type, Optional<CloudAccount> cloudAccount) { + private Capacity(ClusterResources min, + ClusterResources max, + IntRange groupSize, + boolean required, + boolean canFail, + NodeType type, + Optional<CloudAccount> cloudAccount) { validate(min); validate(max); if (max.smallerThan(min)) @@ -30,6 +39,7 @@ public final class Capacity { throw new IllegalArgumentException("Capacity range does not allow GPU, got min " + min + " and max " + max); this.min = min; this.max = max; + this.groupSize = groupSize; this.required = required; this.canFail = canFail; this.type = type; @@ -45,6 +55,7 @@ public final class Capacity { public ClusterResources minResources() { return min; } public ClusterResources maxResources() { return max; } + public IntRange groupSize() { return groupSize; } /** Returns whether the requested number of nodes must be met exactly for a request for this to succeed */ public boolean isRequired() { return required; } @@ -69,7 +80,11 @@ public final class Capacity { } public Capacity withLimits(ClusterResources min, ClusterResources max) { - return new Capacity(min, max, required, canFail, type, cloudAccount); + return withLimits(min, max, IntRange.empty()); + } + + public Capacity withLimits(ClusterResources min, ClusterResources max, IntRange groupSize) { + return new Capacity(min, max, groupSize, required, canFail, type, cloudAccount); } @Override @@ -85,29 +100,34 @@ public final class Capacity { /** Create a non-required, failable capacity request */ public static Capacity from(ClusterResources min, ClusterResources max) { - return from(min, max, false, true); + return from(min, max, IntRange.empty(), false, true, Optional.empty()); } public static Capacity from(ClusterResources resources, boolean required, boolean canFail) { return from(resources, required, canFail, NodeType.tenant); } - // TODO(mpolden): Remove when config models < 7.590 are gone + // TODO: Remove after February 2023 public static Capacity from(ClusterResources min, ClusterResources max, boolean required, boolean canFail) { - return from(min, max, required, canFail, Optional.empty()); + return new Capacity(min, max, IntRange.empty(), required, canFail, NodeType.tenant, Optional.empty()); } + // TODO: Remove after February 2023 public static Capacity from(ClusterResources min, ClusterResources max, boolean required, boolean canFail, Optional<CloudAccount> cloudAccount) { - return new Capacity(min, max, required, canFail, NodeType.tenant, cloudAccount); + return new Capacity(min, max, IntRange.empty(), required, canFail, NodeType.tenant, cloudAccount); + } + + public static Capacity from(ClusterResources min, ClusterResources max, IntRange groupSize, boolean required, boolean canFail, Optional<CloudAccount> cloudAccount) { + return new Capacity(min, max, groupSize, required, canFail, NodeType.tenant, cloudAccount); } /** Creates this from a node type */ public static Capacity fromRequiredNodeType(NodeType type) { - return from(new ClusterResources(0, 0, NodeResources.unspecified()), true, false, type); + return from(new ClusterResources(0, 1, NodeResources.unspecified()), true, false, type); } private static Capacity from(ClusterResources resources, boolean required, boolean canFail, NodeType type) { - return new Capacity(resources, resources, required, canFail, type, Optional.empty()); + return new Capacity(resources, resources, IntRange.empty(), required, canFail, type, Optional.empty()); } } diff --git a/config-provisioning/src/main/java/com/yahoo/config/provision/ClusterResources.java b/config-provisioning/src/main/java/com/yahoo/config/provision/ClusterResources.java index 9938823768b..5511a9ed1ed 100644 --- a/config-provisioning/src/main/java/com/yahoo/config/provision/ClusterResources.java +++ b/config-provisioning/src/main/java/com/yahoo/config/provision/ClusterResources.java @@ -21,7 +21,7 @@ public class ClusterResources { public ClusterResources(int nodes, int groups, NodeResources nodeResources) { this.nodes = nodes; - this.groups = groups; + this.groups = groups == 0 ? 1 : groups; // TODO: Throw on groups == 0 after February 2023 this.nodeResources = Objects.requireNonNull(nodeResources); } @@ -73,9 +73,8 @@ public class ClusterResources { @Override public boolean equals(Object o) { if (o == this) return true; - if ( ! (o instanceof ClusterResources)) return false; + if ( ! (o instanceof ClusterResources other)) return false; - ClusterResources other = (ClusterResources)o; if (other.nodes != this.nodes) return false; if (other.groups != this.groups) return false; if ( ! other.nodeResources.equals(this.nodeResources)) return false; diff --git a/config-provisioning/src/test/java/com/yahoo/config/provision/CapacityTest.java b/config-provisioning/src/test/java/com/yahoo/config/provision/CapacityTest.java index a6ddb401b2f..ca552003f7a 100644 --- a/config-provisioning/src/test/java/com/yahoo/config/provision/CapacityTest.java +++ b/config-provisioning/src/test/java/com/yahoo/config/provision/CapacityTest.java @@ -1,8 +1,11 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.config.provision; +import com.yahoo.collections.IntRange; import org.junit.jupiter.api.Test; +import java.util.Optional; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.fail; @@ -15,8 +18,11 @@ public class CapacityTest { void testCapacityValidation() { // Equal min and max is allowed Capacity.from(new ClusterResources(4, 2, new NodeResources(1, 2, 3, 4)), - new ClusterResources(4, 2, new NodeResources(1, 2, 3, 4)), - false, true); + new ClusterResources(4, 2, new NodeResources(1, 2, 3, 4)), + IntRange.empty(), + false, + true, + Optional.empty()); assertValidationFailure(new ClusterResources(4, 2, new NodeResources(1, 2, 3, 4)), new ClusterResources(2, 2, new NodeResources(1, 2, 3, 4))); assertValidationFailure(new ClusterResources(4, 4, new NodeResources(1, 2, 3, 4)), @@ -36,7 +42,7 @@ public class CapacityTest { private void assertValidationFailure(ClusterResources min, ClusterResources max) { try { - Capacity.from(min, max, false, true); + Capacity.from(min, max, IntRange.empty(), false, true, Optional.empty()); fail("Expected exception with min " + min + " and max " + max); } catch (IllegalArgumentException e) { diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/application-clusters.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/application-clusters.json index d9021b1d894..b5ae4efd752 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/application-clusters.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/responses/application-clusters.json @@ -88,7 +88,7 @@ { "from": { "nodes": 0, - "groups": 0, + "groups": 1, "nodeResources": { "vcpu": 0.0, "memoryGb": 0.0, diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/applications/Cluster.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/applications/Cluster.java index 0a731f66418..60a5682101f 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/applications/Cluster.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/applications/Cluster.java @@ -1,6 +1,7 @@ // Copyright Yahoo. 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.collections.IntRange; import com.yahoo.config.provision.Capacity; import com.yahoo.config.provision.ClusterResources; import com.yahoo.config.provision.ClusterSpec; @@ -28,6 +29,7 @@ public class Cluster { private final ClusterSpec.Id id; private final boolean exclusive; private final ClusterResources min, max; + private final IntRange groupSize; private final boolean required; private final Autoscaling suggested; private final Autoscaling target; @@ -39,6 +41,7 @@ public class Cluster { boolean exclusive, ClusterResources minResources, ClusterResources maxResources, + IntRange groupSize, boolean required, Autoscaling suggested, Autoscaling target, @@ -47,6 +50,7 @@ public class Cluster { this.exclusive = exclusive; this.min = Objects.requireNonNull(minResources); this.max = Objects.requireNonNull(maxResources); + this.groupSize = Objects.requireNonNull(groupSize); this.required = required; this.suggested = Objects.requireNonNull(suggested); Objects.requireNonNull(target); @@ -68,6 +72,9 @@ public class Cluster { /** 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. @@ -105,16 +112,16 @@ public class Cluster { public Cluster withConfiguration(boolean exclusive, Capacity capacity) { return new Cluster(id, exclusive, - capacity.minResources(), capacity.maxResources(), capacity.isRequired(), + capacity.minResources(), capacity.maxResources(), capacity.groupSize(), capacity.isRequired(), suggested, target, scalingEvents); } public Cluster withSuggested(Autoscaling suggested) { - return new Cluster(id, exclusive, min, max, required, suggested, target, scalingEvents); + return new Cluster(id, exclusive, min, max, groupSize, required, suggested, target, scalingEvents); } public Cluster withTarget(Autoscaling target) { - return new Cluster(id, exclusive, min, max, required, suggested, target, scalingEvents); + return new Cluster(id, exclusive, min, max, groupSize, required, suggested, target, scalingEvents); } /** Add or update (based on "at" time) a scaling event */ @@ -128,7 +135,7 @@ public class Cluster { scalingEvents.add(scalingEvent); prune(scalingEvents); - return new Cluster(id, exclusive, min, max, required, suggested, target, scalingEvents); + return new Cluster(id, exclusive, min, max, groupSize, required, suggested, target, scalingEvents); } @Override @@ -158,7 +165,8 @@ public class Cluster { } public static Cluster create(ClusterSpec.Id id, boolean exclusive, Capacity requested) { - return new Cluster(id, exclusive, requested.minResources(), requested.maxResources(), requested.isRequired(), + return new Cluster(id, exclusive, + requested.minResources(), requested.maxResources(), requested.groupSize(), requested.isRequired(), Autoscaling.empty(), Autoscaling.empty(), List.of()); } diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/autoscale/AllocationOptimizer.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/autoscale/AllocationOptimizer.java index 56a6d61ebee..c3dc0ff250f 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/autoscale/AllocationOptimizer.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/autoscale/AllocationOptimizer.java @@ -1,6 +1,7 @@ // Copyright Yahoo. 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.collections.IntRange; import com.yahoo.config.provision.ClusterResources; import com.yahoo.config.provision.NodeResources; import com.yahoo.vespa.hosted.provision.NodeRepository; @@ -38,7 +39,8 @@ public class AllocationOptimizer { int minimumNodes = AllocationOptimizer.minimumNodes; if (limits.isEmpty()) limits = Limits.of(new ClusterResources(minimumNodes, 1, NodeResources.unspecified()), - new ClusterResources(maximumNodes, maximumNodes, NodeResources.unspecified())); + new ClusterResources(maximumNodes, maximumNodes, NodeResources.unspecified()), + IntRange.empty()); else limits = atLeast(minimumNodes, limits).fullySpecified(current.clusterSpec(), nodeRepository, clusterModel.application().id()); Optional<AllocatableClusterResources> bestAllocation = Optional.empty(); @@ -50,6 +52,7 @@ public class AllocationOptimizer { for (int groups = limits.min().groups(); groups <= limits.max().groups(); groups++) { for (int nodes = limits.min().nodes(); nodes <= limits.max().nodes(); nodes++) { if (nodes % groups != 0) continue; + if ( ! limits.groupSize().includes(nodes / groups)) continue; var resources = new ClusterResources(nodes, groups, diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/autoscale/Limits.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/autoscale/Limits.java index cb5d8dd5042..9de7134a6d7 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/autoscale/Limits.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/autoscale/Limits.java @@ -1,6 +1,7 @@ // Copyright Yahoo. 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.collections.IntRange; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.Capacity; import com.yahoo.config.provision.ClusterResources; @@ -19,13 +20,15 @@ import java.util.Objects; */ public class Limits { - private static final Limits empty = new Limits(null, null); + private static final Limits empty = new Limits(null, null, IntRange.empty()); private final ClusterResources min, max; + private final IntRange groupSize; - private Limits(ClusterResources min, ClusterResources max) { + private Limits(ClusterResources min, ClusterResources max, IntRange groupSize) { this.min = min; this.max = max; + this.groupSize = groupSize; } public static Limits empty() { return empty; } @@ -42,12 +45,14 @@ public class Limits { return max; } + public IntRange groupSize() { return groupSize; } + public Limits withMin(ClusterResources min) { - return new Limits(min, max); + return new Limits(min, max, groupSize); } public Limits withMax(ClusterResources max) { - return new Limits(min, max); + return new Limits(min, max, groupSize); } /** Caps the given resources at the limits of this. If it is empty the node resources are returned as-is */ @@ -66,7 +71,7 @@ public class Limits { var defaultResources = new CapacityPolicies(nodeRepository).defaultNodeResources(clusterSpec, applicationId); var specifiedMin = min.nodeResources().isUnspecified() ? min.with(defaultResources) : min; var specifiedMax = max.nodeResources().isUnspecified() ? max.with(defaultResources) : max; - return new Limits(specifiedMin, specifiedMax); + return new Limits(specifiedMin, specifiedMax, groupSize); } private double between(double min, double max, double value) { @@ -76,21 +81,23 @@ public class Limits { } public static Limits of(Cluster cluster) { - return new Limits(cluster.minResources(), cluster.maxResources()); + return new Limits(cluster.minResources(), cluster.maxResources(), cluster.groupSize()); } public static Limits of(Capacity capacity) { - return new Limits(capacity.minResources(), capacity.maxResources()); + return new Limits(capacity.minResources(), capacity.maxResources(), capacity.groupSize()); } - public static Limits of(ClusterResources min, ClusterResources max) { - return new Limits(Objects.requireNonNull(min, "min"), Objects.requireNonNull(max, "max")); + public static Limits of(ClusterResources min, ClusterResources max, IntRange groupSize) { + return new Limits(Objects.requireNonNull(min, "min"), + Objects.requireNonNull(max, "max"), + Objects.requireNonNull(groupSize, "groupSize")); } @Override public String toString() { if (isEmpty()) return "no limits"; - return "limits: from " + min + " to " + max; + return "limits: from " + min + " to " + max + ( groupSize.isEmpty() ? "" : " with group size " + groupSize); } } diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/ApplicationSerializer.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/ApplicationSerializer.java index 2f5b057e927..4d2fae8a4d7 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/ApplicationSerializer.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/ApplicationSerializer.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.persistence; +import com.yahoo.collections.IntRange; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.ClusterResources; import com.yahoo.config.provision.ClusterSpec; @@ -23,6 +24,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.OptionalInt; /** * Application JSON serializer @@ -48,6 +50,7 @@ public class ApplicationSerializer { private static final String exclusiveKey = "exclusive"; private static final String minResourcesKey = "min"; private static final String maxResourcesKey = "max"; + private static final String groupSizeKey = "groupSize"; private static final String requiredKey = "required"; private static final String suggestedKey = "suggested"; private static final String resourcesKey = "resources"; @@ -122,6 +125,7 @@ public class ApplicationSerializer { clusterObject.setBool(exclusiveKey, cluster.exclusive()); toSlime(cluster.minResources(), clusterObject.setObject(minResourcesKey)); toSlime(cluster.maxResources(), clusterObject.setObject(maxResourcesKey)); + toSlime(cluster.groupSize(), clusterObject.setObject(groupSizeKey)); clusterObject.setBool(requiredKey, cluster.required()); toSlime(cluster.suggested(), clusterObject.setObject(suggestedKey)); toSlime(cluster.target(), clusterObject.setObject(targetKey)); @@ -133,6 +137,7 @@ public class ApplicationSerializer { clusterObject.field(exclusiveKey).asBool(), clusterResourcesFromSlime(clusterObject.field(minResourcesKey)), clusterResourcesFromSlime(clusterObject.field(maxResourcesKey)), + intRangeFromSlime(clusterObject.field(groupSizeKey)), clusterObject.field(requiredKey).asBool(), autoscalingFromSlime(clusterObject.field(suggestedKey), clusterObject.field("nonExisting")), autoscalingFromSlime(clusterObject.field(targetKey), clusterObject.field(autoscalingStatusObjectKey)), @@ -165,6 +170,16 @@ public class ApplicationSerializer { NodeResourcesSerializer.resourcesFromSlime(clusterResourcesObject.field(nodeResourcesKey))); } + private static void toSlime(IntRange range, Cursor rangeObject) { + range.from().ifPresent(from -> rangeObject.setLong(fromKey, from)); + range.to().ifPresent(from -> rangeObject.setLong(toKey, from)); + } + + private static IntRange intRangeFromSlime(Inspector rangeObject) { + if ( ! rangeObject.valid()) return IntRange.empty(); + return new IntRange(optionalInt(rangeObject.field(fromKey)), optionalInt(rangeObject.field(toKey))); + } + private static void toSlime(Load load, Cursor loadObject) { loadObject.setDouble(cpuKey, load.cpu()); loadObject.setDouble(memoryKey, load.memory()); @@ -257,4 +272,8 @@ public class ApplicationSerializer { return inspector.valid() ? Optional.of(Instant.ofEpochMilli(inspector.asLong())) : Optional.empty(); } + private static OptionalInt optionalInt(Inspector inspector) { + return inspector.valid() ? OptionalInt.of((int)inspector.asLong()) : OptionalInt.empty(); + } + } diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/CapacityPolicies.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/CapacityPolicies.java index a1400626658..839e10ba096 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/CapacityPolicies.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/CapacityPolicies.java @@ -39,8 +39,11 @@ public class CapacityPolicies { } public Capacity applyOn(Capacity capacity, ApplicationId application, boolean exclusive) { - return capacity.withLimits(applyOn(capacity.minResources(), capacity, application, exclusive), - applyOn(capacity.maxResources(), capacity, application, exclusive)); + var min = applyOn(capacity.minResources(), capacity, application, exclusive); + var max = applyOn(capacity.maxResources(), capacity, application, exclusive); + var groupSize = capacity.groupSize().fromAtMost(max.nodes() / min.groups()) + .toAtLeast(min.nodes() / max.groups()); + return capacity.withLimits(min, max, groupSize); } private ClusterResources applyOn(ClusterResources resources, Capacity capacity, ApplicationId application, boolean exclusive) { diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/NodeRepositoryProvisioner.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/NodeRepositoryProvisioner.java index 59e67f65f8f..bc623b495c8 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/NodeRepositoryProvisioner.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/provisioning/NodeRepositoryProvisioner.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.provisioning; +import com.yahoo.collections.IntRange; import com.yahoo.component.annotation.Inject; import com.yahoo.config.provision.ActivationContext; import com.yahoo.config.provision.ApplicationId; @@ -98,7 +99,7 @@ public class NodeRepositoryProvisioner implements Provisioner { cluster = capacityPolicies.decideExclusivity(requested, cluster); Capacity actual = capacityPolicies.applyOn(requested, application, cluster.isExclusive()); ClusterResources target = decideTargetResources(application, cluster, actual); - ensureRedundancy(target.nodes(), cluster, actual.canFail(), application); + validate(actual, target, cluster, application); logIfDownscaled(requested.minResources().nodes(), actual.minResources().nodes(), cluster, logger); groups = target.groups(); @@ -204,18 +205,17 @@ public class NodeRepositoryProvisioner implements Provisioner { .advertisedResources(); } - /** - * Throw if the node count is 1 for container and content clusters and we're in a production zone - * - * @throws IllegalArgumentException if only one node is requested and we can fail - */ - private void ensureRedundancy(int nodeCount, ClusterSpec cluster, boolean canFail, ApplicationId application) { - if (! application.instance().isTester() && - canFail && - nodeCount == 1 && - requiresRedundancy(cluster.type()) && - zone.environment().isProduction()) - throw new IllegalArgumentException("Deployments to prod require at least 2 nodes per cluster for redundancy. Not fulfilled for " + cluster); + private void validate(Capacity actual, ClusterResources target, ClusterSpec cluster, ApplicationId application) { + if ( ! actual.canFail()) return; + + if (! application.instance().isTester() && zone.environment().isProduction() && + requiresRedundancy(cluster.type()) && target.nodes() == 1) + throw new IllegalArgumentException("In " + cluster + + ": Deployments to prod require at least 2 nodes per cluster for redundancy."); + + if ( ! actual.groupSize().includes(target.nodes() / target.groups())) + throw new IllegalArgumentException("In " + cluster + ": Group size with " + target + + " is not within allowed group size " + actual.groupSize()); } private static boolean requiresRedundancy(ClusterSpec.Type clusterType) { diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/ApplicationSerializer.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/ApplicationSerializer.java index cb927c72eb5..6936a767936 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/ApplicationSerializer.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/ApplicationSerializer.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.restapi; +import com.yahoo.collections.IntRange; import com.yahoo.config.provision.ClusterResources; import com.yahoo.slime.Cursor; import com.yahoo.slime.Slime; @@ -62,6 +63,8 @@ public class ApplicationSerializer { Limits limits = Limits.of(cluster).fullySpecified(nodes.clusterSpec(), nodeRepository, application.id()); toSlime(limits.min(), clusterObject.setObject("min")); toSlime(limits.max(), clusterObject.setObject("max")); + if ( ! cluster.groupSize().isEmpty()) + toSlime(cluster.groupSize(), clusterObject.setObject("groupSize")); toSlime(currentResources, clusterObject.setObject("current")); if (cluster.shouldSuggestResources(currentResources)) toSlime(cluster.suggested(), clusterObject.setObject("suggested")); @@ -85,6 +88,11 @@ public class ApplicationSerializer { NodeResourcesSerializer.toSlime(resources.nodeResources(), clusterResourcesObject.setObject("resources")); } + private static void toSlime(IntRange range, Cursor rangeObject) { + range.from().ifPresent(from -> rangeObject.setLong("from", range.from().getAsInt())); + range.to().ifPresent(to -> rangeObject.setLong("to", range.to().getAsInt())); + } + private static void toSlime(Load load, Cursor utilizationObject) { utilizationObject.setDouble("cpu", load.cpu()); utilizationObject.setDouble("memory", load.memory()); diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java index 92ffe9828c3..e50db5c0d55 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.testutils; +import com.yahoo.collections.IntRange; import com.yahoo.component.Version; import com.yahoo.config.provision.ActivationContext; import com.yahoo.config.provision.ApplicationId; @@ -193,7 +194,11 @@ public class MockNodeRepository extends NodeRepository { activate(provisioner.prepare(app1Id, cluster1Id, Capacity.from(new ClusterResources(2, 1, new NodeResources(2, 8, 50, 1)), - new ClusterResources(8, 2, new NodeResources(4, 16, 1000, 1)), false, true), + new ClusterResources(8, 2, new NodeResources(4, 16, 1000, 1)), + IntRange.empty(), + false, + true, + Optional.empty()), null), app1Id, provisioner); Application app1 = applications().get(app1Id).get(); Cluster cluster1 = app1.cluster(cluster1Id.id()).get(); diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/autoscale/AutoscalingTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/autoscale/AutoscalingTest.java index e1b32726070..35197da4315 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/autoscale/AutoscalingTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/autoscale/AutoscalingTest.java @@ -1,6 +1,7 @@ // Copyright Yahoo. 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.collections.IntRange; import com.yahoo.config.provision.Capacity; import com.yahoo.config.provision.ClusterResources; import com.yahoo.config.provision.ClusterSpec; @@ -353,6 +354,23 @@ public class AutoscalingTest { } @Test + public void autoscaling_respects_group_size_limit() { + var min = new ClusterResources( 2, 2, new NodeResources(1, 1, 1, 1)); + var now = new ClusterResources(5, 5, new NodeResources(3.0, 10, 10, 1)); + var max = new ClusterResources(18, 6, new NodeResources(100, 1000, 1000, 1)); + var fixture = AutoscalingTester.fixture() + .awsProdSetup(true) + .initialResources(Optional.of(now)) + .capacity(Capacity.from(min, max, IntRange.of(2, 3), false, true, Optional.empty())) + .build(); + fixture.tester().clock().advance(Duration.ofDays(2)); + fixture.loader().applyCpuLoad(0.4, 240); + fixture.tester().assertResources("Scaling cpu up", + 12, 6, 2.8, 4.3, 10.0, + fixture.autoscale()); + } + + @Test public void test_autoscaling_limits_when_min_equals_max() { ClusterResources min = new ClusterResources( 2, 1, new NodeResources(1, 1, 1, 1)); var fixture = AutoscalingTester.fixture().awsProdSetup(true).capacity(Capacity.from(min, min)).build(); @@ -427,7 +445,7 @@ public class AutoscalingTest { } @Test - public void test_autoscaling_group_size_1() { + public void test_autoscaling_group_size_unconstrained() { var min = new ClusterResources( 2, 2, new NodeResources(1, 1, 1, 1)); var now = new ClusterResources(5, 5, new NodeResources(3, 100, 100, 1)); var max = new ClusterResources(20, 20, new NodeResources(10, 1000, 1000, 1)); @@ -444,6 +462,23 @@ public class AutoscalingTest { } @Test + public void test_autoscaling_group_size_1() { + var min = new ClusterResources( 2, 2, new NodeResources(1, 1, 1, 1)); + var now = new ClusterResources(5, 5, new NodeResources(3, 100, 100, 1)); + var max = new ClusterResources(20, 20, new NodeResources(10, 1000, 1000, 1)); + var fixture = AutoscalingTester.fixture() + .awsProdSetup(true) + .initialResources(Optional.of(now)) + .capacity(Capacity.from(min, max, IntRange.of(1), false, true, Optional.empty())) + .build(); + fixture.tester().clock().advance(Duration.ofDays(2)); + fixture.loader().applyCpuLoad(0.9, 120); + fixture.tester().assertResources("Scaling up to 2 nodes, scaling memory and disk down at the same time", + 7, 7, 9.4, 80.8, 85.2, + fixture.autoscale()); + } + + @Test public void test_autoscaling_groupsize_by_cpu_read_dominated() { var min = new ClusterResources( 3, 1, new NodeResources(1, 1, 1, 1)); var now = new ClusterResources(6, 2, new NodeResources(3, 100, 100, 1)); @@ -672,6 +707,23 @@ public class AutoscalingTest { fixture.autoscale().resources().isEmpty()); } + @Test + public void test_autoscaling_in_dev_with_cluster_size_constraint() { + var min = new ClusterResources(4, 1, + new NodeResources(1, 4, 10, 1, NodeResources.DiskSpeed.any)); + var max = new ClusterResources(20, 20, + new NodeResources(100, 1000, 1000, 1, NodeResources.DiskSpeed.any)); + var fixture = AutoscalingTester.fixture() + .awsSetup(true, Environment.dev) + .capacity(Capacity.from(min, max, IntRange.of(3, 5), false, true, Optional.empty())) + .build(); + fixture.tester().clock().advance(Duration.ofDays(2)); + fixture.loader().applyLoad(new Load(1.0, 1.0, 1.0), 200); + fixture.tester().assertResources("Scale only to a single node and group since this is dev", + 1, 1, 0.1, 24.8, 131.1, + fixture.autoscale()); + } + /** Same setup as test_autoscaling_in_dev(), just with required = true */ @Test public void test_autoscaling_in_dev_with_required_resources_preprovisioned() { @@ -680,8 +732,10 @@ public class AutoscalingTest { new NodeResources(1, 1, 1, 1, NodeResources.DiskSpeed.any)), new ClusterResources(20, 1, new NodeResources(100, 1000, 1000, 1, NodeResources.DiskSpeed.any)), + IntRange.empty(), true, - true); + true, + Optional.empty()); var fixture = AutoscalingTester.fixture() .hostCount(5) @@ -700,8 +754,10 @@ public class AutoscalingTest { var requiredCapacity = Capacity.from(new ClusterResources(1, 1, NodeResources.unspecified()), new ClusterResources(3, 1, NodeResources.unspecified()), + IntRange.empty(), + true, true, - true); + Optional.empty()); var fixture = AutoscalingTester.fixture() .hostCount(5) diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/autoscale/AutoscalingTester.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/autoscale/AutoscalingTester.java index 4e6b8dec9ef..05d0822758d 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/autoscale/AutoscalingTester.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/autoscale/AutoscalingTester.java @@ -131,6 +131,7 @@ class AutoscalingTester { cluster.exclusive(), cluster.minResources(), cluster.maxResources(), + cluster.groupSize(), cluster.required(), cluster.suggested(), cluster.target(), diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/AutoscalingMaintainerTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/AutoscalingMaintainerTest.java index 2da65fc1a2f..af47001cd84 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/AutoscalingMaintainerTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/AutoscalingMaintainerTest.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.maintenance; +import com.yahoo.collections.IntRange; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.Capacity; import com.yahoo.config.provision.ClusterResources; @@ -61,10 +62,10 @@ public class AutoscalingMaintainerTest { tester.deploy(app1, cluster1, Capacity.from(new ClusterResources(5, 1, new NodeResources(4, 4, 10, 0.1)), new ClusterResources(5, 1, new NodeResources(4, 4, 10, 0.1)), - false, true)); + IntRange.empty(), false, true, Optional.empty())); tester.deploy(app2, cluster2, Capacity.from(new ClusterResources(5, 1, new NodeResources(4, 4, 10, 0.1)), new ClusterResources(10, 1, new NodeResources(6.5, 9, 20, 0.1)), - false, true)); + IntRange.empty(), false, true, Optional.empty())); tester.clock().advance(Duration.ofMinutes(10)); tester.maintainer().maintain(); // noop diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/HostCapacityMaintainerTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/HostCapacityMaintainerTest.java index 27a944a471e..58589f540cf 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/HostCapacityMaintainerTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/HostCapacityMaintainerTest.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.maintenance; +import com.yahoo.collections.IntRange; import com.yahoo.component.Version; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.Capacity; @@ -466,7 +467,7 @@ public class HostCapacityMaintainerTest { ClusterSpec spec = ProvisioningTester.contentClusterSpec(); ClusterResources resources = new ClusterResources(2, 1, new NodeResources(16, 24, 100, 1)); CloudAccount cloudAccount0 = CloudAccount.from("000000000000"); - Capacity capacity0 = Capacity.from(resources, resources, false, true, Optional.of(cloudAccount0)); + Capacity capacity0 = Capacity.from(resources, resources, IntRange.empty(), false, true, Optional.of(cloudAccount0)); List<HostSpec> prepared = provisioningTester.prepare(applicationId, spec, capacity0); // Hosts are provisioned in requested account @@ -476,7 +477,7 @@ public class HostCapacityMaintainerTest { // Redeployment in different account provisions a new set of hosts CloudAccount cloudAccount1 = CloudAccount.from("100000000000"); - Capacity capacity1 = Capacity.from(resources, resources, false, true, Optional.of(cloudAccount1)); + Capacity capacity1 = Capacity.from(resources, resources, IntRange.empty(), false, true, Optional.of(cloudAccount1)); prepared = provisioningTester.prepare(applicationId, spec, capacity1); provisionHostsIn(cloudAccount1, 2, tester); assertEquals(2, provisioningTester.activate(applicationId, prepared).size()); diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/ScalingSuggestionsMaintainerTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/ScalingSuggestionsMaintainerTest.java index c94d864ba9d..36c9819e10d 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/ScalingSuggestionsMaintainerTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/ScalingSuggestionsMaintainerTest.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.maintenance; +import com.yahoo.collections.IntRange; import com.yahoo.collections.Pair; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.Capacity; @@ -56,10 +57,10 @@ public class ScalingSuggestionsMaintainerTest { tester.deploy(app1, cluster1, Capacity.from(new ClusterResources(5, 1, new NodeResources(4, 4, 10, 0.1)), new ClusterResources(5, 1, new NodeResources(4, 4, 10, 0.1)), - false, true)); + IntRange.empty(), false, true, Optional.empty())); tester.deploy(app2, cluster2, Capacity.from(new ClusterResources(5, 1, new NodeResources(4, 4, 10, 0.1)), new ClusterResources(10, 1, new NodeResources(6.5, 5, 15, 0.1)), - false, true)); + IntRange.empty(), false, true, Optional.empty())); tester.clock().advance(Duration.ofHours(13)); Duration timeAdded = addMeasurements(0.90f, 0.90f, 0.90f, 0, 500, app1, tester.nodeRepository()); @@ -96,7 +97,8 @@ public class ScalingSuggestionsMaintainerTest { addMeasurements(0.7f, 0.7f, 0.7f, 0, 500, app1, tester.nodeRepository()); maintainer.maintain(); var suggested = tester.nodeRepository().applications().get(app1).get().cluster(cluster1.id()).get().suggested().resources().get(); - tester.deploy(app1, cluster1, Capacity.from(suggested, suggested, false, true)); + tester.deploy(app1, cluster1, Capacity.from(suggested, suggested, + IntRange.empty(), false, true, Optional.empty())); tester.clock().advance(Duration.ofDays(2)); addMeasurements(0.2f, 0.65f, 0.6f, 0, 500, app1, tester.nodeRepository()); diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/ApplicationSerializerTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/ApplicationSerializerTest.java index a05cc388bea..51f775c7286 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/ApplicationSerializerTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/ApplicationSerializerTest.java @@ -1,6 +1,7 @@ // Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.persistence; +import com.yahoo.collections.IntRange; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.ClusterResources; import com.yahoo.config.provision.ClusterSpec; @@ -34,6 +35,7 @@ public class ApplicationSerializerTest { false, new ClusterResources( 8, 4, new NodeResources(1, 2, 3, 4)), new ClusterResources(12, 6, new NodeResources(3, 6, 21, 24)), + IntRange.empty(), true, Autoscaling.empty(), Autoscaling.empty(), @@ -43,6 +45,7 @@ public class ApplicationSerializerTest { true, new ClusterResources( 8, 4, minResources), new ClusterResources(14, 7, new NodeResources(3, 6, 21, 24)), + IntRange.of(3, 5), false, new Autoscaling(Autoscaling.Status.unavailable, "", @@ -79,11 +82,11 @@ public class ApplicationSerializerTest { Cluster serializedCluster = serialized.clusters().get(originalCluster.id()); assertNotNull(serializedCluster); assertNotSame(originalCluster, serializedCluster); - assertEquals(originalCluster, serializedCluster); assertEquals(originalCluster.id(), serializedCluster.id()); assertEquals(originalCluster.exclusive(), serializedCluster.exclusive()); assertEquals(originalCluster.minResources(), serializedCluster.minResources()); assertEquals(originalCluster.maxResources(), serializedCluster.maxResources()); + assertEquals(originalCluster.groupSize(), serializedCluster.groupSize()); assertEquals(originalCluster.required(), serializedCluster.required()); assertEquals(originalCluster.suggested(), serializedCluster.suggested()); assertEquals(originalCluster.target(), serializedCluster.target()); diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/provisioning/LoadBalancerProvisionerTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/provisioning/LoadBalancerProvisionerTest.java index 3653e20d848..9a18bbb76ee 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/provisioning/LoadBalancerProvisionerTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/provisioning/LoadBalancerProvisionerTest.java @@ -3,6 +3,7 @@ package com.yahoo.vespa.hosted.provision.provisioning; import ai.vespa.http.DomainName; import com.google.common.collect.Iterators; +import com.yahoo.collections.IntRange; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.Capacity; import com.yahoo.config.provision.CloudAccount; @@ -316,7 +317,7 @@ public class LoadBalancerProvisionerTest { @Test public void load_balancer_with_custom_settings() { ClusterResources resources = new ClusterResources(3, 1, nodeResources); - Capacity capacity = Capacity.from(resources, resources, false, true, Optional.of(CloudAccount.empty)); + Capacity capacity = Capacity.from(resources, resources, IntRange.empty(), false, true, Optional.of(CloudAccount.empty)); tester.activate(app1, prepare(app1, capacity, clusterRequest(ClusterSpec.Type.container, ClusterSpec.Id.from("c1")))); LoadBalancerList loadBalancers = tester.nodeRepository().loadBalancers().list(); assertEquals(1, loadBalancers.size()); @@ -336,7 +337,7 @@ public class LoadBalancerProvisionerTest { ClusterResources resources = new ClusterResources(3, 1, nodeResources); CloudAccount cloudAccount0 = CloudAccount.empty; { - Capacity capacity = Capacity.from(resources, resources, false, true, Optional.of(cloudAccount0)); + Capacity capacity = Capacity.from(resources, resources, IntRange.empty(), false, true, Optional.of(cloudAccount0)); tester.activate(app1, prepare(app1, capacity, clusterRequest(ClusterSpec.Type.container, ClusterSpec.Id.from("c1")))); } LoadBalancerList loadBalancers = tester.nodeRepository().loadBalancers().list(); @@ -345,7 +346,7 @@ public class LoadBalancerProvisionerTest { // Changing account fails if there is an existing LB in the previous account. CloudAccount cloudAccount1 = CloudAccount.from("111111111111"); - Capacity capacity = Capacity.from(resources, resources, false, true, Optional.of(cloudAccount1)); + Capacity capacity = Capacity.from(resources, resources, IntRange.empty(), false, true, Optional.of(cloudAccount1)); try { prepare(app1, capacity, clusterRequest(ClusterSpec.Type.container, ClusterSpec.Id.from("c1"))); fail("Expected exception"); diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/application1.json b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/application1.json index ead4e5fd06a..c9046998e91 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/application1.json +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/application1.json @@ -103,7 +103,7 @@ { "from": { "nodes": 0, - "groups": 0, + "groups": 1, "resources": { "vcpu" : 0.0, "memoryGb": 0.0, diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/application2.json b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/application2.json index f60fdf3e602..4166d20ad8d 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/application2.json +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/application2.json @@ -62,7 +62,7 @@ { "from": { "nodes": 0, - "groups": 0, + "groups": 1, "resources": { "vcpu" : 0.0, "memoryGb": 0.0, diff --git a/vespajlib/src/main/java/com/yahoo/collections/IntRange.java b/vespajlib/src/main/java/com/yahoo/collections/IntRange.java new file mode 100644 index 00000000000..b745322d458 --- /dev/null +++ b/vespajlib/src/main/java/com/yahoo/collections/IntRange.java @@ -0,0 +1,132 @@ +package com.yahoo.collections; + +import java.util.Objects; +import java.util.OptionalInt; + +/** + * An integer range. + * + * @author bratseth + */ +public class IntRange { + + private static final IntRange empty = new IntRange(OptionalInt.empty(), OptionalInt.empty()); + + private final OptionalInt from, to; + + public IntRange(OptionalInt from, OptionalInt to) { + if (from.isPresent() && to.isPresent() && from.getAsInt() > to.getAsInt()) + throw new IllegalArgumentException("from " + from.getAsInt() + " is greater than to " + to.getAsInt()); + this.from = from; + this.to = to; + } + + /** Returns the minimum value which is in this range, or empty if it is open downwards. */ + public OptionalInt from() { return from; } + + /** Returns the maximum value which is in this range, or empty if it is open upwards. */ + public OptionalInt to() { return to; } + + public boolean isEmpty() { + return from.isEmpty() && to.isEmpty(); + } + + /** Returns whether the given value is in this range. */ + public boolean includes(int value) { + if (from.isPresent() && value < from.getAsInt()) return false; + if (to.isPresent() && value > to.getAsInt()) return false; + return true; + } + + /** Returns the given value adjusted minimally to fit within this range. */ + public int fit(int value) { + if (from.isPresent() && value < from.getAsInt()) return from.getAsInt(); + if (to.isPresent() && value > to.getAsInt()) return to.getAsInt(); + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if ( ! (o instanceof IntRange other)) return false; + if ( ! this.from.equals(other.from)) return false; + if ( ! this.to.equals(other.to)) return false; + return true; + } + + @Override + public int hashCode() { + return Objects.hash(from, to); + } + + @Override + public String toString() { + if (isEmpty()) return "[]"; + if (from.equals(to)) return String.valueOf(from.getAsInt()); + return "[" + (from.isPresent() ? from.getAsInt() : "") + ", " + (to.isPresent() ? to.getAsInt() : "") + "]"; + } + + public static IntRange empty() { return empty; } + + public static IntRange from(int from) { + return new IntRange(OptionalInt.of(from), OptionalInt.empty()); + } + + public static IntRange to(int to) { + return new IntRange(OptionalInt.empty(), OptionalInt.of(to)); + } + + public static IntRange of(int fromTo) { + return new IntRange(OptionalInt.of(fromTo), OptionalInt.of(fromTo)); + } + + public static IntRange of(int from, int to) { + return new IntRange(OptionalInt.of(from), OptionalInt.of(to)); + } + + /** Returns this with a 'from' limit which is at most the given value */ + public IntRange fromAtMost(int minLimit) { + if (from.isEmpty()) return this; + if (from.getAsInt() <= minLimit) return this; + return new IntRange(OptionalInt.of(minLimit), to); + } + + /** Returns this with a 'to' limit which is at least the given value */ + public IntRange toAtLeast(int maxLimit) { + if (to.isEmpty()) return this; + if (to.getAsInt() >= maxLimit) return this; + return new IntRange(from, OptionalInt.of(maxLimit)); + } + + /** Parses a value ("value"), value range ("[min-value?, max-value?]"), or empty. */ + public static IntRange from(String s) { + try { + s = s.trim(); + if (s.startsWith("[") && s.endsWith("]")) { + String innards = s.substring(1, s.length() - 1).trim(); + if (innards.isEmpty()) return empty(); + String[] numbers = (" " + innards + " ").split(","); // pad to make sure we get 2 elements + if (numbers.length != 2) throw new IllegalArgumentException("Expected two numbers"); + return new IntRange(parseOptionalInt(numbers[0]), parseOptionalInt(numbers[1])); + } else { + var fromTo = parseOptionalInt(s); + return new IntRange(fromTo, fromTo); + } + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Expected a number or range on the form [min, max], but got '" + s + "'", e); + } + } + + private static OptionalInt parseOptionalInt(String s) { + try { + s = s.trim(); + if (s.isEmpty()) return OptionalInt.empty(); + return OptionalInt.of(Integer.parseInt(s)); + } + catch (IllegalArgumentException e) { + throw new IllegalArgumentException("'" + s + "' is not an integer"); + } + } + +} diff --git a/vespajlib/src/test/java/com/yahoo/collections/IntRangeTestCase.java b/vespajlib/src/test/java/com/yahoo/collections/IntRangeTestCase.java new file mode 100644 index 00000000000..dc3c39ea19b --- /dev/null +++ b/vespajlib/src/test/java/com/yahoo/collections/IntRangeTestCase.java @@ -0,0 +1,38 @@ +package com.yahoo.collections; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * @author bratseth + */ +public class IntRangeTestCase { + + @Test + public void testStringAndEquals() { + assertEquals(IntRange.empty(), IntRange.from(IntRange.from("[]").toString())); + assertEquals(IntRange.from(1), IntRange.from(IntRange.from("[1,]").toString())); + assertEquals(IntRange.to(3), IntRange.from(IntRange.from("[,3]").toString())); + assertEquals(IntRange.of(1, 3), IntRange.from(IntRange.from("[1,3]").toString())); + assertEquals(IntRange.of(1, 3), IntRange.from(IntRange.from("[1, 3]").toString())); + } + + @Test + public void testInclusion() { + assertFalse(IntRange.of(3, 5).includes(2)); + assertTrue(IntRange.of(3, 5).includes(3)); + assertTrue(IntRange.of(3, 5).includes(4)); + assertTrue(IntRange.of(3, 5).includes(5)); + assertFalse(IntRange.of(3, 5).includes(6)); + + assertTrue(IntRange.from(3).includes(1000)); + assertFalse(IntRange.from(3).includes(2)); + + assertTrue(IntRange.to(5).includes(-1000)); + assertFalse(IntRange.to(3).includes(4)); + } + +} |