Implement PID-controlled adaptive rate limiting for database operations

- 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>
This commit is contained in:
2025-12-11 22:45:11 +01:00
parent afa3dce1c9
commit 88b0509ad8
12 changed files with 1511 additions and 13 deletions

218
pkg/ratelimit/pid.go Normal file
View File

@@ -0,0 +1,218 @@
// 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
}