You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
boundary/internal/scheduler/batch/batch_test.go

578 lines
13 KiB

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package batch
import (
"context"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/hashicorp/boundary/internal/scheduler"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
t.Parallel()
t.Run("nil-config", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
got, err := New(ctx, nil)
require.Error(t, err)
assert.Nil(t, got)
})
t.Run("nil-Exec", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
config := &Config{}
got, err := New(ctx, config)
require.Error(t, err)
assert.Nil(t, got)
})
t.Run("minimum-config", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
config := &Config{
Exec: func(ctx context.Context, batchSize int) (int, error) { return 0, nil },
}
got, err := New(ctx, config)
require.NoError(t, err)
assert.NotNil(t, got)
})
}
func TestConfig(t *testing.T) {
t.Parallel()
t.Run("default-config", func(t *testing.T) {
t.Parallel()
assert := assert.New(t)
config := &Config{
Exec: func(ctx context.Context, batchSize int) (int, error) { return 0, nil },
}
assert.Equal(DefaultSize, config.size())
assert.Equal(DefaultMin, config.min())
assert.Equal(DefaultMax, config.max())
assert.Equal(DefaultStatusThreshold-statusThresholdBuffer, config.statusThreshold())
assert.Equal(DefaultTarget, config.target())
config.StatusThreshold = DefaultTarget
config.Target = DefaultTarget
want := DefaultTarget - statusThresholdBuffer
assert.Equal(want, config.target())
})
tests := []struct {
min, max, size int
want int
}{
{0, 0, 0, DefaultSize},
{0, 0, DefaultMin - 1, DefaultMin},
{0, 0, DefaultMax + 1, DefaultMax},
}
for _, tt := range tests {
tt := tt
t.Run(fmt.Sprintf("min-max-size_%d-%d-%d", tt.min, tt.max, tt.size), func(t *testing.T) {
t.Parallel()
assert := assert.New(t)
config := &Config{
Min: tt.min,
Max: tt.max,
Size: tt.size,
}
assert.Equal(tt.want, config.size())
})
}
}
type testStore struct {
called bool
batchSize int
}
func (s *testStore) Store(ctx context.Context, batchSize int) error {
s.called = true
s.batchSize = batchSize
return nil
}
func TestConfig_setSize(t *testing.T) {
t.Parallel()
ctx := context.Background()
config := &Config{
Min: 5,
Max: 15,
Size: 10,
}
tests := []struct {
newSize int
wantSize int
wantCalled bool
}{
{0, 5, true},
{4, 5, true},
{5, 5, true},
{6, 6, true},
{10, 10, false},
{20, 15, true},
{15, 15, true},
}
for _, tt := range tests {
tt := tt
t.Run(fmt.Sprintf("newSize_%d", tt.newSize), func(t *testing.T) {
t.Parallel()
assert, require := assert.New(t), require.New(t)
c := config.clone()
assert.Equal(config, c)
ts := &testStore{}
c.Store = ts.Store
err := c.setSize(ctx, tt.newSize)
require.NoError(err)
assert.Equal(tt.wantSize, c.Size)
assert.Equal(tt.wantCalled, ts.called)
if tt.wantCalled {
assert.Equal(tt.wantSize, ts.batchSize)
}
})
}
}
func TestConfig_targetRange(t *testing.T) {
t.Parallel()
tests := []struct {
target time.Duration
wantUpper time.Duration
wantLower time.Duration
}{
{
target: 1000 * time.Millisecond,
wantLower: 900 * time.Millisecond,
wantUpper: 1100 * time.Millisecond,
},
{
target: 60 * time.Second,
wantLower: 54 * time.Second,
wantUpper: 66 * time.Second,
},
}
for _, tt := range tests {
tt := tt
t.Run(fmt.Sprintf("target_%d", tt.target), func(t *testing.T) {
t.Parallel()
assert := assert.New(t)
config := &Config{
Target: tt.target,
}
assert.Equal(tt.target, config.target())
lower, upper := config.targetRange()
assert.Equal(tt.wantLower, lower)
assert.Equal(tt.wantUpper, upper)
})
}
}
func TestConfig_exponentialDecrease(t *testing.T) {
t.Parallel()
tests := []struct {
batchSize int
attempt int
expected int
}{
{10, 0, DefaultMin},
{10, 1, DefaultMin},
{10, 2, DefaultMin},
{9, 1, DefaultMin},
{9, 2, DefaultMin},
{1000, 0, 500},
{1000, 1, 500},
{1000, 2, 250},
{1000, 3, 125},
{1000, 4, 62},
{1000, 5, 31},
{1000, 6, 15},
{1000, 7, DefaultMin},
{500, 1, 250},
{500, 2, 125},
}
for _, tt := range tests {
tt := tt
t.Run(fmt.Sprintf("batchSize=%d/attempt=%d", tt.batchSize, tt.attempt), func(t *testing.T) {
assert, require := assert.New(t), require.New(t)
ctx := context.Background()
c := &Config{
Size: tt.batchSize,
}
delta := c.size() / 10
err := c.exponentialDecrease(ctx, tt.attempt)
require.NoError(err)
assert.InDelta(tt.expected, c.Size, float64(delta))
})
}
}
func Test_linearDecrease(t *testing.T) {
t.Parallel()
tests := []struct {
batchSize int
attempt int
expected int
}{
{10, 0, DefaultMin},
{10, 1, DefaultMin},
{10, 2, DefaultMin},
{1000, 0, 900},
{1000, 1, 900},
{1000, 2, 800},
{1000, 3, 700},
{1000, 4, 600},
{1000, 5, 500},
{1000, 6, 400},
{1000, 7, 300},
{1000, 10, DefaultMin},
{1000, 11, DefaultMin},
{500, 1, 450},
{500, 2, 400},
{100, 1, 90},
{100, 2, 80},
}
for _, tt := range tests {
tt := tt
t.Run(fmt.Sprintf("batchSize=%d/attempt=%d", tt.batchSize, tt.attempt), func(t *testing.T) {
t.Parallel()
assert, require := assert.New(t), require.New(t)
ctx := context.Background()
c := &Config{
Size: tt.batchSize,
}
delta := c.size() / 10
err := c.linearDecrease(ctx, tt.attempt)
require.NoError(err)
assert.InDelta(tt.expected, c.Size, float64(delta))
})
}
}
func Test_linearIncrease(t *testing.T) {
t.Parallel()
tests := []struct {
batchSize int
attempt int
expected int
}{
{10000, 0, DefaultMax},
{10000, 1, DefaultMax},
{10000, 2, DefaultMax},
{1000, 0, 1100},
{1000, 1, 1100},
{1000, 2, 1200},
{1000, 3, 1300},
{1000, 4, 1400},
{1000, 5, 1500},
{1000, 6, 1600},
{1000, 7, 1700},
{500, 1, 550},
{500, 2, 600},
{100, 1, 110},
{100, 2, 120},
{10, 1, 11},
{10, 2, 12},
}
for _, tt := range tests {
tt := tt
t.Run(fmt.Sprintf("batchSize=%d/attempt=%d", tt.batchSize, tt.attempt), func(t *testing.T) {
t.Parallel()
assert, require := assert.New(t), require.New(t)
ctx := context.Background()
c := &Config{
Size: tt.batchSize,
}
delta := c.size() / 10
err := c.linearIncrease(ctx, tt.attempt)
require.NoError(err)
assert.InDelta(tt.expected, c.Size, float64(delta))
})
}
}
type recorder struct {
execBatchSize int
storeBatchSize int
status scheduler.JobStatus
mu sync.Mutex
}
func (r *recorder) setup(c *Config) {
r.mu.Lock()
defer r.mu.Unlock()
c.Store = r.Store
}
func (r *recorder) Store(ctx context.Context, batchSize int) error {
r.mu.Lock()
defer r.mu.Unlock()
r.storeBatchSize = batchSize
return nil
}
func (r *recorder) Exec(ctx context.Context, batchSize int) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.execBatchSize = batchSize
return 0, nil
}
type testRun struct {
ret func(context.Context, int, *Config) (int, error)
chk func(*testing.T, *recorder)
rec *recorder
mu sync.Mutex
}
func (tr *testRun) validate(t *testing.T) {
if tr.chk != nil {
tr.chk(t, tr.rec)
}
}
func (tr *testRun) recorder(cf *Config) *recorder {
tr.mu.Lock()
defer tr.mu.Unlock()
if tr.rec == nil {
tr.rec = &recorder{}
tr.rec.setup(cf)
}
return tr.rec
}
type testRunner struct {
conf *Config
b *Batch
runs []*testRun
t *testing.T
call int
}
func newTestRunner(t *testing.T, conf *Config, b *Batch) *testRunner {
tr := &testRunner{
t: t,
conf: conf,
b: b,
}
conf.Exec = tr.Exec
return tr
}
func (tr *testRunner) Exec(ctx context.Context, batchSize int) (int, error) {
if tr.call > 0 {
prevRun := tr.runs[tr.call-1]
prevRun.rec.status = tr.b.Status()
prevRun.validate(tr.t)
}
run := tr.runs[tr.call]
rec := run.recorder(tr.conf)
if _, err := rec.Exec(ctx, batchSize); err != nil {
return 0, err
}
tr.call++
return run.ret(ctx, batchSize, tr.conf)
}
func TestRun(t *testing.T) {
const testStatusTotal = 10
t.Parallel()
assertStoreCalled := func() func(*testing.T, *recorder) {
const op = "assertStoreCalled"
return func(t *testing.T, r *recorder) {
assert.Positive(t, r.storeBatchSize, op)
}
}
assertStoreNotCalled := func() func(*testing.T, *recorder) {
const op = "assertStoreNotCalled"
return func(t *testing.T, r *recorder) {
t.Helper()
assert.Zero(t, r.storeBatchSize, op)
}
}
assertRetryCalled := func() func(*testing.T, *recorder) {
const op = "assertRetryCalled"
return func(t *testing.T, r *recorder) {
assert.Positive(t, r.status.Retries, op)
}
}
assertRetryNotCalled := func() func(*testing.T, *recorder) {
const op = "assertRetryNotCalled"
return func(t *testing.T, r *recorder) {
assert.Zero(t, r.status.Retries, op)
}
}
assertCompletedCalled := func() func(*testing.T, *recorder) {
const op = "assertCompletedCalled"
return func(t *testing.T, r *recorder) {
assert.Positive(t, r.status.Completed, op)
}
}
assertCompletedNotCalled := func() func(*testing.T, *recorder) {
const op = "assertCompletedNotCalled"
return func(t *testing.T, r *recorder) {
assert.Zero(t, r.status.Completed, op)
}
}
assertStatusTotal := func() func(*testing.T, *recorder) {
const op = "assertStatusTotal"
return func(t *testing.T, r *recorder) {
assert.Equal(t, testStatusTotal, r.status.Total, op)
}
}
combine := func(fns ...func(*testing.T, *recorder)) func(*testing.T, *recorder) {
return func(t *testing.T, r *recorder) {
for _, fn := range fns {
fn(t, r)
}
}
}
execLessThanBatch := func() func(context.Context, int, *Config) (int, error) {
return func(ctx context.Context, batchSize int, c *Config) (int, error) {
return batchSize - 1, nil
}
}
execTimeout := func() func(context.Context, int, *Config) (int, error) {
return func(ctx context.Context, batchSize int, c *Config) (int, error) {
select {
case <-ctx.Done():
return 0, ctx.Err()
}
}
}
execError := func() func(context.Context, int, *Config) (int, error) {
return func(ctx context.Context, batchSize int, c *Config) (int, error) {
return 0, errors.New("fake error")
}
}
execSlow := func() func(context.Context, int, *Config) (int, error) {
return func(ctx context.Context, batchSize int, c *Config) (int, error) {
_, upper := c.targetRange()
time.Sleep(upper + (2 * time.Millisecond))
return batchSize, nil
}
}
execTargetRange := func() func(context.Context, int, *Config) (int, error) {
return func(ctx context.Context, batchSize int, c *Config) (int, error) {
time.Sleep(c.Target)
return batchSize, nil
}
}
runMap := map[string]*testRun{
"execLessThanBatch": {
ret: execLessThanBatch(),
chk: combine(assertStatusTotal(), assertStoreNotCalled(), assertRetryNotCalled(), assertCompletedCalled()),
},
"execTimeout": {
ret: execTimeout(),
chk: combine(assertStatusTotal(), assertStoreCalled(), assertRetryCalled(), assertCompletedNotCalled()),
},
"execError": {
ret: execError(),
chk: combine(assertStatusTotal(), assertStoreNotCalled(), assertRetryNotCalled(), assertCompletedNotCalled()),
},
"execSlow": {
ret: execSlow(),
chk: combine(assertStatusTotal(), assertStoreCalled(), assertRetryNotCalled(), assertCompletedCalled()),
},
"execTargetRange": {
ret: execTargetRange(),
chk: combine(assertStatusTotal(), assertStoreNotCalled(), assertRetryNotCalled(), assertCompletedCalled()),
},
}
tests := []struct {
name string
conf *Config
runs []*testRun
wantErr bool
}{
{
name: "normal",
conf: &Config{
TotalToComplete: testStatusTotal,
},
runs: []*testRun{runMap["execLessThanBatch"]},
},
{
name: "error",
conf: &Config{
TotalToComplete: testStatusTotal,
},
runs: []*testRun{runMap["execError"]},
wantErr: true,
},
{
name: "timeout-normal",
conf: &Config{
TotalToComplete: testStatusTotal,
StatusThreshold: 5 * time.Millisecond,
},
runs: []*testRun{runMap["execTimeout"], runMap["execLessThanBatch"]},
},
{
name: "slow-normal",
conf: &Config{
TotalToComplete: testStatusTotal,
StatusThreshold: 5 * time.Millisecond,
},
runs: []*testRun{runMap["execSlow"], runMap["execLessThanBatch"]},
},
{
name: "target-normal",
conf: &Config{
TotalToComplete: testStatusTotal,
Target: 1 * time.Second,
},
runs: []*testRun{runMap["execTargetRange"], runMap["execLessThanBatch"]},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert := assert.New(t)
ctx := context.Background()
b := &Batch{
c: tt.conf,
}
tr := newTestRunner(t, tt.conf, b)
tr.runs = tt.runs
if err := b.Run(ctx); tt.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
})
}
}
func TestBatch_batchCompleted(t *testing.T) {
t.Parallel()
assert, require := assert.New(t), require.New(t)
ctx := context.Background()
config := &Config{
Exec: func(ctx context.Context, batchSize int) (int, error) { return 0, nil },
Store: func(ctx context.Context, batchSize int) error {
fmt.Println("batchSize: ", batchSize)
return nil
},
}
b, err := New(ctx, config)
require.NoError(err)
assert.NotNil(b)
err = b.adjustSize(ctx, 10)
require.NoError(err)
}