├── .circleci └── config.yml ├── LICENSE ├── README.md ├── example_pubsub_test.go ├── go.mod ├── nursery.go └── nursery_test.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | 3 | jobs: 4 | "test": &test 5 | docker: 6 | - image: circleci/golang:latest 7 | working_directory: /go/src/github.com/arunsworld/nursery 8 | steps: &steps 9 | - checkout 10 | - run: go test -v -race -cover ./... 11 | 12 | workflows: 13 | version: 2 14 | build: 15 | jobs: 16 | - "test" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nursery: structured concurrency in Go 2 | [![GoDoc](https://godoc.org/github.com/arunsworld/nursery?status.svg)](https://godoc.org/github.com/arunsworld/nursery) 3 | [![GoReportCard](https://goreportcard.com/badge/github.com/arunsworld/nursery)](https://goreportcard.com/badge/github.com/arunsworld/nursery) 4 | [![CircleCI](https://circleci.com/gh/arunsworld/nursery.svg?style=svg)](https://circleci.com/gh/arunsworld/nursery) 5 | ![gopherbadger-tag-do-not-edit](https://img.shields.io/badge/Go%20Coverage-100%25-brightgreen.svg?longCache=true&style=flat) 6 | 7 | ```go 8 | RunConcurrently( 9 | // Job 1 10 | func(context.Context, chan error) { 11 | time.Sleep(time.Millisecond * 10) 12 | log.Println("Job 1 done...") 13 | }, 14 | // Job 2 15 | func(context.Context, chan error) { 16 | time.Sleep(time.Millisecond * 5) 17 | log.Println("Job 2 done...") 18 | }, 19 | ) 20 | log.Println("All jobs done...") 21 | ``` 22 | 23 | ## Installation 24 | ```bash 25 | go get -u github.com/arunsworld/nursery 26 | ``` 27 | 28 | [Notes on structured concurrency, or: Go statement considered harmful](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/#nurseries-a-structured-replacement-for-go-statements) is an article that compares the dangers of goto with the go statement. 29 | 30 | While I don't necessarily agree with the entire content I can appreciate that even with Go's high-level abstraction of concurrency using Goroutines, Channels & the select statement it is possible to end up with unreadable code, deadlocks, leaked goroutines, race conditions and poor error handling. 31 | 32 | Implementing a higher-level abstraction for the use-cases mentioned is very straightforward in Go and this simple package provides just that. 33 | 34 | The following functions are provided: 35 | * `RunConcurrently(jobs ...ConcurrentJob) error`: takes an array of `ConcurrentJob`s and runs them concurrently ensuring that all jobs are completed before the call terminates. If all jobs terminate cleanly error is nil; otherwise the first non-nil error is returned. 36 | * `RunConcurrentlyWithContext(parentCtx context.Context, jobs ...ConcurrentJob) error`: is the RunConcurrently behavior but additionally wraps a context that's passed in allowing cancellations of the parentCtx to get propagated. 37 | * `RunMultipleCopiesConcurrently(copies int, job ConcurrentJob) error`: makes copies of the given job and runs them concurrently. This is useful for cases where we want to execute multiple slow consumers taking jobs from a channel until the job is finished. The channel itself can be fed by a producer that is run concurrently with the job running the consumers. Each job's context is also passed an unique index with key `nursery.JobID` - a 0 based int - that maybe used as a job identity if required. 38 | * `RunMultipleCopiesConcurrentlyWithContext(ctx context.Context, copies int, job ConcurrentJob) error`: is the RunMultipleCopiesConcurrently behavior with a context that allows cancellation to be propagated to the jobs. 39 | * `RunUntilFirstCompletion(jobs ...ConcurrentJob) error`: takes an array of `ConcurrentJob`s and runs them concurrently but terminates after the completion of the earliest completing job. A key point here is that despite early termination it blocks until all jobs have terminated (ie. released any used resources). If all jobs terminate cleanly error is nil; otherwise the first non-nil error is returned. 40 | * `RunUntilFirstCompletionWithContext(parentCtx context.Context, jobs ...ConcurrentJob) error`: is the RunUntilFirstCompletion behavior but additionally wraps a context that's passed in allowing cancellations of the parentCtx to get propagated. 41 | * `RunConcurrentlyWithTimeout(timeout time.Duration, jobs ...ConcurrentJob) error`: is similar in behavior to `RunConcurrently` except it also takes a timeout and can cause the function to terminate earlier if timeout has expired. As before we wait for all jobs to have cleanly terminated. 42 | * `RunUntilFirstCompletionWithTimeout(timeout time.Duration, jobs ...ConcurrentJob) error`: is similar in behavior to `RunUntilFirstCompletion` with an additional timeout clause. 43 | 44 | `ConcurrentJob` is a simple function that takes a context and error channel. We need to ensure that we're listening to the `Done()` channel on context and if invoked to clean-up resources and bail out. Errors are to be published to the error channel for proper handling. 45 | 46 | Note: while this package simplifies the semantics of defining and executing concurrent code it cannot protect against bad concurrent programming such as using shared resources across jobs leading to data corruption or panics due to race conditions. 47 | 48 | You may also be interested in reading [Structured Concurrency in Go](https://medium.com/@arunsworld/structured-concurrency-in-go-b800c7c4434e). 49 | 50 | The library includes a utility function: `IsContextDone(context.Context)` to check if the passed in context is done or not. This can be used as a guard clause in a for loop within a ConcurrentJob using the passed in context to decide whether to stop processing and return or continue. 51 | -------------------------------------------------------------------------------- /example_pubsub_test.go: -------------------------------------------------------------------------------- 1 | package nursery 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log" 8 | "time" 9 | ) 10 | 11 | func Example() { 12 | ch := make(chan int) 13 | err := RunConcurrently( 14 | // producer job: produce numbers into ch and once done close it 15 | func(ctx context.Context, errCh chan error) { 16 | produceNumbers(ctx, ch) 17 | close(ch) 18 | }, 19 | // consumer job 20 | func(ctx context.Context, errCh chan error) { 21 | // run 5 copies of the consumer reading from ch until closed or err encountered 22 | err := RunMultipleCopiesConcurrentlyWithContext(ctx, 5, 23 | func(ctx context.Context, errCh chan error) { 24 | if err := consumeNumbers(ctx, ch); err != nil { 25 | errCh <- err 26 | } 27 | }, 28 | ) 29 | if err != nil { 30 | errCh <- err 31 | // drain the channel to not block the producer in the event of an error 32 | for range ch { 33 | } 34 | } 35 | }, 36 | ) 37 | if err != nil { 38 | log.Fatal(err) 39 | } 40 | } 41 | 42 | func produceNumbers(ctx context.Context, ch chan int) { 43 | for i := 0; i < 200; i++ { 44 | select { 45 | case <-ctx.Done(): 46 | fmt.Printf("producer terminating early after sending numbers up to: %d\n", i) 47 | return 48 | default: 49 | time.Sleep(time.Nanosecond * 100) 50 | ch <- i 51 | } 52 | } 53 | fmt.Println("all numbers produced... now exiting...") 54 | } 55 | 56 | func consumeNumbers(ctx context.Context, ch chan int) error { 57 | jobID := ctx.Value(JobID).(int) 58 | for v := range ch { 59 | select { 60 | case <-ctx.Done(): 61 | fmt.Printf("Job %d terminating early\n", jobID) 62 | return nil 63 | default: 64 | if v == 10 { 65 | fmt.Printf("Job %d received value 10 which is an error\n", jobID) 66 | return errors.New("number 10 received") 67 | } 68 | fmt.Printf("Job %d received value: %d\n", jobID, v) 69 | time.Sleep(time.Millisecond * 10) 70 | } 71 | } 72 | fmt.Printf("Job %d finishing up...\n", jobID) 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/arunsworld/nursery 2 | 3 | go 1.13 4 | -------------------------------------------------------------------------------- /nursery.go: -------------------------------------------------------------------------------- 1 | // Package nursery implements "structured concurrency" in Go. 2 | // 3 | // It's based on this blog post: https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/ 4 | package nursery 5 | 6 | import ( 7 | "context" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | // ConcurrentJob contains procedural code that can run concurrently to another. 13 | // Please ensure that you're listening to `context.Done()` - at which point you're required to clean up and exit. 14 | // Publish any errors into the error channel but note that only the first error across the jobs will be returned. 15 | // Finally ensure that you're not unsafely modifying shared state without protection and using go's built in 16 | // channels for communicating rather than sharing memory. 17 | type ConcurrentJob func(context.Context, chan error) 18 | 19 | // RunConcurrentlyWithContext runs jobs concurrently until all jobs have either finished or any one job encountered an error. 20 | // It wraps the parent context - so if the parent context is Done the jobs get the signal to wrap up 21 | func RunConcurrentlyWithContext(parentCtx context.Context, jobs ...ConcurrentJob) error { 22 | var result error 23 | 24 | ctx, cancel := context.WithCancel(parentCtx) 25 | defer cancel() 26 | 27 | errCh := make(chan error, 10) 28 | waitForErrCompletion := sync.WaitGroup{} 29 | waitForErrCompletion.Add(1) 30 | go func() { 31 | result = cancelOnFirstError(cancel, errCh) 32 | waitForErrCompletion.Done() 33 | }() 34 | 35 | runJobsUntilAllDone(ctx, jobs, errCh) 36 | 37 | close(errCh) 38 | waitForErrCompletion.Wait() 39 | 40 | return result 41 | } 42 | 43 | // RunConcurrently runs jobs concurrently until all jobs have either finished or any one job encountered an error. 44 | func RunConcurrently(jobs ...ConcurrentJob) error { 45 | return RunConcurrentlyWithContext(context.Background(), jobs...) 46 | } 47 | 48 | // JobID is the key used to identify the JobID from the context for jobs running in copies 49 | const JobID = jobIDKey("id") 50 | 51 | type jobIDKey string 52 | 53 | // RunMultipleCopiesConcurrentlyWithContext runs multiple copies of the given job until they have all finished or any 54 | // one has encountered an error. The passed context can be optionally checked for an int value with key JobID counting up from 0 55 | // to identify uniquely the copy that is run. 56 | // It wraps the parent context - so if the parent context is Done the jobs get the signal to wrap up 57 | func RunMultipleCopiesConcurrentlyWithContext(ctx context.Context, copies int, job ConcurrentJob) error { 58 | jobs := make([]ConcurrentJob, 0, copies) 59 | for i := 0; i < copies; i++ { 60 | idOfCopy := i 61 | jobs = append(jobs, func(ctx context.Context, errCh chan error) { 62 | ctx = context.WithValue(ctx, JobID, idOfCopy) 63 | job(ctx, errCh) 64 | }) 65 | } 66 | return RunConcurrentlyWithContext(ctx, jobs...) 67 | } 68 | 69 | // RunMultipleCopiesConcurrently runs multiple copies of the given job until they have all finished or any 70 | // one has encountered an error. The passed context can be optionally checked for an int value with key JobID counting up from 0 71 | // to identify uniquely the copy that is run. 72 | func RunMultipleCopiesConcurrently(copies int, job ConcurrentJob) error { 73 | return RunMultipleCopiesConcurrentlyWithContext(context.Background(), copies, job) 74 | } 75 | 76 | // RunUntilFirstCompletion runs jobs concurrently until atleast one job has finished or any job has encountered an error. 77 | func RunUntilFirstCompletion(jobs ...ConcurrentJob) error { 78 | return RunUntilFirstCompletionWithContext(context.Background(), jobs...) 79 | } 80 | 81 | // RunUntilFirstCompletionWithContext runs jobs concurrently until atleast one job has finished or any job has encountered an error. 82 | func RunUntilFirstCompletionWithContext(parentCtx context.Context, jobs ...ConcurrentJob) error { 83 | var result error 84 | 85 | ctx, cancel := context.WithCancel(parentCtx) 86 | defer cancel() 87 | 88 | errCh := make(chan error, 10) 89 | waitForErrCompletion := sync.WaitGroup{} 90 | waitForErrCompletion.Add(1) 91 | go func() { 92 | result = cancelOnFirstError(cancel, errCh) 93 | waitForErrCompletion.Done() 94 | }() 95 | 96 | runJobsUntilAtleastOneDone(ctx, cancel, jobs, errCh) 97 | 98 | close(errCh) 99 | waitForErrCompletion.Wait() 100 | 101 | return result 102 | } 103 | 104 | // RunConcurrentlyWithTimeout runs jobs concurrently until all jobs have either finished or any one job encountered an error. 105 | // or the timeout has expired 106 | func RunConcurrentlyWithTimeout(timeout time.Duration, jobs ...ConcurrentJob) error { 107 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 108 | defer cancel() 109 | 110 | return RunConcurrentlyWithContext(ctx, jobs...) 111 | } 112 | 113 | // RunUntilFirstCompletionWithTimeout runs jobs concurrently until atleast one job has finished or any job has encountered an error 114 | // or the timeout has expired. 115 | func RunUntilFirstCompletionWithTimeout(timeout time.Duration, jobs ...ConcurrentJob) error { 116 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 117 | defer cancel() 118 | 119 | return RunUntilFirstCompletionWithContext(ctx, jobs...) 120 | } 121 | 122 | // IsContextDone is a utility function to check if the context is Done/Cancelled. 123 | func IsContextDone(ctx context.Context) bool { 124 | select { 125 | case <-ctx.Done(): 126 | return true 127 | default: 128 | return false 129 | } 130 | } 131 | 132 | func cancelOnFirstError(cancel context.CancelFunc, errCh chan error) error { 133 | err := <-errCh 134 | if err == nil { 135 | return nil 136 | } 137 | cancel() 138 | // drain the errCh so we don't block producers 139 | for range errCh { 140 | } 141 | return err 142 | } 143 | 144 | func runJobsUntilAllDone(ctx context.Context, jobs []ConcurrentJob, errCh chan error) { 145 | wg := sync.WaitGroup{} 146 | for _, job := range jobs { 147 | wg.Add(1) 148 | go func(job ConcurrentJob) { 149 | job(ctx, errCh) 150 | wg.Done() 151 | }(job) 152 | } 153 | wg.Wait() 154 | } 155 | 156 | func runJobsUntilAtleastOneDone(ctx context.Context, cancel context.CancelFunc, jobs []ConcurrentJob, errCh chan error) { 157 | wg := sync.WaitGroup{} 158 | for _, job := range jobs { 159 | wg.Add(1) 160 | go func(job ConcurrentJob) { 161 | job(ctx, errCh) 162 | cancel() 163 | wg.Done() 164 | }(job) 165 | } 166 | wg.Wait() 167 | } 168 | -------------------------------------------------------------------------------- /nursery_test.go: -------------------------------------------------------------------------------- 1 | package nursery_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "reflect" 8 | "sync" 9 | "testing" 10 | "time" 11 | 12 | "github.com/arunsworld/nursery" 13 | ) 14 | 15 | func ExampleConcurrentJob() { 16 | nursery.RunConcurrently( 17 | // Job 1 18 | func(context.Context, chan error) { 19 | time.Sleep(time.Millisecond * 10) 20 | log.Println("Job 1 done...") 21 | }, 22 | // Job 2 23 | func(context.Context, chan error) { 24 | time.Sleep(time.Millisecond * 5) 25 | log.Println("Job 2 done...") 26 | }, 27 | ) 28 | log.Println("All jobs done...") 29 | } 30 | 31 | func TestRunConcurrently(t *testing.T) { 32 | t.Run("jobs without errors - we wait for the longest running one", func(t *testing.T) { 33 | jobsDone := [3]bool{} 34 | 35 | jobFastest := func(context.Context, chan error) { jobsDone[0] = true } 36 | jobSlower := func(context.Context, chan error) { time.Sleep(time.Millisecond); jobsDone[1] = true } 37 | jobSlowest := func(context.Context, chan error) { time.Sleep(time.Millisecond * 5); jobsDone[2] = true } 38 | 39 | jobs := []nursery.ConcurrentJob{ 40 | jobSlower, jobSlowest, jobFastest, 41 | } 42 | 43 | err := nursery.RunConcurrently(jobs...) 44 | if err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | if jobsDone != [3]bool{true, true, true} { 49 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 50 | } 51 | 52 | // Now in a difference sequence 53 | jobs = []nursery.ConcurrentJob{ 54 | jobFastest, jobSlower, jobSlowest, 55 | } 56 | 57 | err = nursery.RunConcurrently(jobs...) 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | 62 | if jobsDone != [3]bool{true, true, true} { 63 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 64 | } 65 | }) 66 | 67 | t.Run("jobs with one err - error handled; we ensure all jobs are cleaned up", func(t *testing.T) { 68 | jobsDone := [4]bool{} 69 | jobsOutput := [4]int{} 70 | 71 | jobFastest := func(context.Context, chan error) { 72 | jobsOutput[2]++ 73 | jobsDone[2] = true 74 | } 75 | jobSlower := func(context.Context, chan error) { 76 | time.Sleep(time.Millisecond) 77 | jobsOutput[0]++ 78 | jobsDone[0] = true 79 | } 80 | jobSlowest := func(context.Context, chan error) { 81 | time.Sleep(time.Millisecond * 5) 82 | jobsOutput[1]++ 83 | jobsDone[1] = true 84 | } 85 | slowerJobWithErr := func(ctx context.Context, ch chan error) { 86 | time.Sleep(time.Millisecond) 87 | ch <- errors.New("slowerJobWithErr error") 88 | jobsDone[3] = true 89 | } 90 | 91 | err := nursery.RunConcurrently(jobSlower, jobSlowest, jobFastest, slowerJobWithErr) 92 | 93 | if jobsDone != [4]bool{true, true, true, true} { 94 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 95 | } 96 | 97 | if err == nil { 98 | t.Fatal("expected to have received an error but didn't") 99 | } 100 | 101 | if err.Error() != "slowerJobWithErr error" { 102 | t.Fatal("Error not as expected") 103 | } 104 | 105 | }) 106 | 107 | t.Run("jobs with one err - ensure long jobs have an opportunity to bail early", func(t *testing.T) { 108 | jobsDone := [2]bool{} 109 | jobsOutput := [2]int{} 110 | 111 | jobWithErr := func(ctx context.Context, ch chan error) { 112 | time.Sleep(time.Millisecond * 2) 113 | ch <- errors.New("jobWithErr error") 114 | jobsDone[0] = true 115 | } 116 | neverEndingJob := func(ctx context.Context, ch chan error) { 117 | func() { 118 | for { 119 | select { 120 | case <-ctx.Done(): 121 | return 122 | default: 123 | time.Sleep(time.Millisecond) 124 | jobsOutput[1]++ 125 | } 126 | } 127 | }() 128 | jobsDone[1] = true 129 | } 130 | 131 | err := nursery.RunConcurrently(jobWithErr, neverEndingJob) 132 | 133 | if jobsDone != [2]bool{true, true} { 134 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 135 | } 136 | 137 | if err == nil { 138 | t.Fatal("expected to have received an error but didn't") 139 | } 140 | 141 | if jobsOutput[1] < 1 || jobsOutput[1] > 3 { 142 | t.Fatal("expected to have bailed early but didn't") 143 | } 144 | }) 145 | 146 | t.Run("jobs with multiple errors - everything continues to work as expected", func(t *testing.T) { 147 | jobsDone := [3]bool{} 148 | jobsOutput := [3]int{} 149 | 150 | jobWithErr := func(ctx context.Context, ch chan error) { 151 | time.Sleep(time.Millisecond * 2) 152 | ch <- errors.New("jobWithErr error") 153 | jobsDone[0] = true 154 | } 155 | jobWithAnotherErr := func(ctx context.Context, ch chan error) { 156 | time.Sleep(time.Millisecond * 4) 157 | ch <- errors.New("jobWithAnotherErr error") 158 | jobsDone[2] = true 159 | } 160 | neverEndingJob := func(ctx context.Context, ch chan error) { 161 | func() { 162 | for { 163 | select { 164 | case <-ctx.Done(): 165 | return 166 | default: 167 | time.Sleep(time.Millisecond) 168 | jobsOutput[1]++ 169 | } 170 | } 171 | }() 172 | jobsDone[1] = true 173 | } 174 | 175 | err := nursery.RunConcurrently(jobWithErr, neverEndingJob, jobWithAnotherErr) 176 | 177 | if jobsDone != [3]bool{true, true, true} { 178 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 179 | } 180 | 181 | if err == nil { 182 | t.Fatal("expected to have received an error but didn't") 183 | } 184 | 185 | if jobsOutput[1] < 1 || jobsOutput[1] > 3 { 186 | t.Fatal("expected to have bailed early but didn't") 187 | } 188 | }) 189 | } 190 | 191 | func TestRunUntilFirstCompletion(t *testing.T) { 192 | t.Run("jobs without errors - we wait for the short running one", func(t *testing.T) { 193 | jobsDone := [2]bool{} 194 | 195 | jobFastest := func(context.Context, chan error) { jobsDone[0] = true } 196 | jobForever := func(ctx context.Context, errCh chan error) { 197 | delay := time.NewTimer(time.Second * 500) 198 | select { 199 | case <-ctx.Done(): 200 | delay.Stop() 201 | case <-delay.C: 202 | } 203 | jobsDone[1] = true 204 | } 205 | 206 | err := nursery.RunUntilFirstCompletion(jobFastest, jobForever) 207 | if err != nil { 208 | t.Fatal(err) 209 | } 210 | 211 | if jobsDone != [2]bool{true, true} { 212 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 213 | } 214 | }) 215 | 216 | // RunUntilFirstCompletion should also satisfy err cases identical to RunConcurrently 217 | t.Run("jobs with one err - ensure long jobs have an opportunity to bail early", func(t *testing.T) { 218 | jobsDone := [2]bool{} 219 | jobsOutput := [2]int{} 220 | 221 | jobWithErr := func(ctx context.Context, ch chan error) { 222 | time.Sleep(time.Millisecond * 2) 223 | ch <- errors.New("jobWithErr error") 224 | jobsDone[0] = true 225 | } 226 | neverEndingJob := func(ctx context.Context, ch chan error) { 227 | func() { 228 | for { 229 | select { 230 | case <-ctx.Done(): 231 | return 232 | default: 233 | time.Sleep(time.Millisecond) 234 | jobsOutput[1]++ 235 | } 236 | } 237 | }() 238 | jobsDone[1] = true 239 | } 240 | 241 | err := nursery.RunUntilFirstCompletion(jobWithErr, neverEndingJob) 242 | 243 | if jobsDone != [2]bool{true, true} { 244 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 245 | } 246 | 247 | if err == nil { 248 | t.Fatal("expected to have received an error but didn't") 249 | } 250 | 251 | if jobsOutput[1] < 1 || jobsOutput[1] > 3 { 252 | t.Fatal("expected to have bailed early but didn't") 253 | } 254 | }) 255 | 256 | t.Run("jobs with multiple errors - everything continues to work as expected", func(t *testing.T) { 257 | jobsDone := [3]bool{} 258 | jobsOutput := [3]int{} 259 | 260 | jobWithErr := func(ctx context.Context, ch chan error) { 261 | time.Sleep(time.Millisecond * 2) 262 | ch <- errors.New("jobWithErr error") 263 | jobsDone[0] = true 264 | } 265 | jobWithAnotherErr := func(ctx context.Context, ch chan error) { 266 | time.Sleep(time.Millisecond * 4) 267 | ch <- errors.New("jobWithAnotherErr error") 268 | jobsDone[2] = true 269 | } 270 | neverEndingJob := func(ctx context.Context, ch chan error) { 271 | func() { 272 | for { 273 | select { 274 | case <-ctx.Done(): 275 | return 276 | default: 277 | time.Sleep(time.Millisecond) 278 | jobsOutput[1]++ 279 | } 280 | } 281 | }() 282 | jobsDone[1] = true 283 | } 284 | 285 | err := nursery.RunUntilFirstCompletion(jobWithErr, neverEndingJob, jobWithAnotherErr) 286 | 287 | if jobsDone != [3]bool{true, true, true} { 288 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 289 | } 290 | 291 | if err == nil { 292 | t.Fatal("expected to have received an error but didn't") 293 | } 294 | 295 | if jobsOutput[1] < 1 || jobsOutput[1] > 3 { 296 | t.Fatal("expected to have bailed early but didn't") 297 | } 298 | }) 299 | } 300 | 301 | func TestRunConcurrentlyWithTimeout(t *testing.T) { 302 | t.Run("jobs without errors - timeout stops running processes", func(t *testing.T) { 303 | jobsDone := [2]bool{} 304 | jobsCount := [2]int{} 305 | 306 | jobForeverA := func(ctx context.Context, errCh chan error) { 307 | ticker := time.NewTicker(time.Millisecond) 308 | func() { 309 | for { 310 | select { 311 | case <-ctx.Done(): 312 | return 313 | case <-ticker.C: 314 | jobsCount[0]++ 315 | } 316 | } 317 | }() 318 | ticker.Stop() 319 | jobsDone[0] = true 320 | } 321 | jobForeverB := func(ctx context.Context, errCh chan error) { 322 | ticker := time.NewTicker(time.Millisecond) 323 | func() { 324 | for { 325 | select { 326 | case <-ctx.Done(): 327 | return 328 | case <-ticker.C: 329 | jobsCount[1]++ 330 | } 331 | } 332 | }() 333 | ticker.Stop() 334 | jobsDone[1] = true 335 | } 336 | 337 | err := nursery.RunConcurrentlyWithTimeout(time.Millisecond*10, jobForeverA, jobForeverB) 338 | if err != nil { 339 | log.Fatal(err) 340 | } 341 | 342 | if jobsDone != [2]bool{true, true} { 343 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 344 | } 345 | 346 | if jobsCount[0]+jobsCount[1] < 10 || jobsCount[0]+jobsCount[1] > 22 { 347 | t.Fatalf("jobsCount out of range. Expected 10 < total < 22 but got: %v", jobsCount) 348 | } 349 | }) 350 | 351 | t.Run("jobs without errors - jobs finish before timeout", func(t *testing.T) { 352 | jobsDone := [2]bool{} 353 | jobsCount := [2]int{} 354 | 355 | quickJobA := func(ctx context.Context, errCh chan error) { 356 | ticker := time.NewTicker(time.Millisecond) 357 | func() { 358 | for i := 0; i < 5; i++ { 359 | select { 360 | case <-ctx.Done(): 361 | return 362 | case <-ticker.C: 363 | jobsCount[0]++ 364 | } 365 | } 366 | }() 367 | ticker.Stop() 368 | jobsDone[0] = true 369 | } 370 | notAsQuickJobB := func(ctx context.Context, errCh chan error) { 371 | ticker := time.NewTicker(time.Millisecond) 372 | func() { 373 | for i := 0; i < 10; i++ { 374 | select { 375 | case <-ctx.Done(): 376 | return 377 | case <-ticker.C: 378 | jobsCount[1]++ 379 | } 380 | } 381 | }() 382 | ticker.Stop() 383 | jobsDone[1] = true 384 | } 385 | 386 | err := nursery.RunConcurrentlyWithTimeout(time.Second, quickJobA, notAsQuickJobB) 387 | if err != nil { 388 | log.Fatal(err) 389 | } 390 | 391 | if jobsDone != [2]bool{true, true} { 392 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 393 | } 394 | 395 | if jobsCount[0]+jobsCount[1] < 13 || jobsCount[0]+jobsCount[1] > 17 { 396 | t.Fatalf("jobsCount out of range. Expected 13 < total < 17 but got: %v", jobsCount) 397 | } 398 | }) 399 | 400 | // RunUntilFirstCompletion should also satisfy err cases identical to RunConcurrently 401 | t.Run("jobs with one err - ensure long jobs have an opportunity to bail early", func(t *testing.T) { 402 | jobsDone := [2]bool{} 403 | jobsOutput := [2]int{} 404 | 405 | jobWithErr := func(ctx context.Context, ch chan error) { 406 | time.Sleep(time.Millisecond * 2) 407 | ch <- errors.New("jobWithErr error") 408 | jobsDone[0] = true 409 | } 410 | neverEndingJob := func(ctx context.Context, ch chan error) { 411 | func() { 412 | for { 413 | select { 414 | case <-ctx.Done(): 415 | return 416 | default: 417 | time.Sleep(time.Millisecond) 418 | jobsOutput[1]++ 419 | } 420 | } 421 | }() 422 | jobsDone[1] = true 423 | } 424 | 425 | err := nursery.RunConcurrentlyWithTimeout(time.Second, jobWithErr, neverEndingJob) 426 | 427 | if jobsDone != [2]bool{true, true} { 428 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 429 | } 430 | 431 | if err == nil { 432 | t.Fatal("expected to have received an error but didn't") 433 | } 434 | 435 | if jobsOutput[1] < 1 || jobsOutput[1] > 3 { 436 | t.Fatal("expected to have bailed early but didn't") 437 | } 438 | }) 439 | 440 | t.Run("jobs with multiple errors - everything continues to work as expected", func(t *testing.T) { 441 | jobsDone := [3]bool{} 442 | jobsOutput := [3]int{} 443 | 444 | jobWithErr := func(ctx context.Context, ch chan error) { 445 | time.Sleep(time.Millisecond * 2) 446 | ch <- errors.New("jobWithErr error") 447 | jobsDone[0] = true 448 | } 449 | jobWithAnotherErr := func(ctx context.Context, ch chan error) { 450 | time.Sleep(time.Millisecond * 4) 451 | ch <- errors.New("jobWithAnotherErr error") 452 | jobsDone[2] = true 453 | } 454 | neverEndingJob := func(ctx context.Context, ch chan error) { 455 | func() { 456 | for { 457 | select { 458 | case <-ctx.Done(): 459 | return 460 | default: 461 | time.Sleep(time.Millisecond) 462 | jobsOutput[1]++ 463 | } 464 | } 465 | }() 466 | jobsDone[1] = true 467 | } 468 | 469 | err := nursery.RunConcurrentlyWithTimeout(time.Second, jobWithErr, neverEndingJob, jobWithAnotherErr) 470 | 471 | if jobsDone != [3]bool{true, true, true} { 472 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 473 | } 474 | 475 | if err == nil { 476 | t.Fatal("expected to have received an error but didn't") 477 | } 478 | 479 | if jobsOutput[1] < 1 || jobsOutput[1] > 3 { 480 | t.Fatal("expected to have bailed early but didn't") 481 | } 482 | }) 483 | } 484 | 485 | func TestRunUntilFirstCompletionWithTimeout(t *testing.T) { 486 | t.Run("jobs without errors - timeout stops running processes", func(t *testing.T) { 487 | jobsDone := [2]bool{} 488 | jobsCount := [2]int{} 489 | 490 | jobForeverA := func(ctx context.Context, errCh chan error) { 491 | ticker := time.NewTicker(time.Millisecond) 492 | func() { 493 | for { 494 | select { 495 | case <-ctx.Done(): 496 | return 497 | case <-ticker.C: 498 | jobsCount[0]++ 499 | } 500 | } 501 | }() 502 | ticker.Stop() 503 | jobsDone[0] = true 504 | } 505 | jobForeverB := func(ctx context.Context, errCh chan error) { 506 | ticker := time.NewTicker(time.Millisecond) 507 | func() { 508 | for { 509 | select { 510 | case <-ctx.Done(): 511 | return 512 | case <-ticker.C: 513 | jobsCount[1]++ 514 | } 515 | } 516 | }() 517 | ticker.Stop() 518 | jobsDone[1] = true 519 | } 520 | 521 | err := nursery.RunUntilFirstCompletionWithTimeout(time.Millisecond*10, jobForeverA, jobForeverB) 522 | if err != nil { 523 | log.Fatal(err) 524 | } 525 | 526 | if jobsDone != [2]bool{true, true} { 527 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 528 | } 529 | 530 | if jobsCount[0]+jobsCount[1] < 18 || jobsCount[0]+jobsCount[1] > 22 { 531 | t.Fatalf("jobsCount out of range. Expected 18 < total < 22 but got: %v", jobsCount) 532 | } 533 | }) 534 | 535 | t.Run("jobs without errors - quick job finishes before timeout", func(t *testing.T) { 536 | jobsDone := [2]bool{} 537 | jobsCount := [2]int{} 538 | 539 | quickJobA := func(ctx context.Context, errCh chan error) { 540 | ticker := time.NewTicker(time.Millisecond) 541 | func() { 542 | for i := 0; i < 5; i++ { 543 | select { 544 | case <-ctx.Done(): 545 | return 546 | case <-ticker.C: 547 | jobsCount[0]++ 548 | } 549 | } 550 | }() 551 | ticker.Stop() 552 | jobsDone[0] = true 553 | } 554 | notAsQuickJobB := func(ctx context.Context, errCh chan error) { 555 | ticker := time.NewTicker(time.Millisecond) 556 | func() { 557 | for i := 0; i < 100; i++ { 558 | select { 559 | case <-ctx.Done(): 560 | return 561 | case <-ticker.C: 562 | jobsCount[1]++ 563 | } 564 | } 565 | }() 566 | ticker.Stop() 567 | jobsDone[1] = true 568 | } 569 | 570 | err := nursery.RunUntilFirstCompletionWithTimeout(time.Millisecond*10, quickJobA, notAsQuickJobB) 571 | if err != nil { 572 | log.Fatal(err) 573 | } 574 | 575 | if jobsDone != [2]bool{true, true} { 576 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 577 | } 578 | 579 | if jobsCount[0]+jobsCount[1] < 8 || jobsCount[0]+jobsCount[1] > 12 { 580 | t.Fatalf("jobsCount out of range. Expected 8 < total < 12 but got: %v", jobsCount) 581 | } 582 | }) 583 | 584 | // RunUntilFirstCompletion should also satisfy err cases identical to RunConcurrently 585 | t.Run("jobs with one err - ensure long jobs have an opportunity to bail early", func(t *testing.T) { 586 | jobsDone := [2]bool{} 587 | jobsOutput := [2]int{} 588 | 589 | jobWithErr := func(ctx context.Context, ch chan error) { 590 | time.Sleep(time.Millisecond * 2) 591 | ch <- errors.New("jobWithErr error") 592 | jobsDone[0] = true 593 | } 594 | neverEndingJob := func(ctx context.Context, ch chan error) { 595 | func() { 596 | for { 597 | select { 598 | case <-ctx.Done(): 599 | return 600 | default: 601 | time.Sleep(time.Millisecond) 602 | jobsOutput[1]++ 603 | } 604 | } 605 | }() 606 | jobsDone[1] = true 607 | } 608 | 609 | err := nursery.RunUntilFirstCompletionWithTimeout(time.Second, jobWithErr, neverEndingJob) 610 | 611 | if jobsDone != [2]bool{true, true} { 612 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 613 | } 614 | 615 | if err == nil { 616 | t.Fatal("expected to have received an error but didn't") 617 | } 618 | 619 | if jobsOutput[1] < 1 || jobsOutput[1] > 3 { 620 | t.Fatal("expected to have bailed early but didn't") 621 | } 622 | }) 623 | 624 | t.Run("jobs with multiple errors - everything continues to work as expected", func(t *testing.T) { 625 | jobsDone := [3]bool{} 626 | jobsOutput := [3]int{} 627 | 628 | jobWithErr := func(ctx context.Context, ch chan error) { 629 | time.Sleep(time.Millisecond * 2) 630 | ch <- errors.New("jobWithErr error") 631 | jobsDone[0] = true 632 | } 633 | jobWithAnotherErr := func(ctx context.Context, ch chan error) { 634 | time.Sleep(time.Millisecond * 4) 635 | ch <- errors.New("jobWithAnotherErr error") 636 | jobsDone[2] = true 637 | } 638 | neverEndingJob := func(ctx context.Context, ch chan error) { 639 | func() { 640 | for { 641 | select { 642 | case <-ctx.Done(): 643 | return 644 | default: 645 | time.Sleep(time.Millisecond) 646 | jobsOutput[1]++ 647 | } 648 | } 649 | }() 650 | jobsDone[1] = true 651 | } 652 | 653 | jobs := []nursery.ConcurrentJob{ 654 | jobWithErr, neverEndingJob, jobWithAnotherErr, 655 | } 656 | 657 | err := nursery.RunUntilFirstCompletionWithTimeout(time.Second, jobs...) 658 | 659 | if jobsDone != [3]bool{true, true, true} { 660 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 661 | } 662 | 663 | if err == nil { 664 | t.Fatal("expected to have received an error but didn't") 665 | } 666 | 667 | if jobsOutput[1] < 1 || jobsOutput[1] > 3 { 668 | t.Fatal("expected to have bailed early but didn't") 669 | } 670 | }) 671 | } 672 | 673 | func TestRunConcurrentlyWithContext(t *testing.T) { 674 | t.Run("jobs without errors - parent context cancellation stops running processes", func(t *testing.T) { 675 | jobsDone := [2]bool{} 676 | jobsCount := [2]int{} 677 | 678 | jobForeverA := func(ctx context.Context, errCh chan error) { 679 | ticker := time.NewTicker(time.Millisecond) 680 | func() { 681 | for { 682 | select { 683 | case <-ctx.Done(): 684 | return 685 | case <-ticker.C: 686 | jobsCount[0]++ 687 | } 688 | } 689 | }() 690 | ticker.Stop() 691 | jobsDone[0] = true 692 | } 693 | jobForeverB := func(ctx context.Context, errCh chan error) { 694 | ticker := time.NewTicker(time.Millisecond) 695 | func() { 696 | for { 697 | select { 698 | case <-ctx.Done(): 699 | return 700 | case <-ticker.C: 701 | jobsCount[1]++ 702 | } 703 | } 704 | }() 705 | ticker.Stop() 706 | jobsDone[1] = true 707 | } 708 | 709 | ctx, cancel := context.WithCancel(context.Background()) 710 | go func() { 711 | time.Sleep(time.Millisecond * 10) 712 | cancel() 713 | }() 714 | 715 | err := nursery.RunConcurrentlyWithContext(ctx, jobForeverA, jobForeverB) 716 | if err != nil { 717 | log.Fatal(err) 718 | } 719 | 720 | if jobsDone != [2]bool{true, true} { 721 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 722 | } 723 | 724 | if jobsCount[0]+jobsCount[1] < 10 || jobsCount[0]+jobsCount[1] > 25 { 725 | t.Fatalf("jobsCount out of range. Expected 10 < total < 25 but got: %v", jobsCount) 726 | } 727 | }) 728 | } 729 | 730 | func TestRunUntilFirstCompletionWithContext(t *testing.T) { 731 | t.Run("jobs without errors - parent context cancellation stops running processes", func(t *testing.T) { 732 | jobsDone := [2]bool{} 733 | jobsCount := [2]int{} 734 | 735 | jobForeverA := func(ctx context.Context, errCh chan error) { 736 | ticker := time.NewTicker(time.Millisecond) 737 | func() { 738 | for { 739 | select { 740 | case <-ctx.Done(): 741 | return 742 | case <-ticker.C: 743 | jobsCount[0]++ 744 | } 745 | } 746 | }() 747 | ticker.Stop() 748 | jobsDone[0] = true 749 | } 750 | jobForeverB := func(ctx context.Context, errCh chan error) { 751 | ticker := time.NewTicker(time.Millisecond) 752 | func() { 753 | for { 754 | select { 755 | case <-ctx.Done(): 756 | return 757 | case <-ticker.C: 758 | jobsCount[1]++ 759 | } 760 | } 761 | }() 762 | ticker.Stop() 763 | jobsDone[1] = true 764 | } 765 | 766 | ctx, cancel := context.WithCancel(context.Background()) 767 | go func() { 768 | time.Sleep(time.Millisecond * 10) 769 | cancel() 770 | }() 771 | 772 | err := nursery.RunUntilFirstCompletionWithContext(ctx, jobForeverA, jobForeverB) 773 | if err != nil { 774 | log.Fatal(err) 775 | } 776 | 777 | if jobsDone != [2]bool{true, true} { 778 | t.Fatalf("expected all jobs to be done but instead got: %v", jobsDone) 779 | } 780 | 781 | if jobsCount[0]+jobsCount[1] < 18 || jobsCount[0]+jobsCount[1] > 25 { 782 | t.Fatalf("jobsCount out of range. Expected 18 < total < 25 but got: %v", jobsCount) 783 | } 784 | }) 785 | } 786 | 787 | func TestRunMultipleCopiesConcurrently(t *testing.T) { 788 | t.Run("producer and multiple concurrent consumers", func(t *testing.T) { 789 | ch := make(chan struct{}) 790 | result := make(map[int]int) 791 | nursery.RunConcurrently( 792 | // producer job producing 10 items 793 | func(context.Context, chan error) { 794 | for i := 0; i < 10; i++ { 795 | ch <- struct{}{} 796 | } 797 | close(ch) 798 | }, 799 | // consumer job 800 | func(context.Context, chan error) { 801 | mu := sync.Mutex{} 802 | nursery.RunMultipleCopiesConcurrently(5, 803 | // the job listens on ch and if activated will update 804 | // result map with a counter for it's own Job ID 805 | func(ctx context.Context, errCh chan error) { 806 | myJobID := ctx.Value(nursery.JobID).(int) + 1 807 | for range ch { 808 | time.Sleep(time.Millisecond * 10) 809 | mu.Lock() 810 | v := result[myJobID] 811 | result[myJobID] = v + 1 812 | mu.Unlock() 813 | } 814 | }, 815 | ) 816 | }, 817 | ) 818 | if !reflect.DeepEqual(result, map[int]int{ 819 | 1: 2, 2: 2, 3: 2, 4: 2, 5: 2, 820 | }) { 821 | t.Fatalf("expected a different result than: %#v", result) 822 | } 823 | }) 824 | } 825 | 826 | func TestIsContextDone(t *testing.T) { 827 | // Given 828 | ctx, cancel := context.WithCancel(context.Background()) 829 | t.Run("detects contexts that are alive (not done)", func(t *testing.T) { 830 | // Then 831 | status := nursery.IsContextDone(ctx) 832 | if status == true { 833 | t.Fatal("expected context not to be done, but it is") 834 | } 835 | }) 836 | t.Run("detects contexts that are not alive (done or cancelled)", func(t *testing.T) { 837 | // When 838 | cancel() 839 | // Then 840 | status := nursery.IsContextDone(ctx) 841 | if status == false { 842 | t.Fatal("expected context to be done, but it is not") 843 | } 844 | }) 845 | } 846 | --------------------------------------------------------------------------------