Skip to content

Commit 212e445

Browse files
[PRODENG-3633] Add overall apply/reset deadline and surface retry errors (#668)
Option A from the ticket: a Manager-level timeout wrapper rather than threading context.Context through all 30+ phase implementations. phase.Manager gains a Deadline time.Duration field. Run races each phase's Run() against the deadline in a goroutine; on timeout it returns an error naming the in-progress phase instead of blocking forever, and does not start the next phase. The phase's goroutine is left running on timeout rather than force-stopped -- launchpad is a short-lived CLI process, so it exits shortly after and reclaims it. This does not make individual waits (WaitGroup.Wait, channel ops) cancellable, only bounds the total time Run can spend. Wired through a new Product.SetTimeout(time.Duration) method rather than adding a parameter to Apply/Reset, so none of the existing callers (cmd/apply.go, cmd/reset.go, test/integration, test/smoke/*) need to change signatures; only cmd/apply.go and cmd/reset.go call it, from a new --timeout flag defaulting to 90m (matching the longest existing smoke test timeout in the Makefile). --timeout 0 disables the deadline. Separately, pkg/product/mke/config/cluster_spec.go's pingHost (the MKE health-check retry loop) only logged "waiting for MKE ... to become healthy" on every attempt and never the actual failure reason mid-retry, which is why diagnosing the original PRODENG-3594 deadlock cost a full 50-minute CI run. Added retry.OnRetry to log the real error on each failed attempt. The final returned error already carried every attempt's error via retry-go's own Error type; nothing was actually being discarded, it just wasn't visible while waiting. Audited every other sync.WaitGroup use in the codebase (connect.go, disconnect.go, run_hooks.go) for the double-send-on-channel pattern that caused the original deadlock: none have it, all use defer wg.Done() with mutex-protected shared state instead of a fixed-capacity channel. Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
1 parent dea1d78 commit 212e445

9 files changed

Lines changed: 181 additions & 2 deletions

File tree

‎cmd/apply.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ func NewApplyCommand() *cli.Command {
5454
Usage: "force upgrade to run on compatible components, even if it doesn't look necessary",
5555
Value: false,
5656
},
57+
&cli.DurationFlag{
58+
Name: "timeout",
59+
Usage: "Overall deadline for apply; fails naming the in-progress phase if exceeded instead of blocking forever (0 disables)",
60+
Value: 90 * time.Minute,
61+
},
5762
}...),
5863
Before: actions(initLogger, startUpgradeCheck, initAnalytics, checkLicense, initExec),
5964
After: actions(closeAnalytics, upgradeCheckResult),
@@ -72,6 +77,8 @@ func NewApplyCommand() *cli.Command {
7277
return fmt.Errorf("failed to load product config: %w", err)
7378
}
7479

80+
product.SetTimeout(ctx.Duration("timeout"))
81+
7582
defer func() {
7683
if err != nil && logFile != nil {
7784
log.Infof("See %s for more logs ", logFile.Name())

‎cmd/reset.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ func NewResetCommand() *cli.Command {
2828
Usage: "Don't ask for confirmation",
2929
Aliases: []string{"f"},
3030
},
31+
&cli.DurationFlag{
32+
Name: "timeout",
33+
Usage: "Overall deadline for reset; fails naming the in-progress phase if exceeded instead of blocking forever (0 disables)",
34+
Value: 90 * time.Minute,
35+
},
3136
}...),
3237
Before: actions(initLogger, initAnalytics, checkLicense, initExec, requireForce),
3338
After: actions(closeAnalytics),
@@ -39,6 +44,8 @@ func NewResetCommand() *cli.Command {
3944
return fmt.Errorf("failed to load product config: %w", err)
4045
}
4146

47+
product.SetTimeout(ctx.Duration("timeout"))
48+
4249
err = product.Reset()
4350
if err != nil {
4451
analytics.TrackEvent("Cluster Reset Failed", nil)

‎pkg/phase/manager.go‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ type Manager struct {
3838
config interface{}
3939
IgnoreErrors bool
4040
SkipCleanup bool
41+
// Deadline bounds the total wall-clock time Run may spend across all
42+
// phases. Zero means no deadline. A phase that is still running when the
43+
// deadline elapses is abandoned (its goroutine is not force-stopped) and
44+
// Run returns an error naming that phase; the caller is expected to be a
45+
// short-lived CLI process that exits shortly after, taking the abandoned
46+
// goroutine down with it.
47+
Deadline time.Duration
4148
}
4249

4350
// NewManager constructs new phase manager.
@@ -59,8 +66,16 @@ func (m *Manager) AddPhase(p phase) {
5966
m.phases = append(m.phases, p)
6067
}
6168

62-
// Run executes all the added Phases in order.
69+
// Run executes all the added Phases in order. If Deadline is set, the total
70+
// time spent across all phases is bounded; a phase still running when the
71+
// deadline elapses causes Run to return an error naming that phase instead
72+
// of blocking forever.
6373
func (m *Manager) Run() error {
74+
var deadlineAt time.Time
75+
if m.Deadline > 0 {
76+
deadlineAt = time.Now().Add(m.Deadline)
77+
}
78+
6479
for _, phase := range m.phases {
6580
title := phase.Title()
6681

@@ -89,7 +104,10 @@ func (m *Manager) Run() error {
89104
log.Infof(text, title)
90105
start := time.Now()
91106

92-
result := phase.Run()
107+
timedOut, result := m.runPhase(phase, title, deadlineAt)
108+
if timedOut {
109+
return fmt.Errorf("exceeded overall deadline of %s while running phase %q", m.Deadline, title)
110+
}
93111

94112
duration := time.Since(start)
95113
log.Debugf("phase '%s' took %s", title, duration.Truncate(time.Minute))
@@ -126,3 +144,42 @@ func (m *Manager) Run() error {
126144

127145
return nil
128146
}
147+
148+
// runPhase runs a single phase, optionally racing it against deadlineAt. It
149+
// returns the phase's error and whether the deadline elapsed first. On
150+
// timeout the phase's goroutine is left running; the caller (a short-lived
151+
// CLI process) is expected to exit shortly after, which reclaims it. Cleanup
152+
// hooks are not invoked on timeout since the phase never signaled it stopped
153+
// touching shared state.
154+
func (m *Manager) runPhase(target phase, title string, deadlineAt time.Time) (timedOut bool, result error) {
155+
if deadlineAt.IsZero() {
156+
if err := target.Run(); err != nil {
157+
return false, fmt.Errorf("%w", err)
158+
}
159+
return false, nil
160+
}
161+
162+
remaining := time.Until(deadlineAt)
163+
if remaining <= 0 {
164+
return true, nil
165+
}
166+
167+
done := make(chan error, 1)
168+
go func() {
169+
done <- target.Run()
170+
}()
171+
172+
timer := time.NewTimer(remaining)
173+
defer timer.Stop()
174+
175+
select {
176+
case err := <-done:
177+
if err != nil {
178+
return false, fmt.Errorf("%w", err)
179+
}
180+
return false, nil
181+
case <-timer.C:
182+
log.Errorf("phase '%s' exceeded the overall deadline of %s", title, m.Deadline)
183+
return true, nil
184+
}
185+
}

‎pkg/phase/manager_test.go‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package phase
2+
3+
import (
4+
"errors"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
type fakePhase struct {
14+
title string
15+
run func() error
16+
}
17+
18+
func (f *fakePhase) Title() string { return f.title }
19+
func (f *fakePhase) Run() error { return f.run() }
20+
21+
func TestManagerRun_NoDeadlineRunsToCompletion(t *testing.T) {
22+
m := NewManager(struct{}{})
23+
m.AddPhases(
24+
&fakePhase{title: "a", run: func() error { return nil }},
25+
&fakePhase{title: "b", run: func() error { return nil }},
26+
)
27+
28+
require.NoError(t, m.Run())
29+
}
30+
31+
func TestManagerRun_DeadlineNotExceededRunsToCompletion(t *testing.T) {
32+
m := NewManager(struct{}{})
33+
m.Deadline = time.Second
34+
m.AddPhases(
35+
&fakePhase{title: "fast", run: func() error { return nil }},
36+
)
37+
38+
require.NoError(t, m.Run())
39+
}
40+
41+
func TestManagerRun_DeadlineExceededNamesTheHungPhase(t *testing.T) {
42+
m := NewManager(struct{}{})
43+
m.Deadline = 20 * time.Millisecond
44+
m.AddPhases(
45+
&fakePhase{title: "quick", run: func() error { return nil }},
46+
&fakePhase{title: "hangs-forever", run: func() error {
47+
select {} // block forever, simulating an unbounded wait
48+
}},
49+
)
50+
51+
err := m.Run()
52+
require.Error(t, err)
53+
assert.True(t, strings.Contains(err.Error(), "hangs-forever"), "error should name the hung phase, got: %s", err)
54+
assert.True(t, strings.Contains(err.Error(), "deadline"), "error should mention the deadline, got: %s", err)
55+
}
56+
57+
func TestManagerRun_DeadlineExceededBeforeStartingNextPhase(t *testing.T) {
58+
m := NewManager(struct{}{})
59+
m.Deadline = 10 * time.Millisecond
60+
started := false
61+
m.AddPhases(
62+
&fakePhase{title: "slow", run: func() error {
63+
time.Sleep(50 * time.Millisecond)
64+
return nil
65+
}},
66+
&fakePhase{title: "never-reached", run: func() error {
67+
started = true
68+
return nil
69+
}},
70+
)
71+
72+
err := m.Run()
73+
require.Error(t, err)
74+
assert.False(t, started, "the second phase must not start once the deadline has already elapsed")
75+
}
76+
77+
func TestManagerRun_PhaseErrorStillPropagatesWithDeadlineSet(t *testing.T) {
78+
m := NewManager(struct{}{})
79+
m.Deadline = time.Second
80+
wantErr := errors.New("boom")
81+
m.AddPhases(
82+
&fakePhase{title: "fails", run: func() error { return wantErr }},
83+
)
84+
85+
err := m.Run()
86+
require.Error(t, err)
87+
assert.ErrorIs(t, err, wantErr)
88+
}

‎pkg/product/mke/apply.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
func (p *MKE) Apply(disableCleanup, force bool, concurrency int, forceUpgrade bool) error {
1616
phaseManager := phase.NewManager(&p.ClusterConfig)
1717
phaseManager.SkipCleanup = disableCleanup
18+
phaseManager.Deadline = p.Timeout
1819

1920
phaseManager.AddPhases(
2021
&mke.UpgradeCheck{},

‎pkg/product/mke/config/cluster_spec.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,9 @@ func pingHost(host *Host, address string, waitgroup *sync.WaitGroup, errCh chan<
277277
}
278278
return nil
279279
},
280+
retry.OnRetry(func(n uint, err error) {
281+
log.Errorf("%s: MKE health check attempt %d of 10 failed: %s", host, n+1, err.Error())
282+
}),
280283
retry.MaxJitter(time.Second*3),
281284
retry.Delay(time.Second*30),
282285
retry.DelayType(retry.FixedDelay),

‎pkg/product/mke/mke.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mke
22

33
import (
44
"fmt"
5+
"time"
56

67
"github.com/Mirantis/launchpad/pkg/product/mke/config"
78
"gopkg.in/yaml.v2"
@@ -10,6 +11,15 @@ import (
1011
// MKE is the product.
1112
type MKE struct {
1213
ClusterConfig config.ClusterConfig
14+
// Timeout bounds the total wall-clock time Apply or Reset may spend
15+
// across all phases. Zero (the default) means no deadline. Set via
16+
// SetTimeout.
17+
Timeout time.Duration
18+
}
19+
20+
// SetTimeout sets the overall deadline for Apply and Reset.
21+
func (p *MKE) SetTimeout(timeout time.Duration) {
22+
p.Timeout = timeout
1323
}
1424

1525
// ClusterName returns the cluster name.

‎pkg/product/mke/reset.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
// Reset uninstalls a Docker Enterprise cluster.
1212
func (p *MKE) Reset() error {
1313
phaseManager := phase.NewManager(&p.ClusterConfig)
14+
phaseManager.Deadline = p.Timeout
1415

1516
phaseManager.AddPhases(
1617
&common.Connect{},

‎pkg/product/product.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package product
22

3+
import "time"
4+
35
// Product is an interface that represents a product that launchpad can manage.
46
type Product interface {
57
Apply(disableCleanup, force bool, concurrency int, forceUpgrade bool) error
@@ -8,4 +10,7 @@ type Product interface {
810
ClientConfig() error
911
Exec(target []string, interactive, first, all, parallel bool, role, os, cmd string) error
1012
ClusterName() string
13+
// SetTimeout bounds the total wall-clock time Apply or Reset may spend
14+
// across all phases. Zero (the default) means no deadline.
15+
SetTimeout(timeout time.Duration)
1116
}

0 commit comments

Comments
 (0)