diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..0842a01 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @TanmoySG \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3b735ec..3e1696d 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ # Go workspace file go.work + +# macOS junk +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 1d61846..7afdbb7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,173 @@ -# go-steps -[WIP] A Go Library for executing functions-as-steps + + +# GoSteps + +GoSteps is a go library that helps in running functions as steps and reminds you to step out and get active (kidding!). + +

+ Sublime's custom image +

+ +The idea behind `gosteps` is to define set of functions as steps-chain (kind of a linked list) and execute them in a sequential fashion by piping output (other than error) from previous step, as arguments, into the next steps (not necessarily using the args). + +## Usage + +The `Step` type contains the requirments to execute a step function and move to next one. + +```go +type Step struct { + Name StepName + Function interface{} + AdditionalArgs []interface{} + NextSteps []Step + NextStepResolver interface{} + ErrorsToRetry []error + StrictErrorCheck bool + SkipRetry bool + MaxAttempts int + RetrySleep time.Duration +} + +``` + +| Field | Description | +|------------------|--------------------------------------------------------------------------------------------------------------------------| +| Name | Name of step | +| Function | The function to execute | +| AdditionalArgs | any additional arguments need to pass to te step | +| NextSteps | Candidate functions for next step (multiple next steps in-case of condition based execution) | +| NextStepResolver | A function that returns the step name, based on conditions, that is used to pick the nextStep from NextSteps | +| ErrorsToRetry | A list of error to retry step for | +| StrictErrorCheck | If set to `true` exact error is matched, else only presence of error is checked | +| SkipRetry | If set to `true` step is not retried for any error | +| MaxAttempts | Max attempts are the number of times the step is tried (first try + subsequent retries). If not set, it'll run 100 times | +| RetrySleep | Sleep duration (type time.Duration) between each re-attempts | + +### Defining Steps + +To define steps, use the `gosteps.Steps` type and link the next steps in the `NextSteps` field as follows + +```go +var steps = gosteps.Steps{ + { + Name: "add", + Function: funcs.Add, + AdditionalArgs: []interface{}{2, 3}, + NextSteps: gosteps.Steps{ + { + Name: "sub", + Function: funcs.Sub, + AdditionalArgs: []interface{}{4}, + }, + }, + }, +} +``` + +Here the first step is `Add` and next step (and final) is `Sub`, so the output of Add is piped to Sub and that gives the final output. + +### Conditional Steps + +Some steps might have multiple candidates for next step and the executable next step is to be picked based on the output of the current step. To do so, steps with multiple next step candidates must use the `NextStepResolver` field passing a resolver function that returns the Name of the function to use as next step. + +The resolver function should be of type `func(args ...any) string`, where `args` are the output of current step and returned string is the name of the step to use. + +```go +func nextStepResolver(args ...any) string { + if args[0].(int) < 5 { + fmt.Printf("StepResolver [%v]: Arguments is Negative, going with Multiply\n", args) + return "add" + } + + fmt.Printf("StepResolver [%v]: Arguments is Positive, going with Divide\n", args) + return "sub" +} +``` + +### Executing Steps + +To execute steps use the `Execute(initArgs ...any)` method, passing the (optional) initializing arguments. + +```go +import ( + gosteps "github.com/TanmoySG/go-steps" + funcs "github.com/TanmoySG/go-steps/example/funcs" +) + +func main() { + initArgs := []interface{}{1, 2} + finalOutput, err := steps.Execute(initArgs...) + if err != nil { + fmt.Printf("error executing steps: %s, final output: [%s]\n", err, finalOutput) + } + + fmt.Printf("Final Output: [%v]\n", finalOutput) +} +``` + +### Retrying for Error + +To retry a step for particular erors, use the `ErrorsToRetry` field passing the list of errors. To make sure the error matches exactly as that of the Errors to retry, pass `true` for the `StrictErrorCheck` field, otherwise only error-substring presense will be checked. + +```go +{ + ErrorsToRetry: []error{ + fmt.Errorf("error to retry"), + }, + StrictErrorCheck: true, + MaxAttempts: 5, + RetrySleep: 1 * time.Second, +} +``` + +To skip retry on error pass `true` to the `SkipRetry` field. + +Additionally, + +- To limit the number of tries, use the `MaxAttempts` field, passing the number of max tries. If not set then Default Max Attempts (of 100 tries) is used. To avoid infinite runs due to the MaxAttempts not being set, we're keeping the default attempts to 100. If required, import and use the `gosteps.MaxMaxAttempts`. Please note that the Max value is `9223372036854775807`, which is not infinite but a huge number of attempts, please use cautiously. +- To add sleep between each attempts, use the `RetrySleep` parameter passing the duration of sleep (of type time.Duration) like `2 * time.Second`. + +## Constraints + +To keep the step execution same, all step functions must be of type `func(args ..any) ([]interface{}, error)` + +Here all arguments passed to the step function need to be type asserted within the step function, as + +```go +func Multiply(args ...any) ([]interface{}, error) { + return []interface{}{args[0].(int) * args[1].(int)}, nil +} +``` + +The step function must also return all parameters (other than error) as type `[]interface{ret1, ret2, ...}` and error must be returned for all functions even if nil. + +### Help Wanted + +If you want to help fix the above constraint or other bugs/issues, feel free to raise an Issue or Pull Request with the changes. It'd be an immense help! + +## Example + +In [this example](./example/main.go), we've used a set of complex steps with conditional step and retry. The flow of the same is + +![flow](./example/diag.png) + +Execute the example steps + +```sh +go run example/main.go + +// output +Adding [1 2] +Sub [3 4] +StepResolver [[-1]]: Arguments is Negative, going with Multiply +Multiply [-1 -5] +Adding [5 100] +Running fake error function for arg [[105]] +Running fake error function for arg [[105]] +Running fake error function for arg [[105]] +Running fake error function for arg [[105]] +Multiply [3150 5250] +Final Output: [[16537500]] +``` + +*Please Note:* This example flow might change and can be out-of-date. Please check the example. \ No newline at end of file diff --git a/example/diag.png b/example/diag.png new file mode 100644 index 0000000..af39f13 Binary files /dev/null and b/example/diag.png differ diff --git a/example/funcs/funcs.go b/example/funcs/funcs.go new file mode 100644 index 0000000..3d58685 --- /dev/null +++ b/example/funcs/funcs.go @@ -0,0 +1,42 @@ +package funcs + +import "fmt" + +var itr int = 1 + +func Add(args ...any) ([]interface{}, error) { + fmt.Printf("Adding %v\n", args) + return []interface{}{args[0].(int) + args[1].(int)}, nil +} + +func Sub(args ...any) ([]interface{}, error) { + fmt.Printf("Sub %v\n", args) + return []interface{}{args[0].(int) - args[1].(int)}, nil +} + +func Multiply(args ...any) ([]interface{}, error) { + fmt.Printf("Multiply %v\n", args) + return []interface{}{args[0].(int) * args[1].(int)}, nil +} + +func Divide(args ...any) ([]interface{}, error) { + fmt.Printf("Divide %v\n", args) + return []interface{}{args[0].(int) / args[1].(int)}, nil +} + +// Step will error 3times and return arg*30 and arg*31 on the 4th try +func StepWillError3Times(args ...any) ([]interface{}, error) { + fmt.Printf("Running fake error function for arg [%v]\n", args) + if itr == 3 { + return []interface{}{args[0].(int) * 30, args[0].(int) * 50}, nil + } + + itr += 1 + return nil, fmt.Errorf("error to retry") +} + +// Step will error infinitely +func StepWillErrorInfinitely(args ...any) ([]interface{}, error) { + fmt.Printf("Running infinite fake error function for arg [%v]\n", args) + return nil, fmt.Errorf("error to retry") +} diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..39e6e57 --- /dev/null +++ b/example/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "fmt" + "time" + + gosteps "github.com/TanmoySG/go-steps" + "github.com/TanmoySG/go-steps/example/funcs" +) + +const ( + stepMultiply = "Multiply" + stepDivide = "Divide" +) + +// reading/maintaining this is a bit tricky will add +// a functional way to create this in the next version +var steps = gosteps.Steps{ + { + Function: funcs.Add, + NextSteps: gosteps.Steps{ + { + Function: funcs.Sub, + AdditionalArgs: []interface{}{4}, + NextStepResolver: nextStepResolver, + NextSteps: gosteps.Steps{ + { + Name: stepMultiply, + Function: funcs.Multiply, + AdditionalArgs: []interface{}{-5}, + NextSteps: gosteps.Steps{ + { + Function: funcs.Add, + AdditionalArgs: []interface{}{100}, + NextSteps: gosteps.Steps{ + { + Function: funcs.StepWillError3Times, + ErrorsToRetry: []error{ + fmt.Errorf("error"), + }, + NextSteps: gosteps.Steps{ + { + Function: funcs.StepWillErrorInfinitely, + ErrorsToRetry: []error{ + fmt.Errorf("error"), + }, + NextSteps: gosteps.Steps{ + { + Function: funcs.Multiply, + }, + }, + StrictErrorCheck: true, + MaxAttempts: 5, // use gosteps.MaxMaxAttempts for Maximum Possible reattempts + }, + }, + MaxAttempts: 5, + RetrySleep: 1 * time.Second, + }, + }, + }, + }, + }, + { + Name: stepDivide, + Function: funcs.Divide, + AdditionalArgs: []interface{}{-2}, + }, + }, + }, + }, + }, +} + +func main() { + initArgs := []interface{}{1, 2} + finalOutput, err := steps.Execute(initArgs...) + if err != nil { + fmt.Printf("error executing steps: %s, final output: [%s]\n", err, finalOutput) + } + + fmt.Printf("Final Output: [%v]\n", finalOutput) +} + +// step resolver +func nextStepResolver(args ...any) string { + if args[0].(int) < 0 { + fmt.Printf("StepResolver [%v]: Arguments is Negative, going with Multiply\n", args) + return stepMultiply + } + + fmt.Printf("StepResolver [%v]: Arguments is Positive, going with Divide\n", args) + return stepDivide +} diff --git a/go-steps.go b/go-steps.go new file mode 100644 index 0000000..6f3abbe --- /dev/null +++ b/go-steps.go @@ -0,0 +1,148 @@ +package gosteps + +import ( + "fmt" + "math" + "strings" + "time" +) + +const ( + // to avoid infinite runs due to the MaxAttempts not being set, we're keeping the default attempts to 100 + // if required, import and use the MaxMaxAttempts in the step.MaxAttempts field + DefaultMaxAttempts = 100 + + // the Max value is 9223372036854775807, which is not infinite but a huge number of attempts + MaxMaxAttempts = math.MaxInt +) + +var ( + unresolvedStepError = "error: step [%s] is unresolved, no step found with this name." +) + +type StepName string + +type Step struct { + Name StepName + Function interface{} + AdditionalArgs []interface{} + NextSteps []Step + NextStepResolver interface{} + ErrorsToRetry []error + StrictErrorCheck bool + SkipRetry bool + MaxAttempts int + RetrySleep time.Duration +} + +type Steps []Step + +func (steps Steps) Execute(initArgs ...any) ([]interface{}, error) { + // final output for steps execution + var finalOutput []interface{} + + // initialize step output and step error + var stepOutput []interface{} + var stepError error + + // entry step + var isEntryStep bool = true + step := steps[0] + + // step reattepts + var stepReAttemptsLeft int = step.MaxAttempts + + for { + // piping output from previous step as arguments for current step + stepArgs := []interface{}{} + stepArgs = append(stepArgs, stepOutput...) + + // only runs for first step in steps + if isEntryStep { + stepArgs = initArgs + isEntryStep = false + } + + // piping additional arguments as arguments for current step + stepArgs = append(stepArgs, step.AdditionalArgs...) + + // execute current step passing step arguments + stepOutput, stepError = step.Function.(func(...interface{}) ([]interface{}, error))(stepArgs...) + if stepError != nil { + if !step.SkipRetry && step.shouldRetry(stepError) && stepReAttemptsLeft > 0 { + // piping args as output for re-running same step + stepOutput = stepArgs + + // decrementing re-attempts left for current run + stepReAttemptsLeft -= 1 + + // sleep step.RetrySleep duration if set + if step.RetrySleep > 0 { + time.Sleep(step.RetrySleep) + } + + continue + } + + // skip retry as step error not retryable + // return output of previous step and error + return stepArgs, stepError + } + + // no next step, this is the final step + if step.NextSteps == nil { + finalOutput = stepOutput + return finalOutput, nil + } + + // if there are multiple next steps but no resolver, + // first one in the list is considered as next step + var nextStep Step = step.NextSteps[0] + + // next step is dependant on conditions + if step.NextSteps != nil && step.NextStepResolver != nil { + nextStepName := step.NextStepResolver.(func(...interface{}) string)(stepOutput...) + resolvedStep := step.resolveNextStep(StepName(nextStepName)) + if resolvedStep == nil { + return stepOutput, fmt.Errorf(unresolvedStepError, step.Name) + } + + nextStep = *resolvedStep + } + + // set step as resolved or default nextStep + step = nextStep + + // if step.MaxAttempts is not set, set default max value + if step.MaxAttempts < 1 { + step.MaxAttempts = DefaultMaxAttempts + } + + // reset step re-attempts + stepReAttemptsLeft = step.MaxAttempts - 1 + } +} + +// should retry for error +func (step Step) shouldRetry(err error) bool { + for _, errorToRetry := range step.ErrorsToRetry { + if step.StrictErrorCheck && err.Error() == errorToRetry.Error() { + return true + } else if !step.StrictErrorCheck && strings.Contains(err.Error(), errorToRetry.Error()) { + return true + } + } + + return false +} + +// resolve next step by step name +func (step Step) resolveNextStep(stepName StepName) *Step { + for _, nextStep := range step.NextSteps { + if nextStep.Name == stepName { + return &nextStep + } + } + + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c55a2ce --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/TanmoySG/go-steps + +go 1.18