├── .gitignore ├── LICENSE ├── README.md ├── example ├── http_server.go └── policies.go ├── go.mod ├── go.sum ├── options.go ├── rutina.go └── rutina_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexander Kiryukhin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rutina 2 | 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/neonxp/rutina.svg)](https://pkg.go.dev/github.com/neonxp/rutina) 4 | 5 | Package Rutina (russian "рутина" - ordinary boring everyday work) is routine orchestrator for your application. 6 | 7 | It seems like https://godoc.org/golang.org/x/sync/errgroup with some different: 8 | 9 | 1) propagates context to every routines. So routine can check if context stopped (`ctx.Done()`). 10 | 2) has flexible run/stop policy. i.e. one routine restarts when it errors (useful on daemons) but if errors another - all routines will be cancelled 11 | 3) already has optional signal handler `ListenOsSignals()` 12 | 13 | ## When it need? 14 | 15 | Usually, when your program consists of several routines (i.e.: http server, metrics server and os signals subscriber) and you want to stop all routines when one of them ends (i.e.: by TERM os signal in signal subscriber). 16 | 17 | ## Usage 18 | 19 | ### New instance 20 | 21 | With default options: 22 | 23 | ```go 24 | r := rutina.New() 25 | ``` 26 | 27 | or with custom options: 28 | 29 | ```go 30 | r := rutina.New( 31 | ParentContext(ctx context.Context), // Pass parent context to Rutina (otherwise it uses own new context) 32 | ListenOsSignals(listenOsSignals ...os.Signal), // Auto listen OS signals and close context on Kill, Term signal 33 | Logger(l logger), // Pass logger for debug, i.e. `log.Printf` 34 | Errors(errCh chan error), // Set errors channel for errors from routines in Restart/DoNothing errors policy 35 | ) 36 | ``` 37 | 38 | ### Start new routine 39 | 40 | ```go 41 | r.Go(func (ctx context.Context) error { 42 | ...do something... 43 | }) 44 | ``` 45 | 46 | #### Run Options 47 | 48 | ```go 49 | r.Go( 50 | func (ctx context.Context) error { 51 | ...do something... 52 | }, 53 | SetOnDone(policy Policy), // Run policy if returns no error (default: Shutdown) 54 | SetOnError(policy Policy), // Run policy if returns error (default: Shutdown) 55 | SetTimeout(timeout time.Duration), // Timeout to routine (after it context will be closed) 56 | SetMaxCount(maxCount int), // Max tries on Restart policy 57 | ) 58 | ``` 59 | 60 | #### Run policies 61 | 62 | * `DoNothing` - do not affect other routines 63 | * `Restart` - restart current routine 64 | * `Shutdown` - shutdown all routines 65 | 66 | #### Example of run policies 67 | 68 | ```go 69 | r.Go(func(ctx context.Context) error { 70 | // If this routine produce no error - all other routines will shutdown (because context cancels) 71 | // If it returns error - all other routines will shutdown (because context cancels) 72 | },) 73 | 74 | r.Go(func(ctx context.Context) error { 75 | // If this routine produce no error - it restarts 76 | // If it returns error - all other routines will shutdown (because context cancels) 77 | }, SetOnDone(rutina.Restart)) 78 | 79 | r.Go(func(ctx context.Context) error { 80 | // If this routine produce no error - all other routines will shutdown (because context cancels) 81 | // If it returns error - it will be restarted (maximum 10 times) 82 | }, SetOnError(rutina.Restart), SetMaxCount(10)) 83 | 84 | r.Go(func(ctx context.Context) error { 85 | // If this routine stopped by any case other routines will work as before. 86 | }, SetOnDone(rutina.DoNothing)) 87 | 88 | r.ListenOsSignals() // Shutdown all routines by OS signal 89 | ``` 90 | 91 | ### Wait routines to complete 92 | 93 | ```go 94 | err := r.Wait() 95 | ``` 96 | 97 | Here err = error that shutdowns all routines (may be will be changed at future) 98 | 99 | ### Kill routines 100 | 101 | ```go 102 | id := r.Go(func (ctx context.Context) error { ... }) 103 | ... 104 | r.Kill(id) // Closes individual context for #id routine that must shutdown it 105 | ``` 106 | 107 | ### List of routines 108 | 109 | ```go 110 | list := r.Processes() 111 | ``` 112 | 113 | Returns ids of working routines 114 | 115 | ### Get errors channel 116 | 117 | ```go 118 | err := <- r.Errors() 119 | ``` 120 | 121 | Disabled by default. Used when passed errors channel to rutina options 122 | 123 | ## Example 124 | 125 | HTTP server with graceful shutdown [`example/http_server.go`](https://github.com/NeonXP/rutina/blob/master/example/http_server.go) 126 | 127 | Different run policies [`example/policies.go`](https://github.com/NeonXP/rutina/blob/master/example/policies.go) 128 | -------------------------------------------------------------------------------- /example/http_server.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "io" 8 | "log" 9 | "net/http" 10 | "os" 11 | 12 | "github.com/neonxp/rutina/v3" 13 | ) 14 | 15 | func main() { 16 | // New instance with builtin context 17 | r := rutina.New(rutina.ListenOsSignals(os.Interrupt, os.Kill)) 18 | 19 | srv := &http.Server{Addr: ":8080"} 20 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 21 | io.WriteString(w, "hello world\n") 22 | }) 23 | 24 | // Starting http server and listen connections 25 | r.Go(func(ctx context.Context) error { 26 | if err := srv.ListenAndServe(); err != nil { 27 | return err 28 | } 29 | log.Println("Server stopped") 30 | return nil 31 | }) 32 | 33 | // Gracefully stopping server when context canceled 34 | r.Go(func(ctx context.Context) error { 35 | <-ctx.Done() 36 | log.Println("Stopping server...") 37 | return srv.Shutdown(ctx) 38 | }) 39 | 40 | if err := r.Wait(); err != nil { 41 | log.Fatal(err) 42 | } 43 | log.Println("All routines successfully stopped") 44 | } 45 | -------------------------------------------------------------------------------- /example/policies.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "log" 9 | "os" 10 | "time" 11 | 12 | "github.com/neonxp/rutina/v3" 13 | ) 14 | 15 | func main() { 16 | // New instance with builtin context 17 | r := rutina.New(rutina.Logger(log.Printf), rutina.ListenOsSignals(os.Interrupt, os.Kill)) 18 | 19 | r.Go(func(ctx context.Context) error { 20 | <-time.After(1 * time.Second) 21 | log.Println("Do something 1 second without errors and restart") 22 | return nil 23 | }) 24 | 25 | r.Go(func(ctx context.Context) error { 26 | <-time.After(2 * time.Second) 27 | log.Println("Do something 2 seconds without errors and do nothing") 28 | return nil 29 | }) 30 | 31 | r.Go(func(ctx context.Context) error { 32 | select { 33 | case <-time.After(time.Second): 34 | return errors.New("max 10 times") 35 | case <-ctx.Done(): 36 | return nil 37 | } 38 | }, rutina.OnError(rutina.Restart), rutina.MaxCount(10)) 39 | 40 | r.Go(func(ctx context.Context) error { 41 | select { 42 | case <-time.After(time.Second): 43 | return errors.New("max 10 seconds") 44 | case <-ctx.Done(): 45 | return nil 46 | } 47 | }, rutina.OnError(rutina.Restart), rutina.SetTimeout(10*time.Second)) 48 | 49 | if err := r.Wait(); err != nil { 50 | log.Fatal(err) 51 | } else { 52 | log.Println("Routines stopped") 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/neonxp/rutina 2 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neonxp/rutina/01f2df0f273ba3b488d1d3ce85c059db6e46d016/go.sum -------------------------------------------------------------------------------- /options.go: -------------------------------------------------------------------------------- 1 | package rutina 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "time" 7 | ) 8 | 9 | type Options struct { 10 | ParentContext context.Context 11 | ListenOsSignals []os.Signal 12 | Logger func(format string, v ...interface{}) 13 | Errors chan error 14 | } 15 | 16 | func ParentContext(ctx context.Context) Options { 17 | return Options{ 18 | ParentContext: ctx, 19 | } 20 | } 21 | 22 | func ListenOsSignals(signals ...os.Signal) Options { 23 | return Options{ 24 | ListenOsSignals: signals, 25 | } 26 | } 27 | 28 | func Logger(l logger) Options { 29 | return Options{ 30 | Logger: l, 31 | } 32 | } 33 | 34 | func Errors(errCh chan error) Options { 35 | return Options{ 36 | Errors: errCh, 37 | } 38 | } 39 | 40 | func composeOptions(opts []Options) Options { 41 | res := Options{ 42 | ParentContext: context.Background(), 43 | Logger: nopLogger, 44 | ListenOsSignals: []os.Signal{}, 45 | } 46 | for _, o := range opts { 47 | if o.ParentContext != nil { 48 | res.ParentContext = o.ParentContext 49 | } 50 | if o.Errors != nil { 51 | res.Errors = o.Errors 52 | } 53 | if o.ListenOsSignals != nil { 54 | res.ListenOsSignals = o.ListenOsSignals 55 | } 56 | if o.Logger != nil { 57 | res.Logger = o.Logger 58 | } 59 | } 60 | return res 61 | } 62 | 63 | type Policy int 64 | 65 | const ( 66 | DoNothing Policy = iota 67 | Shutdown 68 | Restart 69 | ) 70 | 71 | type RunOptions struct { 72 | OnDone Policy 73 | OnError Policy 74 | Timeout *time.Duration 75 | MaxCount *int 76 | } 77 | 78 | func OnDone(policy Policy) RunOptions { 79 | return RunOptions{ 80 | OnDone: policy, 81 | } 82 | } 83 | 84 | func OnError(policy Policy) RunOptions { 85 | return RunOptions{ 86 | OnError: policy, 87 | } 88 | } 89 | 90 | func Timeout(timeout time.Duration) RunOptions { 91 | return RunOptions{ 92 | Timeout: &timeout, 93 | } 94 | } 95 | 96 | func MaxCount(maxCount int) RunOptions { 97 | return RunOptions{ 98 | MaxCount: &maxCount, 99 | } 100 | } 101 | 102 | func composeRunOptions(opts []RunOptions) RunOptions { 103 | res := RunOptions{ 104 | OnDone: Shutdown, 105 | OnError: Shutdown, 106 | } 107 | for _, o := range opts { 108 | if o.OnDone != res.OnDone { 109 | res.OnDone = o.OnDone 110 | } 111 | if o.OnError != res.OnError { 112 | res.OnError = o.OnError 113 | } 114 | if o.MaxCount != nil { 115 | res.MaxCount = o.MaxCount 116 | } 117 | if o.Timeout != nil { 118 | res.Timeout = o.Timeout 119 | } 120 | } 121 | return res 122 | } 123 | -------------------------------------------------------------------------------- /rutina.go: -------------------------------------------------------------------------------- 1 | package rutina 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "os/signal" 8 | "sync" 9 | "sync/atomic" 10 | "syscall" 11 | "time" 12 | ) 13 | 14 | var ( 15 | ErrRunLimit = errors.New("rutina run limit") 16 | ErrTimeoutOrKilled = errors.New("rutina timeouted or killed") 17 | ErrProcessNotFound = errors.New("process not found") 18 | ErrShutdown = errors.New("shutdown") 19 | ) 20 | 21 | type logger func(format string, v ...interface{}) 22 | 23 | var nopLogger = func(format string, v ...interface{}) {} 24 | 25 | // Rutina is routine manager 26 | type Rutina struct { 27 | ctx context.Context // State of application (started/stopped) 28 | Cancel func() // Cancel func that stops all routines 29 | wg sync.WaitGroup // WaitGroup that wait all routines to complete 30 | onceErr sync.Once // Flag that prevents overwrite first error that shutdowns all routines 31 | onceWait sync.Once // Flag that prevents wait already waited rutina 32 | err error // First error that shutdowns all routines 33 | logger logger // Optional logger 34 | counter *uint64 // Optional counter that names routines with increment ids for debug purposes at logger 35 | errCh chan error // Optional channel for errors when RestartIfError and DoNothingIfError 36 | autoListenSignals []os.Signal // Optional listening os signals, default disabled 37 | processes map[uint64]*process 38 | mu sync.Mutex 39 | } 40 | 41 | // New instance with builtin context 42 | func New(opts ...Options) *Rutina { 43 | if opts == nil { 44 | opts = []Options{} 45 | } 46 | options := composeOptions(opts) 47 | ctx, cancel := context.WithCancel(options.ParentContext) 48 | var counter uint64 49 | return &Rutina{ 50 | ctx: ctx, 51 | Cancel: cancel, 52 | wg: sync.WaitGroup{}, 53 | onceErr: sync.Once{}, 54 | onceWait: sync.Once{}, 55 | err: nil, 56 | logger: options.Logger, 57 | counter: &counter, 58 | errCh: options.Errors, 59 | autoListenSignals: options.ListenOsSignals, 60 | processes: map[uint64]*process{}, 61 | mu: sync.Mutex{}, 62 | } 63 | } 64 | 65 | // Go routine 66 | func (r *Rutina) Go(doer func(ctx context.Context) error, opts ...RunOptions) uint64 { 67 | options := composeRunOptions(opts) 68 | // Check that context is not canceled yet 69 | if r.ctx.Err() != nil { 70 | return 0 71 | } 72 | 73 | r.mu.Lock() 74 | id := atomic.AddUint64(r.counter, 1) 75 | process := process{ 76 | id: id, 77 | doer: doer, 78 | onDone: options.OnDone, 79 | onError: options.OnError, 80 | restartLimit: options.MaxCount, 81 | restartCount: 0, 82 | timeout: options.Timeout, 83 | } 84 | r.processes[id] = &process 85 | r.mu.Unlock() 86 | 87 | r.wg.Add(1) 88 | go func() { 89 | defer r.wg.Done() 90 | if err := process.run(r.ctx, r.errCh, r.logger); err != nil { 91 | if err != ErrShutdown { 92 | r.onceErr.Do(func() { 93 | r.err = err 94 | }) 95 | } 96 | r.Cancel() 97 | } 98 | r.mu.Lock() 99 | defer r.mu.Unlock() 100 | delete(r.processes, process.id) 101 | r.logger("completed #%d", process.id) 102 | }() 103 | return id 104 | } 105 | 106 | func (r *Rutina) Processes() []uint64 { 107 | var procesess []uint64 108 | for id, _ := range r.processes { 109 | procesess = append(procesess, id) 110 | } 111 | return procesess 112 | } 113 | 114 | // Errors returns chan for all errors, event if DoNothingIfError or RestartIfError set. 115 | // By default it nil. Use MixinErrChan to turn it on 116 | func (r *Rutina) Errors() <-chan error { 117 | return r.errCh 118 | } 119 | 120 | // ListenOsSignals is simple OS signals handler. By default listen syscall.SIGINT and syscall.SIGTERM 121 | func (r *Rutina) ListenOsSignals(signals ...os.Signal) { 122 | if len(signals) == 0 { 123 | signals = []os.Signal{syscall.SIGINT, syscall.SIGTERM} 124 | } 125 | go func() { 126 | sig := make(chan os.Signal, 1) 127 | signal.Notify(sig, signals...) 128 | r.logger("starting OS signals listener") 129 | select { 130 | case s := <-sig: 131 | r.logger("stopping by OS signal (%v)", s) 132 | r.Cancel() 133 | case <-r.ctx.Done(): 134 | } 135 | }() 136 | } 137 | 138 | // Wait all routines and returns first error or nil if all routines completes without errors 139 | func (r *Rutina) Wait() error { 140 | if len(r.autoListenSignals) > 0 { 141 | r.ListenOsSignals(r.autoListenSignals...) 142 | } 143 | r.onceWait.Do(func() { 144 | r.wg.Wait() 145 | if r.errCh != nil { 146 | close(r.errCh) 147 | } 148 | }) 149 | return r.err 150 | } 151 | 152 | // Kill process by id 153 | func (r *Rutina) Kill(id uint64) error { 154 | p, ok := r.processes[id] 155 | if !ok { 156 | return ErrProcessNotFound 157 | } 158 | if p.cancel != nil { 159 | p.cancel() 160 | } 161 | return nil 162 | } 163 | 164 | type process struct { 165 | id uint64 166 | doer func(ctx context.Context) error 167 | cancel func() 168 | onDone Policy 169 | onError Policy 170 | restartLimit *int 171 | restartCount int 172 | timeout *time.Duration 173 | } 174 | 175 | func (p *process) run(pctx context.Context, errCh chan error, logger logger) error { 176 | var ctx context.Context 177 | if p.timeout != nil { 178 | ctx, p.cancel = context.WithTimeout(pctx, *p.timeout) 179 | defer p.cancel() 180 | } else { 181 | ctx, p.cancel = context.WithCancel(pctx) 182 | } 183 | for { 184 | logger("starting process #%d", p.id) 185 | p.restartCount++ 186 | currentAction := p.onDone 187 | err := p.doer(ctx) 188 | if err != nil { 189 | if p.onError == Shutdown { 190 | return err 191 | } 192 | currentAction = p.onError 193 | logger("error on process #%d: %s", p.id, err) 194 | if errCh != nil { 195 | errCh <- err 196 | } 197 | } 198 | switch currentAction { 199 | case DoNothing: 200 | return nil 201 | case Shutdown: 202 | return ErrShutdown 203 | case Restart: 204 | if ctx.Err() != nil { 205 | if p.onError == Shutdown { 206 | return ErrTimeoutOrKilled 207 | } else { 208 | if errCh != nil { 209 | errCh <- ErrTimeoutOrKilled 210 | } 211 | return nil 212 | } 213 | } 214 | if p.restartLimit == nil || p.restartCount > *p.restartLimit { 215 | logger("run count limit process #%d", p.id) 216 | if p.onError == Shutdown { 217 | return ErrRunLimit 218 | } else { 219 | if errCh != nil { 220 | errCh <- ErrRunLimit 221 | } 222 | return ErrRunLimit 223 | } 224 | } 225 | logger("restarting process #%d", p.id) 226 | } 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /rutina_test.go: -------------------------------------------------------------------------------- 1 | package rutina 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestSuccess(t *testing.T) { 11 | r := New() 12 | counter := 0 13 | f := func(name string, ttl time.Duration) error { 14 | counter++ 15 | <-time.After(ttl) 16 | counter-- 17 | t.Log(name) 18 | return nil 19 | } 20 | r.Go(func(ctx context.Context) error { 21 | return f("one", 1*time.Second) 22 | }) 23 | r.Go(func(ctx context.Context) error { 24 | return f("two", 2*time.Second) 25 | }) 26 | r.Go(func(ctx context.Context) error { 27 | return f("three", 3*time.Second) 28 | }) 29 | if err := r.Wait(); err != nil { 30 | t.Error("Unexpected error", err) 31 | } 32 | if counter == 0 { 33 | t.Log("All routines done") 34 | } else { 35 | t.Error("Not all routines stopped") 36 | } 37 | } 38 | 39 | func TestError(t *testing.T) { 40 | r := New() 41 | f := func(name string, ttl time.Duration) error { 42 | <-time.After(ttl) 43 | t.Log(name) 44 | return errors.New("error from " + name) 45 | } 46 | r.Go(func(ctx context.Context) error { 47 | return f("one", 1*time.Second) 48 | }) 49 | r.Go(func(ctx context.Context) error { 50 | return f("two", 2*time.Second) 51 | }) 52 | r.Go(func(ctx context.Context) error { 53 | return f("three", 3*time.Second) 54 | }) 55 | if err := r.Wait(); err != nil { 56 | if err.Error() != "error from one" { 57 | t.Error("Must be error from first routine") 58 | } 59 | t.Log(err) 60 | } 61 | t.Log("All routines done") 62 | } 63 | 64 | func TestErrorWithRestart(t *testing.T) { 65 | maxCount := 2 66 | 67 | r := New() 68 | r.Go(func(ctx context.Context) error { 69 | return nil 70 | }) 71 | r.Go(func(ctx context.Context) error { 72 | return errors.New("error") 73 | }, RunOptions{ 74 | OnError: Restart, 75 | MaxCount: &maxCount, 76 | }) 77 | 78 | err := r.Wait() 79 | if err != ErrRunLimit { 80 | t.Error("Must be an error ErrRunLimit from r.Wait since all restarts was executed") 81 | } 82 | } 83 | 84 | func TestContext(t *testing.T) { 85 | r := New() 86 | cc := false 87 | r.Go(func(ctx context.Context) error { 88 | <-time.After(1 * time.Second) 89 | return nil 90 | }, RunOptions{OnDone: Shutdown}) 91 | r.Go(func(ctx context.Context) error { 92 | select { 93 | case <-ctx.Done(): 94 | cc = true 95 | return nil 96 | case <-time.After(3 * time.Second): 97 | return errors.New("Timeout") 98 | } 99 | }) 100 | if err := r.Wait(); err != nil { 101 | t.Error("Unexpected error", err) 102 | } 103 | if cc { 104 | t.Log("Second routine successfully complete by context done") 105 | } else { 106 | t.Error("Routine not completed by context") 107 | } 108 | } 109 | --------------------------------------------------------------------------------