- 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>
177 lines
4.2 KiB
Go
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")
|
|
}
|
|
}
|