├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ └── codeql-analysis.yml ├── LICENSE ├── README.md ├── async_test.go ├── batch.go ├── batch_bench_test.go ├── batch_test.go ├── database_test.go ├── error.go ├── go.mod ├── go.sum ├── goroutine.go ├── logo.svg ├── metric.go └── metric_test.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: elgopher 7 | 8 | --- 9 | 10 | Describe the bug here. Put code to reproduce the bug. Add information about **batch** version. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: elgopher 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v3 18 | with: 19 | go-version: 1.18 20 | 21 | - name: Build 22 | run: go build -v ./... 23 | 24 | - name: Test 25 | run: go test -race -v ./... 26 | 27 | coverage: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v3 31 | 32 | - name: Set up Go 33 | uses: actions/setup-go@v3 34 | with: 35 | go-version: 1.18 36 | 37 | - name: Test 38 | run: go test -v -coverprofile=coverage.txt -covermode=atomic ./... 39 | 40 | - name: Upload coverage to Codecov 41 | run: bash <(curl -s https://codecov.io/bash) 42 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | schedule: 9 | - cron: '33 21 * * 0' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ 'go' ] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v3 28 | 29 | # Initializes the CodeQL tools for scanning. 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v2 32 | with: 33 | languages: ${{ matrix.language }} 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v2 37 | 38 | # ℹ️ Command-line programs to run using the OS shell. 39 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 40 | 41 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 42 | # and modify them (or add more) to build your code if your project 43 | # uses a compiled language 44 | 45 | #- run: | 46 | # make bootstrap 47 | # make release 48 | 49 | - name: Perform CodeQL Analysis 50 | uses: github/codeql-action/analyze@v2 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jacek Olszak 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 | [![Build](https://github.com/elgopher/batch/actions/workflows/build.yml/badge.svg)](https://github.com/elgopher/batch/actions/workflows/build.yml) 2 | [![Go Reference](https://pkg.go.dev/badge/github.com/elgopher/batch.svg)](https://pkg.go.dev/github.com/elgopher/batch) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/elgopher/batch)](https://goreportcard.com/report/github.com/elgopher/batch) 4 | [![codecov](https://codecov.io/gh/elgopher/batch/branch/master/graph/badge.svg)](https://codecov.io/gh/elgopher/batch) 5 | [![Project Status: Active – The project has reached a stable, usable state and is being actively developed.](https://www.repostatus.org/badges/latest/active.svg)](https://www.repostatus.org/#active) 6 | 7 | 8 | ## What it can be used for? 9 | 10 | To **increase** database-driven web application **throughput** without sacrificing *data consistency* and *data durability* or making source code and architecture complex. 11 | 12 | The **batch** package simplifies writing Go applications that process incoming requests (HTTP, GRPC etc.) in a batch manner: 13 | instead of processing each request separately, they group incoming requests to a batch and run whole group at once. 14 | This method of processing can significantly speed up the application and reduce the consumption of disk, network or CPU. 15 | 16 | The **batch** package can be used to write any type of *servers* that handle thousands of requests per second. 17 | Thanks to this small library, you can create relatively simple code without the need to use low-level data structures. 18 | 19 | ## Why batch processing improves performance? 20 | 21 | Normally a web application is using following pattern to modify data in the database: 22 | 23 | 1. **Load resource** from database. **Resource** is some portion of data 24 | such as set of records from relational database, document from Document-oriented database or value from KV store 25 | (in Domain-Driven Design terms it is called an [aggregate](https://martinfowler.com/bliki/DDD_Aggregate.html)). 26 | Lock the entire resource [optimistically](https://www.martinfowler.com/eaaCatalog/optimisticOfflineLock.html) 27 | by reading version number. 28 | 2. **Apply change** to data in plain Go 29 | 3. **Save resource** to database. Release the lock by running 30 | atomic update with version check. 31 | 32 | But such architecture does not scale well if the number of requests 33 | for a single resource is very high 34 | (meaning hundreds or thousands of requests per second). 35 | The lock contention in such case is very high and database is significantly 36 | overloaded. Also, round-trips between application server and database add latency. 37 | Practically, the number of concurrent requests is severely limited. 38 | 39 | One solution to this problem is to reduce the number of costly operations. 40 | Because a single resource is loaded and saved thousands of times per second 41 | we can instead: 42 | 43 | 1. Load the resource **once** (let's say once per second) 44 | 2. Execute all the requests from this period of time on an already loaded resource. Run them all sequentially to keep things simple and data consistent. 45 | 3. Save the resource and send responses to all clients if data was stored successfully. 46 | 47 | Such solution could improve the performance by a factor of 1000. And resource is still stored in a consistent state. 48 | 49 | The **batch** package does exactly that. You configure the duration of window, provide functions 50 | to load and save resource and once the request comes in - you run a function: 51 | 52 | ```go 53 | // Set up the batch processor: 54 | processor := batch.StartProcessor( 55 | batch.Options[*YourResource]{ // YourResource is your own Go struct 56 | MinDuration: 100 * time.Millisecond, 57 | LoadResource: func(ctx context.Context, resourceKey string) (*YourResource, error){ 58 | // resourceKey uniquely identifies the resource 59 | ... 60 | }, 61 | SaveResource: ..., 62 | }, 63 | ) 64 | 65 | // And use the processor inside http/grpc handler or technology-agnostic service. 66 | // ctx is a standard context.Context and resourceKey can be taken from request parameter 67 | err := processor.Run(ctx, resourceKey, func(r *YourResource) { 68 | // Here you put the code which will executed sequentially inside batch 69 | }) 70 | ``` 71 | 72 | **For real-life example see [example web application](https://github.com/elgopher/batch-example).** 73 | 74 | ## Installation 75 | 76 | ```sh 77 | # Add batch to your Go module: 78 | go get github.com/elgopher/batch 79 | ``` 80 | Please note that at least **Go 1.18** is required. The package is using generics, which was added in 1.18. 81 | 82 | ## Scaling out 83 | 84 | Single Go http server is able to handle up to tens of thousands of requests per second on a commodity hardware. 85 | This is a lot, but very often you also need: 86 | 87 | * high availability (if one server goes down you want other to handle the traffic) 88 | * you want to handle hundred-thousands or millions of requests per second 89 | 90 | For both cases you need to deploy **multiple servers** and put a **load balancer** in front of them. 91 | Please note though, that you have to carefully configure the load balancing algorithm. 92 | _Round-robin_ is not an option here, because sooner or later you will have problems with locking 93 | (multiple server instances will run batches on the same resource). 94 | Ideal solution is to route requests based on URL path or query string parameters. 95 | For example some http query string parameter could have a resource key. You can instruct load balancer 96 | to calculate hash on this parameter and always route requests with the same key 97 | to the same backend. If backend will be no longer available the load balancer should route request to a different 98 | server. 99 | -------------------------------------------------------------------------------- /async_test.go: -------------------------------------------------------------------------------- 1 | // (c) 2022 Jacek Olszak 2 | // This code is licensed under MIT license (see LICENSE for details) 3 | 4 | package batch_test 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func FutureValue[V any]() Value[V] { 14 | return Value[V]{ 15 | done: make(chan V, 1), 16 | } 17 | } 18 | 19 | type Value[V any] struct { 20 | done chan V 21 | } 22 | 23 | func (d Value[V]) Set(result V) { 24 | d.done <- result 25 | } 26 | 27 | func (d Value[V]) Get(t *testing.T) V { 28 | select { 29 | case r, _ := <-d.done: 30 | return r 31 | case <-time.After(time.Second): 32 | assert.FailNow(t, "timeout waiting for value") 33 | var r V 34 | return r 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /batch.go: -------------------------------------------------------------------------------- 1 | // (c) 2022 Jacek Olszak 2 | // This code is licensed under MIT license (see LICENSE for details) 3 | 4 | package batch 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | // Options represent parameters for batch.Processor. They should be passed to StartProcessor function. All options 14 | // (as the name suggest) are optional and have default values. 15 | type Options[Resource any] struct { 16 | // All batches will be run for at least MinDuration. 17 | // 18 | // By default, 100ms. 19 | MinDuration time.Duration 20 | 21 | // Batch will have timeout with MaxDuration. Context with this timeout will be passed to 22 | // LoadResource and SaveResource functions, which can abort the batch by returning an error. 23 | // 24 | // By default, 2*MinDuration. 25 | MaxDuration time.Duration 26 | 27 | // LoadResource loads resource with given key from a database. Returning an error aborts the batch. 28 | // This function is called in the beginning of each new batch. 29 | // 30 | // Context passed as a first parameter has a timeout calculated using batch MaxDuration. 31 | // You can watch context cancellation in order to abort loading resource if it takes too long. 32 | // Context is also cancelled after batch was ended. 33 | // 34 | // By default, returns zero-value Resource. 35 | LoadResource func(_ context.Context, key string) (Resource, error) 36 | 37 | // SaveResource saves resource with given key to a database. Returning an error aborts the batch. 38 | // This function is called at the end of each batch. 39 | // 40 | // Context passed as a first parameter has a timeout calculated using batch MaxDuration. 41 | // You can watch context cancellation in order to abort saving resource if it takes too long 42 | // (thus aborting the entire batch). Context is also cancelled after batch was ended. 43 | // 44 | // By default, does nothing. 45 | SaveResource func(_ context.Context, key string, _ Resource) error 46 | } 47 | 48 | // StartProcessor starts batch processor which will run operations in batches. 49 | // 50 | // Please note that Processor is a go-routine pool internally and should be stopped when no longer needed. 51 | // Please use Processor.Stop method to stop it. 52 | func StartProcessor[Resource any](options Options[Resource]) *Processor[Resource] { 53 | options = options.withDefaults() 54 | 55 | return &Processor[Resource]{ 56 | options: options, 57 | stopped: make(chan struct{}), 58 | batches: map[string]temporaryBatch[Resource]{}, 59 | metricBroker: &metricBroker{}, 60 | } 61 | } 62 | 63 | // Processor represents instance of batch processor which can be used to issue operations which run in a batch manner. 64 | type Processor[Resource any] struct { 65 | options Options[Resource] 66 | stopped chan struct{} 67 | allBatchesFinished sync.WaitGroup 68 | mutex sync.Mutex 69 | batches map[string]temporaryBatch[Resource] 70 | metricBroker *metricBroker 71 | } 72 | 73 | type temporaryBatch[Resource any] struct { 74 | incomingOperations chan operation[Resource] 75 | closed chan struct{} 76 | } 77 | 78 | func (s Options[Resource]) withDefaults() Options[Resource] { 79 | if s.LoadResource == nil { 80 | s.LoadResource = func(context.Context, string) (Resource, error) { 81 | var r Resource 82 | return r, nil 83 | } 84 | } 85 | 86 | if s.SaveResource == nil { 87 | s.SaveResource = func(context.Context, string, Resource) error { 88 | return nil 89 | } 90 | } 91 | 92 | if s.MinDuration == 0 { 93 | s.MinDuration = 100 * time.Millisecond 94 | } 95 | 96 | if s.MaxDuration == 0 { 97 | s.MaxDuration = 2 * s.MinDuration 98 | } 99 | 100 | return s 101 | } 102 | 103 | // Run lets you run an operation on a resource with given key. Operation will run along other operations in batches. 104 | // If there is no pending batch then the new batch will be started and will run for at least MinDuration. After the 105 | // MinDuration no new operations will be accepted and SaveResource function will be called. 106 | // 107 | // Operations are run sequentially. No manual synchronization is required inside operation. Operation should be fast, which 108 | // basically means that any I/O should be avoided at all cost. Operations (together with LoadResource and SaveResource) 109 | // are run on a batch dedicated go-routine. 110 | // 111 | // Operation must leave Resource in a consistent state, so the next operation in batch can be executed on the same resource. 112 | // When operation cannot be executed because some conditions are not met then operation should not change the state 113 | // of resource at all. This could be achieved easily by dividing operation into two sections: 114 | // 115 | // - first section validates if operation is possible and returns error if not 116 | // - second section change the Resource state 117 | // 118 | // Run ends when the entire batch has ended. 119 | // 120 | // Error is returned when batch is aborted or processor is stopped. Only LoadResource and SaveResource functions can abort 121 | // the batch by returning an error. If error was reported for a batch, all Run calls assigned to this batch will get this error. 122 | // 123 | // Please always check the returned error. Operations which query the resource get uncommitted data. If there is 124 | // a problem with saving changes to the database, then you could have a serious inconsistency between your db and what you've 125 | // just sent to the users. 126 | // 127 | // Operation which is still waiting to be run can be canceled by cancelling ctx. If operation was executed but batch 128 | // is pending then Run waits until batch ends. When ctx is cancelled then OperationCancelled error is returned. 129 | func (p *Processor[Resource]) Run(ctx context.Context, key string, _operation func(Resource)) error { 130 | select { 131 | case <-p.stopped: 132 | return ProcessorStopped 133 | default: 134 | } 135 | 136 | result := make(chan error) 137 | defer close(result) 138 | 139 | operationMessage := operation[Resource]{ 140 | run: _operation, 141 | result: result, 142 | } 143 | 144 | for { 145 | tempBatch := p.temporaryBatch(key) 146 | 147 | select { 148 | case <-ctx.Done(): 149 | return OperationCancelled 150 | 151 | case tempBatch.incomingOperations <- operationMessage: 152 | err := <-result 153 | if err != nil { 154 | return fmt.Errorf("running batch failed for key '%s': %w", key, err) 155 | } 156 | return nil 157 | 158 | case <-tempBatch.closed: 159 | } 160 | } 161 | 162 | } 163 | 164 | func (p *Processor[Resource]) temporaryBatch(key string) temporaryBatch[Resource] { 165 | p.mutex.Lock() 166 | defer p.mutex.Unlock() 167 | 168 | batchChannel, ok := p.batches[key] 169 | if !ok { 170 | batchChannel.incomingOperations = make(chan operation[Resource]) 171 | batchChannel.closed = make(chan struct{}) 172 | p.batches[key] = batchChannel 173 | 174 | go p.startBatch(key, batchChannel) 175 | } 176 | 177 | return batchChannel 178 | } 179 | 180 | func (p *Processor[Resource]) startBatch(key string, batchChannels temporaryBatch[Resource]) { 181 | p.allBatchesFinished.Add(1) 182 | defer p.allBatchesFinished.Done() 183 | 184 | now := time.Now() 185 | 186 | w := &batch[Resource]{ 187 | Options: p.options, 188 | resourceKey: key, 189 | incomingOperations: batchChannels.incomingOperations, 190 | stopped: p.stopped, 191 | softDeadline: now.Add(p.options.MinDuration), 192 | hardDeadline: now.Add(p.options.MaxDuration), 193 | } 194 | w.process() 195 | p.metricBroker.publish(w.metric) 196 | 197 | p.mutex.Lock() 198 | defer p.mutex.Unlock() 199 | delete(p.batches, key) 200 | close(batchChannels.closed) 201 | } 202 | 203 | // Stop ends all running batches. No new operations will be accepted. 204 | // Stop blocks until all pending batches are ended and resources saved. 205 | func (p *Processor[Resource]) Stop() { 206 | close(p.stopped) 207 | p.allBatchesFinished.Wait() 208 | p.metricBroker.stop() 209 | } 210 | 211 | // SubscribeBatchMetrics subscribes to all batch metrics. Returned channel 212 | // is closed after Processor was stopped. It is safe to execute 213 | // method multiple times. Each call will create a new separate subscription. 214 | // 215 | // As soon as subscription is created all Metric messages **must be** 216 | // consumed from the channel. Otherwise, Processor will block. 217 | // Please note that slow consumer could potentially slow down entire Processor, 218 | // limiting the amount of operations which can be run. The amount of batches 219 | // per second can reach 100k, so be ready to handle such traffic. This 220 | // basically means that Metric consumer should not directly do any blocking IO. 221 | // Instead, it should aggregate data and publish it asynchronously. 222 | func (p *Processor[Resource]) SubscribeBatchMetrics() <-chan Metric { 223 | select { 224 | case <-p.stopped: 225 | closedChan := make(chan Metric) 226 | close(closedChan) 227 | return closedChan 228 | default: 229 | } 230 | 231 | return p.metricBroker.subscribe() 232 | } 233 | 234 | // Metric contains measurements for one finished batch. 235 | type Metric struct { 236 | BatchStart time.Time 237 | ResourceKey string 238 | OperationCount int 239 | LoadResourceDuration time.Duration 240 | SaveResourceDuration time.Duration 241 | TotalDuration time.Duration 242 | Error error 243 | } 244 | -------------------------------------------------------------------------------- /batch_bench_test.go: -------------------------------------------------------------------------------- 1 | // (c) 2022 Jacek Olszak 2 | // This code is licensed under MIT license (see LICENSE for details) 3 | 4 | package batch_test 5 | 6 | import ( 7 | "context" 8 | "strconv" 9 | "sync" 10 | "testing" 11 | 12 | "github.com/elgopher/batch" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func BenchmarkProcessor_Run(b *testing.B) { 17 | resources := []int{ 18 | 1, 8, 64, 512, 4096, 32768, 262144, 2097152, 19 | } 20 | 21 | for _, resourceCount := range resources { 22 | b.Run(strconv.Itoa(resourceCount), func(b *testing.B) { 23 | b.ReportAllocs() 24 | b.ResetTimer() 25 | 26 | processor := batch.StartProcessor(batch.Options[empty]{}) 27 | defer processor.Stop() 28 | 29 | var allOperationsFinished sync.WaitGroup 30 | allOperationsFinished.Add(b.N) 31 | 32 | b.ResetTimer() 33 | 34 | for i := 0; i < b.N; i++ { 35 | key := strconv.Itoa(i % resourceCount) 36 | go func() { 37 | // when 38 | err := processor.Run(context.Background(), key, operation) 39 | require.NoError(b, err) 40 | allOperationsFinished.Done() 41 | }() 42 | } 43 | 44 | b.StopTimer() 45 | 46 | allOperationsFinished.Wait() 47 | }) 48 | } 49 | } 50 | 51 | func operation(empty) {} 52 | -------------------------------------------------------------------------------- /batch_test.go: -------------------------------------------------------------------------------- 1 | // (c) 2022 Jacek Olszak 2 | // This code is licensed under MIT license (see LICENSE for details) 3 | 4 | package batch_test 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "reflect" 10 | "sync" 11 | "sync/atomic" 12 | "testing" 13 | "time" 14 | 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | 18 | "github.com/elgopher/batch" 19 | ) 20 | 21 | var noTimeout = context.Background() 22 | 23 | func TestProcessor_Run(t *testing.T) { 24 | t.Run("should run callback on zero-value resource when LoadResource was not provided", func(t *testing.T) { 25 | futureValue := FutureValue[*resource]() 26 | 27 | processor := batch.StartProcessor(batch.Options[*resource]{}) 28 | defer processor.Stop() 29 | // when 30 | err := processor.Run(noTimeout, "key", func(c *resource) { 31 | futureValue.Set(c) 32 | }) 33 | require.NoError(t, err) 34 | 35 | assert.Nil(t, futureValue.Get(t)) // nil is a zero-value for pointer 36 | }) 37 | 38 | t.Run("should run callback on the loaded resource", func(t *testing.T) { 39 | futureValue := FutureValue[*resource]() 40 | key := "key" 41 | res := &resource{value: 1} 42 | 43 | processor := batch.StartProcessor( 44 | batch.Options[*resource]{ 45 | LoadResource: func(_ context.Context, actualKey string) (*resource, error) { 46 | require.Equal(t, key, actualKey) 47 | return res, nil 48 | }, 49 | }) 50 | defer processor.Stop() 51 | // when 52 | err := processor.Run(noTimeout, key, func(r *resource) { 53 | futureValue.Set(r) 54 | }) 55 | require.NoError(t, err) 56 | 57 | assert.Same(t, res, futureValue.Get(t)) 58 | }) 59 | 60 | t.Run("should save modified resource", func(t *testing.T) { 61 | key := "key" 62 | 63 | db := newDatabase[resource]() 64 | db.SaveOrFail(t, key, &resource{value: 1}) 65 | 66 | processor := batch.StartProcessor( 67 | batch.Options[*resource]{ 68 | LoadResource: db.Load, 69 | SaveResource: db.Save, 70 | }) 71 | defer processor.Stop() 72 | // when 73 | err := processor.Run(noTimeout, key, func(r *resource) { 74 | r.value = 2 75 | }) 76 | require.NoError(t, err) 77 | 78 | modifiedResourceIsSaved := func() bool { 79 | v := db.LoadOrFail(t, key) 80 | return reflect.DeepEqual(v, &resource{value: 2}) 81 | } 82 | assert.Eventually(t, modifiedResourceIsSaved, time.Second, time.Millisecond) 83 | }) 84 | 85 | t.Run("should run batch for at least min duration", func(t *testing.T) { 86 | processor := batch.StartProcessor( 87 | batch.Options[empty]{ 88 | MinDuration: 100 * time.Millisecond, 89 | }, 90 | ) 91 | defer processor.Stop() 92 | 93 | started := time.Now() 94 | // when 95 | err := processor.Run(noTimeout, "", func(empty) {}) 96 | require.NoError(t, err) 97 | 98 | elapsed := time.Now().Sub(started) 99 | assert.True(t, elapsed >= 100*time.Millisecond, "batch should take at least 100ms") 100 | }) 101 | 102 | t.Run("should run batch with default min duration", func(t *testing.T) { 103 | processor := batch.StartProcessor(batch.Options[empty]{}) 104 | defer processor.Stop() 105 | 106 | started := time.Now() 107 | // when 108 | err := processor.Run(noTimeout, "", func(empty) {}) 109 | require.NoError(t, err) 110 | 111 | elapsed := time.Now().Sub(started) 112 | assert.True(t, elapsed >= 100*time.Millisecond, "batch should take 100ms by default") 113 | }) 114 | 115 | t.Run("should end batch if operation took too long", func(t *testing.T) { 116 | var batchCount int32 117 | 118 | processor := batch.StartProcessor( 119 | batch.Options[empty]{ 120 | MinDuration: time.Millisecond, 121 | MaxDuration: time.Minute, 122 | SaveResource: func(context.Context, string, empty) error { 123 | atomic.AddInt32(&batchCount, 1) 124 | return nil 125 | }, 126 | }) 127 | defer processor.Stop() 128 | 129 | key := "" 130 | 131 | err := processor.Run(noTimeout, key, func(empty) { 132 | time.Sleep(100 * time.Millisecond) 133 | }) 134 | require.NoError(t, err) 135 | 136 | err = processor.Run(noTimeout, key, func(empty) {}) 137 | require.NoError(t, err) 138 | 139 | assert.Equal(t, int32(2), atomic.LoadInt32(&batchCount)) 140 | }) 141 | 142 | t.Run("should abort batch", func(t *testing.T) { 143 | key := "key" 144 | timeoutError := errors.New("timeout") 145 | 146 | t.Run("when LoadResource returned error", func(t *testing.T) { 147 | customError := errors.New("error") 148 | db := newDatabase[resource]() 149 | processor := batch.StartProcessor( 150 | batch.Options[*resource]{ 151 | LoadResource: func(ctx context.Context, _ string) (*resource, error) { 152 | return nil, customError 153 | }, 154 | SaveResource: db.Save, 155 | }, 156 | ) 157 | defer processor.Stop() 158 | // when 159 | err := processor.Run(noTimeout, key, func(*resource) {}) 160 | // then 161 | assert.ErrorIs(t, err, customError) 162 | // and 163 | db.AssertResourceNotFound(t, key) 164 | }) 165 | 166 | t.Run("when LoadResource returned error after context was timed out", func(t *testing.T) { 167 | db := newDatabase[resource]() 168 | processor := batch.StartProcessor( 169 | batch.Options[*resource]{ 170 | MinDuration: time.Millisecond, 171 | MaxDuration: time.Millisecond, 172 | LoadResource: func(ctx context.Context, _ string) (*resource, error) { 173 | select { 174 | case <-ctx.Done(): 175 | return nil, timeoutError 176 | case <-time.After(100 * time.Millisecond): 177 | require.FailNow(t, "context was not timed out") 178 | } 179 | return nil, nil 180 | }, 181 | SaveResource: db.Save, 182 | }, 183 | ) 184 | defer processor.Stop() 185 | // when 186 | err := processor.Run(noTimeout, key, func(*resource) {}) 187 | // then 188 | assert.ErrorIs(t, err, timeoutError) 189 | // and 190 | db.AssertResourceNotFound(t, key) 191 | }) 192 | 193 | t.Run("when SaveResource returned error after context was timed out", func(t *testing.T) { 194 | processor := batch.StartProcessor( 195 | batch.Options[empty]{ 196 | MinDuration: time.Millisecond, 197 | MaxDuration: time.Millisecond, 198 | SaveResource: func(ctx context.Context, _ string, _ empty) error { 199 | select { 200 | case <-ctx.Done(): 201 | return timeoutError 202 | case <-time.After(100 * time.Millisecond): 203 | require.FailNow(t, "context was not timed out") 204 | } 205 | return nil 206 | }, 207 | }, 208 | ) 209 | defer processor.Stop() 210 | // when 211 | err := processor.Run(noTimeout, key, func(empty) {}) 212 | // then 213 | assert.ErrorIs(t, err, timeoutError) 214 | }) 215 | }) 216 | 217 | t.Run("should run operations sequentially on a single resource (run with -race flag)", func(t *testing.T) { 218 | processor := batch.StartProcessor( 219 | batch.Options[*resource]{ 220 | LoadResource: func(context.Context, string) (*resource, error) { 221 | return &resource{}, nil 222 | }, 223 | }, 224 | ) 225 | defer processor.Stop() 226 | 227 | const iterations = 1000 228 | 229 | var group sync.WaitGroup 230 | group.Add(iterations) 231 | 232 | for i := 0; i < iterations; i++ { 233 | go func() { 234 | err := processor.Run(noTimeout, "key", func(r *resource) { 235 | r.value++ // value is not guarded so data race should be reported by `go test` 236 | }) 237 | require.NoError(t, err) 238 | 239 | group.Done() 240 | }() 241 | } 242 | 243 | group.Wait() 244 | }) 245 | 246 | t.Run("should cancel operation if operation is still waiting to be run", func(t *testing.T) { 247 | processor := batch.StartProcessor(batch.Options[empty]{}) 248 | defer processor.Stop() 249 | 250 | var slowOperationStarted sync.WaitGroup 251 | slowOperationStarted.Add(1) 252 | 253 | var slowOperationStopped sync.WaitGroup 254 | slowOperationStopped.Add(1) 255 | 256 | key := "key" 257 | 258 | go processor.Run(noTimeout, key, func(empty) { 259 | slowOperationStarted.Done() 260 | slowOperationStopped.Wait() 261 | }) 262 | 263 | slowOperationStarted.Wait() 264 | 265 | ctx, cancel := context.WithCancel(context.Background()) 266 | // when 267 | cancel() 268 | err := processor.Run(ctx, key, func(empty) {}) 269 | // then 270 | assert.ErrorIs(t, err, batch.OperationCancelled) 271 | // cleanup 272 | slowOperationStopped.Done() 273 | }) 274 | 275 | t.Run("should use same context for LoadResource and SaveResource", func(t *testing.T) { 276 | loadResourceContext := FutureValue[context.Context]() 277 | saveResourceContext := FutureValue[context.Context]() 278 | 279 | processor := batch.StartProcessor(batch.Options[empty]{ 280 | LoadResource: func(ctx context.Context, key string) (empty, error) { 281 | loadResourceContext.Set(ctx) 282 | return empty{}, nil 283 | }, 284 | SaveResource: func(ctx context.Context, key string, r empty) error { 285 | saveResourceContext.Set(ctx) 286 | return nil 287 | }, 288 | MinDuration: time.Millisecond, 289 | }) 290 | // when 291 | _ = processor.Run(context.Background(), "key", func(empty) {}) 292 | // then 293 | assert.Same(t, loadResourceContext.Get(t), saveResourceContext.Get(t)) 294 | }) 295 | 296 | t.Run("should cancel context passed to LoadResource once Run finished", func(t *testing.T) { 297 | loadResourceContext := FutureValue[context.Context]() 298 | 299 | processor := batch.StartProcessor(batch.Options[empty]{ 300 | LoadResource: func(ctx context.Context, key string) (empty, error) { 301 | loadResourceContext.Set(ctx) 302 | return empty{}, nil 303 | }, 304 | MinDuration: time.Millisecond, 305 | MaxDuration: time.Minute, 306 | }) 307 | // when 308 | _ = processor.Run(context.Background(), "key", func(empty) {}) 309 | // then 310 | select { 311 | case <-loadResourceContext.Get(t).Done(): 312 | case <-time.After(time.Second): 313 | assert.Fail(t, "Timeout waiting for canceling the context") 314 | } 315 | }) 316 | } 317 | 318 | func TestProcessor_Stop(t *testing.T) { 319 | t.Run("after Stop no new operation can be run", func(t *testing.T) { 320 | processor := batch.StartProcessor(batch.Options[empty]{}) 321 | processor.Stop() 322 | 323 | err := processor.Run(noTimeout, "key", func(empty) {}) 324 | assert.ErrorIs(t, err, batch.ProcessorStopped) 325 | }) 326 | 327 | t.Run("should end running batch", func(t *testing.T) { 328 | minDuration := 10 * time.Second 329 | 330 | processor := batch.StartProcessor( 331 | batch.Options[empty]{ 332 | MinDuration: minDuration, 333 | }, 334 | ) 335 | 336 | var operationExecuted sync.WaitGroup 337 | operationExecuted.Add(1) 338 | 339 | var batchFinished sync.WaitGroup 340 | batchFinished.Add(1) 341 | 342 | started := time.Now() 343 | 344 | go func() { 345 | err := processor.Run(noTimeout, "key", func(empty) { 346 | operationExecuted.Done() 347 | }) 348 | require.NoError(t, err) 349 | batchFinished.Done() 350 | }() 351 | operationExecuted.Wait() 352 | // when 353 | processor.Stop() 354 | // then 355 | batchFinished.Wait() 356 | elapsed := time.Now().Sub(started) 357 | assert.True(t, elapsed < minDuration, "stopped batch should take less time than batch min duration") 358 | }) 359 | 360 | t.Run("Stop should wait until all batches are finished", func(t *testing.T) { 361 | var operationExecuted sync.WaitGroup 362 | operationExecuted.Add(1) 363 | 364 | batchFinished := false 365 | processor := batch.StartProcessor( 366 | batch.Options[empty]{ 367 | MinDuration: time.Second, 368 | MaxDuration: time.Second, 369 | SaveResource: func(ctx context.Context, key string, _ empty) error { 370 | <-ctx.Done() 371 | batchFinished = true 372 | return nil 373 | }, 374 | }, 375 | ) 376 | go func() { 377 | _ = processor.Run(noTimeout, "key", func(empty) { 378 | operationExecuted.Done() 379 | }) 380 | }() 381 | operationExecuted.Wait() 382 | // when 383 | processor.Stop() 384 | // then 385 | assert.True(t, batchFinished) 386 | }) 387 | } 388 | 389 | type resource struct{ value int } 390 | type empty struct{} 391 | -------------------------------------------------------------------------------- /database_test.go: -------------------------------------------------------------------------------- 1 | // (c) 2022 Jacek Olszak 2 | // This code is licensed under MIT license (see LICENSE for details) 3 | 4 | package batch_test 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "sync" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | type database[Value any] struct { 17 | mutex sync.Mutex 18 | valueByKey map[string]Value 19 | } 20 | 21 | func newDatabase[Value any]() *database[Value] { 22 | return &database[Value]{ 23 | valueByKey: map[string]Value{}, 24 | } 25 | } 26 | 27 | func (d *database[Value]) Load(_ context.Context, key string) (*Value, error) { 28 | d.mutex.Lock() 29 | defer d.mutex.Unlock() 30 | 31 | v, found := d.valueByKey[key] 32 | if !found { 33 | return nil, fmt.Errorf("key %v not found", key) 34 | } 35 | 36 | return &v, nil 37 | } 38 | 39 | func (d *database[Value]) LoadOrFail(t *testing.T, key string) *Value { 40 | v, err := d.Load(context.Background(), key) 41 | require.NoError(t, err) 42 | 43 | return v 44 | } 45 | 46 | func (d *database[Value]) AssertResourceNotFound(t *testing.T, key string) { 47 | d.mutex.Lock() 48 | defer d.mutex.Unlock() 49 | 50 | _, found := d.valueByKey[key] 51 | assert.Falsef(t, found, "resource with key %v was found but should not", key) 52 | } 53 | 54 | func (d *database[Value]) Save(_ context.Context, key string, v *Value) error { 55 | d.mutex.Lock() 56 | defer d.mutex.Unlock() 57 | 58 | d.valueByKey[key] = *v 59 | 60 | return nil 61 | } 62 | 63 | func (d *database[Value]) SaveOrFail(t *testing.T, key string, v *Value) { 64 | err := d.Save(context.Background(), key, v) 65 | require.NoError(t, err) 66 | } 67 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | // (c) 2022 Jacek Olszak 2 | // This code is licensed under MIT license (see LICENSE for details) 3 | 4 | package batch 5 | 6 | import "errors" 7 | 8 | var ProcessorStopped = errors.New("run failed: processor is stopped") 9 | var OperationCancelled = errors.New("run failed: operation was canceled before it was run") 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/elgopher/batch 2 | 3 | go 1.18 4 | 5 | require github.com/stretchr/testify v1.8.4 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 6 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /goroutine.go: -------------------------------------------------------------------------------- 1 | // (c) 2022 Jacek Olszak 2 | // This code is licensed under MIT license (see LICENSE for details) 3 | 4 | package batch 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "time" 10 | ) 11 | 12 | type batch[Resource any] struct { 13 | Options[Resource] 14 | resourceKey string 15 | incomingOperations <-chan operation[Resource] 16 | stopped <-chan struct{} // stopped is used to stop batch prematurely 17 | softDeadline time.Time 18 | hardDeadline time.Time 19 | 20 | resource *Resource 21 | results []chan error 22 | 23 | metric Metric 24 | } 25 | 26 | func (b *batch[Resource]) process() { 27 | b.metric.ResourceKey = b.resourceKey 28 | b.metric.BatchStart = time.Now() 29 | defer func() { 30 | b.metric.TotalDuration = time.Since(b.metric.BatchStart) 31 | }() 32 | 33 | hardDeadlineContext, cancel := context.WithDeadline(context.Background(), b.hardDeadline) 34 | defer cancel() 35 | 36 | softDeadlineReached := time.NewTimer(b.softDeadline.Sub(time.Now())) 37 | defer softDeadlineReached.Stop() 38 | 39 | for { 40 | select { 41 | case <-b.stopped: 42 | b.end(hardDeadlineContext) 43 | return 44 | 45 | case <-softDeadlineReached.C: 46 | b.end(hardDeadlineContext) 47 | return 48 | 49 | case _operation := <-b.incomingOperations: 50 | err := b.load(hardDeadlineContext) 51 | if err != nil { 52 | _operation.result <- err 53 | b.metric.Error = err 54 | return 55 | } 56 | 57 | b.results = append(b.results, _operation.result) 58 | _operation.run(*b.resource) 59 | b.metric.OperationCount++ 60 | } 61 | } 62 | } 63 | 64 | func (b *batch[Resource]) end(ctx context.Context) { 65 | if b.resource == nil { 66 | return 67 | } 68 | 69 | err := b.save(ctx) 70 | for _, result := range b.results { 71 | result <- err 72 | } 73 | b.metric.Error = err 74 | } 75 | 76 | func (b *batch[Resource]) save(ctx context.Context) error { 77 | started := time.Now() 78 | defer func() { 79 | b.metric.SaveResourceDuration = time.Since(started) 80 | }() 81 | 82 | if err := b.SaveResource(ctx, b.resourceKey, *b.resource); err != nil { 83 | return fmt.Errorf("saving resource failed: %w", err) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | func (b *batch[Resource]) load(ctx context.Context) error { 90 | if b.alreadyLoaded() { 91 | return nil 92 | } 93 | 94 | started := time.Now() 95 | defer func() { 96 | b.metric.LoadResourceDuration = time.Since(started) 97 | }() 98 | 99 | resource, err := b.LoadResource(ctx, b.resourceKey) 100 | if err != nil { 101 | return fmt.Errorf("loading resource failed: %w", err) 102 | } 103 | 104 | b.resource = &resource 105 | 106 | return nil 107 | } 108 | 109 | func (b *batch[Resource]) alreadyLoaded() bool { 110 | return b.resource != nil 111 | } 112 | 113 | type operation[Resource any] struct { 114 | run func(Resource) 115 | result chan error 116 | } 117 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 10 | 11 | Batch 12 | 14 | -------------------------------------------------------------------------------- /metric.go: -------------------------------------------------------------------------------- 1 | // (c) 2022 Jacek Olszak 2 | // This code is licensed under MIT license (see LICENSE for details) 3 | 4 | package batch 5 | 6 | import "sync" 7 | 8 | const metricBufferSize = 1024 9 | 10 | type metricBroker struct { 11 | mutex sync.Mutex 12 | subscriptions []chan Metric 13 | } 14 | 15 | func (s *metricBroker) subscribe() <-chan Metric { 16 | s.mutex.Lock() 17 | defer s.mutex.Unlock() 18 | 19 | subscription := make(chan Metric, metricBufferSize) 20 | s.subscriptions = append(s.subscriptions, subscription) 21 | 22 | return subscription 23 | } 24 | 25 | func (s *metricBroker) publish(metric Metric) { 26 | s.mutex.Lock() 27 | subscriptionsCopy := make([]chan Metric, len(s.subscriptions)) 28 | copy(subscriptionsCopy, s.subscriptions) 29 | s.mutex.Unlock() 30 | 31 | for _, subscription := range subscriptionsCopy { 32 | subscription <- metric 33 | } 34 | } 35 | 36 | func (s *metricBroker) stop() { 37 | s.mutex.Lock() 38 | defer s.mutex.Unlock() 39 | 40 | for _, subscription := range s.subscriptions { 41 | close(subscription) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /metric_test.go: -------------------------------------------------------------------------------- 1 | // (c) 2022 Jacek Olszak 2 | // This code is licensed under MIT license (see LICENSE for details) 3 | 4 | package batch_test 5 | 6 | import ( 7 | "context" 8 | "errors" 9 | "fmt" 10 | "sync" 11 | "testing" 12 | "time" 13 | 14 | "github.com/elgopher/batch" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | ) 18 | 19 | func TestProcessor_SubscribeBatchMetrics(t *testing.T) { 20 | const key = "key" 21 | ctx := context.Background() 22 | 23 | t.Run("should get closed channel when subscribing metrics on stopped processor", func(t *testing.T) { 24 | processor := batch.StartProcessor(batch.Options[empty]{}) 25 | processor.Stop() 26 | // when 27 | _, ok := <-processor.SubscribeBatchMetrics() 28 | // then 29 | assert.False(t, ok, "metrics channel should be closed") 30 | }) 31 | 32 | t.Run("subscription should get batch metrics", func(t *testing.T) { 33 | var ( 34 | loadResourceDuration = 100 * time.Millisecond 35 | saveResourceDuration = 50 * time.Millisecond 36 | operationDuration = 10 * time.Millisecond 37 | totalDuration = loadResourceDuration + saveResourceDuration + operationDuration 38 | ) 39 | processor := batch.StartProcessor(batch.Options[empty]{ 40 | MinDuration: loadResourceDuration, 41 | LoadResource: func(_ context.Context, key string) (empty, error) { 42 | time.Sleep(loadResourceDuration) 43 | return empty{}, nil 44 | }, 45 | SaveResource: func(_ context.Context, key string, r empty) error { 46 | time.Sleep(saveResourceDuration) 47 | return nil 48 | }, 49 | }) 50 | defer processor.Stop() 51 | subscription := processor.SubscribeBatchMetrics() 52 | err := processor.Run(ctx, key, func(empty) { 53 | time.Sleep(operationDuration) 54 | }) 55 | require.NoError(t, err) 56 | // when 57 | metric := <-subscription 58 | // then 59 | assert.Equal(t, "key", metric.ResourceKey) 60 | assert.NotZero(t, metric.BatchStart) 61 | assert.Equal(t, metric.OperationCount, 1) 62 | assertDurationInDelta(t, totalDuration, metric.TotalDuration, 10*time.Millisecond) 63 | assertDurationInDelta(t, loadResourceDuration, metric.LoadResourceDuration, 10*time.Millisecond) 64 | assertDurationInDelta(t, saveResourceDuration, metric.SaveResourceDuration, 10*time.Millisecond) 65 | assert.NoError(t, metric.Error) 66 | }) 67 | 68 | deliberateError := errors.New("fail") 69 | 70 | t.Run("should get error in metric when LoadResource failed", func(t *testing.T) { 71 | processor := batch.StartProcessor(batch.Options[empty]{ 72 | MinDuration: time.Millisecond, 73 | LoadResource: func(_ context.Context, key string) (empty, error) { 74 | return empty{}, deliberateError 75 | }, 76 | }) 77 | metrics := processor.SubscribeBatchMetrics() 78 | err := processor.Run(ctx, key, func(empty) {}) 79 | require.Error(t, err) 80 | // when 81 | metric := <-metrics 82 | // then 83 | assert.ErrorIs(t, metric.Error, deliberateError) 84 | }) 85 | 86 | t.Run("should get error in metric when SaveResource failed", func(t *testing.T) { 87 | processor := batch.StartProcessor(batch.Options[empty]{ 88 | MinDuration: time.Millisecond, 89 | SaveResource: func(_ context.Context, key string, _ empty) error { 90 | return deliberateError 91 | }, 92 | }) 93 | metrics := processor.SubscribeBatchMetrics() 94 | err := processor.Run(ctx, key, func(empty) {}) 95 | require.Error(t, err) 96 | // when 97 | metric := <-metrics 98 | // then 99 | assert.ErrorIs(t, metric.Error, deliberateError) 100 | }) 101 | 102 | t.Run("each subscription should get all batch metrics", func(t *testing.T) { 103 | processor := batch.StartProcessor(batch.Options[empty]{ 104 | MinDuration: time.Millisecond, 105 | }) 106 | defer processor.Stop() 107 | subscription1 := processor.SubscribeBatchMetrics() 108 | subscription2 := processor.SubscribeBatchMetrics() 109 | err := processor.Run(ctx, key, func(empty) {}) 110 | require.NoError(t, err) 111 | // when 112 | metrics1 := <-subscription1 113 | metrics2 := <-subscription2 114 | // then 115 | assert.Equal(t, metrics1, metrics2) 116 | }) 117 | 118 | t.Run("stopping processor should close subscription", func(t *testing.T) { 119 | processor := batch.StartProcessor(batch.Options[empty]{ 120 | MinDuration: time.Millisecond, 121 | }) 122 | subscription := processor.SubscribeBatchMetrics() 123 | // when 124 | processor.Stop() 125 | _, ok := <-subscription 126 | // then 127 | assert.False(t, ok, "metrics channel should be closed") 128 | }) 129 | 130 | t.Run("should not data race when run with -race flag", func(t *testing.T) { 131 | processor := batch.StartProcessor(batch.Options[empty]{ 132 | MinDuration: time.Millisecond, 133 | }) 134 | defer processor.Stop() 135 | 136 | var g sync.WaitGroup 137 | const goroutines = 1000 138 | g.Add(goroutines) 139 | 140 | for i := 0; i < goroutines/2; i++ { 141 | go func() { 142 | metrics := processor.SubscribeBatchMetrics() 143 | g.Done() 144 | for range metrics { 145 | } 146 | }() 147 | 148 | k := fmt.Sprintf("%d", i) 149 | go func() { 150 | err := processor.Run(ctx, k, func(empty) {}) 151 | require.NoError(t, err) 152 | g.Done() 153 | }() 154 | } 155 | 156 | g.Wait() 157 | }) 158 | } 159 | 160 | func assertDurationInDelta(t *testing.T, expected time.Duration, actual time.Duration, delta time.Duration) { 161 | diff := expected - actual 162 | if diff < 0 { 163 | diff *= -1 164 | } 165 | if diff > delta { 166 | require.Failf(t, "invalid duration", "actual duration %s different than expected %s (max difference is %s)", actual, expected, delta) 167 | } 168 | } 169 | --------------------------------------------------------------------------------