aboutsummaryrefslogtreecommitdiffstats
path: root/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/rotation/RotationRepository.java
blob: 6ea7417dffdbd7cf30e56c9214327eaa73d4e6f6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.controller.rotation;

import com.yahoo.config.application.api.DeploymentSpec;
import com.yahoo.config.application.api.Endpoint;
import com.yahoo.config.provision.ApplicationId;
import com.yahoo.config.provision.ClusterSpec;
import com.yahoo.config.provision.Environment;
import com.yahoo.config.provision.InstanceName;
import com.yahoo.config.provision.RegionName;
import com.yahoo.vespa.hosted.controller.ApplicationController;
import com.yahoo.vespa.hosted.controller.Instance;
import com.yahoo.vespa.hosted.controller.application.AssignedRotation;
import com.yahoo.vespa.hosted.controller.application.EndpointId;
import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
import com.yahoo.vespa.hosted.rotation.config.RotationsConfig;

import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.logging.Logger;
import java.util.stream.Collectors;

import static java.util.stream.Collectors.collectingAndThen;

/**
 * The rotation repository offers global rotations to Vespa applications.
 *
 * The list of rotations comes from RotationsConfig, which is set in the controller's services.xml.
 *
 * @author Oyvind Gronnesby
 * @author mpolden
 */
public class RotationRepository {

    private static final Logger log = Logger.getLogger(RotationRepository.class.getName());

    private final Map<RotationId, Rotation> allRotations;
    private final ApplicationController applications;
    private final CuratorDb curator;

    public RotationRepository(RotationsConfig rotationsConfig, ApplicationController applications, CuratorDb curator) {
        this.allRotations = from(rotationsConfig);
        this.applications = applications;
        this.curator = curator;
    }

    /** Acquire a exclusive lock for this */
    public RotationLock lock() {
        return new RotationLock(curator.lockRotations());
    }

    /** Get rotation by given rotationId */
    public Optional<Rotation> getRotation(RotationId rotationId) {
        return Optional.of(allRotations.get(rotationId));
    }

    /**
     * Returns a single rotation for the given application. This is only used when a rotation is assigned through the
     * use of a global service ID.
     *
     * If a rotation is already assigned to the application, that rotation will be returned.
     * If no rotation is assigned, return an available rotation. The caller is responsible for assigning the rotation.
     *
     * @param deploymentSpec the deployment spec for the application
     * @param instance the instance requesting a rotation
     * @param lock lock which must be acquired by the caller
     */
    private Rotation getOrAssignRotation(DeploymentSpec deploymentSpec, Instance instance, RotationLock lock) {
        if ( ! instance.rotations().isEmpty()) {
            return allRotations.get(instance.rotations().get(0).rotationId());
        }

        if (deploymentSpec.requireInstance(instance.name()).globalServiceId().isEmpty()) {
            throw new IllegalArgumentException("global-service-id is not set in deployment spec for instance '" +
                                               instance.name() + "'");
        }
        long productionZones = deploymentSpec.requireInstance(instance.name()).zones().stream()
                                                     .filter(zone -> zone.deploysTo(Environment.prod))
                                                     .count();
        if (productionZones < 2) {
            throw new IllegalArgumentException("global-service-id is set but less than 2 prod zones are defined " +
                                               "in instance '" + instance.name() + "'");
        }
        return findAvailableRotation(instance.id(), lock);
    }

    /**
     * Returns rotation assignments for all endpoints in application.
     *
     * If rotations are already assigned, these will be returned.
     * If rotations are not assigned, a new assignment will be created taking new rotations from the repository.
     * This method supports both global-service-id as well as the new endpoints tag.
     *
     * @param deploymentSpec The deployment spec of the application
     * @param instance The application requesting rotations
     * @param lock Lock which by acquired by the caller
     * @return List of rotation assignments - either new or existing
     */
    public List<AssignedRotation> getOrAssignRotations(DeploymentSpec deploymentSpec, Instance instance, RotationLock lock) {
        // Skip assignment if no rotations are configured in this system
        if (allRotations.isEmpty()) {
            return List.of();
        }

        // Only allow one kind of configuration syntax
        if (deploymentSpec.requireInstance(instance.name()).globalServiceId().isPresent()
            && ! deploymentSpec.requireInstance(instance.name()).endpoints().isEmpty()) {
            throw new IllegalArgumentException("Cannot provision rotations with both global-service-id and 'endpoints'");
        }

        // Support the older case of setting global-service-id
        if (deploymentSpec.requireInstance(instance.name()).globalServiceId().isPresent()) {
            var regions = deploymentSpec.requireInstance(instance.name()).zones().stream()
                                                .filter(zone -> zone.environment().isProduction())
                                                .flatMap(zone -> zone.region().stream())
                                                .collect(Collectors.toSet());

            var rotation = getOrAssignRotation(deploymentSpec, instance, lock);

            return List.of(
                    new AssignedRotation(
                            new ClusterSpec.Id(deploymentSpec.requireInstance(instance.name()).globalServiceId().get()),
                            EndpointId.defaultId(),
                            rotation.id(),
                            regions
                    )
            );
        }

        Map<EndpointId, AssignedRotation> existingAssignments = existingEndpointAssignments(deploymentSpec, instance);
        Map<EndpointId, AssignedRotation> updatedAssignments = assignRotationsToEndpoints(deploymentSpec, existingAssignments, instance.name(), lock);

        existingAssignments.putAll(updatedAssignments);

        return List.copyOf(existingAssignments.values());
    }

    private Map<EndpointId, AssignedRotation> assignRotationsToEndpoints(DeploymentSpec deploymentSpec,
                                                                         Map<EndpointId, AssignedRotation> existingAssignments,
                                                                         InstanceName instance,
                                                                         RotationLock lock) {
        var availableRotations = new ArrayList<>(availableRotations(lock).values());

        var neededRotations = deploymentSpec.requireInstance(instance).endpoints().stream()
                                            .filter(Predicate.not(endpoint -> existingAssignments.containsKey(EndpointId.of(endpoint.endpointId()))))
                                            .collect(Collectors.toSet());

        if (neededRotations.size() > availableRotations.size()) {
            throw new IllegalStateException("Hosted Vespa ran out of rotations, unable to assign rotation: need " + neededRotations.size() + ", have " + availableRotations.size());
        }

        return neededRotations.stream()
                .map(endpoint -> {
                        return new AssignedRotation(
                                new ClusterSpec.Id(endpoint.containerId()),
                                EndpointId.of(endpoint.endpointId()),
                                availableRotations.remove(0).id(),
                                endpoint.regions()
                        );
                })
                .collect(
                        Collectors.toMap(
                                AssignedRotation::endpointId,
                                Function.identity(),
                                (a, b) -> { throw new IllegalStateException("Duplicate entries:" + a + ", " + b); },
                                LinkedHashMap::new
                        )
                );
    }

    private Map<EndpointId, AssignedRotation> existingEndpointAssignments(DeploymentSpec deploymentSpec, Instance instance) {
        // Get the regions that has been configured for an endpoint.  Empty set if the endpoint
        // is no longer mentioned in the configuration file.
        Function<EndpointId, Set<RegionName>> configuredRegionsForEndpoint = endpointId ->
            deploymentSpec.requireInstance(instance.name()).endpoints().stream()
                                 .filter(endpoint -> endpointId.id().equals(endpoint.endpointId()))
                                 .map(Endpoint::regions)
                                 .findFirst()
                                 .orElse(Set.of());

        // Build a new AssignedRotation instance where we update set of regions from the configuration instead
        // of using the one already mentioned in the assignment.  This allows us to overwrite the set of regions.
        Function<AssignedRotation, AssignedRotation> assignedRotationWithConfiguredRegions = assignedRotation ->
            new AssignedRotation(
                    assignedRotation.clusterId(),
                    assignedRotation.endpointId(),
                    assignedRotation.rotationId(),
                    configuredRegionsForEndpoint.apply(assignedRotation.endpointId()));

        return instance.rotations().stream()
                       .collect(Collectors.toMap(
                                          AssignedRotation::endpointId,
                                          assignedRotationWithConfiguredRegions,
                                          (a, b) -> {
                                              throw new IllegalStateException("Duplicate entries: " + a + ", " + b);
                                          },
                                          LinkedHashMap::new
                                  )
                          );
    }

    /**
     * Returns all unassigned rotations
     * @param lock Lock which must be acquired by the caller
     */
    public Map<RotationId, Rotation> availableRotations(@SuppressWarnings("unused") RotationLock lock) {
        List<RotationId> assignedRotations = applications.asList().stream()
                                                         .flatMap(application -> application.instances().values().stream())
                                                         .flatMap(instance -> instance.rotations().stream())
                                                         .map(AssignedRotation::rotationId)
                                                         .collect(Collectors.toList());
        Map<RotationId, Rotation> unassignedRotations = new LinkedHashMap<>(this.allRotations);
        assignedRotations.forEach(unassignedRotations::remove);
        return Collections.unmodifiableMap(unassignedRotations);
    }

    private Rotation findAvailableRotation(ApplicationId id, RotationLock lock) {
        Map<RotationId, Rotation> availableRotations = availableRotations(lock);
        if (availableRotations.isEmpty()) {
            throw new IllegalStateException("Unable to assign global rotation to " + id
                                            + " - no rotations available");
        }
        // Return first available rotation
        RotationId rotation = availableRotations.keySet().iterator().next();
        log.info(String.format("Offering %s to application %s", rotation, id));
        return allRotations.get(rotation);
    }

    /** Returns a immutable map of rotation ID to rotation sorted by rotation ID */
    private static Map<RotationId, Rotation> from(RotationsConfig rotationConfig) {
        return rotationConfig.rotations().entrySet().stream()
                             .map(entry -> new Rotation(new RotationId(entry.getKey()), entry.getValue().trim()))
                             .sorted(Comparator.comparing(rotation -> rotation.id().asString()))
                             .collect(collectingAndThen(Collectors.toMap(Rotation::id,
                                                                         rotation -> rotation,
                                                                         (k, v) -> v,
                                                                         LinkedHashMap::new),
                                                        Collections::unmodifiableMap));
    }

}