Skip to content

Commit 2feb060

Browse files
committed
scheduler: perform feasibility checks for system canaries before computing placements
Canaries for system jobs are placed on a tg.update.canary percent of eligible nodes. Some of these nodes may not be feasible, and until now we removed infeasible nodes during placement computation. However, if it happens to be that the first eligible node we picked to place a canary on is infeasible, this will lead to the scheduler halting deployment. The solution presented here simplifies canary deployments: initially, system jobs that use canary updates get allocations placed on all eligible nodes, but before we start computing actual placements, a method called `evictCanaries` is called (much like `evictAndPlace` is for honoring MaxParallel), and performs a feasibility check on each node up to the amount of required canaries per task group. Feasibility checks are expensive, but this way we only check all the nodes in the worst case scenario (with canary=100), otherwise we stop checks once we know we are ready to place enough canaries.
1 parent 50d7595 commit 2feb060

File tree

3 files changed

+102
-321
lines changed

3 files changed

+102
-321
lines changed

scheduler/reconciler/reconcile_node.go

Lines changed: 9 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ package reconciler
55

66
import (
77
"fmt"
8-
"maps"
9-
"math"
108
"slices"
119
"time"
1210

@@ -61,19 +59,14 @@ func (nr *NodeReconciler) Compute(
6159
// Create the required task groups.
6260
required := materializeSystemTaskGroups(job)
6361

64-
// Canary deployments deploy to the TaskGroup.UpdateStrategy.Canary
65-
// percentage of eligible nodes, so we create a mapping of task group name
66-
// to a list of nodes that canaries should be placed on.
67-
canaryNodes, canariesPerTG := nr.computeCanaryNodes(required, nodeAllocs, terminal, eligibleNodes)
68-
6962
compatHadExistingDeployment := nr.DeploymentCurrent != nil
7063

7164
result := new(NodeReconcileResult)
7265
var deploymentComplete bool
7366
for nodeID, allocs := range nodeAllocs {
7467
diff, deploymentCompleteForNode := nr.computeForNode(job, nodeID, eligibleNodes,
75-
notReadyNodes, taintedNodes, canaryNodes[nodeID], canariesPerTG, required,
76-
allocs, terminal, serverSupportsDisconnectedClients)
68+
notReadyNodes, taintedNodes, required, allocs, terminal,
69+
serverSupportsDisconnectedClients)
7770
result.Append(diff)
7871

7972
deploymentComplete = deploymentCompleteForNode
@@ -95,88 +88,6 @@ func (nr *NodeReconciler) Compute(
9588
return result
9689
}
9790

98-
// computeCanaryNodes is a helper function that, given required task groups,
99-
// mappings of nodes to their live allocs and terminal allocs, and a map of
100-
// eligible nodes, outputs a map[nodeID] -> map[TG] -> bool which indicates
101-
// which TGs this node is a canary for, and a map[TG] -> int to indicate how
102-
// many total canaries are to be placed for a TG.
103-
func (nr *NodeReconciler) computeCanaryNodes(required map[string]*structs.TaskGroup,
104-
liveAllocs map[string][]*structs.Allocation, terminalAllocs structs.TerminalByNodeByName,
105-
eligibleNodes map[string]*structs.Node) (map[string]map[string]bool, map[string]int) {
106-
107-
canaryNodes := map[string]map[string]bool{}
108-
eligibleNodesList := slices.Collect(maps.Values(eligibleNodes))
109-
canariesPerTG := map[string]int{}
110-
111-
for _, tg := range required {
112-
if tg.Update.IsEmpty() || tg.Update.Canary == 0 {
113-
continue
114-
}
115-
116-
// round up to the nearest integer
117-
numberOfCanaryNodes := int(math.Ceil(float64(tg.Update.Canary) * float64(len(eligibleNodes)) / 100))
118-
canariesPerTG[tg.Name] = numberOfCanaryNodes
119-
120-
// check if there are any live allocations on any nodes that are/were
121-
// canaries.
122-
for nodeID, allocs := range liveAllocs {
123-
for _, a := range allocs {
124-
eligibleNodesList, numberOfCanaryNodes = nr.findOldCanaryNodes(
125-
eligibleNodesList, numberOfCanaryNodes, a, tg, canaryNodes, nodeID)
126-
}
127-
}
128-
129-
// check if there are any terminal allocations that were canaries
130-
for nodeID, terminalAlloc := range terminalAllocs {
131-
for _, a := range terminalAlloc {
132-
eligibleNodesList, numberOfCanaryNodes = nr.findOldCanaryNodes(
133-
eligibleNodesList, numberOfCanaryNodes, a, tg, canaryNodes, nodeID)
134-
}
135-
}
136-
137-
for i, n := range eligibleNodesList {
138-
if i > numberOfCanaryNodes-1 {
139-
break
140-
}
141-
142-
if _, ok := canaryNodes[n.ID]; !ok {
143-
canaryNodes[n.ID] = map[string]bool{}
144-
}
145-
146-
canaryNodes[n.ID][tg.Name] = true
147-
}
148-
}
149-
150-
return canaryNodes, canariesPerTG
151-
}
152-
153-
func (nr *NodeReconciler) findOldCanaryNodes(nodesList []*structs.Node, numberOfCanaryNodes int,
154-
a *structs.Allocation, tg *structs.TaskGroup, canaryNodes map[string]map[string]bool, nodeID string) ([]*structs.Node, int) {
155-
156-
if a.DeploymentStatus == nil || a.DeploymentStatus.Canary == false ||
157-
nr.DeploymentCurrent == nil {
158-
return nodesList, numberOfCanaryNodes
159-
}
160-
161-
nodes := nodesList
162-
numberOfCanaries := numberOfCanaryNodes
163-
if a.TaskGroup == tg.Name {
164-
if _, ok := canaryNodes[nodeID]; !ok {
165-
canaryNodes[nodeID] = map[string]bool{}
166-
}
167-
canaryNodes[nodeID][tg.Name] = true
168-
169-
// this node should no longer be considered when searching
170-
// for canary nodes
171-
numberOfCanaries -= 1
172-
nodes = slices.DeleteFunc(
173-
nodes,
174-
func(n *structs.Node) bool { return n.ID == nodeID },
175-
)
176-
}
177-
return nodes, numberOfCanaries
178-
}
179-
18091
// computeForNode is used to do a set difference between the target
18192
// allocations and the existing allocations for a particular node. This returns
18293
// 8 sets of results:
@@ -199,8 +110,6 @@ func (nr *NodeReconciler) computeForNode(
199110
eligibleNodes map[string]*structs.Node,
200111
notReadyNodes map[string]struct{}, // nodes that are not ready, e.g. draining
201112
taintedNodes map[string]*structs.Node, // nodes which are down (by node id)
202-
canaryNode map[string]bool, // indicates whether this node is a canary node for tg
203-
canariesPerTG map[string]int, // indicates how many canary placements we expect per tg
204113
required map[string]*structs.TaskGroup, // set of allocations that must exist
205114
liveAllocs []*structs.Allocation, // non-terminal allocations that exist
206115
terminal structs.TerminalByNodeByName, // latest terminal allocations (by node, id)
@@ -225,9 +134,6 @@ func (nr *NodeReconciler) computeForNode(
225134
deploymentFailed = nr.DeploymentCurrent.Status == structs.DeploymentStatusFailed
226135
}
227136

228-
// Track desired total and desired canaries across all loops
229-
desiredCanaries := map[string]int{}
230-
231137
// Track whether we're during a canary update
232138
isCanarying := map[string]bool{}
233139

@@ -388,17 +294,14 @@ func (nr *NodeReconciler) computeForNode(
388294

389295
// If the definition is updated we need to update
390296
if job.JobModifyIndex != alloc.Job.JobModifyIndex {
391-
if canariesPerTG[tg.Name] > 0 && dstate != nil && !dstate.Promoted {
297+
if !tg.Update.IsEmpty() && tg.Update.Canary > 0 && dstate != nil && !dstate.Promoted {
392298
isCanarying[tg.Name] = true
393-
if canaryNode[tg.Name] {
394-
result.Update = append(result.Update, AllocTuple{
395-
Name: name,
396-
TaskGroup: tg,
397-
Alloc: alloc,
398-
Canary: true,
399-
})
400-
desiredCanaries[tg.Name] += 1
401-
}
299+
result.Update = append(result.Update, AllocTuple{
300+
Name: name,
301+
TaskGroup: tg,
302+
Alloc: alloc,
303+
Canary: true,
304+
})
402305
} else {
403306
result.Update = append(result.Update, AllocTuple{
404307
Name: name,
@@ -443,10 +346,6 @@ func (nr *NodeReconciler) computeForNode(
443346
dstate.DesiredTotal = len(eligibleNodes)
444347
}
445348

446-
if isCanarying[tg.Name] && !dstate.Promoted {
447-
dstate.DesiredCanaries = canariesPerTG[tg.Name]
448-
}
449-
450349
// Check for an existing allocation
451350
if _, ok := existing[name]; !ok {
452351

0 commit comments

Comments
 (0)