Repository navigation
Expand file tree
/
Copy pathscheduler.go
More file actions
628 lines (560 loc) · 20.1 KB
/
Copy pathscheduler.go
File metadata and controls
628 lines (560 loc) · 20.1 KB
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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package exegraph
import (
"cmp"
"context"
"errors"
"fmt"
"log"
"runtime"
"runtime/debug"
"slices"
"sync"
"time"
"github.com/azure/azure-dev/cli/azd/internal/tracing"
"github.com/azure/azure-dev/cli/azd/internal/tracing/events"
"github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
)
// ErrorPolicy controls how the scheduler handles step failures.
type ErrorPolicy int
const (
// FailFast cancels all in-flight steps when the first step fails.
FailFast ErrorPolicy = iota
// ContinueOnError lets all independent steps run to completion and
// aggregates errors at the end. Steps whose dependencies failed are skipped.
ContinueOnError
)
// String returns the string representation of the ErrorPolicy.
func (p ErrorPolicy) String() string {
switch p {
case FailFast:
return "fail_fast"
case ContinueOnError:
return "continue_on_error"
default:
return "unknown"
}
}
// RunOptions configures the execution graph scheduler.
type RunOptions struct {
// MaxConcurrency limits the number of steps running simultaneously.
// Zero (the default) caps at GOMAXPROCS×2; a positive value is honored
// as the upper bound (useful to bump or cap for IO-bound workloads).
// Negative values are treated as zero.
MaxConcurrency int
// GroupConcurrency limits the number of simultaneously running steps in each
// named [Step.ConcurrencyGroup]. Non-positive limits are ignored. Group limits
// are enforced beneath MaxConcurrency and do not reserve worker capacity.
GroupConcurrency map[string]int
// ErrorPolicy determines behavior on step failure.
ErrorPolicy ErrorPolicy
// StepTimeout imposes a per-step deadline. When positive, each step's
// context is wrapped with context.WithTimeout before execution. If the
// step does not complete within the duration the context expires with
// context.DeadlineExceeded, which the scheduler treats as a normal step
// failure (respecting ErrorPolicy). Zero (the default) means no per-step
// timeout is applied.
StepTimeout time.Duration
// OnStepStart is called (if non-nil) when a step begins execution.
// It is invoked from worker goroutines and must be safe for concurrent use.
OnStepStart func(stepName string)
// OnStepDone is called (if non-nil) when a step reaches a terminal state,
// including when it is skipped. It may be invoked from worker goroutines or
// the scheduler coordinator and must be safe for concurrent use.
OnStepDone func(stepName string, err error)
}
// Run executes all steps in the graph respecting dependency order, with bounded
// concurrency. It returns nil if all steps succeed, or a
// combined error describing all failures.
//
// The returned error may contain both genuine step failures and [StepSkippedError]
// values (for steps whose dependencies failed). Use [RunWithResult] when you need
// to distinguish failures from transitive skips via [RunResult.Steps].
func Run(ctx context.Context, g *Graph, opts RunOptions) (err error) {
result := RunWithResult(ctx, g, opts)
return result.Error
}
// RunWithResult executes all steps in the graph and returns a [RunResult]
// containing per-step timing, status, and aggregate error. This is the
// instrumented variant of [Run] for callers that need execution telemetry.
func RunWithResult(ctx context.Context, g *Graph, opts RunOptions) (result *RunResult) {
result = &RunResult{}
ctx, span := tracing.Start(ctx, events.ExeGraphRunEvent)
defer func() { span.EndWithStatus(result.Error) }()
if g == nil {
result.Error = errors.New("exegraph: graph must not be nil")
return result
}
setRunSpanAttributes(span, g, opts)
if g.Len() == 0 {
return result
}
if err := g.Validate(); err != nil {
result.Error = fmt.Errorf("graph validation failed: %w", err)
return result
}
result = execute(ctx, g, opts)
return result
}
// execute implements an event-driven scheduler with a bounded worker pool.
// A single coordinator admits ready steps in work-conserving round-robin order
// across concurrency groups, then workers execute admitted steps and report
// completion. Capacity is never acquired by workers, so a saturated group
// cannot occupy workers while waiting for another step in that group to finish.
func execute(ctx context.Context, g *Graph, opts RunOptions) *RunResult {
n := g.Len()
result := &RunResult{
Steps: make([]StepTiming, 0, n),
}
var timingMu sync.Mutex
// Build in-degree map and reverse adjacency list (step → dependents).
inDegree := make(map[string]int, n)
dependents := make(map[string][]string, n)
for _, s := range g.steps {
if _, ok := inDegree[s.Name]; !ok {
inDegree[s.Name] = 0
}
for _, dep := range s.DependsOn {
inDegree[s.Name]++
dependents[dep] = append(dependents[dep], s.Name)
}
}
// Track failed steps for dependency-skip logic.
failed := make(map[string]bool, n)
var allErrors []error
// Derive a cancellable context for FailFast tear-down.
runCtx, runCancel := context.WithCancel(ctx)
defer runCancel()
// Worker pool size: when MaxConcurrency is explicitly set (>0), honor it
// as the upper bound (still clamped by the step count). Otherwise default
// to GOMAXPROCS*2 to avoid unbounded goroutine creation for the large
// pure-CPU-bound case.
numWorkers := min(n, runtime.GOMAXPROCS(0)*2)
if opts.MaxConcurrency > 0 {
numWorkers = min(n, opts.MaxConcurrency)
}
type stepCompletion struct {
name string
err error
start time.Time
end time.Time
// schedulerCanceled captures whether runCtx was already canceled at
// the moment the step finished executing. This is observed by the
// worker (not by the event loop) so a subsequent FailFast tear-down
// from a *different* step's failure cannot mis-classify this step's
// own per-step timeout or genuine failure as a scheduler cancellation.
schedulerCanceled bool
}
workQueue := make(chan string, n)
completions := make(chan stepCompletion, n)
// Start bounded worker pool.
var workerWg sync.WaitGroup
for range numWorkers {
workerWg.Go(func() {
for name := range workQueue {
// Skip execution if the run has been canceled (FailFast or parent
// context). Without this, queued steps start with a canceled context
// and waste time before discovering the cancellation.
if err := runCtx.Err(); err != nil {
// Emit lifecycle callbacks so consumers tracking step state see
// a terminal event for every queued step (parity with the drain
// loop's StepSkipped cascade below).
safeNotifyStart(opts, name)
safeNotifyDone(opts, name, err)
now := time.Now()
completions <- stepCompletion{
name: name, err: err, start: now, end: now,
schedulerCanceled: true,
}
continue
}
step := g.steps[name]
start := time.Now()
err := runStep(runCtx, step, opts)
end := time.Now()
// Snapshot runCtx state immediately after the step's Action returns.
// This is the canonical "was this cancellation from the scheduler?"
// signal — doing it later in the event loop risks racing with a
// FailFast tear-down triggered by an unrelated step's failure,
// which would incorrectly re-classify a genuine per-step timeout
// or action failure as a scheduler cancellation.
completions <- stepCompletion{
name: name, err: err, start: start, end: end,
schedulerCanceled: runCtx.Err() != nil,
}
}
})
}
runStart := time.Now()
// Ready steps are partitioned by concurrency group. The order within each
// group preserves the graph's critical-path priority. The group cursor
// rotates after every admission so a continuously ready group cannot starve
// another group, while an uncontested group can still consume all capacity.
priorityOrder := g.priorityOrder()
priorityRank := make(map[string]int, len(priorityOrder))
for rank, name := range priorityOrder {
priorityRank[name] = rank
}
compareReady := func(a, b string) int {
return cmp.Compare(priorityRank[a], priorityRank[b])
}
readyByGroup := make(map[string][]string)
var groupOrder []string
groupActive := make(map[string]int)
groupCursor := 0
readyCount := 0
inflight := 0
enqueueReady := func(names []string) {
names = slices.Clone(names)
slices.SortFunc(names, compareReady)
touchedGroups := make(map[string]struct{})
for _, name := range names {
group := g.steps[name].ConcurrencyGroup
if _, ok := readyByGroup[group]; !ok {
groupOrder = append(groupOrder, group)
}
readyByGroup[group] = append(readyByGroup[group], name)
touchedGroups[group] = struct{}{}
readyCount++
}
for group := range touchedGroups {
slices.SortFunc(readyByGroup[group], compareReady)
}
}
groupHasCapacity := func(group string) bool {
limit := opts.GroupConcurrency[group]
return limit <= 0 || groupActive[group] < limit
}
nextReady := func() (string, bool) {
for offset := range len(groupOrder) {
index := (groupCursor + offset) % len(groupOrder)
group := groupOrder[index]
queue := readyByGroup[group]
if len(queue) == 0 || !groupHasCapacity(group) {
continue
}
name := queue[0]
readyByGroup[group] = queue[1:]
readyCount--
groupCursor = (index + 1) % len(groupOrder)
return name, true
}
return "", false
}
dispatchReady := func() {
for inflight < numWorkers && readyCount > 0 {
name, ok := nextReady()
if !ok {
return
}
groupActive[g.steps[name].ConcurrencyGroup]++
inflight++
workQueue <- name
}
}
releaseCapacity := func(name string) {
group := g.steps[name].ConcurrencyGroup
groupActive[group]--
}
// Seed ready queues with zero in-degree steps, sorted by transitive
// dependent count descending (critical-path heuristic).
var initialReady []string
for _, name := range priorityOrder {
if inDegree[name] == 0 {
initialReady = append(initialReady, name)
}
}
enqueueReady(initialReady)
dispatchReady()
// Event loop: process completions as they arrive. Each completion may
// unblock successors whose in-degree drops to zero.
for inflight > 0 {
comp := <-completions
inflight--
releaseCapacity(comp.name)
status, isRealFailure := classifyStepResult(comp.err, comp.schedulerCanceled)
timingMu.Lock()
result.Steps = append(result.Steps, StepTiming{
Name: comp.name,
Status: status,
Start: comp.start,
End: comp.end,
Duration: comp.end.Sub(comp.start),
Tags: g.steps[comp.name].Tags,
Err: comp.err,
})
timingMu.Unlock()
if comp.err != nil {
if isRealFailure {
failed[comp.name] = true
allErrors = append(allErrors, comp.err)
}
if isRealFailure && opts.ErrorPolicy == FailFast {
runCancel()
// Drain remaining in-flight work.
for inflight > 0 {
r := <-completions
inflight--
releaseCapacity(r.name)
drainStatus, drainIsReal := classifyStepResult(r.err, r.schedulerCanceled)
if drainIsReal {
allErrors = append(allErrors, r.err)
}
timingMu.Lock()
result.Steps = append(result.Steps, StepTiming{
Name: r.name,
Status: drainStatus,
Start: r.start,
End: r.end,
Duration: r.end.Sub(r.start),
Tags: g.steps[r.name].Tags,
Err: r.err,
})
timingMu.Unlock()
}
break
}
}
// Decrement in-degrees of dependents; enqueue newly ready steps.
//
// When a step succeeds, iterate dependents read-only (no allocation).
// When a step fails under ContinueOnError, skip propagation may append
// to the queue — use a clone to avoid aliasing the shared slice.
//
// SAFETY: This loop runs in the single coordinator goroutine. The
// `queue = append(queue, ...)` pattern (growing the slice while
// iterating by index) is safe here because there is no concurrent
// access. Do not refactor to parallel processing without replacing
// this with a proper work-stealing queue.
deps := dependents[comp.name]
needsClone := false
var readyBatch []string // collect newly ready steps for grouped enqueue
for i := 0; i < len(deps); i++ {
dep := deps[i]
inDegree[dep]--
if inDegree[dep] == 0 {
if hasFailedDep(g.steps[dep], failed) {
failed[dep] = true
skipErr := &StepSkippedError{StepName: dep}
allErrors = append(allErrors, skipErr)
safeNotifyDone(opts, dep, skipErr)
// Record skipped step timing (zero duration).
now := time.Now()
timingMu.Lock()
result.Steps = append(result.Steps, StepTiming{
Name: dep,
Status: StepSkipped,
Start: now,
End: now,
Tags: g.steps[dep].Tags,
Err: skipErr,
})
timingMu.Unlock()
// Cascade: process skipped step's dependents too.
// Clone on first mutation to avoid aliasing the shared dependents slice.
if !needsClone {
deps = slices.Clone(deps)
needsClone = true
}
deps = append(deps, dependents[dep]...)
} else {
readyBatch = append(readyBatch, dep)
}
}
}
// enqueueReady sorts each touched group by priority, so newly ready
// steps merge into their group's queue in critical-path order.
enqueueReady(readyBatch)
dispatchReady()
}
close(workQueue)
workerWg.Wait()
result.TotalDuration = time.Since(runStart)
// FailFast / parent-cancel completeness: synthesize StepSkipped timings
// for any steps that never appeared in result.Steps. Without this, a
// FailFast tear-down leaves the post-break unqueued downstream steps
// invisible to result.Steps, so consumers iterating timings (e.g. log
// lines, dashboards) see an incomplete picture — a step that was
// structurally blocked by the failure is indistinguishable from a step
// that silently went missing due to a scheduler bug.
if runCtx.Err() != nil {
seen := make(map[string]bool, len(result.Steps))
for _, st := range result.Steps {
seen[st.Name] = true
}
now := time.Now()
var skippedSteps []StepTiming
for _, name := range g.order {
if seen[name] {
continue
}
skipErr := &StepSkippedError{StepName: name}
safeNotifyDone(opts, name, skipErr)
skippedSteps = append(skippedSteps, StepTiming{
Name: name,
Status: StepSkipped,
Start: now,
End: now,
Tags: g.steps[name].Tags,
Err: skipErr,
})
}
timingMu.Lock()
result.Steps = append(result.Steps, skippedSteps...)
timingMu.Unlock()
}
// Post-execution completeness check: verify every step was
// either executed (completed/failed) or skipped. A corrupted in-degree map
// or a missed dependency edge would leave steps permanently pending with no
// way to detect the bug at runtime. This is a read-only safety net.
//
// Skip when the run was aborted (FailFast or parent cancel) — unreachable
// steps with inDegree > 0 are expected in that case.
if runCtx.Err() == nil {
resolved := 0
for _, name := range g.order {
if inDegree[name] == 0 {
resolved++
}
}
if resolved != n {
var stuckNames []string
for _, name := range g.order {
if inDegree[name] > 0 {
stuckNames = append(stuckNames, fmt.Sprintf("%s(in-degree=%d)", name, inDegree[name]))
}
}
allErrors = append(allErrors, fmt.Errorf(
"exegraph: %d of %d steps never became ready; this indicates a scheduler bug: %v",
n-resolved, n, stuckNames,
))
}
}
// If the parent ctx was canceled (user ctrl-C, deploy timeout, etc.) and
// no step-level failures were recorded, surface the cancellation once at
// the run boundary so callers see a non-nil RunResult.Error / Run() error.
// Individual canceled steps are NOT added to allErrors (see the main loop
// above) to avoid duplicating `context.Canceled` for every in-flight step.
if runCtx.Err() != nil && ctx.Err() != nil && len(allErrors) == 0 {
allErrors = append(allErrors, ctx.Err())
}
result.Error = errors.Join(allErrors...)
return result
}
// setRunSpanAttributes records scheduler configuration on the run span.
func setRunSpanAttributes(span tracing.Span, g *Graph, opts RunOptions) {
span.SetAttributes(
fields.ExeGraphStepCountKey.Int(g.Len()),
fields.ExeGraphMaxConcurrencyKey.Int(opts.MaxConcurrency),
fields.ExeGraphErrorPolicyKey.String(opts.ErrorPolicy.String()),
)
if limit, ok := opts.GroupConcurrency["package"]; ok {
span.SetAttributes(fields.ExeGraphPackageConcurrencyKey.Int(limit))
}
if limit, ok := opts.GroupConcurrency["provision"]; ok {
span.SetAttributes(fields.ExeGraphProvisionConcurrencyKey.Int(limit))
}
if limit, ok := opts.GroupConcurrency["deploy"]; ok {
span.SetAttributes(fields.ExeGraphDeployConcurrencyKey.Int(limit))
}
}
// setStepSpanAttributes records identifying attributes for step on span. The step
// name and DependsOn entries embed user-chosen identifiers from azure.yaml (service
// names, layer names) and are hashed before emission per
// docs/specs/metrics-audit/privacy-review-checklist.md. Tags are a fixed internal
// vocabulary ("provision", "deploy", "cmdhook", ...) and are emitted raw.
func setStepSpanAttributes(span tracing.Span, step *Step) {
span.SetAttributes(fields.StringHashed(fields.ExeGraphStepNameKey, step.Name))
if len(step.DependsOn) > 0 {
span.SetAttributes(fields.StringSliceHashed(fields.ExeGraphStepDepsKey, step.DependsOn))
}
if len(step.Tags) > 0 {
span.SetAttributes(fields.ExeGraphStepTagsKey.StringSlice(step.Tags))
}
}
// runStep executes a single step with panic recovery, tracing, and callbacks.
func runStep(ctx context.Context, step *Step, opts RunOptions) (stepErr error) {
ctx, span := tracing.Start(ctx, events.ExeGraphStepEvent)
setStepSpanAttributes(span, step)
if opts.StepTimeout > 0 {
span.SetAttributes(fields.ExeGraphStepTimeoutKey.Int(int(opts.StepTimeout.Seconds())))
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, opts.StepTimeout)
defer cancel()
}
defer func() {
if r := recover(); r != nil {
log.Printf("exegraph: recovered panic in step %q (this is likely an internal bug): %v", step.Name, r)
stepErr = fmt.Errorf("step %q panicked: %v\n%s", step.Name, r, debug.Stack())
}
span.EndWithStatus(stepErr)
safeNotifyDone(opts, step.Name, stepErr)
}()
safeNotifyStart(opts, step.Name)
if err := step.Action(ctx); err != nil {
return fmt.Errorf("step %q failed: %w", step.Name, err)
}
return nil
}
// hasFailedDep checks if any of a step's dependencies are in the failed set.
func hasFailedDep(s *Step, failed map[string]bool) bool {
for _, dep := range s.DependsOn {
if failed[dep] {
return true
}
}
return false
}
// classifyStepResult determines the execution status for a completed step.
// It returns the status and whether the error is a real failure (as opposed to
// a scheduler cancellation that should not count toward the failure set).
//
// A step whose own per-step timeout (StepTimeout) fired but whose parent context
// (runCtx) was NOT canceled is classified as StepFailed — the timeout is a
// genuine step-level failure, not a scheduler tear-down side effect.
func classifyStepResult(err error, schedulerCanceled bool) (StepStatus, bool) {
if err == nil {
return StepDone, false
}
if IsStepSkipped(err) {
return StepSkipped, true
}
if (errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)) && schedulerCanceled {
return StepSkipped, false
}
return StepFailed, true
}
func notifyStart(opts RunOptions, name string) {
if opts.OnStepStart != nil {
opts.OnStepStart(name)
}
}
func notifyDone(opts RunOptions, name string, err error) {
if opts.OnStepDone != nil {
opts.OnStepDone(name, err)
}
}
// safeNotifyStart wraps notifyStart with panic recovery. Without this, a
// panicking OnStepStart callback kills the worker goroutine and deadlocks
// the scheduler.
func safeNotifyStart(opts RunOptions, name string) {
defer func() {
if r := recover(); r != nil {
log.Printf("exegraph: callback panic for %q: %v", name, r)
}
}()
notifyStart(opts, name)
}
// safeNotifyDone wraps notifyDone with panic recovery. An unrecovered panic in
// a callback would kill the goroutine (and in Go, the entire process). This is
// used in the coordinator goroutine and in runStep's deferred cleanup where
// the caller-supplied OnStepDone is invoked.
func safeNotifyDone(opts RunOptions, name string, err error) {
defer func() {
if r := recover(); r != nil {
log.Printf("exegraph: callback panic for %q: %v", name, r)
}
}()
notifyDone(opts, name, err)
}