Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fxtest: Enforceable Timeouts on Lifecycle #1203

Merged
merged 4 commits into from
May 29, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions fxtest/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,34 +66,86 @@ func (t *panicT) FailNow() {
panic("test lifecycle failed")
}

// LifecycleOption modifies the behavior of the [Lifecycle]
// when passed to [NewLifecycle].
type LifecycleOption interface {
apply(*Lifecycle)
}

// EnforceTimeout will cause the [Lifecycle]'s Start and Stop methods
// to return an error as soon as context expires,
// regardless of whether specific hooks respect the timeout.
func EnforceTimeout(enforce bool) LifecycleOption {
return &enforceTimeout{
enforce: enforce,
}
}

type enforceTimeout struct {
enforce bool
}

func (e *enforceTimeout) apply(lc *Lifecycle) {
lc.enforceTimeout = e.enforce
}

var _ LifecycleOption = (*enforceTimeout)(nil)

// Lifecycle is a testing spy for fx.Lifecycle. It exposes Start and Stop
// methods (and some test-specific helpers) so that unit tests can exercise
// hooks.
type Lifecycle struct {
t TB
lc *lifecycle.Lifecycle

enforceTimeout bool
}

var _ fx.Lifecycle = (*Lifecycle)(nil)

// NewLifecycle creates a new test lifecycle.
func NewLifecycle(t TB) *Lifecycle {
func NewLifecycle(t TB, opts ...LifecycleOption) *Lifecycle {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strictly speaking, this is considered a breaking change per Go compatibility guarantees.
Adding variadic options to a function that didn't previously accept them still changes the signature.
It breaks cases like passing a reference to the function, for example.

I'll defer to y'all about whether you'd like to take the risk, since passing fastest.NewLifecycle would be a pretty niche use.

Alternatively, we can add one of NewLifecycleWith, NewLifecycleWithOptions, or NewStrictLifecycle instead. (The last defaulting to enforceTimeout = true. In that case, we could drop the EnforceTimeout option completely, and instead just add Options for future expansion.)

Copy link
Contributor Author

@JacobOaks JacobOaks May 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah - makes sense. I did consider that it is technically a breaking change, but I'm assuming that the impact radius is minimal/non-existent. To justify this assumption, I did run Uber's entire unit test suite with this PR and did not see any issues, so I'm personally comfortable with this change to avoid adding multiple NewLifecycleX APIs. Especially since any issues that do exist will (or at least, should) be isolated to test code.

That said, I'm open to adding a new API instead if somebody feels strongly about this.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel strongly about this; will defer to other maintainers.
I think this is fine as-is. Passing fxtest.NewLifecycle as a function parameter is a niche enough use case that I expect zero breakage.

This isn't authoritative, but just searching public code on GitHub, there are no references to fxtest.NewLifecycle not followed by a (, so they're always invocations, and won't be broken.
https://github.com/search?q=%2Ffxtest%5C.NewLifecycle%5B%5E%5C%28%5D%2F+path%3A%2F.go%24%2F&type=code

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 @sywhang or @tchung1118 - do you have strong opinions on this? Otherwise I will merge this as-is.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine as-is, too.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a strong opinion against this either but I do prefer making a slightly breaking change in this API rather than overloading the API surface with a variant of NewLifecycle given how niche the set of use cases that break from this.

var w io.Writer
if t != nil {
w = testutil.WriteSyncer{T: t}
} else {
w = os.Stderr
t = &panicT{W: os.Stderr}
}
return &Lifecycle{
lc := &Lifecycle{
lc: lifecycle.New(fxlog.DefaultLogger(w), fxclock.System),
t: t,
}
for _, opt := range opts {
opt.apply(lc)
}
return lc
}

func (l *Lifecycle) withTimeout(ctx context.Context, fn func(context.Context) error) error {
if !l.enforceTimeout {
return fn(ctx)
}

c := make(chan error, 1)
JacobOaks marked this conversation as resolved.
Show resolved Hide resolved
go func() {
c <- fn(ctx)
}()
Comment on lines +136 to +138
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last thing: Just for the sake of easier/faster cleanup, we should wrap the context with a WithCancel here so that when this function returns because of a timeout, the context that fn(ctx) is operating on is also closed, and the function returns early (if possible).

Suggested change
go func() {
c <- fn(ctx)
}()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
c <- fn(ctx)
}()

Copy link
Contributor Author

@JacobOaks JacobOaks May 29, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, shouldn't the context that fn is operating on already be closed if its deadline is exceeded? Or are you referring to a case where fn is only returning early when canceled and not exceeded deadline for some reason? I can add this to cover this case.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 You're right. My mistake.
The only way this function returns early is when the context has expired.


var err error
select {
case err = <-c:
case <-ctx.Done():
err = ctx.Err()
}
return err
}

// Start executes all registered OnStart hooks in order, halting at the first
// hook that doesn't succeed.
func (l *Lifecycle) Start(ctx context.Context) error { return l.lc.Start(ctx) }
func (l *Lifecycle) Start(ctx context.Context) error {
return l.withTimeout(ctx, l.lc.Start)
}

// RequireStart calls Start with context.Background(), failing the test if an
// error is encountered.
Expand All @@ -114,7 +166,9 @@ func (l *Lifecycle) RequireStart() *Lifecycle {
// If any hook returns an error, execution continues for a best-effort
// cleanup. Any errors encountered are collected into a single error and
// returned.
func (l *Lifecycle) Stop(ctx context.Context) error { return l.lc.Stop(ctx) }
func (l *Lifecycle) Stop(ctx context.Context) error {
return l.withTimeout(ctx, l.lc.Stop)
}

// RequireStop calls Stop with context.Background(), failing the test if an error
// is encountered.
Expand Down
97 changes: 97 additions & 0 deletions fxtest/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"errors"
"fmt"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -117,6 +118,102 @@ func TestLifecycle(t *testing.T) {
})
}

func TestEnforceTimeout(t *testing.T) {
// These tests directly call Start and Stop
// rather than RequireStart and RequireStop
// because EnforceTimeout does not apply to those.

t.Run("StartHookTimeout", func(t *testing.T) {
t.Parallel()

wait := make(chan struct{})
defer close(wait) // force timeout by blocking OnStart until end of test

spy := newTB()
abhinav marked this conversation as resolved.
Show resolved Hide resolved
lc := NewLifecycle(spy, EnforceTimeout(true))
lc.Append(fx.Hook{
OnStart: func(context.Context) error {
<-wait
return nil
},
})

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
assert.ErrorIs(t, lc.Start(ctx), context.DeadlineExceeded)
assert.Zero(t, spy.failures)
})

t.Run("StopHookTimeout", func(t *testing.T) {
t.Parallel()

wait := make(chan struct{})
defer close(wait) // force timeout by blocking OnStop until end of test

spy := newTB()
lc := NewLifecycle(spy, EnforceTimeout(true))
lc.Append(fx.Hook{
OnStop: func(context.Context) error {
<-wait
return nil
},
})

require.NoError(t, lc.Start(context.Background()))
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
assert.ErrorIs(t, lc.Stop(ctx), context.DeadlineExceeded)
assert.Zero(t, spy.failures)
})

t.Run("NoTimeout", func(t *testing.T) {
t.Parallel()

var (
started bool
stopped bool
)

spy := newTB()
lc := NewLifecycle(spy, EnforceTimeout(true))
lc.Append(fx.Hook{
OnStart: func(context.Context) error {
started = true
return nil
},
OnStop: func(context.Context) error {
stopped = true
return nil
},
})

ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
require.NoError(t, lc.Start(ctx))
require.NoError(t, lc.Stop(ctx))
assert.True(t, started)
assert.True(t, stopped)
assert.Zero(t, spy.failures)
})

t.Run("OtherError", func(t *testing.T) {
t.Parallel()

spy := newTB()
lc := NewLifecycle(spy, EnforceTimeout(true))
lc.Append(fx.Hook{
OnStart: func(context.Context) error {
return errors.New("NOT a context-related error")
},
})

ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
defer cancel()
assert.ErrorContains(t, lc.Start(ctx), "NOT a context-related error")
assert.Zero(t, spy.failures)
})
}

func TestLifecycle_OptionalT(t *testing.T) {
t.Parallel()

Expand Down