mirror of
https://github.com/luxfi/timer.git
synced 2026-10-30 15:15:40 +00:00
- Add container dependency for adaptive timeout manager - Update various indirect dependencies
277 lines
8.5 KiB
Go
277 lines
8.5 KiB
Go
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package timer
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/math"
|
|
"github.com/luxfi/metric"
|
|
|
|
"github.com/luxfi/container/heap"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/timer/mockable"
|
|
)
|
|
|
|
var (
|
|
errNonPositiveHalflife = errors.New("timeout halflife must be positive")
|
|
errInitialTimeoutAboveMaximum = errors.New("initial timeout cannot be greater than maximum timeout")
|
|
errInitialTimeoutBelowMinimum = errors.New("initial timeout cannot be less than minimum timeout")
|
|
errTooSmallTimeoutCoefficient = errors.New("timeout coefficient must be >= 1")
|
|
|
|
_ AdaptiveTimeoutManager = (*adaptiveTimeoutManager)(nil)
|
|
)
|
|
|
|
type adaptiveTimeout struct {
|
|
id ids.RequestID // Unique ID of this timeout
|
|
handler func() // Function to execute if timed out
|
|
duration time.Duration // How long this timeout was set for
|
|
deadline time.Time // When this timeout should be fired
|
|
measureLatency bool // Whether this request should impact latency
|
|
}
|
|
|
|
// AdaptiveTimeoutConfig contains the parameters provided to the
|
|
// adaptive timeout manager.
|
|
type AdaptiveTimeoutConfig struct {
|
|
InitialTimeout time.Duration `json:"initialTimeout"`
|
|
MinimumTimeout time.Duration `json:"minimumTimeout"`
|
|
MaximumTimeout time.Duration `json:"maximumTimeout"`
|
|
// Timeout is [timeoutCoefficient] * average response time
|
|
// [timeoutCoefficient] must be > 1
|
|
TimeoutCoefficient float64 `json:"timeoutCoefficient"`
|
|
// Larger halflife --> less volatile timeout
|
|
// [timeoutHalfLife] must be positive
|
|
TimeoutHalflife time.Duration `json:"timeoutHalflife"`
|
|
}
|
|
|
|
type AdaptiveTimeoutManager interface {
|
|
// Start the timeout manager.
|
|
// Must be called before any other method.
|
|
// Must only be called once.
|
|
Dispatch()
|
|
// Stop the timeout manager.
|
|
// Must only be called once.
|
|
Stop()
|
|
// Returns the current network timeout duration.
|
|
TimeoutDuration() time.Duration
|
|
// Registers a timeout for the item with the given [id].
|
|
// If the timeout occurs before the item is Removed, [timeoutHandler] is called.
|
|
Put(id ids.RequestID, measureLatency bool, timeoutHandler func())
|
|
// Remove the timeout associated with [id].
|
|
// Its timeout handler will not be called.
|
|
Remove(id ids.RequestID)
|
|
// ObserveLatency manually registers a response latency.
|
|
// We use this to pretend that it a query to a benched validator
|
|
// timed out when actually, we never even sent them a request.
|
|
ObserveLatency(latency time.Duration)
|
|
}
|
|
|
|
type adaptiveTimeoutManager struct {
|
|
lock sync.Mutex
|
|
// Tells the time. Can be faked for testing.
|
|
clock mockable.Clock
|
|
networkTimeoutMetric, avgLatency metric.Gauge
|
|
numTimeouts metric.Counter
|
|
numPendingTimeouts metric.Gauge
|
|
// Averages the response time from all peers
|
|
averager math.Averager
|
|
// Timeout is [timeoutCoefficient] * average response time
|
|
// [timeoutCoefficient] must be > 1
|
|
timeoutCoefficient float64
|
|
minimumTimeout time.Duration
|
|
maximumTimeout time.Duration
|
|
currentTimeout time.Duration // Amount of time before a timeout
|
|
timeoutHeap heap.Map[ids.RequestID, *adaptiveTimeout]
|
|
timer *Timer // Timer that will fire to clear the timeouts
|
|
}
|
|
|
|
func NewAdaptiveTimeoutManager(
|
|
config *AdaptiveTimeoutConfig,
|
|
registry metric.Registry,
|
|
) (AdaptiveTimeoutManager, error) {
|
|
switch {
|
|
case config.InitialTimeout > config.MaximumTimeout:
|
|
return nil, fmt.Errorf("%w: (%s) > (%s)", errInitialTimeoutAboveMaximum, config.InitialTimeout, config.MaximumTimeout)
|
|
case config.InitialTimeout < config.MinimumTimeout:
|
|
return nil, fmt.Errorf("%w: (%s) < (%s)", errInitialTimeoutBelowMinimum, config.InitialTimeout, config.MinimumTimeout)
|
|
case config.TimeoutCoefficient < 1:
|
|
return nil, fmt.Errorf("%w: %f", errTooSmallTimeoutCoefficient, config.TimeoutCoefficient)
|
|
case config.TimeoutHalflife <= 0:
|
|
return nil, errNonPositiveHalflife
|
|
}
|
|
|
|
metricsInstance := metric.NewWithRegistry("adaptive_timeout", registry)
|
|
|
|
tm := &adaptiveTimeoutManager{
|
|
networkTimeoutMetric: metricsInstance.NewGauge(
|
|
"current_timeout",
|
|
"Duration of current network timeout in nanoseconds",
|
|
),
|
|
avgLatency: metricsInstance.NewGauge(
|
|
"average_latency",
|
|
"Average network latency in nanoseconds",
|
|
),
|
|
numTimeouts: metricsInstance.NewCounter(
|
|
"timeouts",
|
|
"Number of timed out requests",
|
|
),
|
|
numPendingTimeouts: metricsInstance.NewGauge(
|
|
"pending_timeouts",
|
|
"Number of pending timeouts",
|
|
),
|
|
minimumTimeout: config.MinimumTimeout,
|
|
maximumTimeout: config.MaximumTimeout,
|
|
currentTimeout: config.InitialTimeout,
|
|
timeoutCoefficient: config.TimeoutCoefficient,
|
|
timeoutHeap: heap.NewMap[ids.RequestID, *adaptiveTimeout](func(a, b *adaptiveTimeout) bool {
|
|
return a.deadline.Before(b.deadline)
|
|
}),
|
|
}
|
|
tm.timer = NewTimer(tm.timeout)
|
|
tm.averager = math.NewAverager(float64(config.InitialTimeout), config.TimeoutHalflife, tm.clock.Time())
|
|
|
|
return tm, nil
|
|
}
|
|
|
|
func (tm *adaptiveTimeoutManager) TimeoutDuration() time.Duration {
|
|
tm.lock.Lock()
|
|
defer tm.lock.Unlock()
|
|
|
|
return tm.currentTimeout
|
|
}
|
|
|
|
func (tm *adaptiveTimeoutManager) Dispatch() {
|
|
tm.timer.Dispatch()
|
|
}
|
|
|
|
func (tm *adaptiveTimeoutManager) Stop() {
|
|
tm.timer.Stop()
|
|
}
|
|
|
|
func (tm *adaptiveTimeoutManager) Put(id ids.RequestID, measureLatency bool, timeoutHandler func()) {
|
|
tm.lock.Lock()
|
|
defer tm.lock.Unlock()
|
|
|
|
tm.put(id, measureLatency, timeoutHandler)
|
|
}
|
|
|
|
// Assumes [tm.lock] is held
|
|
func (tm *adaptiveTimeoutManager) put(id ids.RequestID, measureLatency bool, handler func()) {
|
|
now := tm.clock.Time()
|
|
tm.remove(id, now)
|
|
|
|
timeout := &adaptiveTimeout{
|
|
id: id,
|
|
handler: handler,
|
|
duration: tm.currentTimeout,
|
|
deadline: now.Add(tm.currentTimeout),
|
|
measureLatency: measureLatency,
|
|
}
|
|
tm.timeoutHeap.Push(id, timeout)
|
|
tm.numPendingTimeouts.Set(float64(tm.timeoutHeap.Len()))
|
|
|
|
tm.setNextTimeoutTime()
|
|
}
|
|
|
|
func (tm *adaptiveTimeoutManager) Remove(id ids.RequestID) {
|
|
tm.lock.Lock()
|
|
defer tm.lock.Unlock()
|
|
|
|
tm.remove(id, tm.clock.Time())
|
|
}
|
|
|
|
// Assumes [tm.lock] is held
|
|
func (tm *adaptiveTimeoutManager) remove(id ids.RequestID, now time.Time) {
|
|
// Observe the response time to update average network response time.
|
|
timeout, exists := tm.timeoutHeap.Remove(id)
|
|
if !exists {
|
|
return
|
|
}
|
|
|
|
if timeout.measureLatency {
|
|
timeoutRegisteredAt := timeout.deadline.Add(-1 * timeout.duration)
|
|
latency := now.Sub(timeoutRegisteredAt)
|
|
tm.observeLatencyAndUpdateTimeout(latency, now)
|
|
}
|
|
tm.numPendingTimeouts.Set(float64(tm.timeoutHeap.Len()))
|
|
}
|
|
|
|
// Assumes [tm.lock] is not held.
|
|
func (tm *adaptiveTimeoutManager) timeout() {
|
|
tm.lock.Lock()
|
|
defer tm.lock.Unlock()
|
|
|
|
now := tm.clock.Time()
|
|
for {
|
|
// getNextTimeoutHandler returns nil once there is nothing left to remove
|
|
timeoutHandler := tm.getNextTimeoutHandler(now)
|
|
if timeoutHandler == nil {
|
|
break
|
|
}
|
|
tm.numTimeouts.Inc()
|
|
|
|
// Don't execute a callback with a lock held
|
|
tm.lock.Unlock()
|
|
timeoutHandler()
|
|
tm.lock.Lock()
|
|
}
|
|
tm.setNextTimeoutTime()
|
|
}
|
|
|
|
func (tm *adaptiveTimeoutManager) ObserveLatency(latency time.Duration) {
|
|
tm.lock.Lock()
|
|
defer tm.lock.Unlock()
|
|
|
|
tm.observeLatencyAndUpdateTimeout(latency, tm.clock.Time())
|
|
}
|
|
|
|
// Assumes [tm.lock] is held
|
|
func (tm *adaptiveTimeoutManager) observeLatencyAndUpdateTimeout(latency time.Duration, now time.Time) {
|
|
tm.averager.Observe(float64(latency), now)
|
|
avgLatency := tm.averager.Read()
|
|
tm.currentTimeout = time.Duration(tm.timeoutCoefficient * avgLatency)
|
|
if tm.currentTimeout > tm.maximumTimeout {
|
|
tm.currentTimeout = tm.maximumTimeout
|
|
} else if tm.currentTimeout < tm.minimumTimeout {
|
|
tm.currentTimeout = tm.minimumTimeout
|
|
}
|
|
// Update the metrics
|
|
tm.networkTimeoutMetric.Set(float64(tm.currentTimeout))
|
|
tm.avgLatency.Set(avgLatency)
|
|
}
|
|
|
|
// Returns the handler function associated with the next timeout.
|
|
// If there are no timeouts, or if the next timeout is after [now],
|
|
// returns nil.
|
|
// Assumes [tm.lock] is held
|
|
func (tm *adaptiveTimeoutManager) getNextTimeoutHandler(now time.Time) func() {
|
|
_, nextTimeout, ok := tm.timeoutHeap.Peek()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if nextTimeout.deadline.After(now) {
|
|
return nil
|
|
}
|
|
tm.remove(nextTimeout.id, now)
|
|
return nextTimeout.handler
|
|
}
|
|
|
|
// Calculate the time of the next timeout and set
|
|
// the timer to fire at that time.
|
|
func (tm *adaptiveTimeoutManager) setNextTimeoutTime() {
|
|
_, nextTimeout, ok := tm.timeoutHeap.Peek()
|
|
if !ok {
|
|
// There are no pending timeouts
|
|
tm.timer.Cancel()
|
|
return
|
|
}
|
|
|
|
now := tm.clock.Time()
|
|
timeToNextTimeout := nextTimeout.deadline.Sub(now)
|
|
tm.timer.SetTimeoutIn(timeToNextTimeout)
|
|
}
|