- Add LoadMonitor interface in pkg/interfaces/loadmonitor/ for database load metrics - Implement PIDController with filtered derivative to suppress high-frequency noise - Proportional (P): immediate response to current error - Integral (I): eliminates steady-state offset with anti-windup clamping - Derivative (D): rate-of-change prediction with low-pass filtering - Create BadgerLoadMonitor tracking L0 tables, compaction score, and cache hit ratio - Create Neo4jLoadMonitor tracking query semaphore usage and latencies - Add AdaptiveRateLimiter combining PID controllers for reads and writes - Configure via environment variables: - ORLY_RATE_LIMIT_ENABLED: enable/disable rate limiting - ORLY_RATE_LIMIT_TARGET_MB: target memory limit (default 1500MB) - ORLY_RATE_LIMIT_*_K[PID]: PID gains for reads/writes - ORLY_RATE_LIMIT_MAX_*_MS: maximum delays - ORLY_RATE_LIMIT_*_TARGET: setpoints for reads/writes - Integrate rate limiter into Server struct and lifecycle management - Add comprehensive unit tests for PID controller behavior Files modified: - app/config/config.go: Add rate limiting configuration options - app/main.go: Initialize and start/stop rate limiter - app/server.go: Add rateLimiter field to Server struct - main.go: Create rate limiter with appropriate monitor - pkg/run/run.go: Pass disabled limiter for test instances - pkg/interfaces/loadmonitor/: New LoadMonitor interface - pkg/ratelimit/: New PID controller and limiter implementation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
219 lines
6.9 KiB
Go
219 lines
6.9 KiB
Go
// Package ratelimit provides adaptive rate limiting using PID control.
|
|
// The PID controller uses proportional, integral, and derivative terms
|
|
// with a low-pass filter on the derivative to suppress high-frequency noise.
|
|
package ratelimit
|
|
|
|
import (
|
|
"math"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// PIDController implements a PID controller with filtered derivative.
|
|
// It is designed for rate limiting database operations based on load metrics.
|
|
//
|
|
// The controller computes a delay recommendation based on:
|
|
// - Proportional (P): Immediate response to current error
|
|
// - Integral (I): Accumulated error to eliminate steady-state offset
|
|
// - Derivative (D): Rate of change prediction (filtered to reduce noise)
|
|
//
|
|
// The filtered derivative uses a low-pass filter to attenuate high-frequency
|
|
// noise that would otherwise cause erratic control behavior.
|
|
type PIDController struct {
|
|
// Gains
|
|
Kp float64 // Proportional gain
|
|
Ki float64 // Integral gain
|
|
Kd float64 // Derivative gain
|
|
|
|
// Setpoint is the target process variable value (e.g., 0.85 for 85% of target memory).
|
|
// The controller drives the process variable toward this setpoint.
|
|
Setpoint float64
|
|
|
|
// DerivativeFilterAlpha is the low-pass filter coefficient for the derivative term.
|
|
// Range: 0.0-1.0, where lower values provide stronger filtering.
|
|
// Recommended: 0.2 for strong filtering, 0.5 for moderate filtering.
|
|
DerivativeFilterAlpha float64
|
|
|
|
// Integral limits for anti-windup
|
|
IntegralMax float64
|
|
IntegralMin float64
|
|
|
|
// Output limits
|
|
OutputMin float64 // Minimum output (typically 0 = no delay)
|
|
OutputMax float64 // Maximum output (max delay in seconds)
|
|
|
|
// Internal state (protected by mutex)
|
|
mu sync.Mutex
|
|
integral float64
|
|
prevError float64
|
|
prevFilteredError float64
|
|
lastUpdate time.Time
|
|
initialized bool
|
|
}
|
|
|
|
// DefaultPIDControllerForWrites creates a PID controller tuned for write operations.
|
|
// Writes benefit from aggressive integral and moderate proportional response.
|
|
func DefaultPIDControllerForWrites() *PIDController {
|
|
return &PIDController{
|
|
Kp: 0.5, // Moderate proportional response
|
|
Ki: 0.1, // Steady integral to eliminate offset
|
|
Kd: 0.05, // Small derivative for prediction
|
|
Setpoint: 0.85, // Target 85% of memory limit
|
|
DerivativeFilterAlpha: 0.2, // Strong filtering (20% new, 80% old)
|
|
IntegralMax: 10.0, // Anti-windup: max 10 seconds accumulated
|
|
IntegralMin: -2.0, // Allow small negative for faster recovery
|
|
OutputMin: 0.0, // No delay minimum
|
|
OutputMax: 1.0, // Max 1 second delay per write
|
|
}
|
|
}
|
|
|
|
// DefaultPIDControllerForReads creates a PID controller tuned for read operations.
|
|
// Reads should be more responsive but with less aggressive throttling.
|
|
func DefaultPIDControllerForReads() *PIDController {
|
|
return &PIDController{
|
|
Kp: 0.3, // Lower proportional (reads are more important)
|
|
Ki: 0.05, // Lower integral (don't accumulate as aggressively)
|
|
Kd: 0.02, // Very small derivative
|
|
Setpoint: 0.90, // Target 90% (more tolerant of memory use)
|
|
DerivativeFilterAlpha: 0.15, // Very strong filtering
|
|
IntegralMax: 5.0, // Lower anti-windup limit
|
|
IntegralMin: -1.0, // Allow small negative
|
|
OutputMin: 0.0, // No delay minimum
|
|
OutputMax: 0.5, // Max 500ms delay per read
|
|
}
|
|
}
|
|
|
|
// NewPIDController creates a new PID controller with custom parameters.
|
|
func NewPIDController(
|
|
kp, ki, kd float64,
|
|
setpoint float64,
|
|
derivativeFilterAlpha float64,
|
|
integralMin, integralMax float64,
|
|
outputMin, outputMax float64,
|
|
) *PIDController {
|
|
return &PIDController{
|
|
Kp: kp,
|
|
Ki: ki,
|
|
Kd: kd,
|
|
Setpoint: setpoint,
|
|
DerivativeFilterAlpha: derivativeFilterAlpha,
|
|
IntegralMin: integralMin,
|
|
IntegralMax: integralMax,
|
|
OutputMin: outputMin,
|
|
OutputMax: outputMax,
|
|
}
|
|
}
|
|
|
|
// Update computes the PID output based on the current process variable.
|
|
// The process variable should be in the range [0.0, 1.0+] representing load level.
|
|
//
|
|
// Returns the recommended delay in seconds. A value of 0 means no delay needed.
|
|
func (p *PIDController) Update(processVariable float64) float64 {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
|
|
// Initialize on first call
|
|
if !p.initialized {
|
|
p.lastUpdate = now
|
|
p.prevError = processVariable - p.Setpoint
|
|
p.prevFilteredError = p.prevError
|
|
p.initialized = true
|
|
return 0 // No delay on first call
|
|
}
|
|
|
|
// Calculate time delta
|
|
dt := now.Sub(p.lastUpdate).Seconds()
|
|
if dt <= 0 {
|
|
dt = 0.001 // Minimum 1ms to avoid division by zero
|
|
}
|
|
p.lastUpdate = now
|
|
|
|
// Calculate current error (positive when above setpoint = need to throttle)
|
|
error := processVariable - p.Setpoint
|
|
|
|
// Proportional term: immediate response to current error
|
|
pTerm := p.Kp * error
|
|
|
|
// Integral term: accumulate error over time
|
|
// Apply anti-windup by clamping the integral
|
|
p.integral += error * dt
|
|
p.integral = clamp(p.integral, p.IntegralMin, p.IntegralMax)
|
|
iTerm := p.Ki * p.integral
|
|
|
|
// Derivative term with low-pass filter
|
|
// Apply exponential moving average to filter high-frequency noise:
|
|
// filtered = alpha * new + (1 - alpha) * old
|
|
// This is equivalent to a first-order low-pass filter
|
|
filteredError := p.DerivativeFilterAlpha*error + (1-p.DerivativeFilterAlpha)*p.prevFilteredError
|
|
|
|
// Derivative of the filtered error
|
|
var dTerm float64
|
|
if dt > 0 {
|
|
dTerm = p.Kd * (filteredError - p.prevFilteredError) / dt
|
|
}
|
|
|
|
// Update previous values for next iteration
|
|
p.prevError = error
|
|
p.prevFilteredError = filteredError
|
|
|
|
// Compute total output and clamp to limits
|
|
output := pTerm + iTerm + dTerm
|
|
output = clamp(output, p.OutputMin, p.OutputMax)
|
|
|
|
// Only return positive delays (throttle when above setpoint)
|
|
if output < 0 {
|
|
return 0
|
|
}
|
|
return output
|
|
}
|
|
|
|
// Reset clears the controller state, useful when conditions change significantly.
|
|
func (p *PIDController) Reset() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
p.integral = 0
|
|
p.prevError = 0
|
|
p.prevFilteredError = 0
|
|
p.initialized = false
|
|
}
|
|
|
|
// SetSetpoint updates the target setpoint.
|
|
func (p *PIDController) SetSetpoint(setpoint float64) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.Setpoint = setpoint
|
|
}
|
|
|
|
// SetGains updates the PID gains.
|
|
func (p *PIDController) SetGains(kp, ki, kd float64) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.Kp = kp
|
|
p.Ki = ki
|
|
p.Kd = kd
|
|
}
|
|
|
|
// GetState returns the current internal state for monitoring/debugging.
|
|
func (p *PIDController) GetState() (integral, prevError, prevFilteredError float64) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.integral, p.prevError, p.prevFilteredError
|
|
}
|
|
|
|
// clamp restricts a value to the range [min, max].
|
|
func clamp(value, min, max float64) float64 {
|
|
if math.IsNaN(value) {
|
|
return 0
|
|
}
|
|
if value < min {
|
|
return min
|
|
}
|
|
if value > max {
|
|
return max
|
|
}
|
|
return value
|
|
}
|