chore: refactor autobuild/notify to use clock test (#13566)

Refactor autobuild/notify and tests to use the clock testing library.

I also rewrote some of the comments because I didn't understand them when I was looking at the package.
This commit is contained in:
Spike Curtis
2024-06-13 16:01:17 +04:00
committed by GitHub
parent 4b0b9b08d5
commit 0268c7a659
3 changed files with 80 additions and 73 deletions

View File

@ -711,12 +711,12 @@ func tryPollWorkspaceAutostop(ctx context.Context, client *codersdk.Client, work
lock := flock.New(filepath.Join(os.TempDir(), "coder-autostop-notify-"+workspace.ID.String())) lock := flock.New(filepath.Join(os.TempDir(), "coder-autostop-notify-"+workspace.ID.String()))
conditionCtx, cancelCondition := context.WithCancel(ctx) conditionCtx, cancelCondition := context.WithCancel(ctx)
condition := notifyCondition(conditionCtx, client, workspace.ID, lock) condition := notifyCondition(conditionCtx, client, workspace.ID, lock)
stopFunc := notify.Notify(condition, workspacePollInterval, autostopNotifyCountdown...) notifier := notify.New(condition, workspacePollInterval, autostopNotifyCountdown)
return func() { return func() {
// With many "ssh" processes running, `lock.TryLockContext` can be hanging until the context canceled. // With many "ssh" processes running, `lock.TryLockContext` can be hanging until the context canceled.
// Without this cancellation, a CLI process with failed remote-forward could be hanging indefinitely. // Without this cancellation, a CLI process with failed remote-forward could be hanging indefinitely.
cancelCondition() cancelCondition()
stopFunc() notifier.Close()
} }
} }

View File

@ -5,9 +5,16 @@ import (
"sort" "sort"
"sync" "sync"
"time" "time"
"github.com/coder/coder/v2/clock"
) )
// Notifier calls a Condition at most once for each count in countdown. // Notifier triggers callbacks at given intervals until some event happens. The
// intervals (e.g. 10 minute warning, 5 minute warning) are given in the
// countdown. The Notifier periodically polls the condition to get the time of
// the event (the Condition's deadline) and the callback. The callback is
// called at most once per entry in the countdown, the first time the time to
// the deadline is shorter than the duration.
type Notifier struct { type Notifier struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@ -17,12 +24,15 @@ type Notifier struct {
condition Condition condition Condition
notifiedAt map[time.Duration]bool notifiedAt map[time.Duration]bool
countdown []time.Duration countdown []time.Duration
// for testing
clock clock.Clock
} }
// Condition is a function that gets executed with a certain time. // Condition is a function that gets executed periodically, and receives the
// current time as an argument.
// - It should return the deadline for the notification, as well as a // - It should return the deadline for the notification, as well as a
// callback function to execute once the time to the deadline is // callback function to execute. If deadline is the zero
// less than one of the notify attempts. If deadline is the zero
// time, callback will not be executed. // time, callback will not be executed.
// - Callback is executed once for every time the difference between deadline // - Callback is executed once for every time the difference between deadline
// and the current time is less than an element of countdown. // and the current time is less than an element of countdown.
@ -30,23 +40,19 @@ type Notifier struct {
// the returned deadline to the minimum interval. // the returned deadline to the minimum interval.
type Condition func(now time.Time) (deadline time.Time, callback func()) type Condition func(now time.Time) (deadline time.Time, callback func())
// Notify is a convenience function that initializes a new Notifier type Option func(*Notifier)
// with the given condition, interval, and countdown.
// It is the responsibility of the caller to call close to stop polling. // WithTestClock is used in tests to inject a mock Clock
func Notify(cond Condition, interval time.Duration, countdown ...time.Duration) (closeFunc func()) { func WithTestClock(clk clock.Clock) Option {
notifier := New(cond, countdown...) return func(n *Notifier) {
ticker := time.NewTicker(interval) n.clock = clk
go notifier.Poll(ticker.C)
return func() {
ticker.Stop()
_ = notifier.Close()
} }
} }
// New returns a Notifier that calls cond once every time it polls. // New returns a Notifier that calls cond once every time it polls.
// - Duplicate values are removed from countdown, and it is sorted in // - Duplicate values are removed from countdown, and it is sorted in
// descending order. // descending order.
func New(cond Condition, countdown ...time.Duration) *Notifier { func New(cond Condition, interval time.Duration, countdown []time.Duration, opts ...Option) *Notifier {
// Ensure countdown is sorted in descending order and contains no duplicates. // Ensure countdown is sorted in descending order and contains no duplicates.
ct := unique(countdown) ct := unique(countdown)
sort.Slice(ct, func(i, j int) bool { sort.Slice(ct, func(i, j int) bool {
@ -61,38 +67,36 @@ func New(cond Condition, countdown ...time.Duration) *Notifier {
countdown: ct, countdown: ct,
condition: cond, condition: cond,
notifiedAt: make(map[time.Duration]bool), notifiedAt: make(map[time.Duration]bool),
clock: clock.NewReal(),
} }
for _, opt := range opts {
opt(n)
}
go n.poll(interval)
return n return n
} }
// Poll polls once immediately, and then once for every value from ticker. // poll polls once immediately, and then periodically according to the interval.
// Poll exits when ticker is closed. // Poll exits when ticker is closed.
func (n *Notifier) Poll(ticker <-chan time.Time) { func (n *Notifier) poll(interval time.Duration) {
defer close(n.pollDone) defer close(n.pollDone)
// poll once immediately // poll once immediately
n.pollOnce(time.Now()) _ = n.pollOnce()
for { tkr := n.clock.TickerFunc(n.ctx, interval, n.pollOnce, "notifier", "poll")
select { _ = tkr.Wait()
case <-n.ctx.Done():
return
case t, ok := <-ticker:
if !ok {
return
}
n.pollOnce(t)
}
}
} }
func (n *Notifier) Close() error { func (n *Notifier) Close() {
n.cancel() n.cancel()
<-n.pollDone <-n.pollDone
return nil
} }
func (n *Notifier) pollOnce(tick time.Time) { // pollOnce only returns an error so it matches the signature expected of TickerFunc
// nolint: revive // bare returns are fine here
func (n *Notifier) pollOnce() (_ error) {
tick := n.clock.Now()
n.lock.Lock() n.lock.Lock()
defer n.lock.Unlock() defer n.lock.Unlock()
@ -113,6 +117,7 @@ func (n *Notifier) pollOnce(tick time.Time) {
n.notifiedAt[tock] = true n.notifiedAt[tock] = true
return return
} }
return
} }
func unique(ds []time.Duration) []time.Duration { func unique(ds []time.Duration) []time.Duration {

View File

@ -1,34 +1,36 @@
package notify_test package notify_test
import ( import (
"sync"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/atomic"
"go.uber.org/goleak" "go.uber.org/goleak"
"github.com/coder/coder/v2/clock"
"github.com/coder/coder/v2/coderd/autobuild/notify" "github.com/coder/coder/v2/coderd/autobuild/notify"
"github.com/coder/coder/v2/testutil"
) )
func TestNotifier(t *testing.T) { func TestNotifier(t *testing.T) {
t.Parallel() t.Parallel()
now := time.Now() now := time.Date(2022, 5, 13, 0, 0, 0, 0, time.UTC)
testCases := []struct { testCases := []struct {
Name string Name string
Countdown []time.Duration Countdown []time.Duration
Ticks []time.Time PollInterval time.Duration
NTicks int
ConditionDeadline time.Time ConditionDeadline time.Time
NumConditions int64 NumConditions int
NumCallbacks int64 NumCallbacks int
}{ }{
{ {
Name: "zero deadline", Name: "zero deadline",
Countdown: durations(), Countdown: durations(),
Ticks: fakeTicker(now, time.Second, 0), PollInterval: time.Second,
NTicks: 0,
ConditionDeadline: time.Time{}, ConditionDeadline: time.Time{},
NumConditions: 1, NumConditions: 1,
NumCallbacks: 0, NumCallbacks: 0,
@ -36,7 +38,8 @@ func TestNotifier(t *testing.T) {
{ {
Name: "no calls", Name: "no calls",
Countdown: durations(), Countdown: durations(),
Ticks: fakeTicker(now, time.Second, 0), PollInterval: time.Second,
NTicks: 0,
ConditionDeadline: now, ConditionDeadline: now,
NumConditions: 1, NumConditions: 1,
NumCallbacks: 0, NumCallbacks: 0,
@ -44,7 +47,8 @@ func TestNotifier(t *testing.T) {
{ {
Name: "exactly one call", Name: "exactly one call",
Countdown: durations(time.Second), Countdown: durations(time.Second),
Ticks: fakeTicker(now, time.Second, 1), PollInterval: time.Second,
NTicks: 1,
ConditionDeadline: now.Add(time.Second), ConditionDeadline: now.Add(time.Second),
NumConditions: 2, NumConditions: 2,
NumCallbacks: 1, NumCallbacks: 1,
@ -52,7 +56,8 @@ func TestNotifier(t *testing.T) {
{ {
Name: "two calls", Name: "two calls",
Countdown: durations(4*time.Second, 2*time.Second), Countdown: durations(4*time.Second, 2*time.Second),
Ticks: fakeTicker(now, time.Second, 5), PollInterval: time.Second,
NTicks: 5,
ConditionDeadline: now.Add(5 * time.Second), ConditionDeadline: now.Add(5 * time.Second),
NumConditions: 6, NumConditions: 6,
NumCallbacks: 2, NumCallbacks: 2,
@ -60,7 +65,8 @@ func TestNotifier(t *testing.T) {
{ {
Name: "wrong order should not matter", Name: "wrong order should not matter",
Countdown: durations(2*time.Second, 4*time.Second), Countdown: durations(2*time.Second, 4*time.Second),
Ticks: fakeTicker(now, time.Second, 5), PollInterval: time.Second,
NTicks: 5,
ConditionDeadline: now.Add(5 * time.Second), ConditionDeadline: now.Add(5 * time.Second),
NumConditions: 6, NumConditions: 6,
NumCallbacks: 2, NumCallbacks: 2,
@ -68,7 +74,8 @@ func TestNotifier(t *testing.T) {
{ {
Name: "ssh autostop notify", Name: "ssh autostop notify",
Countdown: durations(5*time.Minute, time.Minute), Countdown: durations(5*time.Minute, time.Minute),
Ticks: fakeTicker(now, 30*time.Second, 120), PollInterval: 30 * time.Second,
NTicks: 120,
ConditionDeadline: now.Add(30 * time.Minute), ConditionDeadline: now.Add(30 * time.Minute),
NumConditions: 121, NumConditions: 121,
NumCallbacks: 2, NumCallbacks: 2,
@ -79,30 +86,33 @@ func TestNotifier(t *testing.T) {
testCase := testCase testCase := testCase
t.Run(testCase.Name, func(t *testing.T) { t.Run(testCase.Name, func(t *testing.T) {
t.Parallel() t.Parallel()
ch := make(chan time.Time) ctx := testutil.Context(t, testutil.WaitShort)
numConditions := atomic.NewInt64(0) mClock := clock.NewMock(t)
numCalls := atomic.NewInt64(0) mClock.Set(now).MustWait(ctx)
numConditions := 0
numCalls := 0
cond := func(time.Time) (time.Time, func()) { cond := func(time.Time) (time.Time, func()) {
numConditions.Inc() numConditions++
return testCase.ConditionDeadline, func() { return testCase.ConditionDeadline, func() {
numCalls.Inc() numCalls++
} }
} }
var wg sync.WaitGroup
go func() { trap := mClock.Trap().TickerFunc("notifier", "poll")
defer wg.Done() defer trap.Close()
n := notify.New(cond, testCase.Countdown...)
defer n.Close() n := notify.New(cond, testCase.PollInterval, testCase.Countdown, notify.WithTestClock(mClock))
n.Poll(ch) defer n.Close()
}()
wg.Add(1) trap.MustWait(ctx).Release() // ensure ticker started
for _, tick := range testCase.Ticks { for i := 0; i < testCase.NTicks; i++ {
ch <- tick interval, w := mClock.AdvanceNext()
w.MustWait(ctx)
require.Equal(t, testCase.PollInterval, interval)
} }
close(ch)
wg.Wait() require.Equal(t, testCase.NumCallbacks, numCalls)
require.Equal(t, testCase.NumCallbacks, numCalls.Load()) require.Equal(t, testCase.NumConditions, numConditions)
require.Equal(t, testCase.NumConditions, numConditions.Load())
}) })
} }
} }
@ -111,14 +121,6 @@ func durations(ds ...time.Duration) []time.Duration {
return ds return ds
} }
func fakeTicker(t time.Time, d time.Duration, n int) []time.Time {
var ts []time.Time
for i := 1; i <= n; i++ {
ts = append(ts, t.Add(time.Duration(n)*d))
}
return ts
}
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) goleak.VerifyTestMain(m)
} }