Skip to content

Commit

Permalink
story(issue-361): add interrupt on appbuilder middleware (#362)
Browse files Browse the repository at this point in the history
  • Loading branch information
Zaba505 authored Feb 13, 2025
1 parent 33ff6e7 commit 1f44488
Show file tree
Hide file tree
Showing 5 changed files with 132 additions and 5 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
[![Mentioned in Awesome Go](https://awesome.re/mentioned-badge.svg)](https://github.com/avelino/awesome-go)
[![Go Reference](https://pkg.go.dev/badge/github.com/z5labs/bedrock.svg)](https://pkg.go.dev/github.com/z5labs/bedrock)
[![Go Report Card](https://goreportcard.com/badge/github.com/z5labs/bedrock)](https://goreportcard.com/report/github.com/z5labs/bedrock)
![Coverage](https://img.shields.io/badge/Coverage-96.1%25-brightgreen)
![Coverage](https://img.shields.io/badge/Coverage-96.3%25-brightgreen)
[![build](https://github.com/z5labs/bedrock/actions/workflows/build.yaml/badge.svg)](https://github.com/z5labs/bedrock/actions/workflows/build.yaml)

**bedrock provides a minimal, modular and composable foundation for
Expand Down
28 changes: 28 additions & 0 deletions appbuilder/appbuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ package appbuilder
import (
"context"
"errors"
"os"
"os/signal"

"github.com/z5labs/bedrock"
"github.com/z5labs/bedrock/app"
Expand All @@ -20,6 +22,9 @@ import (
func Recover[T any](builder bedrock.AppBuilder[T]) bedrock.AppBuilder[T] {
return bedrock.AppBuilderFunc[T](func(ctx context.Context, cfg T) (_ bedrock.App, err error) {
defer try.Recover(&err)
if ctx.Err() != nil {
return nil, ctx.Err()
}

return builder.Build(ctx, cfg)
})
Expand All @@ -29,6 +34,10 @@ func Recover[T any](builder bedrock.AppBuilder[T]) bedrock.AppBuilder[T] {
// the given [bedrock.AppBuilder]s input type, T, from a [config.Source].
func FromConfig[T any](builder bedrock.AppBuilder[T]) bedrock.AppBuilder[config.Source] {
return bedrock.AppBuilderFunc[config.Source](func(ctx context.Context, src config.Source) (bedrock.App, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}

m, err := config.Read(src)
if err != nil {
return nil, err
Expand All @@ -51,6 +60,10 @@ func FromConfig[T any](builder bedrock.AppBuilder[T]) bedrock.AppBuilder[config.
// [bedrock.AppBuilder] fails.
func LifecycleContext[T any](builder bedrock.AppBuilder[T], lc *lifecycle.Context) bedrock.AppBuilder[T] {
return bedrock.AppBuilderFunc[T](func(ctx context.Context, cfg T) (bedrock.App, error) {
if ctx.Err() != nil {
return nil, ctx.Err()
}

ctx = lifecycle.NewContext(ctx, lc)
base, err := builder.Build(ctx, cfg)
if err != nil {
Expand All @@ -66,3 +79,18 @@ func LifecycleContext[T any](builder bedrock.AppBuilder[T], lc *lifecycle.Contex
return base, nil
})
}

// InterruptOn wraps a given [bedrock.AppBuilder] in an implementation
// that cancels the [context.Context] that's passed to builder.Build if an [os.Signal]
// is received by the running process. Once builder.Build completes the [os.Signal]
// listening is stopped. Thus, this middleware only applies to the given builder and does
// not wrap the returned [bedrock.App] with signal cancellation. For [bedrock.App] signal
// cancellation, please use the [app.InterruptOn] middleware.
func InterruptOn[T any](builder bedrock.AppBuilder[T], signals ...os.Signal) bedrock.AppBuilder[T] {
return bedrock.AppBuilderFunc[T](func(ctx context.Context, cfg T) (bedrock.App, error) {
sigCtx, stop := signal.NotifyContext(ctx, signals...)
defer stop()

return builder.Build(sigCtx, cfg)
})
}
61 changes: 61 additions & 0 deletions appbuilder/appbuilder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ import (

func TestRecover(t *testing.T) {
t.Run("will return an error", func(t *testing.T) {
t.Run("if the build context was cancelled before starting to build", func(t *testing.T) {
builder := bedrock.AppBuilderFunc[struct{}](func(ctx context.Context, cfg struct{}) (bedrock.App, error) {
return nil, nil
})

ctx, cancel := context.WithCancel(context.Background())
cancel()

_, err := Recover(builder).Build(ctx, struct{}{})
if !assert.ErrorIs(t, err, context.Canceled) {
return
}
})

t.Run("if the underlying App returns an error", func(t *testing.T) {
buildErr := errors.New("failed to build")
builder := Recover(bedrock.AppBuilderFunc[struct{}](func(ctx context.Context, cfg struct{}) (bedrock.App, error) {
Expand Down Expand Up @@ -75,6 +89,20 @@ func (f configSourceFunc) Apply(store config.Store) error {

func TestFromConfig(t *testing.T) {
t.Run("will return an error", func(t *testing.T) {
t.Run("if the build context was cancelled before starting to build", func(t *testing.T) {
builder := bedrock.AppBuilderFunc[struct{}](func(ctx context.Context, cfg struct{}) (bedrock.App, error) {
return nil, nil
})

ctx, cancel := context.WithCancel(context.Background())
cancel()

_, err := FromConfig(builder).Build(ctx, nil)
if !assert.ErrorIs(t, err, context.Canceled) {
return
}
})

t.Run("if the config source fails to apply to the config store", func(t *testing.T) {
applyErr := errors.New("failed to apply config")
cfgSrc := configSourceFunc(func(s config.Store) error {
Expand All @@ -95,6 +123,20 @@ func TestFromConfig(t *testing.T) {

func TestLifecycleContext(t *testing.T) {
t.Run("will return a single error", func(t *testing.T) {
t.Run("if the build context was cancelled before starting to build", func(t *testing.T) {
builder := bedrock.AppBuilderFunc[struct{}](func(ctx context.Context, cfg struct{}) (bedrock.App, error) {
return nil, nil
})

ctx, cancel := context.WithCancel(context.Background())
cancel()

_, err := LifecycleContext(builder, &lifecycle.Context{}).Build(ctx, struct{}{})
if !assert.ErrorIs(t, err, context.Canceled) {
return
}
})

t.Run("if the given AppBuilder fails to build and no post run hook is registered", func(t *testing.T) {
buildErr := errors.New("build failed")
builder := bedrock.AppBuilderFunc[struct{}](func(ctx context.Context, cfg struct{}) (bedrock.App, error) {
Expand Down Expand Up @@ -156,3 +198,22 @@ func TestLifecycleContext(t *testing.T) {
})
})
}

func TestInterruptOn(t *testing.T) {
t.Run("will propogate context cancellation", func(t *testing.T) {
t.Run("if the parent context is cancelled", func(t *testing.T) {
builder := InterruptOn(bedrock.AppBuilderFunc[struct{}](func(ctx context.Context, _ struct{}) (bedrock.App, error) {
<-ctx.Done()
return nil, ctx.Err()
}))

ctx, cancel := context.WithCancel(context.Background())
cancel()

_, err := builder.Build(ctx, struct{}{})
if !assert.ErrorIs(t, err, context.Canceled) {
return
}
})
})
}
17 changes: 13 additions & 4 deletions appbuilder/otel.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package appbuilder

import (
"context"
"errors"

"github.com/z5labs/bedrock"
"github.com/z5labs/bedrock/app"
Expand All @@ -25,12 +26,11 @@ type OTelInitializer interface {
// stops running.
func OTel[T OTelInitializer](builder bedrock.AppBuilder[T]) bedrock.AppBuilder[T] {
return bedrock.AppBuilderFunc[T](func(ctx context.Context, cfg T) (bedrock.App, error) {
err := cfg.InitializeOTel(ctx)
if err != nil {
return nil, err
if ctx.Err() != nil {
return nil, ctx.Err()
}

base, err := builder.Build(ctx, cfg)
err := cfg.InitializeOTel(ctx)
if err != nil {
return nil, err
}
Expand All @@ -41,6 +41,15 @@ func OTel[T OTelInitializer](builder bedrock.AppBuilder[T]) bedrock.AppBuilder[T
tryShutdown(global.GetLoggerProvider()),
)

base, err := builder.Build(ctx, cfg)
if err != nil {
shutdownErr := onPostRun.Run(ctx)
if shutdownErr == nil {
return nil, err
}
return nil, errors.Join(err, shutdownErr)
}

lc, ok := lifecycle.FromContext(ctx)
if !ok {
base = app.PostRun(base, onPostRun)
Expand Down
29 changes: 29 additions & 0 deletions appbuilder/otel_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,20 @@ func (loggerProviderInitOTel) InitializeOTel(ctx context.Context) error {

func TestOTel(t *testing.T) {
t.Run("bedrock.AppBuilder will return an error", func(t *testing.T) {
t.Run("if the build context was cancelled before starting to build", func(t *testing.T) {
builder := bedrock.AppBuilderFunc[noopInitOTel](func(ctx context.Context, cfg noopInitOTel) (bedrock.App, error) {
return nil, nil
})

ctx, cancel := context.WithCancel(context.Background())
cancel()

_, err := OTel(builder).Build(ctx, noopInitOTel{})
if !assert.ErrorIs(t, err, context.Canceled) {
return
}
})

t.Run("if InitializeOTel fails", func(t *testing.T) {
b := OTel(bedrock.AppBuilderFunc[failToInitOTel](func(ctx context.Context, cfg failToInitOTel) (bedrock.App, error) {
return nil, nil
Expand All @@ -141,6 +155,21 @@ func TestOTel(t *testing.T) {
return
}
})

t.Run("if the given bedrock.AppBuilder fails and it fails to shutdown the otel providers", func(t *testing.T) {
buildErr := errors.New("failed to build")
b := OTel(bedrock.AppBuilderFunc[tracerProviderInitOTel](func(ctx context.Context, cfg tracerProviderInitOTel) (bedrock.App, error) {
return nil, buildErr
}))

_, err := b.Build(context.Background(), tracerProviderInitOTel{})
if !assert.ErrorIs(t, err, buildErr) {
return
}
if !assert.ErrorIs(t, err, errTracerProviderFailedShutdown) {
return
}
})
})

t.Run("the built bedrock.App will return an error", func(t *testing.T) {
Expand Down

0 comments on commit 1f44488

Please sign in to comment.