tailscale/tstime/rate/value.go
Joe Tsai 7732377cd7
tstime/rate: implement Value.{Marshal,Unmarshal}JSON (#8481)
Implement support for marshaling and unmarshaling a Value.

Updates tailscale/corp#8427

Signed-off-by: Joe Tsai <joetsai@digital-static.net>
2024-01-16 13:48:34 -08:00

223 lines
7.9 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package rate
import (
"encoding/json"
"fmt"
"math"
"sync"
"time"
"tailscale.com/tstime/mono"
)
// Value measures the rate at which events occur,
// exponentially weighted towards recent activity.
// It is guaranteed to occupy O(1) memory, operate in O(1) runtime,
// and is safe for concurrent use.
// The zero value is safe for immediate use.
//
// The algorithm is based on and semantically equivalent to
// [exponentially weighted moving averages (EWMAs)],
// but modified to avoid assuming that event samples are gathered
// at fixed and discrete time-step intervals.
//
// In EWMA literature, the average is typically tuned with a λ parameter
// that determines how much weight to give to recent event samples.
// A high λ value reacts quickly to new events favoring recent history,
// while a low λ value reacts more slowly to new events.
// The EWMA is computed as:
//
// zᵢ = λxᵢ + (1-λ)zᵢ₋₁
//
// where:
// - λ is the weight parameter, where 0 ≤ λ ≤ 1
// - xᵢ is the number of events that has since occurred
// - zᵢ is the newly computed moving average
// - zᵢ₋₁ is the previous moving average one time-step ago
//
// As mentioned, this implementation does not assume that the average
// is updated periodically on a fixed time-step interval,
// but allows the application to indicate that events occurred
// at any point in time by simply calling Value.Add.
// Thus, for every time Value.Add is called, it takes into consideration
// the amount of time elapsed since the last call to Value.Add as
// opposed to assuming that every call to Value.Add is evenly spaced
// some fixed time-step interval apart.
//
// Since time is critical to this measurement, we tune the metric not
// with the weight parameter λ (a unit-less constant between 0 and 1),
// but rather as a half-life period t½. The half-life period is
// mathematically equivalent but easier for humans to reason about.
// The parameters λ and t½ and directly related in the following way:
//
// t½ = -(ln(2) · ΔT) / ln(1 - λ)
//
// λ = 1 - 2^-(ΔT / t½)
//
// where:
// - t½ is the half-life commonly used with exponential decay
// - λ is the unit-less weight parameter commonly used with EWMAs
// - ΔT is the discrete time-step interval used with EWMAs
//
// The internal algorithm does not use the EWMA formula,
// but is rather based on [half-life decay].
// The formula for half-life decay is mathematically related
// to the formula for computing the EWMA.
// The calculation of an EWMA is a geometric progression [[1]] and
// is essentially a discrete version of an exponential function [[2]],
// for which half-life decay is one particular expression.
// Given sufficiently small time-steps, the EWMA and half-life
// algorithms provide equivalent results.
//
// The Value type does not take ΔT as a parameter since it relies
// on a timer with nanosecond resolution. In a way, one could treat
// this algorithm as operating on a ΔT of 1ns. Practically speaking,
// the computation operates on non-discrete time intervals.
//
// [exponentially weighted moving averages (EWMAs)]: https://en.wikipedia.org/wiki/EWMA_chart
// [half-life decay]: https://en.wikipedia.org/wiki/Half-life
// [1]: https://en.wikipedia.org/wiki/Exponential_smoothing#%22Exponential%22_naming
// [2]: https://en.wikipedia.org/wiki/Exponential_decay
type Value struct {
// HalfLife specifies how quickly the rate reacts to rate changes.
//
// Specifically, if there is currently a steady-state rate of
// 0 events per second, and then immediately the rate jumped to
// N events per second, then it will take HalfLife seconds until
// the Value represents a rate of N/2 events per second and
// 2*HalfLife seconds until the Value represents a rate of 3*N/4
// events per second, and so forth. The rate represented by Value
// will asymptotically approach N events per second over time.
//
// In order for Value to stably represent a steady-state rate,
// the HalfLife should be larger than the average period between
// calls to Value.Add.
//
// A zero or negative HalfLife is by default 1 second.
HalfLife time.Duration
mu sync.Mutex
updated mono.Time
value float64 // adjusted count of events
}
// halfLife returns the half-life period in seconds.
func (r *Value) halfLife() float64 {
if r.HalfLife <= 0 {
return time.Second.Seconds()
}
return time.Duration(r.HalfLife).Seconds()
}
// Add records that n number of events just occurred,
// which must be a finite and non-negative number.
func (r *Value) Add(n float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.addNow(mono.Now(), n)
}
func (r *Value) addNow(now mono.Time, n float64) {
if n < 0 || math.IsInf(n, 0) || math.IsNaN(n) {
panic(fmt.Sprintf("invalid count %f; must be a finite, non-negative number", n))
}
r.value = r.valueNow(now) + n
r.updated = now
}
// valueNow computes the number of events after some elapsed time.
// The total count of events decay exponentially so that
// the computed rate is biased towards recent history.
func (r *Value) valueNow(now mono.Time) float64 {
// This uses the half-life formula:
// N(t) = N₀ · 2^-(t / t½)
// where:
// N(t) is the amount remaining after time t,
// N₀ is the initial quantity, and
// t½ is the half-life of the decaying quantity.
//
// See https://en.wikipedia.org/wiki/Half-life
age := now.Sub(r.updated).Seconds()
return r.value * math.Exp2(-age/r.halfLife())
}
// Rate computes the rate as events per second.
func (r *Value) Rate() float64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.rateNow(mono.Now())
}
func (r *Value) rateNow(now mono.Time) float64 {
// The stored value carries the units "events"
// while we want to compute "events / second".
//
// In the trivial case where the events never decay,
// the average rate can be computed by dividing the total events
// by the total elapsed time since the start of the Value.
// This works because the weight distribution is uniform such that
// the weight of an event in the distant past is equal to
// the weight of a recent event. This is not the case with
// exponentially decaying weights, which complicates computation.
//
// Since our events are decaying, we can divide the number of events
// by the total possible accumulated value, which we determine
// by integrating the half-life formula from t=0 until t=∞,
// assuming that N₀ is 1:
// ∫ N(t) dt = t½ / ln(2)
//
// Recall that the integral of a curve is the area under a curve,
// which carries the units of the X-axis multiplied by the Y-axis.
// In our case this would be the units "events · seconds".
// By normalizing N₀ to 1, the Y-axis becomes a unit-less quantity,
// resulting in a integral unit of just "seconds".
// Dividing the events by the integral quantity correctly produces
// the units of "events / second".
return r.valueNow(now) / r.normalizedIntegral()
}
// normalizedIntegral computes the quantity t½ / ln(2).
// It carries the units of "seconds".
func (r *Value) normalizedIntegral() float64 {
return r.halfLife() / math.Ln2
}
type jsonValue struct {
// TODO: Use v2 "encoding/json" for native time.Duration formatting.
HalfLife string `json:"halfLife,omitempty,omitzero"`
Value float64 `json:"value,omitempty,omitzero"`
Updated mono.Time `json:"updated,omitempty,omitzero"`
}
func (r *Value) MarshalJSON() ([]byte, error) {
if r == nil {
return []byte("null"), nil
}
r.mu.Lock()
defer r.mu.Unlock()
v := jsonValue{Value: r.value, Updated: r.updated}
if r.HalfLife > 0 {
v.HalfLife = r.HalfLife.String()
}
return json.Marshal(v)
}
func (r *Value) UnmarshalJSON(b []byte) error {
var v jsonValue
if err := json.Unmarshal(b, &v); err != nil {
return err
}
halfLife, err := time.ParseDuration(v.HalfLife)
if err != nil && v.HalfLife != "" {
return fmt.Errorf("invalid halfLife: %w", err)
}
r.mu.Lock()
defer r.mu.Unlock()
r.HalfLife = halfLife
r.value = v.Value
r.updated = v.Updated
return nil
}