Documentation
¶
Overview ¶
Package adaptx provides adaptive concurrency limiting for production Go services.
A Limiter discovers a backend's safe concurrency on its own. It starts at a configured limit and moves that limit up or down once per sample window from latency and error feedback, using one of three control laws — AIMD, Vegas, or Gradient. Where a static bulkhead (see bulkx) must be sized by hand to a fixed guess, an adaptive limiter tracks capacity as it changes: it opens up when the backend is fast and healthy, and clamps down the moment latency climbs or errors appear, so callers wait (or are turned away) instead of piling onto a struggling backend.
Quick Start ¶
l := adaptx.New(
adaptx.WithAlgorithm(adaptx.Gradient),
adaptx.WithInitialLimit(10),
)
defer l.Close()
rows, err := adaptx.Execute(l, ctx,
func(ctx context.Context, ac adaptx.AdaptController) (*sql.Rows, error) {
if ac.InFlight() > ac.Limit()/2 {
return db.QueryContext(ctx, simpleSQL) // shed load near saturation
}
return db.QueryContext(ctx, complexSQL)
})
The callback receives an AdaptController exposing the limit and in-flight count at admission and an AdaptController.SkipSample method to keep outlier latencies out of the feedback signal. For tracked admission without a callback, use Limiter.Acquire and call the returned release function.
Each callback is wrapped with github.com/aasyanov/urx/panix for panic recovery; a panicking function yields a *panix.PanicError instead of crashing the process, and the in-flight slot is always released.
Dependencies ¶
adaptx depends only on the Go standard library and the urx panix package.
Index ¶
- Constants
- Variables
- func Execute[T any](l *Limiter, ctx context.Context, fn AdaptFunc[T]) (T, error)
- func TryExecute[T any](l *Limiter, ctx context.Context, fn AdaptFunc[T]) (bool, T, error)
- type AdaptController
- type AdaptFunc
- type Algorithm
- type Limiter
- func (l *Limiter) Acquire(ctx context.Context) (release func(success bool, latency time.Duration), err error)
- func (l *Limiter) Allow() bool
- func (l *Limiter) Close() error
- func (l *Limiter) CloseWithTimeout(timeout time.Duration) error
- func (l *Limiter) InFlight() int
- func (l *Limiter) IsClosed() bool
- func (l *Limiter) Limit() int
- func (l *Limiter) ResetStats()
- func (l *Limiter) Stats() Stats
- func (l *Limiter) TryAcquire() (release func(success bool, latency time.Duration), ok bool)
- type Option
- func WithAlgorithm(a Algorithm) Option
- func WithDecreaseRatio(r float64) Option
- func WithIncreaseRate(r float64) Option
- func WithInitialLimit(n int) Option
- func WithJitter(f float64) Option
- func WithMaxLimit(n int) Option
- func WithMinLatencyDecay(f float64) Option
- func WithMinLimit(n int) Option
- func WithOnLimitChange(fn func(oldLimit, newLimit int)) Option
- func WithOp(op string) Option
- func WithSampleWindow(d time.Duration) Option
- func WithSmoothing(f float64) Option
- func WithTargetLatency(d time.Duration) Option
- func WithTolerance(f float64) Option
- func WithUtilization(f float64) Option
- func WithWarmupSamples(n int) Option
- type Stats
Examples ¶
Constants ¶
const ( // DefaultInitialLimit is the concurrency limit a [Limiter] starts at before // any adaptation, applied when [WithInitialLimit] is not supplied. DefaultInitialLimit = 10 // DefaultMinLimit is the floor the adaptive limit is never driven below, // applied when [WithMinLimit] is not supplied. A floor of 1 keeps the // limiter able to make forward progress even under sustained failure. DefaultMinLimit = 1 // DefaultMaxLimit is the ceiling the adaptive limit is never driven above, // applied when [WithMaxLimit] is not supplied. DefaultMaxLimit = 1000 // DefaultSmoothing is the EMA weight applied to each window's mean RTT when // updating the smoothed average, applied when [WithSmoothing] is not // supplied. Higher reacts faster but is noisier. DefaultSmoothing = 0.2 // DefaultIncreaseRate is the additive credit [AIMD] accumulates on each // successful, high-utilization window, applied when [WithIncreaseRate] is // not supplied. Values below 1 grow the limit every few windows (0.5 → +1 // every two windows). DefaultIncreaseRate = 1.0 // DefaultDecreaseRatio is the multiplicative factor the limit is scaled by // on a backoff window, applied when [WithDecreaseRatio] is not supplied. // 0.5 halves the limit, matching TCP multiplicative decrease. DefaultDecreaseRatio = 0.5 // DefaultUtilization is the in-flight fraction of the live limit that [AIMD] // requires before it will add credit, applied when [WithUtilization] is not // supplied. A window whose peak in-flight is below ceil(limit·utilization) // holds the limit even if every sample succeeded. DefaultUtilization = 0.9 // DefaultTargetLatency is the latency [Vegas] treats as the operating point, // applied when [WithTargetLatency] is not supplied. DefaultTargetLatency = 100 * time.Millisecond // DefaultTolerance is the fractional latency deviation [Vegas] and // [Gradient] tolerate before reacting, applied when [WithTolerance] is not // supplied. DefaultTolerance = 0.1 // DefaultSampleWindow is the interval over which samples are aggregated // into one control-law adjustment and over which [Stats] computes latency // percentiles, applied when [WithSampleWindow] is not supplied. DefaultSampleWindow = 1 * time.Second // DefaultWarmupSamples is the number of recorded samples collected before // adaptation begins, applied when [WithWarmupSamples] is not supplied. It // stops the controller from reacting to the first few unrepresentative // calls. DefaultWarmupSamples = 10 // DefaultMinLatencyDecay is the fraction by which RTT_min drifts toward the // running average on each completed window, applied when // [WithMinLatencyDecay] is not supplied. It prevents [Vegas] from sticking // to an anomalously low minimum forever. 0 disables decay. DefaultMinLatencyDecay = 0.001 // DefaultJitter is the fraction of each limit increase that may be randomly // withheld to desynchronize many limiters, applied when [WithJitter] is not // supplied. 0 disables jitter. DefaultJitter = 0.1 )
const ( // DefaultCloseTimeout is a suggested drain bound for [Limiter.CloseWithTimeout]. // [Limiter.Close] itself does not wait. DefaultCloseTimeout = 30 * time.Second )
Variables ¶
var ( // ErrClosed is returned by [Limiter.Acquire], [Execute], and related methods // after [Limiter.Close] has been called. Safe to compare with == or // [errors.Is]. Also returned by a second [Limiter.CloseWithTimeout] after // the first call has already begun shutdown. ErrClosed = errors.New("adaptx: limiter is closed") // ErrTimeout is returned when a blocking acquire exceeds its deadline before // a slot becomes available. The joined error carries [context.DeadlineExceeded]; // reach it with [errors.Unwrap] or test it with [errors.Is]. Safe to compare // with == or [errors.Is]. ErrTimeout = errors.New("adaptx: acquire timed out") // ErrCancelled is returned when the caller's context is cancelled before a // slot becomes available. The joined error carries ctx.Err(); reach it with // [errors.Unwrap] or test it with [errors.Is]. Safe to compare with == or // [errors.Is]. ErrCancelled = errors.New("adaptx: acquire cancelled") // ErrNilFunc is returned by [Execute] and [TryExecute] when the supplied // function is nil. Safe to compare with == or [errors.Is]. ErrNilFunc = errors.New("adaptx: nil function") // ErrDrainTimeout is returned by [Limiter.CloseWithTimeout] when in-flight // work is still running after the drain deadline. The limiter stays closed; // remaining work is not cancelled. Safe to compare with == or [errors.Is]. ErrDrainTimeout = errors.New("adaptx: drain timed out") )
Functions ¶
func Execute ¶
Execute admits one operation and runs fn under panic recovery. Because Go methods cannot have type parameters, Execute is a package-level generic function taking the Limiter as its first argument; it is the recommended way to use the limiter.
Execute blocks for a permit exactly as Limiter.Acquire does and reports the same admission errors: ErrClosed, ErrTimeout, or ErrCancelled. It returns ErrNilFunc if fn is nil. On admission the permit is held for the duration of fn and released even if fn panics — the callback runs under panix.Safe, so a panic becomes a *panix.PanicError. The call's latency and outcome feed the adaptive algorithm unless the callback invokes AdaptController.SkipSample.
Example ¶
ExampleExecute shows the recommended callback form: the limiter admits the call, the callback adapts its work to the admission snapshot, and the result feeds the adaptive algorithm.
package main
import (
"context"
"errors"
"fmt"
"github.com/aasyanov/urx/adaptx"
)
func main() {
l := adaptx.New(
adaptx.WithAlgorithm(adaptx.Gradient),
adaptx.WithInitialLimit(10),
)
defer l.Close()
result, err := adaptx.Execute(l, context.Background(),
func(ctx context.Context, ac adaptx.AdaptController) (string, error) {
if ac.InFlight() > ac.Limit()/2 {
return "cheap", nil // shed load near saturation
}
return "full", nil
})
switch {
case errors.Is(err, adaptx.ErrClosed):
fmt.Println("closed")
case err != nil:
fmt.Println("failed:", err)
default:
fmt.Println("ok:", result)
}
}
Output: ok: full
func TryExecute ¶
TryExecute runs fn only if a permit is immediately available, without blocking. It returns (true, val, err) when fn ran and (false, zero, nil) when no permit was free. Returns (false, zero, ErrClosed) if the limiter is closed, (false, zero, ErrNilFunc) if fn is nil, and (false, zero, ErrCancelled or ErrTimeout) when ctx is already cancelled or its deadline has expired (no permit consumed). The permit is released when fn returns or panics.
Example ¶
ExampleTryExecute shows the non-blocking variant: when no permit is free the call is skipped rather than queued.
package main
import (
"context"
"fmt"
"github.com/aasyanov/urx/adaptx"
)
func main() {
l := adaptx.New(adaptx.WithInitialLimit(1), adaptx.WithMaxLimit(1))
defer l.Close()
ran, val, err := adaptx.TryExecute(l, context.Background(),
func(ctx context.Context, ac adaptx.AdaptController) (int, error) {
return 7, nil
})
fmt.Printf("ran=%v val=%d err=%v\n", ran, val, err)
}
Output: ran=true val=7 err=<nil>
Types ¶
type AdaptController ¶
type AdaptController interface {
// Limit returns the concurrency limit in effect at admission time.
Limit() int
// InFlight returns the number of operations in flight at admission time,
// excluding this one.
InFlight() int
// Algorithm returns the active adaptation algorithm.
Algorithm() Algorithm
// SkipSample tells the limiter not to feed this call's latency and outcome
// into the adaptive algorithm. Use it for outlier operations whose latency
// would mislead the controller (cache misses, cold starts, admin calls).
// The call still counts toward the success/failure totals in [Stats] but
// does not raise the window peak in-flight used by AIMD utilization. Safe
// to call multiple times; only the first call has an effect.
SkipSample()
}
AdaptController exposes the admission snapshot to the Execute callback and lets it opt out of feeding its result into the adaptive algorithm. The implementation is private; callers interact only through this interface. An AdaptController is bound to a single Execute call and must not be retained after the callback returns.
The concurrency limit is decided at admission: by the time the callback runs the request is already admitted. The controller therefore exposes the limit and in-flight count captured at admission so the callback can adapt its work to the observed pressure — for example, serve a cheaper query when the limiter is near saturation. AdaptController.SkipSample removes outlier calls (cache misses, cold starts) from the feedback signal so a single anomalous latency does not mislead the controller.
type AdaptFunc ¶
type AdaptFunc[T any] func(ctx context.Context, ac AdaptController) (T, error)
AdaptFunc is the unit of work run by Execute and TryExecute. It receives the call context and an AdaptController, and runs under panic recovery: a panicking function becomes a *panix.PanicError.
type Algorithm ¶
type Algorithm uint8
Algorithm selects the strategy a Limiter uses to move its concurrency limit in response to latency and error feedback. Each law runs once per completed sample window, not once per request.
const ( // AIMD is Additive Increase / Multiplicative Decrease: after a successful // window that reached the utilization gate the limit grows by a fractional // credit of [WithIncreaseRate]; a window with any failure is cut once by // [WithDecreaseRatio]. It needs no latency target and is the safest default // — the same control law TCP congestion avoidance uses. Best when failures // (not latency) are the overload signal. AIMD Algorithm = iota // Vegas estimates queue build-up from round-trip time, in the spirit of // TCP Vegas. It compares the window's mean RTT against the best latency // seen (RTT_min) as queue = limit·(1 − minRTT/rtt), then grows the limit // while the estimated queue is below α and shrinks it when the queue // exceeds β = α·2. Best when a backend has a stable, measurable floor // latency. Vegas // Gradient reacts to the trend of latency: it grows the limit while the // window mean is at or below the EMA average and backs off in proportion // to how far the window mean has risen above it. Best for backends whose // floor latency drifts, where an absolute target would go stale. Gradient )
type Limiter ¶
type Limiter struct {
// contains filtered or unexported fields
}
Limiter is a thread-safe adaptive concurrency limiter. Create one with New, run work with Execute or admit it manually with Limiter.Acquire, inspect counters with Limiter.Stats, and release resources with Limiter.Close.
It is safe for concurrent use from multiple goroutines. Admission rides a buffered-channel semaphore; only the periodic windowed adaptation step and the percentile snapshot take the mutex.
After a shrink, in-flight work may briefly exceed the live limit until released permits pay the shrink debt. New admissions never take a permit that is not in the semaphore; in-flight never exceeds WithMaxLimit.
func New ¶
New creates a Limiter with the given options applied on top of the package defaults (AIMD, initial limit DefaultInitialLimit, bounds DefaultMinLimit–DefaultMaxLimit). Invalid options are ignored and cross-field invariants are enforced, so New never returns an unusable limiter.
func (*Limiter) Acquire ¶
func (l *Limiter) Acquire(ctx context.Context) (release func(success bool, latency time.Duration), err error)
Acquire blocks until a permit is available, the context is cancelled, or the limiter is closed. It returns a release function that MUST be called exactly once with the operation outcome and measured latency; the release function is idempotent, so extra calls are no-ops.
Returns ErrClosed if the limiter has been closed, ErrTimeout if the context deadline is exceeded while waiting, or ErrCancelled if the context is cancelled. Acquire is the building block for code that cannot use the callback form of Execute; the caller owns the returned release function and must invoke it to free the permit and feed the adaptive algorithm.
Example ¶
ExampleLimiter_Acquire shows manual admission for code that cannot use a single callback. The release function must be called exactly once with the outcome and measured latency.
package main
import (
"context"
"fmt"
"github.com/aasyanov/urx/adaptx"
)
func main() {
l := adaptx.New(adaptx.WithInitialLimit(5))
defer l.Close()
release, err := l.Acquire(context.Background())
if err != nil {
fmt.Println("acquire failed:", err)
return
}
// ... do work, measure latency ...
release(true, 0)
fmt.Println("in-flight:", l.InFlight())
}
Output: in-flight: 0
func (*Limiter) Allow ¶
Allow reports whether a permit is currently free without acquiring it. It does not track anything or mutate any counter; use Execute, TryExecute, or Limiter.Acquire for tracked admission. Returns false once the limiter is closed.
Allow is a best-effort hint: it compares in-flight work against the live limit without claiming a slot, so a concurrent admission may change the outcome before the caller acts. After a shrink, in-flight may still exceed the live limit (debt not yet paid), in which case Allow reports false even though no new permit is available. Only the tracked entry points enforce the concurrency bound.
func (*Limiter) Close ¶
Close shuts the limiter down without waiting for in-flight work. Use Limiter.CloseWithTimeout (for example with DefaultCloseTimeout) when drain must complete before return. Close is idempotent: the first and every later call return nil. An incomplete drain is swallowed; the limiter is still closed.
func (*Limiter) CloseWithTimeout ¶
CloseWithTimeout shuts the limiter down, waiting up to timeout for in-flight operations to drain before returning. Blocked Limiter.Acquire waiters are released immediately with ErrClosed. Subsequent Limiter.Acquire, Limiter.TryAcquire, Execute, and TryExecute calls return ErrClosed. A zero or negative timeout returns immediately without waiting. If in-flight work remains after the wait, CloseWithTimeout returns ErrDrainTimeout and the limiter stays closed. The first call performs shutdown; later calls return ErrClosed.
func (*Limiter) InFlight ¶
InFlight returns the number of operations currently admitted and running.
func (*Limiter) IsClosed ¶
IsClosed reports whether Limiter.Close has been called.
func (*Limiter) ResetStats ¶
func (l *Limiter) ResetStats()
ResetStats zeroes the cumulative counters and resets the adaptive state back to the initial limit, clearing the latency estimators, window counters, and sample history. It does not affect the in-flight count or the closed state. When in-flight work exceeds the configured initial limit the live limit is raised to that count so permits never go negative; the permit pool is reconciled immediately.
func (*Limiter) Stats ¶
Stats returns a snapshot of limiter statistics. Latency percentiles are computed over the samples recorded within the configured sample window; with no recent samples the latency fields are zero.
func (*Limiter) TryAcquire ¶
TryAcquire attempts to take a permit without blocking. It returns the release function and true on success, or (nil, false) when no permit is immediately available or the limiter is closed. The release function MUST be called exactly once on success and is idempotent.
type Option ¶
type Option func(*config)
Option configures a Limiter created by New.
func WithAlgorithm ¶
WithAlgorithm selects the adaptation strategy. Default: AIMD. An unknown value falls back to AIMD at adaptation time.
func WithDecreaseRatio ¶
WithDecreaseRatio sets the multiplicative backoff factor applied to the limit on a failure or overload window. Default: DefaultDecreaseRatio. Values outside (0, 1) are ignored.
func WithIncreaseRate ¶
WithIncreaseRate sets the additive credit AIMD accumulates on each successful window that meets the utilization gate. Default: DefaultIncreaseRate. Values <= 0 are ignored. Fractional rates keep a remainder so 0.5 grows the limit by 1 every two windows.
func WithInitialLimit ¶
WithInitialLimit sets the concurrency limit the limiter starts at before any adaptation. Default: DefaultInitialLimit. Values <= 0 are ignored; the final value is clamped into [min, max].
func WithJitter ¶
WithJitter sets the fraction of each limit increase that may be randomly withheld, desynchronizing many limiters so they do not all step up in lockstep (thundering herd). Default: DefaultJitter. 0 disables jitter. Values outside [0, 1] are ignored.
func WithMaxLimit ¶
WithMaxLimit sets the ceiling the adaptive limit is never driven above and the hard cap on concurrently admitted operations. Default: DefaultMaxLimit. Values <= 0 are ignored; a value below the minimum is raised to it.
func WithMinLatencyDecay ¶
WithMinLatencyDecay sets the fraction by which the observed minimum latency drifts toward the running average on each completed window, preventing Vegas from sticking to an anomalously low minimum. Default: DefaultMinLatencyDecay. 0 disables decay. Values outside [0, 1] are ignored.
func WithMinLimit ¶
WithMinLimit sets the floor the adaptive limit is never driven below. Default: DefaultMinLimit. Values <= 0 are ignored; the final value is floored to 1.
func WithOnLimitChange ¶
WithOnLimitChange registers a callback invoked synchronously whenever the adaptive limit changes, receiving the old and new values. Default: none. The callback must not block or panic; it runs on the goroutine that closed the sample window, and a panic is recovered and discarded.
func WithOp ¶
WithOp sets the logical operation name attached to panic reports raised by the callback (e.g. "db.query", "api.search"). Default: [opExecute] for Execute and [opTryExecute] for TryExecute. Empty values are ignored.
func WithSampleWindow ¶
WithSampleWindow sets the interval over which completed operations are aggregated into one control-law adjustment, and over which Stats computes latency percentiles. Default: DefaultSampleWindow. Values <= 0 are ignored.
func WithSmoothing ¶
WithSmoothing sets the EMA weight applied to each window's mean RTT. Default: DefaultSmoothing. Values outside (0, 1] are ignored.
func WithTargetLatency ¶
WithTargetLatency sets the round-trip latency Vegas treats as the operating point when scaling the queue target band. Default: DefaultTargetLatency. Values <= 0 are ignored. When target latency is at or below the observed minimum RTT the band falls back to limit·tolerance.
func WithTolerance ¶
WithTolerance sets the fractional latency deviation Vegas and Gradient tolerate before reacting. Default: DefaultTolerance. Values outside (0, 1] are ignored.
func WithUtilization ¶ added in v1.5.2
WithUtilization sets the in-flight fraction of the live limit that AIMD requires before it will add increase credit. Default: DefaultUtilization. Values outside (0, 1] are ignored. A window whose peak in-flight is below ceil(limit·utilization) holds the limit.
func WithWarmupSamples ¶
WithWarmupSamples sets the number of recorded samples collected before adaptation begins. Default: DefaultWarmupSamples. 0 disables warmup so adaptation starts on the first completed window. Negative values are ignored.
type Stats ¶
type Stats struct {
Algorithm string `json:"algorithm"`
Limit int `json:"limit"`
MinLimit int `json:"min_limit"`
MaxLimit int `json:"max_limit"`
InFlight int `json:"in_flight"`
Total int64 `json:"total"`
Success int64 `json:"success"`
Failures int64 `json:"failures"`
Rejected int64 `json:"rejected"`
Increases int64 `json:"increases"`
Decreases int64 `json:"decreases"`
AvgLat time.Duration `json:"avg_latency"`
MinLat time.Duration `json:"min_latency"`
MaxLat time.Duration `json:"max_latency"`
P50Lat time.Duration `json:"p50_latency"`
P99Lat time.Duration `json:"p99_latency"`
}
Stats holds a point-in-time snapshot of limiter counters and latency percentiles computed over the configured sample window.