├── .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 | [](https://github.com/elgopher/batch/actions/workflows/build.yml)
2 | [](https://pkg.go.dev/github.com/elgopher/batch)
3 | [](https://goreportcard.com/report/github.com/elgopher/batch)
4 | [](https://codecov.io/gh/elgopher/batch)
5 | [](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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------