Files
next.orly.dev/pkg/ratelimit/pid_test.go
mleku 88b0509ad8 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>
2025-12-11 22:45:11 +01:00

177 lines
4.2 KiB
Go

package ratelimit
import (
"testing"
"time"
)
func TestPIDController_BasicOperation(t *testing.T) {
pid := DefaultPIDControllerForWrites()
// First call should return 0 (initialization)
delay := pid.Update(0.5)
if delay != 0 {
t.Errorf("expected 0 delay on first call, got %v", delay)
}
// Sleep a bit to ensure dt > 0
time.Sleep(10 * time.Millisecond)
// Process variable below setpoint (0.5 < 0.85) should return 0 delay
delay = pid.Update(0.5)
if delay != 0 {
t.Errorf("expected 0 delay when below setpoint, got %v", delay)
}
// Process variable above setpoint should return positive delay
time.Sleep(10 * time.Millisecond)
delay = pid.Update(0.95) // 0.95 > 0.85 setpoint
if delay <= 0 {
t.Errorf("expected positive delay when above setpoint, got %v", delay)
}
}
func TestPIDController_IntegralAccumulation(t *testing.T) {
pid := NewPIDController(
0.5, 0.5, 0.0, // High Ki, no Kd
0.5, // setpoint
0.2, // filter alpha
-10, 10, // integral bounds
0, 1.0, // output bounds
)
// Initialize
pid.Update(0.5)
time.Sleep(10 * time.Millisecond)
// Continuously above setpoint should accumulate integral
for i := 0; i < 10; i++ {
time.Sleep(10 * time.Millisecond)
pid.Update(0.8) // 0.3 above setpoint
}
integral, _, _ := pid.GetState()
if integral <= 0 {
t.Errorf("expected positive integral after sustained error, got %v", integral)
}
}
func TestPIDController_FilteredDerivative(t *testing.T) {
pid := NewPIDController(
0.0, 0.0, 1.0, // Only Kd
0.5, // setpoint
0.5, // 50% filtering
-10, 10,
0, 1.0,
)
// Initialize with low value
pid.Update(0.5)
time.Sleep(10 * time.Millisecond)
// Second call with same value - derivative should be near zero
pid.Update(0.5)
_, _, prevFiltered := pid.GetState()
time.Sleep(10 * time.Millisecond)
// Big jump - filtered derivative should be dampened
delay := pid.Update(1.0)
// The filtered derivative should cause some response, but dampened
// Since we only have Kd=1.0 and alpha=0.5, the response should be modest
if delay < 0 {
t.Errorf("expected non-negative delay, got %v", delay)
}
_, _, newFiltered := pid.GetState()
// Filtered error should have moved toward the new error but not fully
if newFiltered <= prevFiltered {
t.Errorf("filtered error should increase with rising process variable")
}
}
func TestPIDController_AntiWindup(t *testing.T) {
pid := NewPIDController(
0.0, 1.0, 0.0, // Only Ki
0.5, // setpoint
0.2, // filter alpha
-1.0, 1.0, // tight integral bounds
0, 10.0, // wide output bounds
)
// Initialize
pid.Update(0.5)
// Drive the integral to its limit
for i := 0; i < 100; i++ {
time.Sleep(1 * time.Millisecond)
pid.Update(1.0) // Large positive error
}
integral, _, _ := pid.GetState()
if integral > 1.0 {
t.Errorf("integral should be clamped at 1.0, got %v", integral)
}
}
func TestPIDController_Reset(t *testing.T) {
pid := DefaultPIDControllerForWrites()
// Build up some state
pid.Update(0.5)
time.Sleep(10 * time.Millisecond)
pid.Update(0.9)
time.Sleep(10 * time.Millisecond)
pid.Update(0.95)
// Reset
pid.Reset()
integral, prevErr, prevFiltered := pid.GetState()
if integral != 0 || prevErr != 0 || prevFiltered != 0 {
t.Errorf("expected all state to be zero after reset")
}
// Next call should behave like first call
delay := pid.Update(0.9)
if delay != 0 {
t.Errorf("expected 0 delay on first call after reset, got %v", delay)
}
}
func TestPIDController_SetGains(t *testing.T) {
pid := DefaultPIDControllerForWrites()
// Change gains
pid.SetGains(1.0, 0.5, 0.1)
if pid.Kp != 1.0 || pid.Ki != 0.5 || pid.Kd != 0.1 {
t.Errorf("gains not updated correctly")
}
}
func TestPIDController_SetSetpoint(t *testing.T) {
pid := DefaultPIDControllerForWrites()
pid.SetSetpoint(0.7)
if pid.Setpoint != 0.7 {
t.Errorf("setpoint not updated, got %v", pid.Setpoint)
}
}
func TestDefaultControllers(t *testing.T) {
writePID := DefaultPIDControllerForWrites()
readPID := DefaultPIDControllerForReads()
// Write controller should have higher gains and lower setpoint
if writePID.Kp <= readPID.Kp {
t.Errorf("write Kp should be higher than read Kp")
}
if writePID.Setpoint >= readPID.Setpoint {
t.Errorf("write setpoint should be lower than read setpoint")
}
}