Files
2026-08-20 10:52:03 +10:00

158 lines
4.6 KiB
Go

package ebsctl
import (
"errors"
"sync"
"time"
"github.com/aws/aws-sdk-go/aws/awserr"
)
// msFloat converts a duration to fractional milliseconds, the unit every
// SeriesStats field is reported in.
func msFloat(d time.Duration) float64 {
return float64(d) / float64(time.Millisecond)
}
// errCodeMessage extracts an AWS error code/message pair from err. Non-AWS
// errors (e.g. a transport failure) get a synthetic "non-aws-error" code so
// they still show up in the tally instead of vanishing into "unknown".
func errCodeMessage(err error) (code, message string) {
if err == nil {
return "", ""
}
if aerr, ok := errors.AsType[awserr.Error](err); ok {
return aerr.Code(), aerr.Message()
}
return "non-aws-error", err.Error()
}
// collector accumulates one operation's samples/errors across concurrent
// workers. Safe for concurrent use.
type collector struct {
mu sync.Mutex
api []float64
settle []float64
settleTimeouts int
tally *ErrorTally
attempts int
}
func newCollector() *collector {
return &collector{tally: newErrorTally()}
}
// record logs one attempt. Exactly one of (err != nil) or
// (api recorded, optionally settle/settleTimedOut) applies:
// - err != nil: the API call itself failed; settle/settleTimedOut are
// ignored since there is nothing to settle.
// - err == nil, settleTimedOut: the call succeeded but the resource never
// reached the target state within budget.
// - err == nil, settle != nil: the call succeeded and settle records how
// long the resource took to reach the target state.
// - err == nil, settle == nil, !settleTimedOut: the call succeeded and
// settle time isn't tracked for this operation (e.g. DescribeVolumes).
func (c *collector) record(api time.Duration, settle *time.Duration, settleTimedOut bool, err error) {
c.mu.Lock()
defer c.mu.Unlock()
c.attempts++
if err != nil {
code, msg := errCodeMessage(err)
c.tally.add(code, msg)
return
}
c.api = append(c.api, msFloat(api))
switch {
case settleTimedOut:
c.settleTimeouts++
case settle != nil:
c.settle = append(c.settle, msFloat(*settle))
}
}
// result finalises the collected samples into an OpResult named name.
func (c *collector) result(name string) *OpResult {
c.mu.Lock()
defer c.mu.Unlock()
res := &OpResult{
Operation: name,
Attempts: c.attempts,
APILatency: computeSeries(c.api),
SettleTimeouts: c.settleTimeouts,
Errors: *c.tally,
ErrorRate: c.tally.rate(c.attempts),
}
if len(c.settle) > 0 {
s := computeSeries(c.settle)
res.SettleTime = &s
}
return res
}
// parallelFor runs fn across `concurrency` workers (>=1). Each worker first
// runs `warmup` discarded iterations, then `iterations` measured ones. Warm-up
// is per-worker, not globally barriered — for the default concurrency=1 this
// is exact; at higher concurrency it's an approximation (workers' warm-up
// windows can overlap in wall-clock time), which is an acceptable trade for
// not needing a synchronization barrier no one asked for.
func parallelFor(concurrency, warmup, iterations int, fn func(workerID, iter int, warm bool)) {
if concurrency < 1 {
concurrency = 1
}
var wg sync.WaitGroup
for w := range concurrency {
wg.Go(func() {
for i := range warmup {
fn(w, i, true)
}
for i := range iterations {
fn(w, i, false)
}
})
}
wg.Wait()
}
// drainDelete concurrently drains ids across `concurrency` workers, calling
// del for each and recording the result into the returned collector. The
// first warmupCount completions (in dequeue order, which is not necessarily
// creation order once concurrency>1) are discarded — a work-queue analogue
// of parallelFor's per-worker warm-up, needed because a delete pass doesn't
// have a natural "iterations per worker" shape: it must delete exactly the
// set of ids a paired create pass produced, however many that turned out to
// be if some creates failed.
func drainDelete(ids []string, concurrency, warmupCount int, del func(id string) (api time.Duration, settle *time.Duration, settleTimedOut bool, err error)) *collector {
c := newCollector()
if concurrency < 1 {
concurrency = 1
}
idCh := make(chan string, len(ids))
for _, id := range ids {
idCh <- id
}
close(idCh)
var mu sync.Mutex
warmLeft := warmupCount
var wg sync.WaitGroup
for range concurrency {
wg.Go(func() {
for id := range idCh {
mu.Lock()
warm := warmLeft > 0
if warm {
warmLeft--
}
mu.Unlock()
api, settle, timedOut, err := del(id)
if !warm {
c.record(api, settle, timedOut, err)
}
}
})
}
wg.Wait()
return c
}