161 lines
3.9 KiB
Go
161 lines
3.9 KiB
Go
// Package runtime owns shared client session state. It contains no socket or
|
|
// OS calls, so supervisor workers can outlive a replaceable network session.
|
|
package runtime
|
|
|
|
import (
|
|
"errors"
|
|
"math"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
var ErrInvalidTransition = errors.New("invalid client session transition")
|
|
|
|
type State uint8
|
|
|
|
const (
|
|
StateBackoff State = iota + 1
|
|
StateConnecting
|
|
StateHello
|
|
StateReconciling
|
|
StateActive
|
|
StateClosing
|
|
)
|
|
|
|
type BackoffOptions struct {
|
|
Initial time.Duration
|
|
Maximum time.Duration
|
|
StableReset time.Duration
|
|
}
|
|
|
|
func (options BackoffOptions) Validate() error {
|
|
if options.Initial <= 0 || options.Maximum < options.Initial || options.StableReset <= 0 {
|
|
return errors.New("invalid client reconnect backoff options")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Jitter returns a delay in [0, cap]. It is injected so all backoff boundaries
|
|
// are testable without sleeps or probabilistic assertions.
|
|
type Jitter func(cap time.Duration) time.Duration
|
|
|
|
type SessionMachine struct {
|
|
mu sync.Mutex
|
|
options BackoffOptions
|
|
jitter Jitter
|
|
state State
|
|
failures uint32
|
|
nextConnect time.Duration
|
|
activeSince time.Duration
|
|
}
|
|
|
|
func NewSessionMachine(options BackoffOptions, jitter Jitter) (*SessionMachine, error) {
|
|
if err := options.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if jitter == nil {
|
|
return nil, errors.New("client reconnect jitter is required")
|
|
}
|
|
return &SessionMachine{options: options, jitter: jitter, state: StateBackoff}, nil
|
|
}
|
|
|
|
func (machine *SessionMachine) State() State {
|
|
machine.mu.Lock()
|
|
defer machine.mu.Unlock()
|
|
return machine.state
|
|
}
|
|
|
|
func (machine *SessionMachine) NextConnectAt() time.Duration {
|
|
machine.mu.Lock()
|
|
defer machine.mu.Unlock()
|
|
return machine.nextConnect
|
|
}
|
|
|
|
func (machine *SessionMachine) StartConnecting(now time.Duration) error {
|
|
machine.mu.Lock()
|
|
defer machine.mu.Unlock()
|
|
if machine.state != StateBackoff || now < machine.nextConnect {
|
|
return ErrInvalidTransition
|
|
}
|
|
machine.state = StateConnecting
|
|
return nil
|
|
}
|
|
|
|
func (machine *SessionMachine) TransportConnected() error {
|
|
return machine.transition(StateConnecting, StateHello)
|
|
}
|
|
|
|
func (machine *SessionMachine) Welcomed() error {
|
|
return machine.transition(StateHello, StateReconciling)
|
|
}
|
|
|
|
func (machine *SessionMachine) Reconciled(now time.Duration) error {
|
|
machine.mu.Lock()
|
|
defer machine.mu.Unlock()
|
|
if machine.state != StateReconciling {
|
|
return ErrInvalidTransition
|
|
}
|
|
machine.state = StateActive
|
|
machine.activeSince = now
|
|
return nil
|
|
}
|
|
|
|
func (machine *SessionMachine) BeginClosing() error {
|
|
machine.mu.Lock()
|
|
defer machine.mu.Unlock()
|
|
if machine.state == StateBackoff || machine.state == StateClosing {
|
|
return ErrInvalidTransition
|
|
}
|
|
machine.state = StateClosing
|
|
return nil
|
|
}
|
|
|
|
// Failed moves every in-flight session state to backoff. Only a continuous
|
|
// active period reaching StableReset clears exponential history.
|
|
func (machine *SessionMachine) Failed(now time.Duration) time.Duration {
|
|
machine.mu.Lock()
|
|
defer machine.mu.Unlock()
|
|
if machine.state == StateActive && now >= machine.activeSince && now-machine.activeSince >= machine.options.StableReset {
|
|
machine.failures = 0
|
|
}
|
|
if machine.failures < math.MaxUint32 {
|
|
machine.failures++
|
|
}
|
|
cap := machine.capLocked()
|
|
delay := machine.jitter(cap)
|
|
if delay < 0 {
|
|
delay = 0
|
|
}
|
|
if delay > cap {
|
|
delay = cap
|
|
}
|
|
machine.state = StateBackoff
|
|
machine.nextConnect = now + delay
|
|
machine.activeSince = 0
|
|
return delay
|
|
}
|
|
|
|
func (machine *SessionMachine) transition(from, to State) error {
|
|
machine.mu.Lock()
|
|
defer machine.mu.Unlock()
|
|
if machine.state != from {
|
|
return ErrInvalidTransition
|
|
}
|
|
machine.state = to
|
|
return nil
|
|
}
|
|
|
|
func (machine *SessionMachine) capLocked() time.Duration {
|
|
cap := machine.options.Initial
|
|
for index := uint32(1); index < machine.failures && cap < machine.options.Maximum; index++ {
|
|
if cap > machine.options.Maximum/2 {
|
|
return machine.options.Maximum
|
|
}
|
|
cap *= 2
|
|
}
|
|
if cap > machine.options.Maximum {
|
|
return machine.options.Maximum
|
|
}
|
|
return cap
|
|
}
|