Files
next.orly.dev/pkg/ratelimit/limiter.go
mleku 28b41847a6 Generalize PID controller as reusable library with abstract interfaces
- Create pkg/interfaces/pid for generic PID controller interfaces:
  - ProcessVariable: abstract input (value + timestamp)
  - Source: provides process variable samples
  - Output: controller output with P/I/D components and clamping info
  - Controller: generic PID interface with setpoint/gains
  - Tuning: configuration struct for all PID parameters

- Create pkg/pid as standalone PID controller implementation:
  - Thread-safe with mutex protection
  - Low-pass filtered derivative to suppress high-frequency noise
  - Anti-windup on integral term
  - Configurable output clamping
  - Presets for common use cases: rate limiting, PoW difficulty,
    temperature control, motor speed

- Update pkg/ratelimit to use generic pkg/pid.Controller:
  - Limiter now uses pidif.Controller interface
  - Type assertions for monitoring/debugging state access
  - Maintains backward compatibility with existing API

The generic PID package can now be used for any dynamic adjustment
scenario beyond rate limiting, such as blockchain PoW difficulty
adjustment, temperature regulation, or motor speed control.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 22:53:04 +01:00

435 lines
11 KiB
Go

package ratelimit
import (
"context"
"sync"
"sync/atomic"
"time"
"next.orly.dev/pkg/interfaces/loadmonitor"
pidif "next.orly.dev/pkg/interfaces/pid"
"next.orly.dev/pkg/pid"
)
// OperationType distinguishes between read and write operations
// for applying different rate limiting strategies.
type OperationType int
const (
// Read operations (REQ queries)
Read OperationType = iota
// Write operations (EVENT saves, imports)
Write
)
// String returns a human-readable name for the operation type.
func (o OperationType) String() string {
switch o {
case Read:
return "read"
case Write:
return "write"
default:
return "unknown"
}
}
// Config holds configuration for the adaptive rate limiter.
type Config struct {
// Enabled controls whether rate limiting is active.
Enabled bool
// TargetMemoryMB is the target memory limit in megabytes.
// Memory pressure is calculated relative to this target.
TargetMemoryMB int
// WriteSetpoint is the target process variable for writes (0.0-1.0).
// Default: 0.85 (throttle when load exceeds 85%)
WriteSetpoint float64
// ReadSetpoint is the target process variable for reads (0.0-1.0).
// Default: 0.90 (more tolerant for reads)
ReadSetpoint float64
// PID gains for writes
WriteKp float64
WriteKi float64
WriteKd float64
// PID gains for reads
ReadKp float64
ReadKi float64
ReadKd float64
// MaxWriteDelayMs is the maximum delay for write operations in milliseconds.
MaxWriteDelayMs int
// MaxReadDelayMs is the maximum delay for read operations in milliseconds.
MaxReadDelayMs int
// MetricUpdateInterval is how often to poll the load monitor.
MetricUpdateInterval time.Duration
// MemoryWeight is the weight given to memory pressure in process variable (0.0-1.0).
// The remaining weight is given to the load metric.
// Default: 0.7 (70% memory, 30% load)
MemoryWeight float64
}
// DefaultConfig returns a default configuration for the rate limiter.
func DefaultConfig() Config {
return Config{
Enabled: true,
TargetMemoryMB: 1500, // 1.5GB target
WriteSetpoint: 0.85,
ReadSetpoint: 0.90,
WriteKp: 0.5,
WriteKi: 0.1,
WriteKd: 0.05,
ReadKp: 0.3,
ReadKi: 0.05,
ReadKd: 0.02,
MaxWriteDelayMs: 1000, // 1 second max
MaxReadDelayMs: 500, // 500ms max
MetricUpdateInterval: 100 * time.Millisecond,
MemoryWeight: 0.7,
}
}
// NewConfigFromValues creates a Config from individual configuration values.
// This is useful when loading configuration from environment variables.
func NewConfigFromValues(
enabled bool,
targetMB int,
writeKp, writeKi, writeKd float64,
readKp, readKi, readKd float64,
maxWriteMs, maxReadMs int,
writeTarget, readTarget float64,
) Config {
return Config{
Enabled: enabled,
TargetMemoryMB: targetMB,
WriteSetpoint: writeTarget,
ReadSetpoint: readTarget,
WriteKp: writeKp,
WriteKi: writeKi,
WriteKd: writeKd,
ReadKp: readKp,
ReadKi: readKi,
ReadKd: readKd,
MaxWriteDelayMs: maxWriteMs,
MaxReadDelayMs: maxReadMs,
MetricUpdateInterval: 100 * time.Millisecond,
MemoryWeight: 0.7,
}
}
// Limiter implements adaptive rate limiting using PID control.
// It monitors database load metrics and computes appropriate delays
// to keep the system within its target operating range.
type Limiter struct {
config Config
monitor loadmonitor.Monitor
// PID controllers for reads and writes (using generic pid.Controller)
writePID pidif.Controller
readPID pidif.Controller
// Cached metrics (updated periodically)
metricsLock sync.RWMutex
currentMetrics loadmonitor.Metrics
// Statistics
totalWriteDelayMs atomic.Int64
totalReadDelayMs atomic.Int64
writeThrottles atomic.Int64
readThrottles atomic.Int64
// Lifecycle
ctx context.Context
cancel context.CancelFunc
stopOnce sync.Once
stopped chan struct{}
wg sync.WaitGroup
}
// NewLimiter creates a new adaptive rate limiter.
// If monitor is nil, the limiter will be disabled.
func NewLimiter(config Config, monitor loadmonitor.Monitor) *Limiter {
ctx, cancel := context.WithCancel(context.Background())
l := &Limiter{
config: config,
monitor: monitor,
ctx: ctx,
cancel: cancel,
stopped: make(chan struct{}),
}
// Create PID controllers with configured gains using the generic pid package
l.writePID = pid.New(pidif.Tuning{
Kp: config.WriteKp,
Ki: config.WriteKi,
Kd: config.WriteKd,
Setpoint: config.WriteSetpoint,
DerivativeFilterAlpha: 0.2, // Strong filtering for writes
IntegralMin: -2.0,
IntegralMax: float64(config.MaxWriteDelayMs) / 1000.0 * 2, // Anti-windup limits
OutputMin: 0,
OutputMax: float64(config.MaxWriteDelayMs) / 1000.0,
})
l.readPID = pid.New(pidif.Tuning{
Kp: config.ReadKp,
Ki: config.ReadKi,
Kd: config.ReadKd,
Setpoint: config.ReadSetpoint,
DerivativeFilterAlpha: 0.15, // Very strong filtering for reads
IntegralMin: -1.0,
IntegralMax: float64(config.MaxReadDelayMs) / 1000.0 * 2,
OutputMin: 0,
OutputMax: float64(config.MaxReadDelayMs) / 1000.0,
})
// Set memory target on monitor
if monitor != nil && config.TargetMemoryMB > 0 {
monitor.SetMemoryTarget(uint64(config.TargetMemoryMB) * 1024 * 1024)
}
return l
}
// Start begins the rate limiter's background metric collection.
func (l *Limiter) Start() {
if l.monitor == nil || !l.config.Enabled {
return
}
// Start the monitor
l.monitor.Start()
// Start metric update loop
l.wg.Add(1)
go l.updateLoop()
}
// updateLoop periodically fetches metrics from the monitor.
func (l *Limiter) updateLoop() {
defer l.wg.Done()
ticker := time.NewTicker(l.config.MetricUpdateInterval)
defer ticker.Stop()
for {
select {
case <-l.ctx.Done():
return
case <-ticker.C:
if l.monitor != nil {
metrics := l.monitor.GetMetrics()
l.metricsLock.Lock()
l.currentMetrics = metrics
l.metricsLock.Unlock()
}
}
}
}
// Stop halts the rate limiter.
func (l *Limiter) Stop() {
l.stopOnce.Do(func() {
l.cancel()
if l.monitor != nil {
l.monitor.Stop()
}
l.wg.Wait()
close(l.stopped)
})
}
// Stopped returns a channel that closes when the limiter has stopped.
func (l *Limiter) Stopped() <-chan struct{} {
return l.stopped
}
// Wait blocks until the rate limiter permits the operation to proceed.
// It returns the delay that was applied, or 0 if no delay was needed.
// If the context is cancelled, it returns immediately.
func (l *Limiter) Wait(ctx context.Context, opType OperationType) time.Duration {
if !l.config.Enabled || l.monitor == nil {
return 0
}
delay := l.ComputeDelay(opType)
if delay <= 0 {
return 0
}
// Apply the delay
select {
case <-ctx.Done():
return 0
case <-time.After(delay):
return delay
}
}
// ComputeDelay calculates the recommended delay for an operation.
// This can be used to check the delay without actually waiting.
func (l *Limiter) ComputeDelay(opType OperationType) time.Duration {
if !l.config.Enabled || l.monitor == nil {
return 0
}
// Get current metrics
l.metricsLock.RLock()
metrics := l.currentMetrics
l.metricsLock.RUnlock()
// Compute process variable as weighted combination of memory and load
var loadMetric float64
switch opType {
case Write:
loadMetric = metrics.WriteLoad
case Read:
loadMetric = metrics.ReadLoad
}
// Combine memory pressure and load
// Process variable = memoryWeight * memoryPressure + (1-memoryWeight) * loadMetric
pv := l.config.MemoryWeight*metrics.MemoryPressure + (1-l.config.MemoryWeight)*loadMetric
// Select the appropriate PID controller
var delaySec float64
switch opType {
case Write:
out := l.writePID.UpdateValue(pv)
delaySec = out.Value()
if delaySec > 0 {
l.writeThrottles.Add(1)
l.totalWriteDelayMs.Add(int64(delaySec * 1000))
}
case Read:
out := l.readPID.UpdateValue(pv)
delaySec = out.Value()
if delaySec > 0 {
l.readThrottles.Add(1)
l.totalReadDelayMs.Add(int64(delaySec * 1000))
}
}
if delaySec <= 0 {
return 0
}
return time.Duration(delaySec * float64(time.Second))
}
// RecordLatency records an operation latency for the monitor.
func (l *Limiter) RecordLatency(opType OperationType, latency time.Duration) {
if l.monitor == nil {
return
}
switch opType {
case Write:
l.monitor.RecordWriteLatency(latency)
case Read:
l.monitor.RecordQueryLatency(latency)
}
}
// Stats returns rate limiter statistics.
type Stats struct {
WriteThrottles int64
ReadThrottles int64
TotalWriteDelayMs int64
TotalReadDelayMs int64
CurrentMetrics loadmonitor.Metrics
WritePIDState PIDState
ReadPIDState PIDState
}
// PIDState contains the internal state of a PID controller.
type PIDState struct {
Integral float64
PrevError float64
PrevFilteredError float64
}
// GetStats returns current rate limiter statistics.
func (l *Limiter) GetStats() Stats {
l.metricsLock.RLock()
metrics := l.currentMetrics
l.metricsLock.RUnlock()
stats := Stats{
WriteThrottles: l.writeThrottles.Load(),
ReadThrottles: l.readThrottles.Load(),
TotalWriteDelayMs: l.totalWriteDelayMs.Load(),
TotalReadDelayMs: l.totalReadDelayMs.Load(),
CurrentMetrics: metrics,
}
// Type assert to concrete pid.Controller to access State() method
// This is for monitoring/debugging only
if wCtrl, ok := l.writePID.(*pid.Controller); ok {
integral, prevErr, prevFiltered, _ := wCtrl.State()
stats.WritePIDState = PIDState{
Integral: integral,
PrevError: prevErr,
PrevFilteredError: prevFiltered,
}
}
if rCtrl, ok := l.readPID.(*pid.Controller); ok {
integral, prevErr, prevFiltered, _ := rCtrl.State()
stats.ReadPIDState = PIDState{
Integral: integral,
PrevError: prevErr,
PrevFilteredError: prevFiltered,
}
}
return stats
}
// Reset clears all PID controller state and statistics.
func (l *Limiter) Reset() {
l.writePID.Reset()
l.readPID.Reset()
l.writeThrottles.Store(0)
l.readThrottles.Store(0)
l.totalWriteDelayMs.Store(0)
l.totalReadDelayMs.Store(0)
}
// IsEnabled returns whether rate limiting is active.
func (l *Limiter) IsEnabled() bool {
return l.config.Enabled && l.monitor != nil
}
// UpdateConfig updates the rate limiter configuration.
// This is useful for dynamic tuning.
func (l *Limiter) UpdateConfig(config Config) {
l.config = config
// Update PID controllers - use interface methods for setpoint and gains
l.writePID.SetSetpoint(config.WriteSetpoint)
l.writePID.SetGains(config.WriteKp, config.WriteKi, config.WriteKd)
// Type assert to set output limits (not part of base interface)
if wCtrl, ok := l.writePID.(*pid.Controller); ok {
wCtrl.SetOutputLimits(0, float64(config.MaxWriteDelayMs)/1000.0)
}
l.readPID.SetSetpoint(config.ReadSetpoint)
l.readPID.SetGains(config.ReadKp, config.ReadKi, config.ReadKd)
if rCtrl, ok := l.readPID.(*pid.Controller); ok {
rCtrl.SetOutputLimits(0, float64(config.MaxReadDelayMs)/1000.0)
}
// Update memory target
if l.monitor != nil && config.TargetMemoryMB > 0 {
l.monitor.SetMemoryTarget(uint64(config.TargetMemoryMB) * 1024 * 1024)
}
}