Documentation
¶
Overview ¶
Package upstream is a backend pool for proxies, tunnels and load balancers: it picks a backend for each connection or request, tracks backend health actively and passively, and reports the state of every backend.
The pieces compose but also stand alone:
- A Balancer builds a Picker from Candidate values and needs no pool: RoundRobin, WeightedRoundRobin, Random, PowerOfTwo, LeastConn, Failover and ConsistentHash (a hash ring with optional bounded loads), or any implementation of the interface.
- An OutlierDetector is passive health checking for one backend: consecutive failures or a failure rate eject it, with exponential backoff, and half-open trials let it back in.
- Package health runs active health checks on any set of targets and drives pools with them; package uphttp adds an HTTP check, a per-backend transport cache and an http.RoundTripper over a pool; package upnet dials the backends of a pool.
- A Pool combines them: it holds the members, routes picks through the balancer to eligible backends, counts requests in flight through a Lease, feeds outcomes to an outlier detector per backend, takes active health reports with Pool.ReportCheck, drains and removes backends gracefully, and announces every change as an Event.
Picking ¶
Pick, PickKey and PickHash return a Lease, which must be released exactly once with the request's Outcome; Lease.Report records the outcome early for leases that live long, such as connections. Pool.Do picks, runs a function and releases, retrying failures on other backends as RetryConfig allows, within a retry budget; Attempts does the same for leases that outlive a function. Picks are lock-free and do not allocate: they read an immutable routing view that every change rebuilds and publishes atomically, so updates never disturb picks in flight.
Health ¶
A backend is eligible when it is not draining, not reported unhealthy by active checks and not ejected by passive checks. A half-open backend takes up to OutlierConfig.HalfOpenRequests trial requests at a time. When no backend is eligible, Pick fails with ErrUnavailable, or, with FallbackAll, picks among all members that are not draining.
State changes are edge-triggered: a backend that keeps failing is reported down once.
Concurrency ¶
All types are safe for concurrent use. No lock is held while calling a Balancer, a Clock, a probe or a subscriber, so callbacks may call back into the pool. The pool starts no goroutines; the only timers it owns are those ending ejections and those stepping slow starts, which Close stops. No Close in this module waits; types that own goroutines have a Done channel for callers that need to wait for them.
Example ¶
package main
import (
"fmt"
"github.com/djylb/upstream"
)
func main() {
pool := upstream.New[string](upstream.Config{
Balancer: upstream.PowerOfTwo(),
Outlier: upstream.OutlierConfig{ConsecutiveFailures: 5},
})
defer pool.Close()
err := pool.Sync([]upstream.Target[string]{
{ID: "a", Value: "10.0.0.1:8080", Weight: 2},
{ID: "b", Value: "10.0.0.2:8080"},
})
if err != nil {
fmt.Println(err)
return
}
lease, err := pool.Pick()
if err != nil {
fmt.Println(err)
return
}
fmt.Println("in flight:", lease.Backend().InFlight())
// Connect to lease.Value() here, then report how it went.
lease.Release(upstream.Success)
fmt.Println("in flight:", pool.Status()[0].InFlight+pool.Status()[1].InFlight)
}
Output: in flight: 1 in flight: 0
Index ¶
- Constants
- Variables
- func WithOutcome(err error, o Outcome) error
- type Attempts
- type Backend
- type Balancer
- type Candidate
- type CheckResult
- type Clock
- type Config
- type Drain
- type Event
- type EventKind
- type Fallback
- type HashConfig
- type Health
- type Lease
- type Outcome
- type OutlierConfig
- type OutlierDetector
- type OutlierState
- type OutlierStatus
- type Picker
- type Pool
- func (p *Pool[T]) Attempts(key string) Attempts[T]
- func (p *Pool[T]) Available() int
- func (p *Pool[T]) Backend(id string) (*Backend[T], bool)
- func (p *Pool[T]) Backends() []*Backend[T]
- func (p *Pool[T]) Close()
- func (p *Pool[T]) Do(ctx context.Context, fn func(*Backend[T]) error) error
- func (p *Pool[T]) DoKey(ctx context.Context, key string, fn func(*Backend[T]) error) error
- func (p *Pool[T]) Done() <-chan struct{}
- func (p *Pool[T]) Drain(id string) (*Drain, bool)
- func (p *Pool[T]) Len() int
- func (p *Pool[T]) Pick() (Lease[T], error)
- func (p *Pool[T]) PickHash(hash uint64) (Lease[T], error)
- func (p *Pool[T]) PickKey(key string) (Lease[T], error)
- func (p *Pool[T]) Put(t Target[T]) error
- func (p *Pool[T]) Remove(id string) (*Drain, bool)
- func (p *Pool[T]) ReportCheck(b *Backend[T], h Health, r CheckResult)
- func (p *Pool[T]) ResetOutlier(id string) bool
- func (p *Pool[T]) Resume(id string) bool
- func (p *Pool[T]) Status() []Status
- func (p *Pool[T]) Subscribe(fn func(Event[T])) (cancel func())
- func (p *Pool[T]) Sync(targets []Target[T]) error
- type RetryConfig
- type State
- type Status
- type Target
- type Ticket
Examples ¶
Constants ¶
const MaxWeight = math.MaxInt32
MaxWeight is the largest Target weight. It keeps the products of loads and weights that balancers compare far from overflowing.
Variables ¶
var ( // ErrConsecutiveFailures means the backend failed // OutlierConfig.ConsecutiveFailures requests in a row. ErrConsecutiveFailures = errors.New("upstream: too many consecutive failures") // ErrFailureRate means the share of failed requests in the window // reached OutlierConfig.FailurePercent. ErrFailureRate = errors.New("upstream: failure rate too high") // ErrTrialFailed means a half-open trial request failed. ErrTrialFailed = errors.New("upstream: half-open trial failed") )
Causes recorded when an OutlierDetector ejects its backend.
var ( // ErrNoBackends means the pool has no members. ErrNoBackends = errors.New("upstream: no backends") // unhealthy, ejected, draining or out of half-open trial slots. ErrUnavailable = errors.New("upstream: no backend available") // ErrClosed means the pool was closed. ErrClosed = errors.New("upstream: pool closed") // ErrInvalidTarget means a Target has an empty or duplicate ID or a // weight out of range. ErrInvalidTarget = errors.New("upstream: invalid target") )
Errors returned by Pool.
var ( // ErrRetryLimit means the request has tried RetryConfig.Attempts // backends. ErrRetryLimit = errors.New("upstream: retry limit reached") // ErrRetryBudget means the pool's retry budget is spent. ErrRetryBudget = errors.New("upstream: retry budget exhausted") )
Errors returned by Attempts.Next when it declines a retry.
var ErrDrainCanceled = errors.New("upstream: drain canceled")
ErrDrainCanceled is returned by Drain.Wait when Resume ended the drain before the backend's leases were all released.
Functions ¶
func WithOutcome ¶
WithOutcome returns an error that wraps err and that OutcomeOf maps to o, so that a function run by Pool.Do can say how its error reflects on the backend: for example Neutral for a request the client got wrong, which is neither counted against the backend nor retried. It returns nil if err is nil.
Types ¶
type Attempts ¶
type Attempts[T any] struct { // contains filtered or unexported fields }
Attempts picks distinct backends for one request: the first Next is a pick like PickKey, and every later Next is a retry that leases a backend this Attempts has not returned before, as the pool's RetryConfig allows. It reads the pool's current routing view on every call, so a retry avoids backends that went down meanwhile.
Pool.Do is Attempts with a function; use Attempts directly when a lease outlives a function call, such as for a connection or a response body. An Attempts is for one goroutine. It does not allocate for up to four attempts.
Example ¶
package main
import (
"fmt"
"github.com/djylb/upstream"
)
func main() {
pool := upstream.New[string](upstream.Config{Retry: upstream.RetryConfig{Attempts: 2}})
defer pool.Close()
_ = pool.Sync([]upstream.Target[string]{{ID: "a"}, {ID: "b"}})
// Attempts is Do for leases that outlive a function, such as
// connections: each Next leases a backend not tried before.
attempts := pool.Attempts("client-key")
first, _ := attempts.Next()
first.Release(upstream.Failure)
second, _ := attempts.Next()
fmt.Println(first.Backend() == nil, second.Backend() != nil)
second.Release(upstream.Success)
_, err := attempts.Next()
fmt.Println(err)
}
Output: true true upstream: retry limit reached
func (*Attempts[T]) Next ¶
Next leases the next backend. The first call fails like PickHash; a retry fails with ErrRetryLimit after RetryConfig.Attempts leases, with ErrRetryBudget when the budget is spent, and with ErrUnavailable when every backend that takes traffic has been tried. Release the previous lease, usually with Failure, before or after calling Next.
type Backend ¶
type Backend[T any] struct { // contains filtered or unexported fields }
A Backend is one member of a Pool: a Target as the pool currently routes to it. Changing a target's weight or value in the pool creates a new Backend that shares the old one's counters and health state, so leases keep the version they were issued with.
func (*Backend[T]) InFlight ¶
InFlight returns the number of leases on the backend not yet released.
type Balancer ¶
type Balancer interface {
// Build returns a Picker for candidates, which is never empty. The
// Picker may keep the slice; the caller does not modify it afterwards.
Build(candidates []Candidate) Picker
}
A Balancer builds Pickers for sets of candidates. A Pool calls Build whenever the set of backends that may receive traffic changes, never while holding a lock, and keeps using the previous Picker until Build returns.
Balancers can be used without a Pool: build a Picker from candidates and call Pick.
func ConsistentHash ¶
func ConsistentHash(cfg HashConfig) Balancer
ConsistentHash returns a Balancer that maps keys to candidates on a hash ring.
Each candidate owns ring points derived from its ID, in number proportional to its weight, and a key belongs to the first point at or after the key's hash. Adding or removing a candidate therefore moves only the keys of the ring segments it gains or loses, about 1/n of all keys, and every process that uses the same IDs maps a key to the same candidate. Retries walk the ring to the next distinct candidates, which are the candidates the key would move to if the earlier ones left.
Pick hashes the key it is given again, so any 64-bit key works. Picks do not allocate, except retries and bounded-load walks on rings of more than 256 candidates, which need a scratch bitset.
Example ¶
package main
import (
"fmt"
"github.com/djylb/upstream"
)
func main() {
ring := upstream.ConsistentHash(upstream.HashConfig{}).Build([]upstream.Candidate{
{ID: "cache-1"}, {ID: "cache-2"}, {ID: "cache-3"},
})
key := uint64(12345)
primary := ring.Pick(key, 0)
backup := ring.Pick(key, 1) // where the key goes if the primary leaves
fmt.Println(primary != backup)
}
Output: true
func Failover ¶
func Failover() Balancer
Failover returns a Balancer for active-backup setups: it always picks the first candidate, in membership order, that can take traffic. A Pool leaves unhealthy and drained backends out of the candidates, so traffic moves to the next backend in order and returns when the first recovers.
func LeastConn ¶
func LeastConn() Balancer
LeastConn returns a Balancer that picks the candidate with the fewest requests in flight relative to its weight, breaking ties at random. Each pick scans every candidate. A retry (attempt above 0) picks at random so that a candidate that cannot take the request is not chosen again.
func ParseBalancer ¶
ParseBalancer returns the built-in Balancer with the given name, as its String method writes it: "round-robin", "weighted-round-robin", "random", "power-of-two", "least-conn", "failover" or "consistent-hash" (with the default HashConfig). It suits configuration files; build a ConsistentHash with options in code.
Example ¶
package main
import (
"fmt"
"github.com/djylb/upstream"
)
func main() {
// Balancers and fallbacks by name, as a configuration file gives them.
b, err := upstream.ParseBalancer("least-conn")
if err != nil {
fmt.Println(err)
return
}
f, _ := upstream.ParseFallback("all")
pool := upstream.New[string](upstream.Config{Balancer: b, Fallback: f})
defer pool.Close()
fmt.Println(b, f)
}
Output: least-conn all
func PowerOfTwo ¶
func PowerOfTwo() Balancer
PowerOfTwo returns a Balancer that samples two distinct candidates at random and picks the one with fewer requests in flight relative to its weight. It approaches least-connections balancing at constant cost and avoids the herding that strict least-connections shows when many clients see the same idle backend.
func Random ¶
func Random() Balancer
Random returns a Balancer that picks candidates at random in proportion to their weights, using the alias method so each pick takes constant time.
func RoundRobin ¶
func RoundRobin() Balancer
RoundRobin returns a Balancer that cycles through the candidates in order and ignores weights. Each Picker starts at a random position so that many pools do not hit the same backend in step.
func WeightedRoundRobin ¶
func WeightedRoundRobin() Balancer
WeightedRoundRobin returns a Balancer that implements smooth weighted round-robin: over each cycle every candidate is picked in proportion to its weight, and its picks are spread through the cycle instead of bunched together. For weights 5, 1, 1 a cycle is a a b a c a a.
The cycle is precomputed, which makes Pick a single atomic add, when it is short enough: the sum of the weights divided by their greatest common divisor, times the number of candidates, at most 1<<22. Longer cycles, which take extreme weight ratios, fall back to weighted random picks: proportions then hold on average rather than exactly in every cycle, and picks stay lock-free.
type Candidate ¶
type Candidate struct {
// InFlight counts the backend's requests in flight. Load-aware
// balancers read it; nil means no load information, read as zero.
InFlight *atomic.Int64
// ID identifies the backend. Consistent hashing places backends by ID,
// so equal IDs map keys to the same place in every process.
ID string
// Weight is the backend's relative capacity. Values below 1 mean 1.
Weight int
}
A Candidate is a balancer's view of one backend.
Example ¶
// Balancers work without a pool.
var loadA, loadB atomic.Int64
loadA.Store(5)
p := LeastConn().Build([]Candidate{
{ID: "a", Weight: 1, InFlight: &loadA},
{ID: "b", Weight: 1, InFlight: &loadB},
})
fmt.Println(p.Pick(0, 0))
Output: 1
type CheckResult ¶
type CheckResult struct {
// Time is when the check started.
Time time.Time
// Err is why the check failed, or nil.
Err error
// Latency is how long the check took.
Latency time.Duration
}
CheckResult is the result of one active health check.
type Clock ¶
type Clock interface {
// Now returns the current time.
Now() time.Time
// AfterFunc arranges for f to run in its own goroutine once d has
// elapsed, and returns a function that cancels the call.
AfterFunc(d time.Duration, f func()) (stop func() bool)
}
A Clock tells the time and schedules callbacks. The pool, the outlier detector and the health checker read time only through a Clock, so tests and simulations can substitute a fake one.
Implementations must be safe for concurrent use. AfterFunc must call f in its own goroutine, as time.AfterFunc does; f may start before AfterFunc returns, for example when d is not positive, and callers in this module allow for that. The returned stop function reports whether it prevented f from running, like time.Timer.Stop.
type Config ¶
type Config struct {
// Balancer chooses backends. Nil means WeightedRoundRobin.
Balancer Balancer
// Clock is the time source. Nil means SystemClock.
Clock Clock
// Outlier configures passive health checking, which is disabled by
// default.
Outlier OutlierConfig
// Retry configures how Do, DoKey and Attempts retry on other
// backends. There are no retries by default.
Retry RetryConfig
// SlowStart, if positive, ramps up the traffic of a backend that joins
// the pool or becomes healthy again: its weight grows linearly from a
// small fraction to its full value over SlowStart, so a cold backend is
// not flooded. The pool rebuilds its view 16 times over the ramp. With
// ConsistentHash the ramp moves keys to the backend gradually.
SlowStart time.Duration
// Fallback says what to do when no backend is eligible.
Fallback Fallback
}
Config configures a Pool. The zero value is a pool with smooth weighted round-robin, no passive health checking, no slow start, no retries and FallbackNone.
type Drain ¶
type Drain struct {
// contains filtered or unexported fields
}
A Drain follows a backend that takes no new picks until its leases in flight are released.
func (*Drain) Canceled ¶
Canceled reports whether Resume ended the drain before the backend's leases were all released. It is meaningful once Done is closed, and false before.
type Event ¶
type Event[T any] struct { // Err is the cause of an EventStateChanged to StateUnhealthy (the last // check error) or to StateEjected or StateHalfOpen (the ejection // cause), and nil otherwise. Err error // Backend is the member concerned. Backend *Backend[T] // Kind says what happened. Kind EventKind // From and To are the backend's states before and after the event. From, To State }
An Event reports a change in a Pool. State changes are edge-triggered: a backend that keeps failing produces one event when it goes down, not one per failure.
type EventKind ¶
type EventKind uint8
EventKind says what an Event reports.
const ( // EventAdded reports a new member. EventAdded EventKind = iota // EventUpdated reports a member replaced with a new weight or value; // Backend is the new version. EventUpdated // EventRemoved reports a member that left the pool. EventRemoved // EventStateChanged reports a change of a member's State. EventStateChanged )
func (EventKind) MarshalText ¶
MarshalText encodes the kind as its name.
func (*EventKind) UnmarshalText ¶
UnmarshalText decodes a kind from its name.
type Fallback ¶
type Fallback uint8
Fallback says what a Pool does when no backend is eligible.
const ( // FallbackNone makes Pick fail with ErrUnavailable. FallbackNone Fallback = iota // FallbackAll makes Pick choose among all members that are not // draining, ignoring active and passive health. This is panic mode: a // health signal that marks every backend down is more often wrong than // right, and degraded service beats none. Leases picked in panic mode // still report outcomes. FallbackAll )
func ParseFallback ¶
ParseFallback returns the Fallback named s: "none" or "all".
func (Fallback) MarshalText ¶
MarshalText encodes the fallback as its name.
func (*Fallback) UnmarshalText ¶
UnmarshalText decodes a fallback from its name, as ParseFallback does.
type HashConfig ¶
type HashConfig struct {
// VirtualNodes is the average number of ring points per candidate. More
// points spread keys more evenly at the cost of memory and build time.
// The default is 160.
VirtualNodes int
// LoadFactor enables bounded loads when above zero: a candidate that
// already carries LoadFactor times its weighted share of the requests
// in flight is passed over for the next one on the ring. Values below
// 1 mean 1. Bounded loads keep a hot key from overloading one backend
// but cost a scan of the in-flight counts on every pick.
LoadFactor float64
}
HashConfig configures ConsistentHash.
type Health ¶
type Health uint8
Health is the verdict of active health checking on a backend.
func (Health) MarshalText ¶
MarshalText encodes the verdict as its name.
func (*Health) UnmarshalText ¶
UnmarshalText decodes a verdict from its name.
type Lease ¶
type Lease[T any] struct { // contains filtered or unexported fields }
A Lease is a backend picked for one request or connection. It holds one of the backend's in-flight slots until Release returns it with the request's outcome.
Every lease returned without error must be released exactly once, or the backend's in-flight count stays raised and a drain never completes; Pool.Do and Pool.DoKey do this for the caller. Release zeroes the lease, so releasing the same variable again does nothing. Copies of a lease share one slot: release only one of them. Making every copy idempotent would need state shared by the copies, one allocation per pick, so the pool instead detects what it can without cost: releasing a copy after another copy has been released panics if the backend has no slot left to return, like a negative sync.WaitGroup counter; if other leases are in flight, the extra release is not detected and frees one of their slots.
func (*Lease[T]) Release ¶
Release returns the lease's slot and records the outcome for passive health checking, unless Report already recorded one. It does nothing on a released or zero Lease. If the outcome ejects or restores the backend, Release also rebuilds the pool's view and may deliver events to subscribers on the calling goroutine; the slot is returned first, so a panicking subscriber cannot leak it.
func (*Lease[T]) Report ¶
Report records the outcome for passive health checking now, while the lease keeps its in-flight slot. Use it when the outcome is known long before the lease ends, as for a connection that was established and then stays open: a half-open backend's trial ends with the report instead of with the connection. Only the first Report counts; Release then returns the slot and ignores its outcome. Report does nothing on a released or zero Lease.
Example ¶
package main
import (
"fmt"
"github.com/djylb/upstream"
)
func main() {
pool := upstream.New[string](upstream.Config{Balancer: upstream.LeastConn()})
defer pool.Close()
_ = pool.Sync([]upstream.Target[string]{{ID: "relay", Value: "10.0.0.1:9000"}})
lease, _ := pool.Pick()
// The connection is up: report the outcome now and keep the in-flight
// slot until the connection closes.
lease.Report(upstream.Success)
s := pool.Status()[0]
fmt.Println(s.InFlight, s.Successes)
lease.Release(upstream.Neutral) // the reported outcome stands
s = pool.Status()[0]
fmt.Println(s.InFlight, s.Successes, s.Neutrals)
}
Output: 1 1 0 1 0
type Outcome ¶
type Outcome uint8
Outcome classifies a finished request or connection for passive health checking.
const ( // Success means the backend served the request. It resets the // consecutive-failure count. Success Outcome = iota // Failure means the backend failed the request: it refused the // connection, timed out or answered with an error the caller blames on // the backend. Failure // Neutral means the result says nothing about the backend, for example // because the client went away. It counts towards neither successes nor // failures. Neutral )
func OutcomeOf ¶
OutcomeOf maps an error to an Outcome: nil is Success, an error from WithOutcome has the outcome given there, an error wrapping context.Canceled is Neutral, and any other error is Failure. context.DeadlineExceeded is a Failure because a backend that does not answer in time is the usual cause.
func (Outcome) MarshalText ¶
MarshalText encodes the outcome as its name.
func (*Outcome) UnmarshalText ¶
UnmarshalText decodes an outcome from its name.
type OutlierConfig ¶
type OutlierConfig struct {
// Window is the span over which FailurePercent is measured. The
// default is 10s. The rate is a sliding estimate over the current and
// the previous window.
Window time.Duration
// BaseEjection is the first ejection time. The default is 30s.
BaseEjection time.Duration
// MaxEjection caps the ejection time. The default is 5m; a value below
// BaseEjection is raised to it.
MaxEjection time.Duration
// ConsecutiveFailures ejects the backend after this many failures in a
// row. Zero disables the rule.
ConsecutiveFailures int
// FailurePercent ejects the backend when at least this percentage of
// the requests in Window failed, once MinRequests requests have been
// seen. Zero disables the rule; values above 100 mean 100.
FailurePercent int
// MinRequests is the request volume the failure-rate rule needs before
// it judges the backend. The default is 20.
MinRequests int
// HalfOpenRequests is the number of trial requests a half-open backend
// admits at the same time. The default is 1.
HalfOpenRequests int
// HalfOpenSuccesses is the number of successful trials that restore a
// half-open backend. The default is 1.
HalfOpenSuccesses int
}
OutlierConfig configures passive health checking, which judges a backend by the outcomes of real traffic. The zero value disables it; set ConsecutiveFailures, FailurePercent or both to enable it. Zero durations and counts take the defaults documented on each field.
An ejected backend receives no traffic for an ejection time that starts at BaseEjection and doubles with every ejection that is not followed by a recovery, up to MaxEjection. When the time is up the backend becomes half-open: it admits at most HalfOpenRequests concurrent trial requests. HalfOpenSuccesses successful trials restore it and reset the ejection time; one failed trial ejects it again.
func (OutlierConfig) Enabled ¶
func (c OutlierConfig) Enabled() bool
Enabled reports whether the configuration enables passive health checking.
type OutlierDetector ¶
type OutlierDetector struct {
// contains filtered or unexported fields
}
An OutlierDetector tracks the passive health of one backend. Callers ask it for admission before each request and report each request's outcome afterwards. It is safe for concurrent use. Admit and Record are lock-free apart from state transitions, which are rare.
The detector owns one timer, which ends an ejection. Close stops it.
Example ¶
package main
import (
"errors"
"fmt"
"net"
"github.com/djylb/upstream"
)
func main() {
// Passive health checking for a single connection target, without a
// pool.
d := upstream.NewOutlierDetector(upstream.OutlierConfig{ConsecutiveFailures: 2}, nil, nil)
defer d.Close()
dial := func() error {
t, ok := d.Admit()
if !ok {
return errors.New("backend ejected")
}
_, err := net.Dial("tcp", "invalid address")
d.Record(t, upstream.OutcomeOf(err))
return err
}
for range 3 {
_ = dial()
}
fmt.Println(d.State(), d.Status().Ejections)
}
Output: ejected 1
func NewOutlierDetector ¶
func NewOutlierDetector(cfg OutlierConfig, clock Clock, onChange func()) *OutlierDetector
NewOutlierDetector returns a detector for one backend that reads time from clock; nil means SystemClock. onChange, if not nil, is called after every state transition, without any lock held, from the goroutine that caused the transition: one calling Record, Reset or Close, or the detector's timer goroutine. Calls may overlap, so onChange should read State instead of assuming an order.
A detector whose configuration is not Enabled admits everything and never ejects.
func (*OutlierDetector) Admit ¶
func (d *OutlierDetector) Admit() (Ticket, bool)
Admit reports whether the backend may take a new request now. A healthy backend always admits; an ejected one never does; a half-open one admits while fewer than HalfOpenRequests trials are in flight. When Admit returns true the caller must pass the Ticket to Record exactly once.
func (*OutlierDetector) Close ¶
func (d *OutlierDetector) Close()
Close stops the detector's timer. The detector keeps its current state and still answers Admit and Record, but it no longer changes state. Close is idempotent.
func (*OutlierDetector) Record ¶
func (d *OutlierDetector) Record(t Ticket, o Outcome)
Record reports the outcome of a request admitted with t.
func (*OutlierDetector) Reset ¶
func (d *OutlierDetector) Reset()
Reset clears any ejection and the ejection backoff, as if the backend had just recovered. It is meant for operators overriding the detector.
func (*OutlierDetector) State ¶
func (d *OutlierDetector) State() OutlierState
State returns the current passive health state.
func (*OutlierDetector) Status ¶
func (d *OutlierDetector) Status() OutlierStatus
Status returns a snapshot of the detector.
type OutlierState ¶
type OutlierState uint32
OutlierState is the passive health state of a backend.
const ( // OutlierOK means the backend is not ejected. OutlierOK OutlierState = iota // OutlierEjected means the backend is ejected and admits no traffic // until its ejection time is up. OutlierEjected // OutlierHalfOpen means the ejection time is up and the backend admits // a limited number of trial requests. OutlierHalfOpen )
func (OutlierState) MarshalText ¶
func (s OutlierState) MarshalText() ([]byte, error)
MarshalText encodes the state as its name.
func (OutlierState) String ¶
func (s OutlierState) String() string
String returns the name of the state.
func (*OutlierState) UnmarshalText ¶
func (s *OutlierState) UnmarshalText(b []byte) (err error)
UnmarshalText decodes a state from its name.
type OutlierStatus ¶
type OutlierStatus struct {
// EjectedUntil is when the current ejection ends; it is zero unless
// State is OutlierEjected.
EjectedUntil time.Time
// Cause is why the backend was last ejected; it is nil while State is
// OutlierOK.
Cause error
// Ejections counts every ejection since the detector was created.
Ejections uint64
// ConsecutiveFailures is the current run of failures.
ConsecutiveFailures uint32
// State is the passive health state.
State OutlierState
}
OutlierStatus is a snapshot of an OutlierDetector.
type Picker ¶
type Picker interface {
// Pick returns the index of the chosen candidate. key is the request's
// hash for key-affine balancers, or a random number when the caller
// has no key. attempt is 0 for the first choice; when the caller
// cannot use the chosen backend it asks again with attempt 1, 2 and so
// on, and a Picker that is deterministic for a key should then return
// other candidates.
Pick(key uint64, attempt int) int
}
A Picker chooses among the candidates it was built from. It must be safe for concurrent use and should not allocate.
type Pool ¶
type Pool[T any] struct { // contains filtered or unexported fields }
A Pool is a set of backends with a balancer, health state and accounting. Pick and Release are lock-free and do not allocate; updates to membership and health build a new immutable routing view and publish it atomically, so picks in progress are never disturbed.
A backend is eligible for picks when it is not draining, not reported down by active checks and not ejected by passive checks; a half-open backend is eligible for its trial requests. When no backend is eligible, Config.Fallback decides.
The pool starts no goroutines. It owns the passive health timers and the slow-start timer, which Close stops. Pick never locks; Release locks only when its outcome ejects or restores a backend, to rebuild the view.
Locking: mu guards membership and is never held while calling a Balancer, a Clock or an event subscriber. Inside mu the pool may take qmu (the event queue) and an OutlierDetector's mutex to read its status; neither calls out while held. pubMu (view publication) and rampMu (the slow-start timer) are taken alone.
func (*Pool[T]) Attempts ¶
Attempts returns the picker for one request routed by key; an empty key carries no affinity, as with PickKey.
func (*Pool[T]) Available ¶
Available returns the number of members that are eligible for new picks now, as the routing view sees them. It is zero while the pool is empty or closed, and while no member is eligible: then Pick fails with ErrUnavailable, or, under FallbackAll, still succeeds in panic mode, picking among members that are not eligible. A readiness check that should fail in panic mode tests Available() > 0.
func (*Pool[T]) Close ¶
func (p *Pool[T]) Close()
Close stops the passive health and slow-start timers and ends all subscriptions: no subscriber call starts once Close has begun, and one in progress may finish. Close does not wait: a timer that has already fired may still finish its own bookkeeping, but runs no Balancer, Clock or subscriber code. Close may be called from anywhere, including a subscriber or a Balancer. Later picks fail with ErrClosed and updates are refused; leases already issued may still be released. Close is idempotent.
func (*Pool[T]) Do ¶
Do picks a backend, calls fn with it and releases the lease with OutcomeOf of fn's error, also when fn panics, in which case the outcome is Failure and the panic continues. When the outcome is Failure and ctx is not done, Do retries on another backend as Config.Retry allows. It returns nil once fn succeeds, fn's last error if fn failed, and otherwise ctx's or the first pick's error.
Example ¶
package main
import (
"context"
"errors"
"fmt"
"github.com/djylb/upstream"
)
func main() {
pool := upstream.New[string](upstream.Config{
Balancer: upstream.Failover(),
Retry: upstream.RetryConfig{Attempts: 3, Budget: 0.2},
})
defer pool.Close()
_ = pool.Sync([]upstream.Target[string]{
{ID: "primary", Value: "10.0.0.1:8080"},
{ID: "backup", Value: "10.0.0.2:8080"},
})
// Do releases each lease with the outcome of the function's error and
// retries a failure on a backend it has not tried.
err := pool.Do(context.Background(), func(b *upstream.Backend[string]) error {
if b.ID() == "primary" {
return errors.New("connection refused")
}
fmt.Println("served by", b.ID())
return nil
})
fmt.Println(err)
fmt.Println("primary failures:", pool.Status()[0].Failures)
}
Output: served by backup <nil> primary failures: 1
func (*Pool[T]) Done ¶
func (p *Pool[T]) Done() <-chan struct{}
Done returns a channel that is closed when Close is called. The pool starts no goroutines, so nothing of it runs user code after that, and helpers attached to the pool, such as a health checker started with health.Watch, stop with it.
func (*Pool[T]) Drain ¶
Drain stops new picks of the backend with the given ID while it stays a member, for maintenance or before removal. The returned Drain is done when the backend has no leases in flight, or canceled when Resume ends the drain first. Draining a backend that is already draining returns the same Drain. Drain reports false if there is no such member.
func (*Pool[T]) Pick ¶
Pick leases a backend chosen by the balancer without a key. Key-affine balancers pick at random.
func (*Pool[T]) PickKey ¶
PickKey leases a backend chosen for key, such as a client address or a session ID. With ConsistentHash the same key keeps mapping to the same backend while it is eligible; other balancers ignore the key. An empty key carries no affinity: PickKey("") is Pick.
Example ¶
package main
import (
"fmt"
"github.com/djylb/upstream"
)
func main() {
pool := upstream.New[string](upstream.Config{Balancer: upstream.ConsistentHash(upstream.HashConfig{})})
defer pool.Close()
_ = pool.Sync([]upstream.Target[string]{{ID: "a"}, {ID: "b"}, {ID: "c"}})
first, _ := pool.PickKey("198.51.100.7")
again, _ := pool.PickKey("198.51.100.7")
fmt.Println("sticky:", first.Backend() == again.Backend())
first.Release(upstream.Success)
again.Release(upstream.Success)
}
Output: sticky: true
func (*Pool[T]) Put ¶
Put adds a target, or replaces the member with the same ID, keeping its position, health state and counters. It emits EventAdded or EventUpdated.
func (*Pool[T]) Remove ¶
Remove removes the backend with the given ID gracefully: new picks avoid it at once, and leases already issued finish normally. The returned Drain is done when the last of them is released. Remove reports false if there is no such member.
Example ¶
package main
import (
"fmt"
"github.com/djylb/upstream"
)
func main() {
pool := upstream.New[string](upstream.Config{})
defer pool.Close()
_ = pool.Sync([]upstream.Target[string]{{ID: "old"}, {ID: "new"}})
lease, _ := pool.PickKey("")
for lease.Backend().ID() != "old" {
lease.Release(upstream.Neutral)
lease, _ = pool.PickKey("")
}
drained, _ := pool.Remove("old")
select {
case <-drained.Done():
default:
fmt.Println("waiting for the last lease")
}
lease.Release(upstream.Success)
<-drained.Done()
fmt.Println("drained")
}
Output: waiting for the last lease drained
func (*Pool[T]) ReportCheck ¶
func (p *Pool[T]) ReportCheck(b *Backend[T], h Health, r CheckResult)
ReportCheck records the result of an active health check of b and sets its health. A health checker calls it after every check; the checker, not the pool, applies rise and fall thresholds. HealthDown takes the backend out of rotation; HealthUp and HealthUnknown keep it in. Reports for a backend that has been removed, or replaced by a new member with the same ID, are ignored.
func (*Pool[T]) ResetOutlier ¶
ResetOutlier clears the passive health ejection of the backend with the given ID, if any. It reports false if there is no such member.
func (*Pool[T]) Resume ¶
Resume ends a drain started by Drain; the Drain reports Canceled unless it had already completed. Resume reports false if there is no such member.
func (*Pool[T]) Subscribe ¶
Subscribe calls fn for every later event, starting with an EventAdded for each current member, and returns a function that ends the subscription.
Events are delivered one at a time in the order they happened, never while the pool holds a lock, on the goroutine that caused them or on one already delivering events. fn may call any Pool method; events that call causes are delivered after fn returns. fn should be quick, because the goroutine delivering events may be one releasing a lease. An event may be delivered slightly before picks observe the change it reports. After cancel returns, fn may still be running for an event already in progress but is not called again.
Example ¶
package main
import (
"errors"
"fmt"
"github.com/djylb/upstream"
)
func main() {
pool := upstream.New[string](upstream.Config{})
defer pool.Close()
_ = pool.Sync([]upstream.Target[string]{{ID: "a"}})
cancel := pool.Subscribe(func(ev upstream.Event[string]) {
fmt.Println(ev.Kind, ev.Backend.ID(), ev.From, "->", ev.To, ev.Err)
})
defer cancel()
a, _ := pool.Backend("a")
refused := errors.New("refused")
// Repeated failures report one transition.
pool.ReportCheck(a, upstream.HealthDown, upstream.CheckResult{Err: refused})
pool.ReportCheck(a, upstream.HealthDown, upstream.CheckResult{Err: refused})
pool.ReportCheck(a, upstream.HealthUp, upstream.CheckResult{})
}
Output: added a healthy -> healthy <nil> state-changed a healthy -> unhealthy refused state-changed a unhealthy -> healthy <nil>
func (*Pool[T]) Sync ¶
Sync replaces the pool's membership with targets, in order. Members whose ID is in targets keep their health state and counters and take the new weight and value; new IDs join as healthy; members missing from targets are removed gracefully: they take no new picks while their leases finish. Sync emits EventRemoved, then EventAdded or EventUpdated for each target in order. On error nothing changes.
type RetryConfig ¶
type RetryConfig struct {
// Window is the span over which the budget counts requests and
// retries. The default is 10s. The counts are a sliding estimate over
// the current and the previous window.
Window time.Duration
// Budget, if positive, is the largest ratio of retries to requests,
// such as 0.2 for one retry per five requests. Zero means no budget:
// only Attempts bounds retries.
Budget float64
// Attempts is the most backends one request tries, the first one
// included. Zero and one mean no retries.
Attempts int
// MinRetries is the number of retries per Window that the budget
// allows whatever the request rate, so that a pool with little
// traffic can still retry. Zero means 10; a negative value means none.
MinRetries int
}
RetryConfig configures retries on other backends, by Pool.Do, Pool.DoKey and Attempts. The zero value allows no retries.
A retry budget bounds the extra load that retries put on the backends when many requests fail at once: within any Window, retries may number at most MinRetries plus Budget times the requests started.
type State ¶
type State uint8
State is the routing state of a backend, combining its membership, its active health and its passive health. When several conditions hold, the state is the first that applies in this order: removed, draining, unhealthy, ejected, half-open, healthy.
const ( // StateHealthy means the backend takes new traffic. StateHealthy State = iota // StateHalfOpen means the backend's ejection is over and it takes a // limited number of trial requests. StateHalfOpen // StateEjected means passive health checking ejected the backend. StateEjected // StateUnhealthy means active health checks report the backend down. StateUnhealthy // StateDraining means the backend takes no new traffic but its leases // may still be in flight. StateDraining // StateRemoved means the backend is no longer a member of its pool. StateRemoved )
func (State) MarshalText ¶
MarshalText encodes the state as its name.
func (*State) UnmarshalText ¶
UnmarshalText decodes a state from its name.
type Status ¶
type Status struct {
// LastCheck is the latest active health check result; its Time is zero
// before the first check.
LastCheck CheckResult
// Outlier is the passive health snapshot. Its State is always
// OutlierOK when passive health checking is disabled.
Outlier OutlierStatus
// ID identifies the backend.
ID string
// Weight is the backend's weight.
Weight int
// InFlight is the number of leases not yet released.
InFlight int64
// Successes, Failures and Neutrals count the outcomes of leases, as
// reported by Lease.Report or Lease.Release. Together with InFlight
// they count every pick: each lease is in flight until released and
// has one outcome.
Successes uint64
Failures uint64
Neutrals uint64
// State is the routing state.
State State
// Health is the latest active health verdict, whatever the State.
Health Health
// Draining reports whether the backend is draining or removed.
Draining bool
}
Status is a snapshot of one backend.
type Target ¶
type Target[T any] struct { // ID identifies the backend. It must be unique within the pool. Health // state and counters follow the ID across updates, so give a backend a // new ID when its value stops meaning the same server. ID string // Weight is the backend's relative capacity, from 0 to MaxWeight. Zero // means 1; to stop sending traffic to a backend, drain it. Weight int // Value is what callers connect to, such as an address. Value T }
A Target describes a backend to a Pool.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package health runs active health checks: it probes targets on jittered intervals, applies rise and fall thresholds, measures latency and classifies failures, and reports results and transitions.
|
Package health runs active health checks: it probes targets on jittered intervals, applies rise and fall thresholds, measures latency and classifies failures, and reports results and transitions. |
|
internal
|
|
|
fakeclock
Package fakeclock provides a manually advanced clock for tests.
|
Package fakeclock provides a manually advanced clock for tests. |
|
Package uphttp holds the HTTP-specific parts of upstream: an HTTP health check, a cache of per-backend transports and an http.RoundTripper that balances requests over a pool.
|
Package uphttp holds the HTTP-specific parts of upstream: an HTTP health check, a cache of per-backend transports and an http.RoundTripper that balances requests over a pool. |
|
Package upnet connects to the backends of an upstream.Pool: a Dialer picks a backend, dials it, retries on another backend when the dial fails, and holds the backend's lease for as long as the connection is open, so that in-flight counts, least-connections balancing and drains follow real connections.
|
Package upnet connects to the backends of an upstream.Pool: a Dialer picks a backend, dials it, retries on another backend when the dial fails, and holds the backend's lease for as long as the connection is open, so that in-flight counts, least-connections balancing and drains follow real connections. |