feat: add client reconnect scheduling primitives
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user