Skip to content

Commit

Permalink
Merge pull request #55 from ccremer/recorder
Browse files Browse the repository at this point in the history
Add a pipeline recording feature to resolve dependencies for Steps
  • Loading branch information
ccremer committed Aug 14, 2022
2 parents ca3116e + 145e3b2 commit 51a4fef
Show file tree
Hide file tree
Showing 7 changed files with 497 additions and 2 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ clean: ## Clean the project

.PHONY: test
test: ## Run unit tests
@go test -race -coverprofile cover.out -covermode atomic -count 1 -v ./...
@go test -race -coverprofile cover.out -covermode atomic -count 1 -tags=examples ./...
74 changes: 74 additions & 0 deletions examples/dependency_recorder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//go:build examples

package examples

import (
"context"
"fmt"
"testing"

pipeline "github.com/ccremer/go-command-pipeline"
)

type ClientContext struct {
context.Context
client *Client
createClientFn pipeline.ActionFunc[*ClientContext]
recorder *pipeline.DependencyRecorder[*ClientContext]
}

func TestExample_DependencyRecorder(t *testing.T) {
token := "someverysecuretoken"
ctx := &ClientContext{Context: context.Background(), recorder: pipeline.NewDependencyRecorder[*ClientContext]()}
ctx.createClientFn = createClientFn(token)
pipe := pipeline.NewPipeline[*ClientContext]().WithBeforeHooks(ctx.recorder.Record)
pipe.WithSteps(
pipe.NewStep("create client", ctx.createClientFn),
pipe.NewStep("connect to API", connect),
pipe.NewStep("get resource", getResource),
)
err := pipe.RunWithContext(ctx)
if err != nil {
t.Fatal(err)
}
}

// Client is some sort of client used to connect with some API.
type Client struct {
Token string
}

func (c *Client) Connect() error {
// some logic connect to API using token
return nil
}

func (c *Client) GetResource() (string, error) {
// some logic to get a resource from API, let's assume this is only possible after calling Connect().
// (arguably bad design for a client but let's roll with it for demo purposes)
return "resource", nil
}

func createClientFn(token string) func(ctx *ClientContext) error {
return func(ctx *ClientContext) error {
ctx.client = &Client{Token: token}
return nil
}
}

func connect(ctx *ClientContext) error {
// we need to check first if the client has been created, otherwise the client remains 'nil' and we'd run into a Nil pointer panic.
// This allows us to declare a certain order for the steps at compile time, and checking them at runtime.
if err := ctx.recorder.RequireDependencyByFuncName(ctx.createClientFn); err != nil {
return err
}
return ctx.client.Connect()
}

func getResource(ctx *ClientContext) error {
// We can check for preconditions more easily
ctx.recorder.MustRequireDependencyByFuncName(connect, ctx.createClientFn)
resource, err := ctx.client.GetResource()
fmt.Println(resource)
return err
}
1 change: 1 addition & 0 deletions examples/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
func TestExample_Hooks(t *testing.T) {
p := pipeline.NewPipeline[context.Context]()
p.WithBeforeHooks(func(step pipeline.Step[context.Context]) {
// Hooks can be used for logging purposes.
fmt.Println(fmt.Sprintf("Entering step: %s", step.Name))
})
p.WithSteps(
Expand Down
39 changes: 39 additions & 0 deletions pipelinetest/dummyrecorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package pipelinetest

import (
"context"

pipeline "github.com/ccremer/go-command-pipeline"
)

// NewNoResolver returns a pipeline.DependencyResolver that doesn't actually resolve anything.
// This can be used for testing.
func NewNoResolver[T context.Context]() pipeline.DependencyResolver[T] {
return &NoResolver[T]{}
}

// NoResolver is a pipeline.DependencyResolver that doesn't actually resolve anything.
// This can be used for testing.
type NoResolver[T context.Context] struct{}

func (d NoResolver[T]) Record(_ pipeline.Step[T]) {
// noop
}

func (d NoResolver[T]) RequireDependencyByStepName(_ ...string) error {
// noop
return nil
}

func (d NoResolver[T]) MustRequireDependencyByStepName(_ ...string) {
// noop
}

func (d NoResolver[T]) RequireDependencyByFuncName(_ ...pipeline.ActionFunc[T]) error {
// noop
return nil
}

func (d NoResolver[T]) MustRequireDependencyByFuncName(_ ...pipeline.ActionFunc[T]) {
// noop
}
169 changes: 169 additions & 0 deletions recorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package pipeline

import (
"context"
"fmt"
"reflect"
"runtime"
"strings"
)

// Recorder Records the steps executed in a pipeline.
type Recorder[T context.Context] interface {
// Record adds the step to the execution Records.
Record(step Step[T])
}

// DependencyResolver provides means to query if a pipeline Step is satisfied as a dependency for another Step.
// It is used together with Recorder.
type DependencyResolver[T context.Context] interface {
Recorder[T]
// RequireDependencyByStepName checks if any of the given step names are present in the Records.
// It returns nil if all given step names are in the Records in any order.
RequireDependencyByStepName(stepNames ...string) error
// MustRequireDependencyByStepName is RequireDependencyByStepName but any non-nil errors result in a panic.
MustRequireDependencyByStepName(stepNames ...string)
// RequireDependencyByFuncName checks if any of the given action functions are present in the Records.
// It returns nil if all given functions are in the Records in any order.
// Since functions aren't comparable for equality, the resolver attempts to compare them by name through reflection.
RequireDependencyByFuncName(actions ...ActionFunc[T]) error
// MustRequireDependencyByFuncName is RequireDependencyByFuncName but any non-nil errors result in a panic.
MustRequireDependencyByFuncName(actions ...ActionFunc[T])
}

// DependencyRecorder is a Recorder and DependencyResolver that tracks each Step executed and can be used to query if certain steps are in the Records.
type DependencyRecorder[T context.Context] struct {
// Records contains a slice of Steps that were run.
// It contains also the last Step that failed with an error.
Records []Step[T]
}

// NewDependencyRecorder returns a new instance of DependencyRecorder.
func NewDependencyRecorder[T context.Context]() *DependencyRecorder[T] {
return &DependencyRecorder[T]{Records: []Step[T]{}}
}

// Record implements Recorder.
func (s *DependencyRecorder[T]) Record(step Step[T]) {
s.Records = append(s.Records, step)
}

// RequireDependencyByStepName implements DependencyResolver.RequireDependencyByStepName.
// A DependencyError is returned with a list of names that aren't in the Records.
// Steps that share the same name are not distinguishable.
func (s *DependencyRecorder[T]) RequireDependencyByStepName(stepNames ...string) error {
if len(stepNames) == 0 {
return nil
}
missing := make([]string, 0)
for _, desiredName := range stepNames {
found := false
for _, step := range s.Records {
if step.Name == desiredName {
found = true
break
}
}
if !found {
missing = append(missing, desiredName)
}
}
if len(missing) == 0 {
return nil
}
return fmt.Errorf("%w", &DependencyError{MissingSteps: missing})
}

// MustRequireDependencyByStepName implements DependencyResolver.MustRequireDependencyByStepName.
func (s *DependencyRecorder[T]) MustRequireDependencyByStepName(stepNames ...string) {
err := s.RequireDependencyByStepName(stepNames...)
if err != nil {
panic(err)
}
}

// RequireDependencyByFuncName implements DependencyResolver.RequireDependencyByFuncName.
//
// Direct function pointers can easily be compared:
//
// func myFunc(ctx context.Context) error {
// return nil
// }
// ...
// pipe.AddStep("test", myFunc)
// ...
// recorder.RequireDependencyByFuncName(myFunc)
//
// Note that you may experience unexpected behaviour when dealing with generative functions.
// For example, the following snippet will not work, since the function names from 2 different call locations are different:
//
// generateFunc() func(ctx context.Context) error {
// return func(ctx context.Context) error {
// return nil
// }
// }
// ...
// pipe.AddStep("test", generateFunc())
// ...
// recorder.RequireDependencyByFuncName(generateFunc()) // will end in an error
//
// As an alternative, you may store the generated function in a variable that is accessible from multiple locations:
//
// var genFunc = generateFunc()
// ...
// pipe.AddStep("test", genFunc())
// ...
// recorder.RequireDependencyByFuncName(genFunc()) // works
func (s *DependencyRecorder[T]) RequireDependencyByFuncName(actions ...ActionFunc[T]) error {
if len(actions) == 0 {
return nil
}
missing := make([]string, 0)
for _, desiredAction := range actions {
found := false
desiredActionName := getFunctionName(desiredAction)
for _, step := range s.Records {
actionName := getFunctionName(step.Action)
if actionName == desiredActionName {
found = true
break
}
}
if !found {
missing = append(missing, desiredActionName)
}
}
if len(missing) == 0 {
return nil
}
return fmt.Errorf("%w", &DependencyError{MissingSteps: missing})
}

// MustRequireDependencyByFuncName implements DependencyResolver.MustRequireDependencyByFuncName.
func (s *DependencyRecorder[T]) MustRequireDependencyByFuncName(actions ...ActionFunc[T]) {
err := s.RequireDependencyByFuncName(actions...)
if err != nil {
panic(err)
}
}

func getFunctionName(temp interface{}) string {
value := reflect.ValueOf(temp)
if value.Kind() != reflect.Func {
panic(fmt.Errorf("given value is not a function: %v", temp))
}
strs := runtime.FuncForPC(value.Pointer()).Name()
return strs
}

// DependencyError is an error that indicates which steps did not satisfy dependency requirements.
type DependencyError struct {
// MissingSteps returns a slice of Step or ActionFunc names.
MissingSteps []string
}

// Error returns a stringed list of steps that did not run either by Step or ActionFunc name.
func (d *DependencyError) Error() string {
joined := strings.Join(d.MissingSteps, ", ")
return fmt.Sprintf("required steps did not run: [%s]", joined)
}

0 comments on commit 51a4fef

Please sign in to comment.