Documentation
¶
Overview ¶
Package hedgex provides request hedging (speculative execution) for reducing tail latency in production Go services.
A Hedger launches the same logical request as several copies with staggered delays and keeps the first successful result; the remaining in-flight copies are cancelled as soon as a winner arrives. Hedging trades a bounded amount of extra load for a dramatically tighter latency tail: a request that stalls on one slow backend is rescued by a fresh copy instead of dragging the p99.
h := hedgex.New(
hedgex.WithDelay(50*time.Millisecond),
hedgex.WithMaxParallel(3),
)
val, err := hedgex.Execute(h, ctx, func(ctx context.Context, hc hedgex.HedgeController) (string, error) {
if hc.IsHedge() {
return fetchFromReplica(ctx) // copy 2+ reads a replica
}
return fetchFromPrimary(ctx)
})
The callback receives a HedgeController exposing which copy it is, how many copies were scheduled, and the elapsed time, plus a HedgeController.Cancel method so a copy can remove itself from the race when it knows it cannot win.
Each copy runs under github.com/aasyanov/urx/panix: a panicking function becomes a *panix.PanicError instead of crashing the process and is treated as an ordinary copy failure.
Dependencies ¶
hedgex depends only on the Go standard library and the urx panix package.
Index ¶
Examples ¶
Constants ¶
const ( // DefaultMaxParallel is the maximum number of in-flight copies (original + // hedges) applied when [WithMaxParallel] is not supplied. DefaultMaxParallel = 3 // DefaultDelay is the stagger between launching successive copies applied // when [WithDelay] is not supplied. The first hedge starts one delay after // the original, the second two delays after, and so on (until [DefaultMaxDelay]). DefaultDelay = 100 * time.Millisecond // DefaultMaxDelay caps the total stagger window applied when [WithMaxDelay] // is not supplied. Copies scheduled past this point are spread thinly so a // large MaxParallel does not collapse into a synchronized burst. DefaultMaxDelay = 1 * time.Second // DefaultHedgeProbability is the chance that a call fans out beyond the // original copy when [WithHedgeProbability] is not supplied. 1.0 means // every eligible call may launch hedges. DefaultHedgeProbability = 1.0 )
Variables ¶
var ( // ErrNilFunc is returned by [Execute] and [ExecuteMulti] when no function is // supplied (a nil function for [Execute], an empty or all-nil slice for // [ExecuteMulti]). Safe to compare with == or [errors.Is]. ErrNilFunc = errors.New("hedgex: nil function") // ErrAllFailed is returned when every hedge copy completed with an error // and none succeeded. The joined error carries the first failure observed; // reach it with [errors.Unwrap] or test it with [errors.Is]. Safe to // compare with == or [errors.Is]. ErrAllFailed = errors.New("hedgex: all hedged copies failed") // ErrCancelled is returned when the caller's context is cancelled (or its // deadline expires) before any copy succeeds. 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("hedgex: context cancelled") )
Functions ¶
func Execute ¶
Execute runs fn with hedging: the original copy starts immediately and, if it has not returned a success within WithDelay, a second copy is launched, and so on up to WithMaxParallel. The first copy to succeed wins and its value is returned; all other in-flight copies are then cancelled. Because Go methods cannot be generic, Execute is a package-level function taking the Hedger as its first argument.
Execute returns ErrNilFunc if fn is nil, ErrCancelled if ctx is cancelled before any copy succeeds, and ErrAllFailed (wrapping the first failure) if every copy fails. Each copy runs under panix.Safe, so a panic surfaces as a *panix.PanicError handled like any other copy failure.
The callback receives a per-copy child of the hedge context and a HedgeController exposing the copy's attempt number and a HedgeController.Cancel method that withdraws this copy and cancels its context (flag-only on the single-copy fast path).
Example ¶
ExampleExecute demonstrates the common case: one function hedged across staggered copies. The primary returns first, so no hedge is launched.
package main
import (
"context"
"fmt"
"time"
"github.com/aasyanov/urx/hedgex"
)
func main() {
h := hedgex.New(
hedgex.WithDelay(50*time.Millisecond),
hedgex.WithMaxParallel(3),
)
got, err := hedgex.Execute(h, context.Background(),
func(ctx context.Context, hc hedgex.HedgeController) (string, error) {
if hc.IsHedge() {
return "replica", nil // copy 2+ reads a replica
}
return "primary", nil
})
fmt.Println(got, err)
}
Output: primary <nil>
Example (HedgeWins) ¶
ExampleExecute_hedgeWins shows a stalled primary being rescued by a hedge.
package main
import (
"context"
"fmt"
"time"
"github.com/aasyanov/urx/hedgex"
)
func main() {
h := hedgex.New(
hedgex.WithDelay(20*time.Millisecond),
hedgex.WithMaxParallel(2),
)
got, _ := hedgex.Execute(h, context.Background(),
func(ctx context.Context, hc hedgex.HedgeController) (string, error) {
if hc.IsHedge() {
return "fast-replica", nil
}
<-ctx.Done() // primary stalls until a winner cancels it
return "", ctx.Err()
})
fmt.Println(got)
}
Output: fast-replica
func ExecuteMulti ¶
ExecuteMulti runs each function in fns as a distinct hedge backend, launched with the same staggered schedule as Execute. The first copy to succeed wins. len(fns) is capped at WithMaxParallel; a nil entry within the cap is skipped (its slot never launches). fns is not retained after ExecuteMulti returns.
ExecuteMulti returns ErrNilFunc if fns is empty or every in-cap entry is nil, ErrCancelled if ctx is cancelled before any copy succeeds, and ErrAllFailed (wrapping the first failure) if every launched copy fails. Use it to hedge across heterogeneous backends (primary vs. replica vs. cache) rather than the same function repeated by Execute.
Example ¶
ExampleExecuteMulti hedges across heterogeneous backends: a primary and a cache, each a distinct function.
package main
import (
"context"
"fmt"
"time"
"github.com/aasyanov/urx/hedgex"
)
func main() {
h := hedgex.New(hedgex.WithDelay(10 * time.Millisecond))
fromCache := func(ctx context.Context, _ hedgex.HedgeController) (string, error) {
return "cached-value", nil
}
fromPrimary := func(ctx context.Context, _ hedgex.HedgeController) (string, error) {
<-ctx.Done()
return "", ctx.Err()
}
got, _ := hedgex.ExecuteMulti(h, context.Background(),
[]hedgex.HedgeFunc[string]{fromPrimary, fromCache})
fmt.Println(got)
}
Output: cached-value
Types ¶
type HedgeController ¶
type HedgeController interface {
// Attempt returns the 1-based launch ordinal of this copy among non-nil
// backends: 1 is the original request, 2 the first hedge, and so on. Nil
// slots in an [ExecuteMulti] slice are not counted.
Attempt() int
// IsHedge reports whether this copy is a speculative hedge (Attempt > 1)
// rather than the original request.
IsHedge() bool
// Backends returns the number of launchable copies scheduled for this call:
// non-nil entries after capping at [WithMaxParallel], excluding skipped nil
// slots. Use it to size per-backend selection (replica index = Attempt-1).
Backends() int
// Elapsed returns the wall-clock time since the first copy was launched,
// measured from the start of [Execute]. It lets a late hedge gauge how far
// behind it started.
Elapsed() time.Duration
// Cancel withdraws this copy from the race and cancels this copy's
// context so a well-behaved function can observe ctx.Done() and return.
// The copy's eventual result is still reaped (it is neither winner nor
// failure). Sibling copies keep a live context. On the synchronous
// MaxParallel==1 path there is no per-copy context, so Cancel only sets
// the withdrawn flag. Safe to call multiple times; only the first call
// has an effect.
Cancel()
}
HedgeController exposes per-copy execution context to the hedged function and lets a copy remove itself from the race. The implementation is private; callers interact only through this interface. A HedgeController is bound to a single hedge copy within one Execute call and must not be retained after the function returns.
Hedging launches the same logical request as several copies with staggered delays and keeps the first success. The controller therefore tells a copy which attempt it is (so it can adapt — read from a replica, skip writes) and lets it bow out via HedgeController.Cancel when it knows it cannot win (for example, the chosen backend is unreachable), freeing its slot without failing the whole call.
type HedgeFunc ¶
type HedgeFunc[T any] func(ctx context.Context, hc HedgeController) (T, error)
HedgeFunc is the unit of work hedged by Execute and ExecuteMulti. It runs under panic recovery and receives the call context and a HedgeController.
type Hedger ¶
type Hedger struct {
// contains filtered or unexported fields
}
Hedger runs functions with hedging (speculative execution) to cut tail latency. Create one with New, run work with the package-level Execute or ExecuteMulti, and inspect counters with Hedger.Stats.
A Hedger holds only immutable configuration plus lock-free atomic counters, so it is safe for concurrent use from any number of goroutines and may be shared across the lifetime of a service.
func New ¶
New creates a Hedger with the given options applied on top of the package defaults (DefaultMaxParallel copies, DefaultDelay stagger, DefaultMaxDelay window). Invalid options are clamped, so New never returns an unusable hedger: a non-positive parallelism floors to a single copy (no hedging) and a MaxDelay below the per-copy delay is raised to it.
func (*Hedger) MaxParallel ¶
MaxParallel returns the configured maximum number of concurrent copies.
func (*Hedger) ResetStats ¶
func (h *Hedger) ResetStats()
ResetStats zeroes all cumulative counters. It does not affect any in-flight call.
type Option ¶
type Option func(*config)
Option configures a Hedger created by New.
func WithDelay ¶
WithDelay sets the base stagger between launching successive copies. The next copy fires when its scheduled delay elapses if earlier copies have not yet succeeded; if every in-flight copy finishes without a win, the next copy launches immediately rather than waiting out the remaining delay. Default: DefaultDelay. Values <= 0 are ignored.
func WithHedgeProbability ¶ added in v1.5.2
WithHedgeProbability sets the probability that a call fans out beyond the original copy. Default: DefaultHedgeProbability (1.0). Values <= 0 are ignored; values > 1 are clamped to 1.
func WithMaxDelay ¶
WithMaxDelay caps the total stagger window. Copies scheduled past MaxDelay are spread evenly (delay/4, floored at 1ms apart) to avoid a synchronized burst. Default: DefaultMaxDelay. Values <= 0 are ignored; a MaxDelay below the per-copy delay is raised to the delay so the schedule stays monotonic.
func WithMaxParallel ¶
WithMaxParallel sets the maximum number of concurrent copies (the original request plus hedges). Default: DefaultMaxParallel. Values <= 0 are ignored and a final value below 1 is floored to 1 (which disables hedging).
func WithOnHedge ¶
WithOnHedge registers a callback invoked just before each hedge copy is launched, with the 1-based attempt number (2 for the first hedge, 3 for the second, ...). It runs synchronously on the dispatch goroutine under panic recovery so a panicking hook never crashes the loop; the hook must not block or panic. Default: none.
type Stats ¶
type Stats struct {
// Calls is the total number of [Execute]/[ExecuteMulti] invocations.
Calls int64 `json:"calls"`
// Wins is the number of calls that returned a successful result.
Wins int64 `json:"wins"`
// Hedges is the number of speculative copies launched beyond the original.
Hedges int64 `json:"hedges"`
// Failures is the number of calls that returned an error (all copies
// failed, no function, or context cancellation).
Failures int64 `json:"failures"`
}
Stats holds a point-in-time snapshot of hedger counters.