diff --git a/README.md b/README.md index 0dbe750..19659a3 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,13 @@ if err := services.Start(ctx); err != nil { } ``` +You can combine sequential and parallel boot flows. For example, given services A, B, C and D. Where service A must be started first, then B and C can be started simultaneously and then D can be started only after A, B and C have started: + +```golang +services := boot.Sequentially(a, boot.Simultaneously(b, c), d) +err := services.Start(ctx) +``` + 5. Shutdown all services respecting a timeout ```golang @@ -66,13 +73,19 @@ if err := services.Stop(ctx); err != nil { } ``` -# Advanced usage +# Using of `Application` wrapper -You can combine sequential and parallel boot flows. For example, given services A, B, C and D. Where service A must be started first, then B and C can be started simultaneously and then D can be started only after A, B and C have started: +Almost every application is handling OS signals to trigger a shutdown. The framework provides this convenient wrapper, that allows you to build a complete graceful application: ```golang -services := boot.Sequentially(a, boot.Simultaneously(b, c), d) -err := services.Start(ctx) +func main() { + // creating services: s1, s2, s3 + services := boot.Sequentially(s1, s2, s3) + app := boot.NewApplicationForService(services) + if err := app.Run(context.Background(), 5 * time.Second); err != nil { + fmt.Println("application error:", err) + } +} ``` # License diff --git a/boot/application.go b/boot/application.go new file mode 100644 index 0000000..52bcef3 --- /dev/null +++ b/boot/application.go @@ -0,0 +1,52 @@ +package boot + +import ( + "context" + "os" + "os/signal" + "syscall" + "time" + + "go.uber.org/multierr" +) + +// Application represents an application that supports graceful boot/shutdown. +type Applicaiton interface { + // Start starts the application. This is a blocking, not thread-safe call. + // This method handles SIGTERM/SIGINT signals and automatically shutting down the app. + // The provided user context can be used to signal application to stop gracefully. + Run(ctx context.Context) error +} + +type application struct { + service Service + shutdownTimeout time.Duration +} + +var _ Applicaiton = (*application)(nil) + +// NewApplicationForService creates a new Application from the given (uber) Service, +// that is usually constructed with Sequentially or Simultaneously (or combination). +// A commonly recommended shutdownTimeout range is 5-15 seconds. +func NewApplicationForService(service Service, shutdownTimeout time.Duration) Applicaiton { + return &application{ + service: service, + shutdownTimeout: shutdownTimeout, + } +} + +func (a application) Run(ctx context.Context) error { + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + + startErr := a.service.Start(ctx) + if startErr == nil { + <-ctx.Done() + } + stop() + + ctx, cancel := context.WithTimeout(context.Background(), a.shutdownTimeout) + defer cancel() + + return multierr.Combine(startErr, a.service.Stop(ctx)) +} diff --git a/boot/application_test.go b/boot/application_test.go new file mode 100644 index 0000000..2862f0e --- /dev/null +++ b/boot/application_test.go @@ -0,0 +1,97 @@ +package boot_test + +import ( + "context" + "errors" + "syscall" + "testing" + "time" + + "github.com/pinebit/go-boot/boot" + "github.com/pinebit/go-boot/boot/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestApplication_Run(t *testing.T) { + t.Parallel() + + ctx := testContext(t) + s1 := mocks.NewService(t) + s2 := mocks.NewService(t) + s3 := mocks.NewService(t) + s1.On("Start", mock.Anything).Run(func(args mock.Arguments) { + s2.On("Start", mock.Anything).Run(func(args mock.Arguments) { + s3.On("Start", mock.Anything).Return(nil) + }).Return(nil) + }).Return(nil) + s3.On("Stop", mock.Anything).Run(func(args mock.Arguments) { + s2.On("Stop", mock.Anything).Run(func(args mock.Arguments) { + s1.On("Stop", mock.Anything).Return(nil) + }).Return(nil) + }).Return(nil) + services := boot.Sequentially(s1, s2, s3) + app := boot.NewApplicationForService(services, 5*time.Second) + + t.Run("shutting down due to SIGINT", func(t *testing.T) { + go func() { + <-time.After(100 * time.Millisecond) + syscall.Kill(syscall.Getpid(), syscall.SIGINT) + }() + err := app.Run(ctx) + + assert.NoError(t, err) + }) + + t.Run("shutting down due to SIGTERM", func(t *testing.T) { + go func() { + <-time.After(100 * time.Millisecond) + syscall.Kill(syscall.Getpid(), syscall.SIGTERM) + }() + err := app.Run(ctx) + + assert.NoError(t, err) + }) + + t.Run("shutting down due to user context", func(t *testing.T) { + uctx, cancel := context.WithCancel(ctx) + go func() { + <-time.After(100 * time.Millisecond) + cancel() + }() + err := app.Run(uctx) + + assert.NoError(t, err) + }) +} + +func TestApplication_StartError(t *testing.T) { + t.Parallel() + + startErr := errors.New("start") + ctx := testContext(t) + s1 := mocks.NewService(t) + s1.On("Start", mock.Anything).Return(startErr) + s1.On("Stop", mock.Anything).Return(nil) + app := boot.NewApplicationForService(s1, 5*time.Second) + + err := app.Run(ctx) + + assert.ErrorIs(t, err, startErr) +} + +func TestApplication_StopError(t *testing.T) { + t.Parallel() + + stopErr := errors.New("stop") + ctx, cancel := context.WithCancel(testContext(t)) + s1 := mocks.NewService(t) + s1.On("Start", mock.Anything).Return(nil) + s1.On("Stop", mock.Anything).Return(stopErr) + app := boot.NewApplicationForService(s1, 5*time.Second) + + cancel() + err := app.Run(ctx) + + assert.ErrorIs(t, err, stopErr) +}