├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── go.mod ├── go.sum ├── golangci.yaml ├── internal └── testutil │ └── utils.go ├── syncgroup.go ├── syncgroup_internal_test.go └── syncgroup_test.go /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v4 20 | with: 21 | go-version: '1.22' # Задайте нужную версию Go 22 | 23 | - name: Run make command 24 | run: make ci 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | bin 3 | 4 | coverage.* 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021 Alexander Trapeznikov 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN_DIR = $(PWD)/bin 2 | 3 | GOIMPORTS_BIN = $(BIN_DIR)/goimports 4 | GOLANGCI_BIN = $(BIN_DIR)/golangci-lint 5 | 6 | .PHONY: all 7 | all: tidy fmt lint test 8 | 9 | .PHONY: tidy 10 | tidy: 11 | go mod tidy 12 | 13 | .PHONY: test 14 | test: 15 | go test -v -race -cover -coverprofile=coverage.out -timeout 10s -cpu 1,2,4,8 ./... 16 | go tool cover -html=coverage.out -o coverage.html 17 | 18 | .PHONY: vet 19 | vet: 20 | go vet ./... 21 | 22 | .PHONY: fmt 23 | fmt: 24 | go fmt ./... 25 | $(GOIMPORTS_BIN) -w . 26 | 27 | .PHONY: lint 28 | lint: vet 29 | $(GOLANGCI_BIN) run -c golangci.yaml ./... 30 | 31 | .PHONY: ci 32 | ci: deps lint test 33 | 34 | .PHONY: deps 35 | deps: 36 | go mod download 37 | mkdir -p $(BIN_DIR) 38 | GOBIN=$(BIN_DIR) go install golang.org/x/tools/cmd/goimports@v0.26.0 39 | GOBIN=$(BIN_DIR) go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Syncgroup 2 | 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/kurt212/syncgroup)](https://goreportcard.com/report/github.com/kurt212/syncgroup) 4 | ![Build Status](https://github.com/kurt212/syncgroup/actions/workflows/ci.yml/badge.svg) 5 | ![GitHub issues](https://img.shields.io/github/issues/kurt212/syncgroup) 6 | ![GitHub pull requests](https://img.shields.io/github/issues-pr/kurt212/syncgroup) 7 | 8 | [![Go Reference](https://pkg.go.dev/badge/github.com/kurt212/syncgroup.svg)](https://pkg.go.dev/github.com/kurt212/syncgroup) 9 | ![Go Version](https://img.shields.io/github/go-mod/go-version/kurt212/syncgroup) 10 | 11 | ## Introduction 12 | 13 | Syncgroup is a Go package that provides an abstract synchronization mechanism, allowing you to run independent tasks in separate goroutines and collect all occurred errors. It is similar to `errgroup` but does not cancel the context of the goroutines if any of them returns an error. 14 | 15 | ## Key Features 16 | 17 | - **Convenient API**: Run goroutines and wait for their completion with `sg.Go(func() error)` and `sg.Wait()`. 18 | - **Error Handling**: Collects all errors from goroutines and returns them as a single error, wrapped according to [Go 1.13 errors wrapping rules](https://go.dev/blog/go1.13-errors). 19 | - **Panic Recovery**: Recovers panics in goroutines and returns them as errors. 20 | - **Concurrency Limiting**: Set a limit on the number of concurrent goroutines. 21 | 22 | ## Differences from Industry Standards 23 | 24 | **SyncGroup** offers several enhancements over `sync.WaitGroup` and `errgroup`: 25 | 26 | - **Better API**: Simplified and more convenient. 27 | - **Error Handling**: Automatically collects and returns all errors. 28 | - **Panic Recovery**: Recovers from panics and returns them as errors. 29 | - **Concurrency Limiting**: Allows setting a limit on concurrent goroutines. 30 | - **Comprehensive Error Collection**: Unlike `errgroup`, `SyncGroup` does not cancel the context of the goroutines if any of them returns an error. 31 | `errgroup` is designed to obtain the result only if all jobs are successful. When multiple errors occur, `errgroup` only returns the first one and ignores the rest. 32 | 33 | ## Documentation 34 | See more on [godoc site](https://godoc.org/github.com/kurt212/syncgroup) 35 | 36 | ## Usage 37 | 38 | ### Installation 39 | 40 | ```shell 41 | go get github.com/kurt212/syncgroup 42 | ``` 43 | 44 | ### Example 45 | 46 | Run goroutines in parallel and wait until all of them finish. 47 | 48 | ```go 49 | package main 50 | 51 | import ( 52 | "fmt" 53 | "time" 54 | 55 | "github.com/kurt212/syncgroup" 56 | ) 57 | 58 | func main() { 59 | sg := syncgroup.New() 60 | 61 | for i := range 10 { 62 | sg.Go(func() error { 63 | time.Sleep(1 * time.Second) 64 | 65 | fmt.Printf("Hello from %d\n", i) 66 | 67 | return nil 68 | }) 69 | } 70 | 71 | sg.Wait() 72 | } 73 | ``` 74 | 75 | Collect errors from goroutines. 76 | 77 | ```go 78 | package main 79 | 80 | import ( 81 | "fmt" 82 | "time" 83 | 84 | "github.com/kurt212/syncgroup" 85 | ) 86 | 87 | func main() { 88 | sg := syncgroup.New() 89 | 90 | for i := range 10 { 91 | sg.Go(func() error { 92 | time.Sleep(1 * time.Second) 93 | 94 | return fmt.Errorf("error %d", i) 95 | }) 96 | } 97 | 98 | err := sg.Wait() 99 | if err != nil { 100 | /* 101 | Expected output: 102 | error 1 103 | error 3 104 | error 2 105 | error 4 106 | error 6 107 | error 5 108 | error 0 109 | error 9 110 | error 7 111 | error 8 112 | */ 113 | fmt.Println(err) 114 | } 115 | } 116 | ``` 117 | 118 | Limit the number of concurrent goroutines. 119 | 120 | ```go 121 | package main 122 | 123 | import ( 124 | "fmt" 125 | "time" 126 | 127 | "github.com/kurt212/syncgroup" 128 | ) 129 | 130 | func main() { 131 | sg := syncgroup.New() 132 | 133 | sg.SetLimit(2) 134 | 135 | for i := range 10 { 136 | sg.Go(func() error { 137 | fmt.Printf("Go %d\n", i) 138 | 139 | time.Sleep(1 * time.Second) 140 | 141 | return nil 142 | }) 143 | } 144 | 145 | sg.Wait() 146 | } 147 | ``` 148 | 149 | ## Contributing 150 | 151 | Feel free to contribute to this project. You can report bugs, suggest features or submit pull requests. 152 | 153 | Before submitting a bug report or a feature request, check if there is an existing one and provide as much information as possible. 154 | 155 | ### Submitting a pull request 156 | 157 | 1. Fork it 158 | 2. Create your feature branch (`git checkout -b my-new-feature`) 159 | 3. Run checks (`make all`) 160 | 4. Commit your changes (`git commit -am 'Add some feature'`) 161 | 5. Push to the branch (`git push origin my-new-feature`) 162 | 6. Create a new Pull Request 163 | 7. Wait for CI to pass 164 | 8. Profit! 🎉 165 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kurt212/syncgroup 2 | 3 | go 1.22.1 4 | 5 | toolchain go1.22.5 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kurt212/syncgroup/ecd3afc5eb6bff3917a0b8d38b58881fd482abdb/go.sum -------------------------------------------------------------------------------- /golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable-all: true 3 | disable: 4 | - depguard 5 | - exportloopref 6 | - gomnd 7 | - execinquery 8 | -------------------------------------------------------------------------------- /internal/testutil/utils.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | // Equal is a helper function to check equality of two values. 9 | func Equal(t *testing.T, expected, actual any, optionalMessage ...string) { 10 | t.Helper() 11 | 12 | var message string 13 | if len(optionalMessage) > 0 { 14 | message = optionalMessage[0] 15 | } 16 | 17 | if !reflect.DeepEqual(expected, actual) { 18 | t.Fatalf("expected %v, got %v %q", expected, actual, message) 19 | } 20 | } 21 | 22 | // True is Helper function to check if a value is true. 23 | func True(t *testing.T, actual bool, optionalMessage ...string) { 24 | t.Helper() 25 | 26 | var message string 27 | if len(optionalMessage) > 0 { 28 | message = optionalMessage[0] 29 | } 30 | 31 | if !actual { 32 | t.Fatal("expected true, got false", message) 33 | } 34 | } 35 | 36 | // EqualSlices is a helper function to check equality of two slices. 37 | func EqualSlices[T1, T2 any](t *testing.T, expected []T1, actual []T2) { 38 | t.Helper() 39 | 40 | if len(expected) != len(actual) { 41 | t.Fatalf("expected slice length %d, got %d", len(expected), len(actual)) 42 | 43 | return 44 | } 45 | 46 | for i := range expected { 47 | if !reflect.DeepEqual(expected[i], actual[i]) { 48 | t.Fatalf("at index %d: expected %v, got %v", i, expected[i], actual[i]) 49 | } 50 | } 51 | } 52 | 53 | // Panics is a helper function to check if a function panics. 54 | func Panics(t *testing.T, fnc func(), optionalMessage ...string) { 55 | t.Helper() 56 | 57 | var message string 58 | if len(optionalMessage) > 0 { 59 | message = optionalMessage[0] 60 | } 61 | 62 | defer func() { 63 | if recover() == nil { 64 | t.Fatalf("expected panic, got nil %q", message) 65 | } 66 | }() 67 | 68 | fnc() 69 | } 70 | -------------------------------------------------------------------------------- /syncgroup.go: -------------------------------------------------------------------------------- 1 | // Package syncgroup package that contains an implementation of an abstract 2 | // synchronisation mechanism - synchronisation group. 3 | // The main idea is to have an ability to run independent tasks in separate goroutines which way return errors. 4 | // A user can wait until all goroutines finish running and collect all occurred errors. 5 | // 6 | // The design is similar to errgroup (https://godoc.org/golang.org/x/sync/errgroup), 7 | // but it does not cancel the context of the goroutines if any of them returns an error. 8 | package syncgroup 9 | 10 | import ( 11 | "errors" 12 | "fmt" 13 | "runtime/debug" 14 | "sync" 15 | "sync/atomic" 16 | ) 17 | 18 | // ErrPanicRecovered is a special error that is returned when a panic is recovered from a goroutine. 19 | // It is used to wrap the original panic error and stack trace. 20 | // You can use errors.Is(err, ErrPanicRecovered) to check if the error was caused by a panic. 21 | // If the panic value was an error, you can use errors.Unwrap(err) to get the original error. 22 | var ErrPanicRecovered = errors.New("recovered from panic") 23 | 24 | // SyncGroup is the main abstraction for working with syncgroups. 25 | // A Sync Group is a collection of goroutines that can be waited for. 26 | // 27 | // Additionally, SyncGroup collects all errors returned by goroutines, 28 | // handles panics and provides a way to limit the number of concurrent goroutines. 29 | // It has four main methods: SetLimit(), Go(), TryGo() and Wait() 30 | // 31 | // SetLimit() sets a limit for the number of concurrent goroutines. 32 | // Using SetLimit() is optional, be default there is no limit. 33 | // Inside it uses a semaphore pattern to limit the number concurrent of goroutines. 34 | // 35 | // Go() spawns a new goroutine, which may return an error. 36 | // When using SetLimit(), Go() will wait until a slot in the semaphore is available. 37 | // The returned error will be saved and returned by Wait() method. 38 | // 39 | // TryGo() is similar to Go(), but it runs a goroutine only if there is a slot in the semaphore. 40 | // When using SetLimit(), TryGo() will return false and not block, if there are no available slots. 41 | // If there are available slots, it will run the goroutine and return true. 42 | // If goroutine is run, the returned error will be saved and returned by Wait() method. 43 | // 44 | // Wait() waits until all spawned goroutines finish and returns a wrapper for a slice of errors. 45 | // If there was no error, Wait() would return nil, 46 | // otherwise a non nil error, which can be unwrapped to access all errors. 47 | type SyncGroup struct { 48 | wg sync.WaitGroup 49 | semaphore chan semaphoreToken 50 | 51 | finishedChan chan []error 52 | errorChan chan error 53 | 54 | listeningStarted atomic.Bool 55 | listeningRoutineStarter *sync.Once 56 | } 57 | 58 | type semaphoreToken struct{} 59 | 60 | // New is the default constructor for SyncGroup. 61 | func New() *SyncGroup { 62 | grp := &SyncGroup{ 63 | wg: sync.WaitGroup{}, 64 | semaphore: nil, 65 | finishedChan: make(chan []error), 66 | errorChan: make(chan error), 67 | listeningStarted: atomic.Bool{}, 68 | listeningRoutineStarter: new(sync.Once), 69 | } 70 | 71 | return grp 72 | } 73 | 74 | // Go spawns given function in a new goroutine. 75 | // If group has a limit of concurrent goroutines, goroutine execution will be blocked until a slot is available. 76 | // The returned error will be saved and returned wrapped by Wait() method. 77 | func (g *SyncGroup) Go(fnc func() error) { 78 | g.startListening() 79 | 80 | g.wg.Add(1) 81 | 82 | go func() { 83 | defer g.done() 84 | 85 | // blocks until semaphore slot is acquired 86 | if g.semaphore != nil { 87 | g.semaphore <- semaphoreToken{} 88 | } 89 | 90 | err := fnc() 91 | if err != nil { 92 | g.errorChan <- err 93 | } 94 | }() 95 | } 96 | 97 | // TryGo is similar to Go, but it runs a goroutine only if there is a slot in the semaphore. 98 | // If there are available slots, it will run the goroutine and return true. 99 | // If goroutine is run, the returned error will be saved and returned by Wait() method. 100 | func (g *SyncGroup) TryGo(fnc func() error) bool { 101 | if g.semaphore != nil { 102 | select { 103 | case g.semaphore <- semaphoreToken{}: 104 | default: 105 | return false 106 | } 107 | } 108 | 109 | g.startListening() 110 | g.wg.Add(1) 111 | 112 | go func() { 113 | defer g.done() 114 | 115 | err := fnc() 116 | if err != nil { 117 | g.errorChan <- err 118 | } 119 | }() 120 | 121 | return true 122 | } 123 | 124 | // done is called in every goroutine spawned by SyncGroup in defer statement. 125 | // Its job is to handle panics, release all resources and decrement the WaitGroup counter. 126 | func (g *SyncGroup) done() { 127 | if msg := recover(); msg != nil { 128 | var err error 129 | 130 | switch val := msg.(type) { 131 | case error: 132 | err = fmt.Errorf("%w: %w\n%s", ErrPanicRecovered, val, string(debug.Stack())) 133 | default: 134 | err = fmt.Errorf("%w: %v\n%s", ErrPanicRecovered, val, string(debug.Stack())) 135 | } 136 | 137 | g.errorChan <- err 138 | } 139 | 140 | if g.semaphore != nil { 141 | <-g.semaphore 142 | } 143 | 144 | g.wg.Done() 145 | } 146 | 147 | // startListening starts a single goroutine that listens to all errors and accumulates them. 148 | // It should make sure that the goroutine is started only once. 149 | func (g *SyncGroup) startListening() { 150 | g.listeningRoutineStarter.Do(func() { 151 | g.listeningStarted.Store(true) 152 | go g.listenToErrors() 153 | }) 154 | } 155 | 156 | // listenToErrors is a goroutine that listens to all errors and accumulates them. 157 | // When all goroutines are finished, it sends the accumulated errors to the finishedChan. 158 | func (g *SyncGroup) listenToErrors() { 159 | defer func() { 160 | close(g.finishedChan) 161 | }() 162 | 163 | var accumulatedErrors []error //nolint:prealloc // false positive 164 | for err := range g.errorChan { 165 | accumulatedErrors = append(accumulatedErrors, err) 166 | } 167 | 168 | g.finishedChan <- accumulatedErrors 169 | } 170 | 171 | // Wait waits until all spawned goroutines are finished and returns a wrapped error for all collected errors. 172 | // The result is nil if none of the spawned goroutines returned an error 173 | // 174 | // If error is not nil, the result is guaranteed to implement `Unwrap() []errors` methods to access all errors. 175 | // The error supports unwrapping with standard errors.Unwrap(), errors.Is() and errors.As() functions. 176 | func (g *SyncGroup) Wait() error { 177 | if !g.listeningStarted.Load() { 178 | return nil 179 | } 180 | 181 | g.wg.Wait() 182 | close(g.errorChan) 183 | 184 | errs := <-g.finishedChan 185 | 186 | if len(errs) == 0 { 187 | return nil 188 | } 189 | 190 | return errors.Join(errs...) 191 | } 192 | 193 | // SetLimit sets a limit for the number of concurrent goroutines. 194 | // Using SetLimit() is optional, be default there is no limit. 195 | // Pass 0 to disable the limit. 196 | func (g *SyncGroup) SetLimit(limit int) { 197 | if g.listeningStarted.Load() { 198 | panic("cannot set limit after starting goroutines") 199 | } 200 | 201 | if limit <= 0 { 202 | g.semaphore = nil 203 | 204 | return 205 | } 206 | 207 | g.semaphore = make(chan semaphoreToken, limit) 208 | } 209 | -------------------------------------------------------------------------------- /syncgroup_internal_test.go: -------------------------------------------------------------------------------- 1 | package syncgroup 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/kurt212/syncgroup/internal/testutil" 7 | ) 8 | 9 | type MyError struct { 10 | a string 11 | } 12 | 13 | func (m MyError) Error() string { 14 | return m.a 15 | } 16 | 17 | func TestListenTo(t *testing.T) { 18 | t.Parallel() 19 | 20 | syncgrp := New() 21 | 22 | syncgrp.startListening() 23 | 24 | syncgrp.errorChan <- MyError{"err1"} 25 | syncgrp.errorChan <- MyError{"err2"} 26 | syncgrp.errorChan <- MyError{"err3"} 27 | 28 | close(syncgrp.errorChan) 29 | 30 | res := <-syncgrp.finishedChan 31 | 32 | expected := []error{ 33 | MyError{"err1"}, 34 | MyError{"err2"}, 35 | MyError{"err3"}, 36 | } 37 | 38 | testutil.EqualSlices(t, expected, res) 39 | } 40 | -------------------------------------------------------------------------------- /syncgroup_test.go: -------------------------------------------------------------------------------- 1 | package syncgroup_test 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "sync/atomic" 7 | "testing" 8 | "time" 9 | 10 | "github.com/kurt212/syncgroup" 11 | "github.com/kurt212/syncgroup/internal/testutil" 12 | ) 13 | 14 | type MyError struct { 15 | a string 16 | } 17 | 18 | func (m MyError) Error() string { 19 | return m.a 20 | } 21 | 22 | func TestGoOK(t *testing.T) { 23 | t.Parallel() 24 | 25 | syncgrp := syncgroup.New() 26 | 27 | syncgrp.Go(func() error { 28 | return nil 29 | }) 30 | 31 | syncgrp.Go(func() error { 32 | return nil 33 | }) 34 | 35 | syncgrp.Go(func() error { 36 | return nil 37 | }) 38 | 39 | err := syncgrp.Wait() 40 | if err != nil { 41 | t.Fatalf("expected nil, got %v", err) 42 | } 43 | } 44 | 45 | func TestTryGoOK(t *testing.T) { 46 | t.Parallel() 47 | 48 | syncgrp := syncgroup.New() 49 | 50 | syncgrp.TryGo(func() error { 51 | return nil 52 | }) 53 | 54 | syncgrp.TryGo(func() error { 55 | return nil 56 | }) 57 | 58 | syncgrp.TryGo(func() error { 59 | return nil 60 | }) 61 | 62 | err := syncgrp.Wait() 63 | if err != nil { 64 | t.Fatalf("expected nil, got %v", err) 65 | } 66 | } 67 | 68 | func TestGoWithOneError(t *testing.T) { 69 | t.Parallel() 70 | 71 | syncgrp := syncgroup.New() 72 | 73 | syncgrp.Go(func() error { 74 | return nil 75 | }) 76 | 77 | returnMyErr := MyError{"123"} 78 | 79 | syncgrp.Go(func() error { 80 | return returnMyErr 81 | }) 82 | 83 | syncgrp.Go(func() error { 84 | return nil 85 | }) 86 | 87 | err := syncgrp.Wait() 88 | 89 | if err == nil { 90 | t.Fatalf("expected error, got nil") 91 | } 92 | 93 | if err.Error() != returnMyErr.Error() { 94 | t.Fatalf("expected %v, got %v", returnMyErr, err) 95 | } 96 | 97 | testutil.True(t, errors.Is(err, returnMyErr), "Result error should be found by errors.Is") 98 | } 99 | 100 | func TestTryGoWithOneError(t *testing.T) { 101 | t.Parallel() 102 | 103 | syncgrp := syncgroup.New() 104 | 105 | syncgrp.Go(func() error { 106 | return nil 107 | }) 108 | 109 | returnMyErr := MyError{"123"} 110 | 111 | syncgrp.TryGo(func() error { 112 | return returnMyErr 113 | }) 114 | 115 | syncgrp.TryGo(func() error { 116 | return nil 117 | }) 118 | 119 | err := syncgrp.Wait() 120 | 121 | if err == nil { 122 | t.Fatalf("expected error, got nil") 123 | } 124 | 125 | if err.Error() != returnMyErr.Error() { 126 | t.Fatalf("expected %v, got %v", returnMyErr, err) 127 | } 128 | 129 | testutil.True(t, errors.Is(err, returnMyErr), "Result error should be found by errors.Is") 130 | } 131 | 132 | //nolint:dupl 133 | func TestGoWithTwoErrors(t *testing.T) { 134 | t.Parallel() 135 | 136 | firstErr := MyError{"123"} 137 | secondErr := MyError{"456"} 138 | 139 | syncgrp := syncgroup.New() 140 | 141 | syncgrp.Go(func() error { 142 | return nil 143 | }) 144 | 145 | syncgrp.Go(func() error { 146 | return firstErr 147 | }) 148 | 149 | syncgrp.Go(func() error { 150 | return secondErr 151 | }) 152 | 153 | err := syncgrp.Wait() 154 | 155 | if err == nil { 156 | t.Fatalf("expected error, got nil") 157 | } 158 | 159 | testutil.True(t, errors.Is(err, firstErr), "Result error should be found by errors.Is") 160 | testutil.True(t, errors.Is(err, secondErr), "Result error should be found by errors.Is") 161 | 162 | unwrappableErr, ok := err.(interface { 163 | Unwrap() []error 164 | }) 165 | 166 | testutil.True(t, ok, "Result error should be unwrappable and implement Unwrap() []error interface") 167 | 168 | gotErrors := unwrappableErr.Unwrap() 169 | 170 | testutil.True(t, len(gotErrors) == 2, "Result error should contain 2 errors") 171 | } 172 | 173 | //nolint:dupl 174 | func TestTryGoWithTwoErrors(t *testing.T) { 175 | t.Parallel() 176 | 177 | firstErr := MyError{"123"} 178 | secondErr := MyError{"456"} 179 | 180 | syncgrp := syncgroup.New() 181 | 182 | syncgrp.TryGo(func() error { 183 | return nil 184 | }) 185 | 186 | syncgrp.TryGo(func() error { 187 | return firstErr 188 | }) 189 | 190 | syncgrp.TryGo(func() error { 191 | return secondErr 192 | }) 193 | 194 | err := syncgrp.Wait() 195 | 196 | if err == nil { 197 | t.Fatalf("expected error, got nil") 198 | } 199 | 200 | testutil.True(t, errors.Is(err, firstErr), "Result error should be found by errors.Is") 201 | testutil.True(t, errors.Is(err, secondErr), "Result error should be found by errors.Is") 202 | 203 | unwrappableErr, ok := err.(interface { 204 | Unwrap() []error 205 | }) 206 | 207 | testutil.True(t, ok, "Result error should be unwrappable and implement Unwrap() []error interface") 208 | 209 | gotErrors := unwrappableErr.Unwrap() 210 | 211 | testutil.True(t, len(gotErrors) == 2, "Result error should contain 2 errors") 212 | } 213 | 214 | func TestNoGoroutines(t *testing.T) { 215 | t.Parallel() 216 | 217 | syncgrp := syncgroup.New() 218 | 219 | err := syncgrp.Wait() 220 | if err != nil { 221 | t.Fatalf("expected nil, got %v", err) 222 | } 223 | } 224 | 225 | func TestGoRecoversNonErrorPanic(t *testing.T) { 226 | t.Parallel() 227 | 228 | syncgrp := syncgroup.New() 229 | 230 | panicMsg := "this is message from panic" 231 | 232 | syncgrp.Go(func() error { 233 | panic(panicMsg) 234 | }) 235 | 236 | err := syncgrp.Wait() 237 | 238 | testutil.True( 239 | t, 240 | errors.Is(err, syncgroup.ErrPanicRecovered), 241 | "On panic should return special panic error", 242 | ) 243 | 244 | testutil.True( 245 | t, 246 | strings.Contains(err.Error(), panicMsg), 247 | "Error should contain panic message", 248 | ) 249 | 250 | testutil.True( 251 | t, 252 | strings.Contains(err.Error(), "goroutine"), 253 | "Error should contain stack trace", 254 | ) 255 | } 256 | 257 | func TestTryGoRecoversNonErrorPanic(t *testing.T) { 258 | t.Parallel() 259 | 260 | syncgrp := syncgroup.New() 261 | 262 | panicMsg := "this is message from panic" 263 | 264 | syncgrp.TryGo(func() error { 265 | panic(panicMsg) 266 | }) 267 | 268 | err := syncgrp.Wait() 269 | 270 | testutil.True( 271 | t, 272 | errors.Is(err, syncgroup.ErrPanicRecovered), 273 | "On panic should return special panic error", 274 | ) 275 | 276 | testutil.True( 277 | t, 278 | strings.Contains(err.Error(), panicMsg), 279 | "Error should contain panic message", 280 | ) 281 | 282 | testutil.True( 283 | t, 284 | strings.Contains(err.Error(), "goroutine"), 285 | "Error should contain stack trace", 286 | ) 287 | } 288 | 289 | func TestGoRecoversErrorPanic(t *testing.T) { 290 | t.Parallel() 291 | 292 | syncgrp := syncgroup.New() 293 | 294 | panicErr := errors.New("this is error from panic") //nolint:err113 295 | 296 | syncgrp.Go(func() error { 297 | panic(panicErr) 298 | }) 299 | 300 | err := syncgrp.Wait() 301 | 302 | testutil.True( 303 | t, 304 | errors.Is(err, syncgroup.ErrPanicRecovered), 305 | "On panic should return special panic error", 306 | ) 307 | 308 | testutil.True( 309 | t, 310 | errors.Is(err, panicErr), 311 | "Error should wrap panic error", 312 | ) 313 | 314 | testutil.True( 315 | t, 316 | strings.Contains(err.Error(), "goroutine"), 317 | "Error should contain stack trace", 318 | ) 319 | } 320 | 321 | func TestTryGoRecoversErrorPanic(t *testing.T) { 322 | t.Parallel() 323 | 324 | syncgrp := syncgroup.New() 325 | 326 | panicErr := errors.New("this is error from panic") //nolint:err113 327 | 328 | syncgrp.TryGo(func() error { 329 | panic(panicErr) 330 | }) 331 | 332 | err := syncgrp.Wait() 333 | 334 | testutil.True( 335 | t, 336 | errors.Is(err, syncgroup.ErrPanicRecovered), 337 | "On panic should return special panic error", 338 | ) 339 | 340 | testutil.True( 341 | t, 342 | errors.Is(err, panicErr), 343 | "Error should wrap panic error", 344 | ) 345 | 346 | testutil.True( 347 | t, 348 | strings.Contains(err.Error(), "goroutine"), 349 | "Error should contain stack trace", 350 | ) 351 | } 352 | 353 | func TestLimitGoroutinesWithGo(t *testing.T) { 354 | t.Parallel() 355 | 356 | const limit = 2 357 | 358 | syncgrp := syncgroup.New() 359 | syncgrp.SetLimit(limit) 360 | 361 | activeCount := atomic.Int32{} 362 | 363 | runnableFunc := func() error { 364 | active := activeCount.Add(1) 365 | defer activeCount.Add(-1) 366 | 367 | if active > limit { 368 | t.Errorf("expected %d active goroutines, got %d", limit, active) 369 | } 370 | 371 | time.Sleep(100 * time.Millisecond) 372 | 373 | return nil 374 | } 375 | 376 | const goroutinesCount = 10 377 | 378 | for range goroutinesCount { 379 | syncgrp.Go(runnableFunc) 380 | } 381 | 382 | err := syncgrp.Wait() 383 | if err != nil { 384 | t.Fatalf("expected nil, got %v", err) 385 | } 386 | } 387 | 388 | func TestLimitGoroutinesWithTryGo(t *testing.T) { 389 | t.Parallel() 390 | 391 | const limit = 2 392 | 393 | syncgrp := syncgroup.New() 394 | syncgrp.SetLimit(limit) 395 | 396 | activeCount := atomic.Int32{} 397 | 398 | runnableFunc := func() error { 399 | active := activeCount.Add(1) 400 | defer activeCount.Add(-1) 401 | 402 | if active > limit { 403 | t.Errorf("expected %d active goroutines, got %d", limit, active) 404 | } 405 | 406 | time.Sleep(100 * time.Millisecond) 407 | 408 | return nil 409 | } 410 | 411 | const goroutinesCount = 10 412 | 413 | for range goroutinesCount { 414 | syncgrp.TryGo(runnableFunc) 415 | } 416 | 417 | err := syncgrp.Wait() 418 | if err != nil { 419 | t.Fatalf("expected nil, got %v", err) 420 | } 421 | } 422 | 423 | func TestTryGoReturnsValidValue(t *testing.T) { 424 | t.Parallel() 425 | 426 | const limit = 2 427 | 428 | syncgrp := syncgroup.New() 429 | syncgrp.SetLimit(limit) 430 | 431 | stopChan := make(chan struct{}) 432 | 433 | runnableFunc := func() error { 434 | <-stopChan 435 | 436 | return nil 437 | } 438 | 439 | const goroutinesCount = 10 440 | 441 | failedToRun := 0 442 | 443 | for range goroutinesCount { 444 | ok := syncgrp.TryGo(runnableFunc) 445 | if !ok { 446 | failedToRun++ 447 | } 448 | } 449 | 450 | testutil.Equal(t, failedToRun, goroutinesCount-limit) 451 | 452 | close(stopChan) 453 | 454 | err := syncgrp.Wait() 455 | if err != nil { 456 | t.Fatalf("expected nil, got %v", err) 457 | } 458 | } 459 | 460 | func TestCanNotChangeLimitAfterGo(t *testing.T) { 461 | t.Parallel() 462 | 463 | syncgrp := syncgroup.New() 464 | 465 | stopChan := make(chan struct{}) 466 | 467 | syncgrp.Go(func() error { 468 | <-stopChan 469 | 470 | return nil 471 | }) 472 | 473 | testutil.Panics(t, func() { 474 | syncgrp.SetLimit(1) 475 | }) 476 | 477 | close(stopChan) 478 | 479 | err := syncgrp.Wait() 480 | if err != nil { 481 | t.Fatalf("expected nil, got %v", err) 482 | } 483 | } 484 | 485 | func TestCanNotChangeLimitAfterTryGo(t *testing.T) { 486 | t.Parallel() 487 | 488 | syncgrp := syncgroup.New() 489 | 490 | stopChan := make(chan struct{}) 491 | 492 | syncgrp.TryGo(func() error { 493 | <-stopChan 494 | 495 | return nil 496 | }) 497 | 498 | testutil.Panics(t, func() { 499 | syncgrp.SetLimit(1) 500 | }) 501 | 502 | close(stopChan) 503 | 504 | err := syncgrp.Wait() 505 | if err != nil { 506 | t.Fatalf("expected nil, got %v", err) 507 | } 508 | } 509 | 510 | func TestUnsetLimitWorksWithGo(t *testing.T) { 511 | t.Parallel() 512 | 513 | syncgrp := syncgroup.New() 514 | 515 | syncgrp.SetLimit(1) 516 | syncgrp.SetLimit(0) 517 | 518 | stopChan := make(chan struct{}) 519 | 520 | activeGoroutines := atomic.Int32{} 521 | 522 | const goroutinesCount = 10 523 | 524 | gouroutineFunc := func() error { 525 | activeGoroutines.Add(1) 526 | defer activeGoroutines.Add(-1) 527 | 528 | <-stopChan 529 | 530 | return nil 531 | } 532 | 533 | for range goroutinesCount { 534 | syncgrp.Go(gouroutineFunc) 535 | } 536 | 537 | for activeGoroutines.Load() != goroutinesCount { 538 | time.Sleep(10 * time.Millisecond) 539 | } 540 | 541 | close(stopChan) 542 | 543 | err := syncgrp.Wait() 544 | if err != nil { 545 | t.Fatalf("expected nil, got %v", err) 546 | } 547 | } 548 | 549 | func TestUnsetLimitWorksWithTryGo(t *testing.T) { 550 | t.Parallel() 551 | 552 | syncgrp := syncgroup.New() 553 | 554 | syncgrp.SetLimit(1) 555 | syncgrp.SetLimit(0) 556 | 557 | stopChan := make(chan struct{}) 558 | 559 | activeGoroutines := atomic.Int32{} 560 | 561 | const goroutinesCount = 10 562 | 563 | goroutineFunc := func() error { 564 | activeGoroutines.Add(1) 565 | defer activeGoroutines.Add(-1) 566 | 567 | <-stopChan 568 | 569 | return nil 570 | } 571 | 572 | for range goroutinesCount { 573 | syncgrp.TryGo(goroutineFunc) 574 | } 575 | 576 | for activeGoroutines.Load() != goroutinesCount { 577 | time.Sleep(10 * time.Millisecond) 578 | } 579 | 580 | close(stopChan) 581 | 582 | err := syncgrp.Wait() 583 | if err != nil { 584 | t.Fatalf("expected nil, got %v", err) 585 | } 586 | } 587 | --------------------------------------------------------------------------------